diff --git a/.gitignore b/.gitignore index a164ef7e8d3..48601173587 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ docker-compose.override.yml **/*.tsbuildinfo **/vite.config.d.ts **/openapi-ts.config.d.ts +**/orval.config.d.ts api_private_key api_public_key diff --git a/ui/apps/console/openapi-ts.config.ts b/ui/apps/console/openapi-ts.config.ts deleted file mode 100644 index f82e080081e..00000000000 --- a/ui/apps/console/openapi-ts.config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { defineConfig } from "@hey-api/openapi-ts"; - -const input = process.env.OPENAPI_SPEC_PATH; -if (!input) { - throw new Error( - "OPENAPI_SPEC_PATH is not set; run `npm run generate -w @shellhub/console`.", - ); -} - -export default defineConfig({ - input, - output: "src/client", - plugins: [ - "@hey-api/typescript", - "@hey-api/sdk", - { - name: "@hey-api/client-fetch", - runtimeConfigPath: "./src/api/fetchClient", - }, - { - name: "@tanstack/react-query", - queryOptions: true, - mutationOptions: true, - includeInEntry: true, - }, - ], -}); diff --git a/ui/apps/console/orval.config.ts b/ui/apps/console/orval.config.ts new file mode 100644 index 00000000000..ee954d48c02 --- /dev/null +++ b/ui/apps/console/orval.config.ts @@ -0,0 +1,205 @@ +import { defineConfig } from "orval"; + +const input = process.env.OPENAPI_SPEC_PATH; +if (!input) { + throw new Error( + "OPENAPI_SPEC_PATH is not set; run `npm run generate -w @shellhub/console`.", + ); +} + +export default defineConfig({ + shellhub: { + input: { + target: input, + }, + output: { + target: "./src/client/api.ts", + schemas: "./src/client/model", + client: "react-query", + httpClient: "fetch", + mode: "single", + clean: true, + override: { + mutator: { + path: "./src/api/customInstance.ts", + name: "customInstance", + }, + fetch: { + includeHttpResponseReturnType: false, + }, + query: { + signal: true, + useInvalidate: true, + mutationInvalidates: [ + { + onMutations: ["apiKeyCreate", "apiKeyUpdate", "apiKeyDelete"], + invalidates: ["apiKeyList"], + }, + { + onMutations: [ + "createPublicKey", + "updatePublicKey", + "deletePublicKey", + ], + invalidates: ["getPublicKeys"], + }, + { + onMutations: [ + "createFirewallRule", + "updateFirewallRule", + "deleteFirewallRule", + ], + invalidates: ["getFirewallRules"], + }, + { + onMutations: [ + "createAccessPolicy", + "updateAccessPolicy", + "deleteAccessPolicy", + ], + invalidates: ["listAccessPolicies"], + }, + { + onMutations: ["createWebEndpoint", "deleteWebEndpoint"], + invalidates: ["listWebEndpoints"], + }, + { + onMutations: ["installKeyCreate", "installKeyUpdate"], + invalidates: ["installKeyList"], + }, + { + onMutations: ["createTag", "updateTag", "deleteTag"], + invalidates: ["getTags", "getDevices"], + }, + { + onMutations: [ + "addNamespaceMember", + "updateNamespaceMember", + "removeNamespaceMember", + ], + invalidates: ["getNamespaces"], + }, + { + onMutations: ["approveUser"], + invalidates: ["getNamespaces", "getUsers"], + }, + { + onMutations: ["createUserAdmin"], + invalidates: ["getUsers"], + }, + { + onMutations: [ + "adminUpdateUser", + "adminDeleteUser", + "adminResetUserPassword", + ], + invalidates: ["getUsers"], + }, + { + onMutations: ["editNamespaceAdmin", "deleteNamespaceAdmin"], + invalidates: ["getNamespacesAdmin"], + }, + { + onMutations: ["createAnnouncement"], + invalidates: ["listAnnouncementsAdmin"], + }, + { + onMutations: ["updateAnnouncement", "deleteAnnouncement"], + invalidates: ["listAnnouncementsAdmin"], + }, + { + onMutations: ["createServiceAccount", "deleteServiceAccount"], + invalidates: ["listServiceAccounts", "listSshIdentities"], + }, + { + onMutations: ["acceptInvite"], + invalidates: ["getMembershipInvitationList", "getNamespaces"], + }, + { + onMutations: ["generateInvitationLink"], + invalidates: ["getNamespaces"], + }, + { + onMutations: ["cancelMembershipInvitation"], + invalidates: ["getNamespaces"], + }, + { + onMutations: ["sendLicense"], + invalidates: ["getLicense"], + }, + { + onMutations: [ + "acceptDevice", + "deleteDevice", + "acceptDevicePairing", + ], + invalidates: [ + "getDevices", + "getStatusDevices", + "getStats", + "installKeyList", + ], + }, + { + onMutations: [ + "updateDeviceStatus", + "updateDevice", + "pullTagFromDevice", + "choiceDevices", + ], + invalidates: ["getDevices", "getStatusDevices", "installKeyList"], + }, + { + onMutations: ["setDeviceCustomField", "deleteDeviceCustomField"], + invalidates: ["getDevices"], + }, + { + onMutations: [ + "updateContainerStatus", + "deleteContainer", + "updateContainer", + "pullTagFromContainer", + ], + invalidates: ["getContainers"], + }, + { + onMutations: ["clsoeSession"], + invalidates: ["getSessions", "getStatusDevices"], + }, + { + onMutations: ["deleteSessionRecord"], + invalidates: ["getSessions"], + }, + { + onMutations: [ + "confirmSshApproval", + "createSshIdentity", + "renameSshIdentity", + "deleteSshIdentity", + ], + invalidates: ["listSshIdentities"], + }, + { + onMutations: ["editNamespace", "setSshAccessMode"], + invalidates: ["getNamespaces"], + }, + { + onMutations: ["createInstanceAPIKey", "deleteInstanceAPIKey"], + invalidates: ["listInstanceAPIKeys"], + }, + { + onMutations: [ + "createCustomer", + "createSubscription", + "attachPaymentMethod", + "detachPaymentMethod", + "setDefaultPaymentMethod", + ], + invalidates: ["getCustomer", "getSubscription"], + }, + ], + }, + }, + }, + }, +}); diff --git a/ui/apps/console/package.json b/ui/apps/console/package.json index 11ca00e0d77..c9bb4a24d5a 100644 --- a/ui/apps/console/package.json +++ b/ui/apps/console/package.json @@ -16,7 +16,6 @@ "@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/free-brands-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", - "@hey-api/openapi-ts": "^0.99.0", "@hookform/resolvers": "^3.10.0", "@shellhub/design-system": "*", "@stripe/react-stripe-js": "^3.1.1", @@ -44,6 +43,8 @@ "@tanstack/react-query-devtools": "^5.102.3", "@types/node-rsa": "^1.1.4", "@types/sshpk": "^1.17.5", - "ansi-styles": "^5.2.0" + "ansi-styles": "^5.2.0", + "msw": "^2.15.0", + "orval": "^8.27.0" } } diff --git a/ui/apps/console/src/api/__tests__/customInstance.test.ts b/ui/apps/console/src/api/__tests__/customInstance.test.ts new file mode 100644 index 00000000000..0504fb7fe93 --- /dev/null +++ b/ui/apps/console/src/api/__tests__/customInstance.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; +import { useAuthStore } from "@/stores/authStore"; +import { useConnectivityStore } from "@/stores/connectivityStore"; +import { seedAuthStore, VALID_JWT } from "@/tests/seedAuthStore"; +import { customInstance, fetchWithHeaders } from "../customInstance"; +import { totalCount } from "../pagination"; + +const EXPIRED_JWT = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjF9.fake-sig"; +const GRACE_MS = 5000; + +let hrefSpy: (v: string) => void; + +beforeEach(() => { + vi.useFakeTimers(); + seedAuthStore(); + useConnectivityStore.setState({ apiReachable: true }); + hrefSpy = vi.fn(); + Object.defineProperty(window, "location", { + value: { + ...window.location, + origin: "http://localhost", + href: "", + search: "", + }, + writable: true, + configurable: true, + }); + Object.defineProperty(window.location, "href", { + set: hrefSpy, + get: () => "http://localhost/", + configurable: true, + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("customInstance", () => { + describe("request building", () => { + it("attaches the bearer token when authenticated", async () => { + let captured: string | null = null; + server.use( + http.get("*/api/test", ({ request }) => { + captured = request.headers.get("Authorization"); + return HttpResponse.json({}); + }), + ); + + await customInstance("/api/test", { method: "GET" }); + expect(captured).toBe(`Bearer ${VALID_JWT}`); + }); + + it("skips auth header when no token is stored", async () => { + useAuthStore.setState({ token: null }); + let captured: string | null = null; + server.use( + http.get("*/api/test", ({ request }) => { + captured = request.headers.get("Authorization"); + return HttpResponse.json({}); + }), + ); + + await customInstance("/api/test", { method: "GET" }); + expect(captured).toBeNull(); + }); + + it("appends query params to the URL", async () => { + let capturedUrl = ""; + server.use( + http.get("*/api/devices", ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json([]); + }), + ); + + await customInstance("/api/devices", { + method: "GET", + params: { status: "accepted", page: "2" }, + }); + const url = new URL(capturedUrl); + expect(url.searchParams.get("status")).toBe("accepted"); + expect(url.searchParams.get("page")).toBe("2"); + }); + + it("sets Content-Type to application/json for string bodies", async () => { + let captured: string | null = null; + server.use( + http.post("*/api/users", ({ request }) => { + captured = request.headers.get("Content-Type"); + return HttpResponse.json({}, { status: 201 }); + }), + ); + + await customInstance("/api/users", { + method: "POST", + body: JSON.stringify({ name: "alice" }), + }); + expect(captured).toBe("application/json"); + }); + }); + + describe("token expiry", () => { + it("throws and logs out when the token is expired", async () => { + useAuthStore.setState({ token: EXPIRED_JWT }); + const logoutSpy = vi.spyOn(useAuthStore.getState(), "logout"); + + await expect( + customInstance("/api/test", { method: "GET" }), + ).rejects.toThrow("Token expired"); + expect(logoutSpy).toHaveBeenCalled(); + expect(hrefSpy).toHaveBeenCalledWith("/login"); + }); + + it("throws but does NOT redirect when on a token-login page", async () => { + useAuthStore.setState({ token: EXPIRED_JWT }); + Object.defineProperty(window.location, "search", { + get: () => "?token=abc", + configurable: true, + }); + + await expect( + customInstance("/api/test", { method: "GET" }), + ).rejects.toThrow("Token expired"); + expect(hrefSpy).not.toHaveBeenCalled(); + }); + + it("treats a malformed JWT as expired", async () => { + useAuthStore.setState({ token: "not-a-jwt" }); + + await expect( + customInstance("/api/test", { method: "GET" }), + ).rejects.toThrow("Token expired"); + }); + }); + + describe("401 handling", () => { + it("stores the MFA token from the response header", async () => { + server.use( + http.post( + "*/api/login", + () => + new HttpResponse(JSON.stringify({ message: "mfa required" }), { + status: 401, + headers: { "x-mfa-token": "mfa-temp-123" }, + }), + ), + ); + + await expect( + customInstance("/api/login", { method: "POST" }), + ).rejects.toThrow(); + expect(useAuthStore.getState().mfaToken).toBe("mfa-temp-123"); + }); + + it("logs out on 401 for a non-login request without MFA header", async () => { + server.use( + http.get( + "*/api/devices", + () => new HttpResponse(JSON.stringify({}), { status: 401 }), + ), + ); + const logoutSpy = vi.spyOn(useAuthStore.getState(), "logout"); + + await expect( + customInstance("/api/devices", { method: "GET" }), + ).rejects.toThrow(); + expect(logoutSpy).toHaveBeenCalled(); + expect(hrefSpy).toHaveBeenCalledWith("/login"); + }); + + it("does NOT log out on 401 for a login request", async () => { + server.use( + http.post( + "*/api/login", + () => + new HttpResponse(JSON.stringify({ message: "bad creds" }), { + status: 401, + }), + ), + ); + const logout = vi.fn(); + useAuthStore.setState({ logout }); + + await expect( + customInstance("/api/login", { method: "POST" }), + ).rejects.toThrow(); + expect(logout).not.toHaveBeenCalled(); + }); + + it("does NOT log out on 401 when on a token-login page", async () => { + Object.defineProperty(window.location, "search", { + get: () => "?token=abc", + configurable: true, + }); + server.use( + http.get( + "*/api/devices", + () => new HttpResponse(JSON.stringify({}), { status: 401 }), + ), + ); + const logout = vi.fn(); + useAuthStore.setState({ logout }); + + await expect( + customInstance("/api/devices", { method: "GET" }), + ).rejects.toThrow(); + expect(logout).not.toHaveBeenCalled(); + expect(hrefSpy).not.toHaveBeenCalled(); + }); + }); + + describe("connectivity tracking", () => { + it("schedules markDown on network error", async () => { + server.use(http.get("*/api/test", () => HttpResponse.error())); + + await expect( + customInstance("/api/test", { method: "GET" }), + ).rejects.toThrow(); + + vi.advanceTimersByTime(GRACE_MS); + expect(useConnectivityStore.getState().apiReachable).toBe(false); + }); + + it.each([502, 503, 504])("schedules markDown on %i", async (status) => { + server.use( + http.get("*/api/test", () => new HttpResponse(null, { status })), + ); + + await expect( + customInstance("/api/test", { method: "GET" }), + ).rejects.toThrow(); + + vi.advanceTimersByTime(GRACE_MS); + expect(useConnectivityStore.getState().apiReachable).toBe(false); + }); + + it("cancels markDown and marks up on a successful response", async () => { + useConnectivityStore.setState({ apiReachable: false }); + server.use(http.get("*/api/test", () => HttpResponse.json({}))); + + await customInstance("/api/test", { method: "GET" }); + expect(useConnectivityStore.getState().apiReachable).toBe(true); + }); + }); + + describe("error enrichment", () => { + it("throws with status and body fields from the API response", async () => { + server.use( + http.post("*/api/users", () => + HttpResponse.json( + { message: "conflict", fields: { username: "taken" } }, + { status: 409 }, + ), + ), + ); + + const err: unknown = await customInstance("/api/users", { + method: "POST", + }).catch((e: unknown) => e); + expect(err).toHaveProperty("status", 409); + expect(err).toHaveProperty("fields", { username: "taken" }); + }); + + it("falls back to empty object when body is not JSON", async () => { + server.use( + http.get( + "*/api/test", + () => new HttpResponse("Internal Server Error", { status: 500 }), + ), + ); + + const err: unknown = await customInstance("/api/test", { + method: "GET", + }).catch((e: unknown) => e); + expect(err).toHaveProperty("status", 500); + }); + }); + + describe("response parsing", () => { + it("returns undefined for 204", async () => { + server.use( + http.delete( + "*/api/devices/uid-1", + () => new HttpResponse(null, { status: 204 }), + ), + ); + + const result = await customInstance("/api/devices/uid-1", { + method: "DELETE", + }); + expect(result).toBeUndefined(); + }); + + it("attaches totalCount from X-Total-Count on array responses", async () => { + server.use( + http.get("*/api/devices", () => + HttpResponse.json([{ uid: "a" }, { uid: "b" }], { + headers: { "X-Total-Count": "50" }, + }), + ), + ); + + const data = await customInstance("/api/devices", { + method: "GET", + }); + expect(data).toHaveLength(2); + expect(totalCount(data)).toBe(50); + }); + + it("returns text when content-type is not JSON", async () => { + server.use( + http.get( + "*/api/test", + () => + new HttpResponse("plain text", { + headers: { "Content-Type": "text/plain" }, + }), + ), + ); + + const result = await customInstance("/api/test", { + method: "GET", + }); + expect(result).toBe("plain text"); + }); + }); +}); + +describe("fetchWithHeaders", () => { + it("returns data and response headers together", async () => { + server.use( + http.get("*/api/devices", () => + HttpResponse.json([{ uid: "a" }], { + headers: { "X-Total-Count": "10" }, + }), + ), + ); + + const { data, headers } = await fetchWithHeaders( + "/api/devices", + { + method: "GET", + }, + ); + expect(data).toHaveLength(1); + expect(headers.get("X-Total-Count")).toBe("10"); + }); + + it("returns undefined data for 204", async () => { + server.use( + http.delete("*/api/test", () => new HttpResponse(null, { status: 204 })), + ); + + const { data } = await fetchWithHeaders("/api/test", { method: "DELETE" }); + expect(data).toBeUndefined(); + }); +}); diff --git a/ui/apps/console/src/api/__tests__/fetchInterceptors.test.ts b/ui/apps/console/src/api/__tests__/fetchInterceptors.test.ts deleted file mode 100644 index 80ca0c57e4a..00000000000 --- a/ui/apps/console/src/api/__tests__/fetchInterceptors.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { client } from "@/client/client.gen"; -import { useAuthStore } from "@/stores/authStore"; -import { useConnectivityStore } from "@/stores/connectivityStore"; -import { isSdkError } from "@/api/errors"; -import "@/api/fetchInterceptors"; - -const GRACE_PERIOD_MS = 5000; - -function makeJwt(exp: number): string { - const header = btoa(JSON.stringify({ alg: "HS256" })); - const payload = btoa(JSON.stringify({ exp })); - return `${header}.${payload}.sig`; -} - -function futureExp() { - return Math.floor(Date.now() / 1000) + 3600; -} - -function pastExp() { - return Math.floor(Date.now() / 1000) - 60; -} - -function respondWith(status: number, headers: Record = {}) { - const fetchMock = vi.fn().mockImplementation((request: Request) => { - const response = new Response(JSON.stringify({}), { - status, - headers: { "Content-Type": "application/json", ...headers }, - }); - Object.defineProperty(response, "url", { value: request.url }); - return Promise.resolve(response); - }); - vi.stubGlobal("fetch", fetchMock); - return fetchMock; -} - -function failToConnect() { - const fetchMock = vi.fn().mockRejectedValue(new TypeError("Failed to fetch")); - vi.stubGlobal("fetch", fetchMock); - return fetchMock; -} - -function setLocation(search = "") { - Object.defineProperty(window, "location", { - writable: true, - value: { href: "", search, replace: vi.fn() }, - }); -} - -beforeEach(() => { - useAuthStore.setState({ - token: null, - user: null, - userId: null, - email: null, - tenant: null, - role: null, - name: null, - loading: false, - error: null, - mfaToken: null, - }); - - useConnectivityStore.getState().markUp(); - setLocation(); -}); - -afterEach(() => { - if (vi.isFakeTimers()) vi.advanceTimersByTime(GRACE_PERIOD_MS); - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -describe("request interceptor", () => { - it("attaches the bearer token when the token is valid", async () => { - const token = makeJwt(futureExp()); - useAuthStore.setState({ token }); - const fetchMock = respondWith(200); - - await client.get({ url: "/test" }); - - const request = fetchMock.mock.calls[0][0] as Request; - expect(request.headers.get("Authorization")).toBe(`Bearer ${token}`); - }); - - it("sends no Authorization header when there is no token", async () => { - const fetchMock = respondWith(200); - - await client.get({ url: "/test" }); - - const request = fetchMock.mock.calls[0][0] as Request; - expect(request.headers.get("Authorization")).toBeNull(); - }); - - it.each([ - ["expired", () => makeJwt(pastExp())], - ["malformed", () => "not-a-jwt"], - ])("rejects a %s token before it reaches the network, and logs out", async (_label, makeToken) => { - vi.useFakeTimers(); - useAuthStore.setState({ token: makeToken() }); - const fetchMock = respondWith(200); - - await expect(client.get({ url: "/test", throwOnError: true })).rejects.toThrow("Token expired"); - - expect(fetchMock).not.toHaveBeenCalled(); - expect(useAuthStore.getState().token).toBeNull(); - expect(window.location.href).toBe("/login"); - }); - - it("keeps the session on an expired token during a token login", async () => { - vi.useFakeTimers(); - setLocation("?token=abc"); - useAuthStore.setState({ token: makeJwt(pastExp()) }); - respondWith(200); - - await expect(client.get({ url: "/test", throwOnError: true })).rejects.toThrow("Token expired"); - - expect(useAuthStore.getState().token).not.toBeNull(); - expect(window.location.href).toBe(""); - }); - - it("leaves the API marked up when the token is rejected before the network", async () => { - vi.useFakeTimers(); - useAuthStore.setState({ token: makeJwt(pastExp()) }); - respondWith(200); - - await client.get({ url: "/test" }); - - vi.advanceTimersByTime(GRACE_PERIOD_MS); - expect(useConnectivityStore.getState().apiReachable).toBe(true); - }); -}); - -describe("response interceptor", () => { - it("logs out and redirects on 401 from a non-login route", async () => { - useAuthStore.setState({ token: makeJwt(futureExp()) }); - respondWith(401); - - await client.get({ url: "/test" }); - - expect(useAuthStore.getState().token).toBeNull(); - expect(window.location.href).toBe("/login"); - }); - - it("keeps the session on 401 from the login route", async () => { - useAuthStore.setState({ token: makeJwt(futureExp()) }); - respondWith(401); - - await client.post({ url: "/api/login" }); - - expect(useAuthStore.getState().token).not.toBeNull(); - expect(window.location.href).not.toBe("/login"); - }); - - it("marks the API up again on a successful response", async () => { - useConnectivityStore.getState().markDown(); - respondWith(200); - - await client.get({ url: "/test" }); - - expect(useConnectivityStore.getState().apiReachable).toBe(true); - }); - - it("stores the MFA token a 401 carries instead of logging out", async () => { - useAuthStore.setState({ token: makeJwt(futureExp()) }); - respondWith(401, { "x-mfa-token": "mfa-temp-token-456" }); - - await client.get({ url: "/test" }); - - expect(useAuthStore.getState().mfaToken).toBe("mfa-temp-token-456"); - expect(useAuthStore.getState().token).not.toBeNull(); - }); - - it("ignores an MFA token on a status other than 401", async () => { - const token = makeJwt(futureExp()); - useAuthStore.setState({ token }); - respondWith(403, { "x-mfa-token": "should-be-ignored" }); - - await client.get({ url: "/test" }); - - expect(useAuthStore.getState().mfaToken).toBeNull(); - expect(useAuthStore.getState().token).toBe(token); - }); -}); - -describe("connectivity tracking", () => { - it("marks the API down after the grace period when the request cannot connect", async () => { - vi.useFakeTimers(); - failToConnect(); - - await client.get({ url: "/test" }); - - expect(useConnectivityStore.getState().apiReachable).toBe(true); - - vi.advanceTimersByTime(GRACE_PERIOD_MS); - expect(useConnectivityStore.getState().apiReachable).toBe(false); - }); - - it.each([502, 503, 504])("marks the API down after the grace period on %i", async (status) => { - vi.useFakeTimers(); - respondWith(status); - - await client.get({ url: "/test" }); - - vi.advanceTimersByTime(GRACE_PERIOD_MS); - expect(useConnectivityStore.getState().apiReachable).toBe(false); - }); - - it.each([400, 404, 422])("leaves the API marked up on %i", async (status) => { - vi.useFakeTimers(); - respondWith(status); - - await client.get({ url: "/test" }); - - vi.advanceTimersByTime(GRACE_PERIOD_MS); - expect(useConnectivityStore.getState().apiReachable).toBe(true); - }); - - it("cancels the pending mark-down when a success arrives inside the grace period", async () => { - vi.useFakeTimers(); - failToConnect(); - await client.get({ url: "/test" }); - - vi.advanceTimersByTime(2000); - expect(useConnectivityStore.getState().apiReachable).toBe(true); - - respondWith(200); - await client.get({ url: "/test" }); - - vi.advanceTimersByTime(GRACE_PERIOD_MS); - expect(useConnectivityStore.getState().apiReachable).toBe(true); - }); -}); - -describe("error interceptor", () => { - it("attaches the status and headers that isSdkError reads", async () => { - respondWith(409, { "x-account-lockout": "60" }); - - const { error } = await client.get({ url: "/test" }); - - expect(isSdkError(error)).toBe(true); - expect((error as { status: number }).status).toBe(409); - expect((error as { headers: Headers }).headers.get("x-account-lockout")).toBe("60"); - }); -}); diff --git a/ui/apps/console/src/api/__tests__/pagination.test.ts b/ui/apps/console/src/api/__tests__/pagination.test.ts index ce426b71907..2928169dd56 100644 --- a/ui/apps/console/src/api/__tests__/pagination.test.ts +++ b/ui/apps/console/src/api/__tests__/pagination.test.ts @@ -1,36 +1,21 @@ -import { describe, it, expect, vi } from "vitest"; -import { paginatedQueryFn } from "../pagination"; +import { describe, it, expect } from "vitest"; +import { totalCount } from "../pagination"; -function mockSdkFn(data: unknown[], headers: Record) { - return vi.fn().mockResolvedValue({ - data, - response: { headers: new Headers(headers) }, +describe("totalCount", () => { + it("returns 0 for undefined", () => { + expect(totalCount(undefined)).toBe(0); }); -} -describe("paginatedQueryFn", () => { - it("returns data and totalCount from X-Total-Count header", async () => { - const devices = [{ uid: "1" }, { uid: "2" }]; - const sdkFn = mockSdkFn(devices, { "X-Total-Count": "42" }); - - const queryFn = paginatedQueryFn(sdkFn, { query: { page: 1 } }); - const result = await queryFn(); - - expect(result).toEqual({ data: devices, totalCount: 42 }); - expect(sdkFn).toHaveBeenCalledWith({ query: { page: 1 }, throwOnError: true }); + it("returns 0 when the property is absent", () => { + expect(totalCount([1, 2, 3])).toBe(0); }); - it("defaults totalCount to 0 when header is missing", async () => { - const sdkFn = mockSdkFn([], {}); - - const result = await paginatedQueryFn(sdkFn, {})(); - - expect(result.totalCount).toBe(0); - }); - - it("propagates SDK errors thrown with throwOnError", async () => { - const sdkFn = vi.fn().mockRejectedValue(new Error("network failure")); - - await expect(paginatedQueryFn(sdkFn, {})()).rejects.toThrow("network failure"); + it("reads a non-enumerable totalCount property", () => { + const data = [1, 2, 3]; + Object.defineProperty(data, "totalCount", { + value: 42, + enumerable: false, + }); + expect(totalCount(data)).toBe(42); }); }); diff --git a/ui/apps/console/src/api/customInstance.ts b/ui/apps/console/src/api/customInstance.ts new file mode 100644 index 00000000000..b84fba932b0 --- /dev/null +++ b/ui/apps/console/src/api/customInstance.ts @@ -0,0 +1,177 @@ +import type { SdkHttpError } from "./errors"; +import { useAuthStore } from "@/stores/authStore"; +import { useConnectivityStore } from "@/stores/connectivityStore"; + +/** Error shape threaded through every generated hook. */ +export type ErrorType<_Error> = SdkHttpError; + +/** Request body passthrough — Orval requires this export. */ +export type BodyType = BodyData; + +const baseURL = window.location.origin; + +function isTokenExpired(token: string): boolean { + try { + const payload: unknown = JSON.parse(atob(token.split(".")[1])); + if (typeof payload === "object" && payload !== null && "exp" in payload) { + const { exp } = payload; + return typeof exp === "number" && exp * 1000 < Date.now(); + } + return false; + } catch { + return true; + } +} + +const GRACE_PERIOD_MS = 5000; +let downTimer: ReturnType | null = null; + +function scheduleMarkDown() { + if (downTimer) return; + downTimer = setTimeout(() => { + downTimer = null; + useConnectivityStore.getState().markDown(); + }, GRACE_PERIOD_MS); +} + +function cancelMarkDown() { + if (downTimer) { + clearTimeout(downTimer); + downTimer = null; + } +} + +function isApiDown(status: number): boolean { + return status === 502 || status === 503 || status === 504; +} + +interface MutatorOptions { + method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + params?: Record; + body?: BodyType; + headers?: Record; + signal?: AbortSignal; +} + +async function doFetch( + url: string, + { method, params, body, headers, signal }: MutatorOptions, +): Promise { + let targetUrl = `${baseURL}${url}`; + + if (params) { + const search = new URLSearchParams(params).toString(); + if (search) targetUrl += `?${search}`; + } + + const requestHeaders: Record = { ...headers }; + + if (typeof body === "string") { + requestHeaders["Content-Type"] ??= "application/json"; + } + + const token = useAuthStore.getState().token; + const isTokenLogin = new URLSearchParams(window.location.search).has("token"); + if (token) { + if (isTokenExpired(token)) { + if (!isTokenLogin) { + useAuthStore.getState().logout(); + window.location.href = "/login"; + } + throw new Error("Token expired"); + } + requestHeaders["Authorization"] = `Bearer ${token}`; + } + + let response: Response; + try { + response = await fetch(targetUrl, { + method, + headers: requestHeaders, + body, + signal, + }); + } catch (error) { + scheduleMarkDown(); + throw error; + } + + if (!isApiDown(response.status)) { + cancelMarkDown(); + if (!useConnectivityStore.getState().apiReachable) { + useConnectivityStore.getState().markUp(); + } + } + + if (response.status === 401) { + const mfaToken = response.headers.get("x-mfa-token"); + if (mfaToken) { + useAuthStore.getState().setMfaToken(mfaToken); + } else { + const isLoginRequest = response.url.includes("/api/login"); + if (!isLoginRequest && !isTokenLogin) { + useAuthStore.getState().logout(); + window.location.href = "/login"; + } + } + } else if (isApiDown(response.status)) { + scheduleMarkDown(); + } + + if (!response.ok) { + const errorBody: unknown = await response.json().catch(() => ({})); + const fields = + typeof errorBody === "object" && errorBody !== null + ? (errorBody as Record) + : {}; + const error = Object.assign(new Error(String(response.status)), { + ...fields, + status: response.status, + headers: response.headers, + }); + throw error; + } + + return response; +} + +/** + * Orval custom mutator — every generated SDK function calls this. `body` is sent as given, so a + * JSON payload must arrive already serialized; the generated functions stringify their own. + */ +export const customInstance = async ( + url: string, + options: MutatorOptions, +): Promise => { + const response = await doFetch(url, options); + if (response.status === 204) return undefined as T; + const ct = response.headers.get("content-type") ?? ""; + if (ct.includes("application/json")) { + const data = (await response.json()) as T; + if (Array.isArray(data)) { + const tc = response.headers.get("X-Total-Count"); + if (tc) { + Object.defineProperty(data, "totalCount", { + value: parseInt(tc, 10), + enumerable: false, + }); + } + } + return data; + } + return (await response.text()) as unknown as T; +}; + +export default customInstance; + +/** Like `customInstance` but also exposes the response headers (for `X-Total-Count`). */ +export async function fetchWithHeaders( + url: string, + options: MutatorOptions, +): Promise<{ data: T; headers: Headers }> { + const response = await doFetch(url, options); + if (response.status === 204) + return { data: undefined as T, headers: response.headers }; + const data = (await response.json()) as T; + return { data, headers: response.headers }; +} diff --git a/ui/apps/console/src/api/errors.ts b/ui/apps/console/src/api/errors.ts index 2eaf87650e0..652eb9c9134 100644 --- a/ui/apps/console/src/api/errors.ts +++ b/ui/apps/console/src/api/errors.ts @@ -1,7 +1,5 @@ /** - * Shape attached to errors by the fetch error interceptor in fetchInterceptors.ts. - * The interceptor monkey-patches `.status` and `.headers` onto the parsed - * response body before it is thrown by the SDK with `throwOnError: true`. + * Shape attached to errors thrown by the custom fetch mutator in customInstance.ts. * * `message` and `fields` come from the body the API sends. `message` is for API clients — the * console renders its own copy, keyed by status; see `apiErrorMessage`. diff --git a/ui/apps/console/src/api/fetchClient.ts b/ui/apps/console/src/api/fetchClient.ts deleted file mode 100644 index eed10b2a660..00000000000 --- a/ui/apps/console/src/api/fetchClient.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { CreateClientConfig } from "../client/client.gen"; - -/** - * Points the generated SDK at the origin serving the console. The API is behind the same gateway - * as the UI, so there is no separate host to configure and none to get wrong. - */ -export const createClientConfig: CreateClientConfig = (config) => ({ - ...config, - baseUrl: `${window.location.protocol}//${window.location.host}`, -}); diff --git a/ui/apps/console/src/api/fetchInterceptors.ts b/ui/apps/console/src/api/fetchInterceptors.ts deleted file mode 100644 index faf35a67c64..00000000000 --- a/ui/apps/console/src/api/fetchInterceptors.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { client } from "../client/client.gen"; -import { useAuthStore } from "../stores/authStore"; -import { useConnectivityStore } from "../stores/connectivityStore"; - -function isTokenExpired(token: string): boolean { - try { - const payload: unknown = JSON.parse(atob(token.split(".")[1])); - if (typeof payload === "object" && payload !== null && "exp" in payload) { - const { exp } = payload; - return typeof exp === "number" && exp * 1000 < Date.now(); - } - return false; - } catch { - return true; - } -} - -class ExpiredTokenError extends Error {} - -const GRACE_PERIOD_MS = 5000; -let downTimer: ReturnType | null = null; - -function scheduleMarkDown() { - if (downTimer) return; - downTimer = setTimeout(() => { - downTimer = null; - useConnectivityStore.getState().markDown(); - }, GRACE_PERIOD_MS); -} - -function cancelMarkDown() { - if (downTimer) { - clearTimeout(downTimer); - downTimer = null; - } -} - -function isApiDown(status: number): boolean { - return status === 502 || status === 503 || status === 504; -} - -client.interceptors.request.use((request) => { - const token = useAuthStore.getState().token; - if (token) { - if (isTokenExpired(token)) { - const isTokenLogin = new URLSearchParams(window.location.search).has( - "token", - ); - if (!isTokenLogin) { - useAuthStore.getState().logout(); - window.location.href = "/login"; - } - throw new ExpiredTokenError("Token expired"); - } - request.headers.set("Authorization", `Bearer ${token}`); - } - return request; -}); - -client.interceptors.response.use((response) => { - if (!isApiDown(response.status)) { - cancelMarkDown(); - if (!useConnectivityStore.getState().apiReachable) { - useConnectivityStore.getState().markUp(); - } - } - - if (response.status === 401) { - const mfaToken = response.headers.get("x-mfa-token"); - if (mfaToken) { - useAuthStore.getState().setMfaToken(mfaToken); - } else { - const isLoginRequest = response.url.includes("/api/login"); - const isTokenLogin = new URLSearchParams(window.location.search).has( - "token", - ); - if (!isLoginRequest && !isTokenLogin) { - useAuthStore.getState().logout(); - window.location.href = "/login"; - } - } - } else if (isApiDown(response.status)) { - scheduleMarkDown(); - } - - return response; -}); - -client.interceptors.error.use((error, response) => { - if (!response) { - if (!(error instanceof ExpiredTokenError)) scheduleMarkDown(); - return error; - } - - const enriched = typeof error === "object" && error !== null ? error : {}; - (enriched as Record).status = response.status; - (enriched as Record).headers = response.headers; - return enriched; -}); diff --git a/ui/apps/console/src/api/pagination.ts b/ui/apps/console/src/api/pagination.ts index a7ad4c1831f..5ef8422fd83 100644 --- a/ui/apps/console/src/api/pagination.ts +++ b/ui/apps/console/src/api/pagination.ts @@ -1,28 +1,8 @@ /** - * A page of results together with the total the filter matched, which is what the pager needs - * and the page itself cannot say. + * Reads the non-enumerable `totalCount` that `customInstance` attaches to array responses carrying + * an `X-Total-Count` header. Returns 0 when the data is undefined or the property is absent. */ -export interface PaginatedResult { - data: T[]; - totalCount: number; -} - -type SdkListFn = ( - options: O & { throwOnError: true }, -) => Promise<{ data: T[]; response: Response }>; - -/** - * Wraps a generated list call as a query function that also reads the total from X-Total-Count. - * The count lives in a header rather than the body, so a plain SDK call cannot page; every - * paginated hook goes through here instead of parsing the header again. - */ -export function paginatedQueryFn( - sdkFn: SdkListFn, - options: O, -): () => Promise> { - return async () => { - const { data, response } = await sdkFn({ ...options, throwOnError: true }); - const totalCount = parseInt(response.headers.get("X-Total-Count") ?? "0", 10); - return { data, totalCount }; - }; +export function totalCount(data: unknown[] | undefined): number { + if (!data) return 0; + return (data as unknown as { totalCount?: number }).totalCount ?? 0; } diff --git a/ui/apps/console/src/components/ConnectDrawer.tsx b/ui/apps/console/src/components/ConnectDrawer.tsx index a5375b3f4de..45bfb1d0b94 100644 --- a/ui/apps/console/src/components/ConnectDrawer.tsx +++ b/ui/apps/console/src/components/ConnectDrawer.tsx @@ -14,7 +14,7 @@ import type { TerminalSession } from "../stores/terminalStore"; import { useVaultStore } from "../stores/vaultStore"; import { useAuthStore } from "../stores/authStore"; import { useNamespace } from "../hooks/useNamespaces"; -import { useCreateSSHIdentity } from "../hooks/useSSHIdentityMutations"; +import { useCreateSshIdentity } from "@/client/api"; import { getFingerprint, validatePrivateKey } from "../utils/sshKeys"; import { ensureBrowserKey, @@ -25,7 +25,7 @@ import { import { BROWSER_KEY_QUERY_KEY } from "@/hooks/useBrowserKey"; import { isRecordingSupported } from "../utils/recordings"; import { isAlreadyEnrolled } from "../utils/sshIdentity"; -import { listSshIdentitiesOptions } from "../client"; +import { getListSshIdentitiesQueryOptions } from "@/client/api"; import BrowserEnrollDialog from "./terminal/BrowserEnrollDialog"; import CopyButton from "./common/CopyButton"; import Drawer from "./common/Drawer"; @@ -155,7 +155,7 @@ export default function ConnectDrawer({ const tenant = useAuthStore((s) => s.tenant); const userId = useAuthStore((s) => s.userId); - const createIdentity = useCreateSSHIdentity(); + const createIdentity = useCreateSshIdentity(); const queryClient = useQueryClient(); const { namespace } = useNamespace(tenant ?? ""); const namespaceRecords = namespace?.settings?.session_record ?? false; @@ -233,7 +233,7 @@ export default function ConnectDrawer({ ) => { try { await createIdentity.mutateAsync({ - body: { name, data: key.publicKeyLine, source: "browser" }, + data: { name, data: key.publicKeyLine, source: "browser" }, }); } catch (err: unknown) { if (!isAlreadyEnrolled(err)) throw err; @@ -270,7 +270,7 @@ export default function ConnectDrawer({ let registered: boolean; try { const identities = await queryClient.fetchQuery( - listSshIdentitiesOptions({}), + getListSshIdentitiesQueryOptions(), ); registered = identities.some((i) => i.fingerprint === key.fingerprint); } catch { diff --git a/ui/apps/console/src/components/ManageTagsDrawer.tsx b/ui/apps/console/src/components/ManageTagsDrawer.tsx index b997a9351cf..b23d48068a1 100644 --- a/ui/apps/console/src/components/ManageTagsDrawer.tsx +++ b/ui/apps/console/src/components/ManageTagsDrawer.tsx @@ -1,12 +1,8 @@ import { useState, useRef, FormEvent } from "react"; import { isSdkError } from "../api/errors"; import { useResetOnOpen } from "../hooks/useResetOnOpen"; -import { useTags } from "../hooks/useTags"; -import { - useCreateTag, - useUpdateTag, - useDeleteTag, -} from "../hooks/useTagMutations"; +import { useCreateTag, useUpdateTag, useDeleteTag } from "@/client/api"; +import { useTagNames } from "@/hooks/useTags"; import Drawer from "./common/Drawer"; import ConfirmDialog from "./common/ConfirmDialog"; import { @@ -38,7 +34,7 @@ export default function ManageTagsDrawer({ onTagRenamed?: (oldName: string, newName: string) => void; onTagDeleted?: (name: string) => void; }) { - const { tags, isLoading } = useTags(); + const { names: tags, isLoading } = useTagNames(); const createTag = useCreateTag(); const updateTag = useUpdateTag(); const deleteTag = useDeleteTag(); @@ -67,7 +63,7 @@ export default function ManageTagsDrawer({ setSubmitting(true); setError(null); try { - await createTag.mutateAsync({ body: { name: newName.trim() } }); + await createTag.mutateAsync({ data: { name: newName.trim() } }); setNewName(""); } catch (err: unknown) { if (isSdkError(err) && err.status === 409) { @@ -105,8 +101,8 @@ export default function ManageTagsDrawer({ setError(null); try { await updateTag.mutateAsync({ - path: { name: currentName }, - body: { name: trimmed }, + name: currentName, + data: { name: trimmed }, }); skipBlurRef.current = true; setEditName(""); @@ -123,7 +119,7 @@ export default function ManageTagsDrawer({ setSubmitting(true); setError(null); try { - await deleteTag.mutateAsync({ path: { name } }); + await deleteTag.mutateAsync({ name }); setDeletingTag(null); onTagDeleted?.(name); } catch { @@ -222,10 +218,10 @@ export default function ManageTagsDrawer({
{tags.map((tag) => (
- {editingTag === tag.name ? ( + {editingTag === tag ? (
{ if (e.key === "Enter") { skipBlurRef.current = true; - void handleRename(tag.name); + void handleRename(tag); } if (e.key === "Escape") { skipBlurRef.current = true; @@ -247,10 +243,15 @@ export default function ManageTagsDrawer({ skipBlurRef.current = false; return; } - void handleRename(tag.name, true); + void handleRename(tag, true); }} - className={cn("w-full px-2.5 py-1 bg-card border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 transition-all", editNameChanged && !editNameValid ? "border-accent-red/50 focus:ring-accent-red/20" : "border-primary/50 focus:ring-primary/20")} + className={cn( + "w-full px-2.5 py-1 bg-card border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 transition-all", + editNameChanged && !editNameValid + ? "border-accent-red/50 focus:ring-accent-red/20" + : "border-primary/50 focus:ring-primary/20", + )} /> {editNameChanged && !editNameValid && (

@@ -266,18 +267,18 @@ export default function ManageTagsDrawer({

- {tag.name} + {tag}
)} - {editingTag !== tag.name && ( + {editingTag !== tag && (
{ - setEditingTag(tag.name); - setEditName(tag.name); + setEditingTag(tag); + setEditName(tag); }} > @@ -285,7 +286,7 @@ export default function ManageTagsDrawer({ setDeletingTag(tag.name)} + onClick={() => setDeletingTag(tag)} > diff --git a/ui/apps/console/src/components/announcements/AnnouncementModal.tsx b/ui/apps/console/src/components/announcements/AnnouncementModal.tsx index 2ed0c6ee8eb..a2d3afef128 100644 --- a/ui/apps/console/src/components/announcements/AnnouncementModal.tsx +++ b/ui/apps/console/src/components/announcements/AnnouncementModal.tsx @@ -13,7 +13,7 @@ import { import BaseDialog from "@/components/common/BaseDialog"; import { formatDateShort } from "@/utils/date"; import { isAllowedUrl } from "@/utils/url"; -import type { Announcement } from "@/client"; +import type { Announcement } from "@/client/model"; import "@/styles/announcement-prose.css"; interface AnnouncementContentProps { diff --git a/ui/apps/console/src/components/announcements/AnnouncementModalTrigger.tsx b/ui/apps/console/src/components/announcements/AnnouncementModalTrigger.tsx index 7c22925fc59..92db3b28f2e 100644 --- a/ui/apps/console/src/components/announcements/AnnouncementModalTrigger.tsx +++ b/ui/apps/console/src/components/announcements/AnnouncementModalTrigger.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; import { getConfig } from "@/env"; -import { useLatestAnnouncement } from "@/hooks/useLatestAnnouncement"; +import { useListAnnouncements, useGetAnnouncement } from "@/client/api"; import AnnouncementModal from "./AnnouncementModal"; -import type { Announcement } from "@/client"; +import type { Announcement } from "@/client/model"; const STORAGE_KEY = "announcement"; @@ -34,7 +34,15 @@ export default function AnnouncementModalTrigger() { } function AnnouncementModalInner() { - const { announcement } = useLatestAnnouncement(); + const { data: latestList = [] } = useListAnnouncements({ + page: 1, + per_page: 1, + order_by: "desc", + }); + const latestUuid = latestList[0]?.uuid ?? ""; + const { data: announcement } = useGetAnnouncement(latestUuid, { + query: { enabled: !!latestUuid }, + }); const [dismissed, setDismissed] = useState(false); const show = @@ -50,6 +58,10 @@ function AnnouncementModalInner() { if (!show || !announcement) return null; return ( - + ); } diff --git a/ui/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx b/ui/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx index e178af8033d..02af40909e4 100644 --- a/ui/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx +++ b/ui/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { Announcement } from "@/client"; +import type { Announcement } from "@/client/model"; vi.mock("@tiptap/react", () => ({ useEditor: vi.fn(() => null), diff --git a/ui/apps/console/src/components/announcements/__tests__/AnnouncementModalTrigger.test.tsx b/ui/apps/console/src/components/announcements/__tests__/AnnouncementModalTrigger.test.tsx index 03e538a63c5..ead443ca126 100644 --- a/ui/apps/console/src/components/announcements/__tests__/AnnouncementModalTrigger.test.tsx +++ b/ui/apps/console/src/components/announcements/__tests__/AnnouncementModalTrigger.test.tsx @@ -1,20 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, cleanup, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { Announcement } from "@/client"; +import type { Announcement } from "@/client/model"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; import { mockAnnouncement, mockAnnouncementFull } from "@/tests/factories"; import { getConfig, defaultConfig } from "@/env"; import AnnouncementModalTrigger from "../AnnouncementModalTrigger"; -const sdk = vi.hoisted(() => - mockSdkGen({ - listAnnouncements: vi.fn(), - getAnnouncement: vi.fn(), - }), -); - vi.mock("../AnnouncementModal", () => ({ default: ({ open, @@ -49,15 +43,13 @@ function setupAnnouncement(overrides: Partial = {}) { title: short.title, date: short.date, }); - sdk.listAnnouncements.mockResolvedValue(paginatedResponse([short])); - sdk.getAnnouncement.mockResolvedValue(mockSdkResponse(full)); + server.use( + http.get("*/api/announcements", () => jsonWithTotal([short])), + http.get("*/api/announcements/:uuid", () => HttpResponse.json(full)), + ); return full; } -function setupNoAnnouncement() { - sdk.listAnnouncements.mockResolvedValue(paginatedResponse([])); -} - function renderTrigger() { return render(, { wrapper: createTestWrapper(), @@ -68,7 +60,7 @@ beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockGetConfig.mockReturnValue({ ...defaultConfig, announcements: true }); - setupNoAnnouncement(); + server.use(http.get("*/api/announcements", () => jsonWithTotal([]))); }); describe("AnnouncementModalTrigger", () => { @@ -81,7 +73,6 @@ describe("AnnouncementModalTrigger", () => { expect( screen.queryByTestId("announcement-modal"), ).not.toBeInTheDocument(); - expect(sdk.listAnnouncements).not.toHaveBeenCalled(); }); }); @@ -102,10 +93,11 @@ describe("AnnouncementModalTrigger", () => { renderTrigger(); - await waitFor(() => expect(sdk.getAnnouncement).toHaveBeenCalled()); - expect( - screen.queryByTestId("announcement-modal"), - ).not.toBeInTheDocument(); + await waitFor(() => { + expect( + screen.queryByTestId("announcement-modal"), + ).not.toBeInTheDocument(); + }); }); }); @@ -199,10 +191,11 @@ describe("AnnouncementModalTrigger", () => { cleanup(); renderTrigger(); - await waitFor(() => expect(sdk.listAnnouncements).toHaveBeenCalled()); - expect( - screen.queryByTestId("announcement-modal"), - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + screen.queryByTestId("announcement-modal"), + ).not.toBeInTheDocument(), + ); }); }); }); diff --git a/ui/apps/console/src/components/billing/BillingCheckout.tsx b/ui/apps/console/src/components/billing/BillingCheckout.tsx index 17bd8e55fd3..5c2b28fc5dd 100644 --- a/ui/apps/console/src/components/billing/BillingCheckout.tsx +++ b/ui/apps/console/src/components/billing/BillingCheckout.tsx @@ -1,12 +1,12 @@ import { Card } from "@shellhub/design-system/primitives"; -import { useCustomer } from "@/hooks/useBilling"; +import { useGetCustomer } from "@/client/api"; import BillingIcon from "./BillingIcon"; /** * The checkout summary: what will be charged, and to which card. */ export default function BillingCheckout() { - const { customer } = useCustomer(); + const { data: customer } = useGetCustomer(); const defaultPm = customer?.payment_methods?.find((pm) => pm.default); return ( diff --git a/ui/apps/console/src/components/billing/BillingDialog.tsx b/ui/apps/console/src/components/billing/BillingDialog.tsx index 103cf15cdd9..f7285fc217e 100644 --- a/ui/apps/console/src/components/billing/BillingDialog.tsx +++ b/ui/apps/console/src/components/billing/BillingDialog.tsx @@ -6,7 +6,7 @@ import { XMarkIcon, } from "@heroicons/react/24/outline"; import BaseDialog from "../common/BaseDialog"; -import { useCreateSubscription, useSubscription } from "@/hooks/useBilling"; +import { useCreateSubscription, useGetSubscription } from "@/client/api"; import BillingLetter from "./BillingLetter"; import BillingPayment from "./BillingPayment"; import BillingCheckout from "./BillingCheckout"; @@ -36,7 +36,9 @@ export default function BillingDialog({ const [hasDefault, setHasDefault] = useState(false); const [error, setError] = useState(""); const createSubscription = useCreateSubscription(); - const { refetch: refetchSubscription } = useSubscription(false); + const { refetch: refetchSubscription } = useGetSubscription({ + query: { enabled: false }, + }); const goNext = useCallback( () => startTransition(() => setStep((s) => s + 1)), @@ -50,7 +52,7 @@ export default function BillingDialog({ const subscribe = async () => { setError(""); try { - await createSubscription.mutateAsync({}); + await createSubscription.mutateAsync(); const { data: sub } = await refetchSubscription(); const subStatus = sub?.status; if (!subStatus || !["active", "trialing"].includes(subStatus)) { diff --git a/ui/apps/console/src/components/billing/BillingPayment.tsx b/ui/apps/console/src/components/billing/BillingPayment.tsx index a881bbb1cce..1e9cfff7e4f 100644 --- a/ui/apps/console/src/components/billing/BillingPayment.tsx +++ b/ui/apps/console/src/components/billing/BillingPayment.tsx @@ -25,10 +25,10 @@ import { useAuthStore } from "@/stores/authStore"; import { useAttachPaymentMethod, useCreateCustomer, - useCustomer, useDetachPaymentMethod, + useGetCustomer, useSetDefaultPaymentMethod, -} from "@/hooks/useBilling"; +} from "@/client/api"; import { stripeErrorMessage } from "@/utils/stripeErrors"; import FieldLabel from "@/components/common/fields/FieldLabel"; import InputField from "@/components/common/fields/InputField"; @@ -104,10 +104,10 @@ function BillingPaymentInner({ const { namespace, refetch: refetchNamespace } = useNamespace(tenantId ?? ""); const hasCustomer = !!namespace?.billing?.customer_id; const { - customer, + data: customer, isLoading: customerLoading, refetch: refetchCustomer, - } = useCustomer(hasCustomer); + } = useGetCustomer({ query: { enabled: hasCustomer } }); const createCustomer = useCreateCustomer(); const attachPm = useAttachPaymentMethod(); @@ -133,7 +133,7 @@ function BillingPaymentInner({ const fresh = await refetchNamespace(); if (cancelled) return; if (!fresh.data?.billing?.customer_id) { - await createCustomer.mutateAsync({}); + await createCustomer.mutateAsync(); if (cancelled) return; await refetchNamespace(); } @@ -188,7 +188,7 @@ function BillingPaymentInner({ setError("Unable to create payment method. Please try again."); return; } - await attachPm.mutateAsync({ body: { id: paymentMethod.id } }); + await attachPm.mutateAsync({ data: { id: paymentMethod.id } }); await refetchCustomer(); card.clear(); setIsAddingCard(false); @@ -211,7 +211,7 @@ function BillingPaymentInner({ const handleSetDefault = async (id: string) => { setError(""); try { - await setDefaultPm.mutateAsync({ body: { id } }); + await setDefaultPm.mutateAsync({ data: { id } }); await refetchCustomer(); } catch { setError("Failed to update default payment method."); @@ -221,7 +221,7 @@ function BillingPaymentInner({ const handleDetach = async (id: string) => { setError(""); try { - await detachPm.mutateAsync({ body: { id } }); + await detachPm.mutateAsync({ data: { id } }); await refetchCustomer(); } catch { setError("Failed to remove payment method."); diff --git a/ui/apps/console/src/components/billing/BillingSection.tsx b/ui/apps/console/src/components/billing/BillingSection.tsx index 55a0ad9f2a2..6f2068963db 100644 --- a/ui/apps/console/src/components/billing/BillingSection.tsx +++ b/ui/apps/console/src/components/billing/BillingSection.tsx @@ -11,10 +11,13 @@ import { import { useAuthStore } from "@/stores/authStore"; import { useHasPermission } from "@/hooks/useHasPermission"; import { useNamespace } from "@/hooks/useNamespaces"; -import { useOpenBillingPortal, useSubscription } from "@/hooks/useBilling"; +import { + useGetSubscription, + useCreateBillingPortalSession, +} from "@/client/api"; import { useInvalidateByIds } from "@/hooks/useInvalidateQueries"; import { formatExpiry } from "@/utils/date"; -import type { BillingStatus } from "@/client"; +import type { BillingStatus } from "@/client/model"; import { cn } from "@shellhub/design-system/cn"; import { Button } from "@shellhub/design-system/primitives"; @@ -194,12 +197,20 @@ export default function BillingSection({ sectionId }: BillingSectionProps) { const { namespace } = useNamespace(tenantId ?? ""); const billing = namespace?.billing; const hasSubscription = !!billing?.customer_id && !!billing?.subscription?.id; - const { subscription, isLoading } = useSubscription(hasSubscription); - const openPortal = useOpenBillingPortal(); + const { data: subscription, isLoading } = useGetSubscription({ + query: { enabled: hasSubscription }, + }); + const openPortal = useCreateBillingPortalSession({ + mutation: { + onSuccess: (data) => { + window.open(data.url, "_blank", "noopener,noreferrer"); + }, + }, + }); const invalidate = useInvalidateByIds( - "getCustomer", - "getSubscription", - "getNamespace", + "/api/billing/customer", + "/api/billing/subscription", + "/api/namespaces/", ); const [wizardOpen, setWizardOpen] = useState(false); diff --git a/ui/apps/console/src/components/billing/DeviceChooserDialog.tsx b/ui/apps/console/src/components/billing/DeviceChooserDialog.tsx index 1251665ab3e..f859b6ebcb0 100644 --- a/ui/apps/console/src/components/billing/DeviceChooserDialog.tsx +++ b/ui/apps/console/src/components/billing/DeviceChooserDialog.tsx @@ -3,6 +3,7 @@ import { KeyboardEvent, startTransition, useId, + useMemo, useRef, useState, } from "react"; @@ -20,12 +21,10 @@ import LastSeenCell from "../common/LastSeenCell"; import SearchField from "@/components/common/fields/SearchField"; import CheckboxField from "@/components/common/fields/CheckboxField"; import { useDevices, type NormalizedDevice } from "@/hooks/useDevices"; +import { normalizeDeviceTags } from "@/utils/deviceTags"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useTableSort } from "@/hooks/useTableSort"; -import { - useChoiceDevices, - useSuggestedDevices, -} from "@/hooks/useDeviceChooser"; +import { useChoiceDevices, useGetDevicesMostUsed } from "@/client/api"; import { isSdkError } from "@/api/errors"; import { FREE_TIER_DEVICE_LIMIT } from "./DeviceChooserTrigger"; import { cn } from "@shellhub/design-system/cn"; @@ -71,11 +70,13 @@ export default function DeviceChooserDialog({ const allPanelId = useId(); const choice = useChoiceDevices(); - const { - devices: suggested, - isLoading: suggestedLoading, - error: suggestedError, - } = useSuggestedDevices(open); + const suggestedQuery = useGetDevicesMostUsed({ query: { enabled: open } }); + const suggested = useMemo( + () => (suggestedQuery.data ?? []).map(normalizeDeviceTags), + [suggestedQuery.data], + ); + const suggestedLoading = suggestedQuery.isLoading; + const suggestedError = suggestedQuery.error; const suggestedEmpty = !suggestedLoading && !suggestedError && suggested.length === 0; @@ -148,7 +149,7 @@ export default function DeviceChooserDialog({ (d) => d.uid, ); if (choices.length === 0) return; - await choice.mutateAsync({ body: { choices } }); + await choice.mutateAsync({ data: { choices } }); onClose(); } catch (err) { const status = isSdkError(err) ? err.status : undefined; @@ -412,7 +413,9 @@ const TabButton = forwardRef( onKeyDown={onKeyDown} className={cn( "relative px-4 py-2.5 text-xs font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/50 rounded-t-md", - selected ? "text-text-primary" : "text-text-muted hover:text-text-secondary", + selected + ? "text-text-primary" + : "text-text-muted hover:text-text-secondary", disabled && "opacity-40 cursor-not-allowed hover:text-text-muted", )} > diff --git a/ui/apps/console/src/components/billing/DeviceChooserTrigger.tsx b/ui/apps/console/src/components/billing/DeviceChooserTrigger.tsx index 01d9a73753d..bd148d3b461 100644 --- a/ui/apps/console/src/components/billing/DeviceChooserTrigger.tsx +++ b/ui/apps/console/src/components/billing/DeviceChooserTrigger.tsx @@ -1,6 +1,6 @@ import { lazy, Suspense, useState } from "react"; import { isCloud } from "@/env"; -import { useStats } from "@/hooks/useStats"; +import { useGetStatusDevices } from "@/client/api"; import { hasActiveSubscription } from "@/utils/billing"; import { useNamespace } from "@/hooks/useNamespaces"; import { useHasPermission } from "@/hooks/useHasPermission"; @@ -29,7 +29,7 @@ function DeviceChooserTriggerInner() { const canChoose = useHasPermission("device:choose"); const tenantId = useAuthStore((s) => s.tenant); const { namespace, isLoading: nsLoading } = useNamespace(tenantId ?? ""); - const { stats, isLoading: statsLoading } = useStats(); + const { data: stats, isLoading: statsLoading } = useGetStatusDevices(); const [dismissed, setDismissed] = useState(false); if (!tenantId || nsLoading || statsLoading || !namespace || !stats) diff --git a/ui/apps/console/src/components/billing/__tests__/BillingDialog.test.tsx b/ui/apps/console/src/components/billing/__tests__/BillingDialog.test.tsx index 2125a1a0b5f..638bde3ec37 100644 --- a/ui/apps/console/src/components/billing/__tests__/BillingDialog.test.tsx +++ b/ui/apps/console/src/components/billing/__tests__/BillingDialog.test.tsx @@ -2,20 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; - -const mockIsSdkError = vi.fn(); -vi.mock("@/api/errors", () => ({ - isSdkError: (err: unknown): boolean => mockIsSdkError(err) as boolean, -})); - -const sdk = vi.hoisted(() => - mockSdkGen({ - createSubscription: vi.fn(), - getSubscription: vi.fn(), - }), -); +import { seedAuthStore } from "@/tests/seedAuthStore"; vi.mock("../BillingPayment", () => ({ default: ({ @@ -72,9 +62,16 @@ import BillingDialog from "../BillingDialog"; beforeEach(() => { vi.clearAllMocks(); - sdk.createSubscription.mockResolvedValue(mockSdkResponse(undefined)); - sdk.getSubscription.mockResolvedValue(mockSdkResponse({ status: "active" })); - mockIsSdkError.mockReturnValue(false); + seedAuthStore(); + server.use( + http.post( + "*/api/billing/subscription", + () => new HttpResponse(null, { status: 204 }), + ), + http.get("*/api/billing/subscription", () => + HttpResponse.json({ status: "active" }), + ), + ); }); function renderDialog(onClose = vi.fn(), onSuccess = vi.fn()) { @@ -193,20 +190,6 @@ describe("BillingDialog", () => { ).toBeInTheDocument(); }); - it("'Confirm subscription' calls createSubscription", async () => { - const user = userEvent.setup(); - renderDialog(); - await goToStep3(user); - await user.click( - screen.getByRole("button", { name: /confirm subscription/i }), - ); - await waitFor(() => - expect(sdk.createSubscription).toHaveBeenCalledWith( - expect.objectContaining({ throwOnError: true }), - ), - ); - }); - it("advances to step 4 after subscription is active", async () => { const user = userEvent.setup(); renderDialog(); @@ -220,8 +203,10 @@ describe("BillingDialog", () => { }); it("advances to step 4 when subscription status is 'trialing'", async () => { - sdk.getSubscription.mockResolvedValue( - mockSdkResponse({ status: "trialing" }), + server.use( + http.get("*/api/billing/subscription", () => + HttpResponse.json({ status: "trialing" }), + ), ); const user = userEvent.setup(); renderDialog(); @@ -235,8 +220,10 @@ describe("BillingDialog", () => { }); it("shows error and stays on step 3 when status is 'incomplete'", async () => { - sdk.getSubscription.mockResolvedValue( - mockSdkResponse({ status: "incomplete" }), + server.use( + http.get("*/api/billing/subscription", () => + HttpResponse.json({ status: "incomplete" }), + ), ); const user = userEvent.setup(); renderDialog(); @@ -253,8 +240,10 @@ describe("BillingDialog", () => { }); it("shows 'wasn't fully activated' error for non-active non-incomplete statuses", async () => { - sdk.getSubscription.mockResolvedValue( - mockSdkResponse({ status: "past_due" }), + server.use( + http.get("*/api/billing/subscription", () => + HttpResponse.json({ status: "past_due" }), + ), ); const user = userEvent.setup(); renderDialog(); @@ -270,9 +259,11 @@ describe("BillingDialog", () => { }); it("shows 'unpaid invoices' error on 402 response", async () => { - const err = { status: 402 }; - sdk.createSubscription.mockRejectedValue(err); - mockIsSdkError.mockImplementation((e: unknown) => e === err); + server.use( + http.post("*/api/billing/subscription", () => + HttpResponse.json({}, { status: 402 }), + ), + ); const user = userEvent.setup(); renderDialog(); await goToStep3(user); @@ -285,8 +276,11 @@ describe("BillingDialog", () => { }); it("shows generic error on non-402 failure", async () => { - sdk.createSubscription.mockRejectedValue(new Error("network failure")); - mockIsSdkError.mockReturnValue(false); + server.use( + http.post("*/api/billing/subscription", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const user = userEvent.setup(); renderDialog(); await goToStep3(user); @@ -301,7 +295,9 @@ describe("BillingDialog", () => { }); it("disables 'Confirm subscription' and shows 'Subscribing…' while pending", async () => { - sdk.createSubscription.mockReturnValue(new Promise(() => {})); + server.use( + http.post("*/api/billing/subscription", () => new Promise(() => {})), + ); const user = userEvent.setup(); renderDialog(); await goToStep3(user); @@ -380,8 +376,10 @@ describe("BillingDialog", () => { }); it("'Back' clears any existing error message", async () => { - sdk.getSubscription.mockResolvedValue( - mockSdkResponse({ status: "past_due" }), + server.use( + http.get("*/api/billing/subscription", () => + HttpResponse.json({ status: "past_due" }), + ), ); const user = userEvent.setup(); renderDialog(); diff --git a/ui/apps/console/src/components/billing/__tests__/BillingSection.test.tsx b/ui/apps/console/src/components/billing/__tests__/BillingSection.test.tsx index cb2d5d6bbab..128d0cc05e8 100644 --- a/ui/apps/console/src/components/billing/__tests__/BillingSection.test.tsx +++ b/ui/apps/console/src/components/billing/__tests__/BillingSection.test.tsx @@ -2,8 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import { mockNamespace } from "@/tests/factories"; import { seedAuthStore } from "@/tests/seedAuthStore"; @@ -17,16 +18,6 @@ vi.mock("react-router-dom", async () => { return { ...actual, useLocation: () => mockLocation }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - getSubscription: vi.fn(), - getNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - createSubscription: vi.fn(), - createBillingPortalSession: vi.fn(), - }), -); - vi.mock("@/api/errors", () => ({ isSdkError: (err: unknown): boolean => typeof err === "object" && err !== null && "status" in err, @@ -59,43 +50,56 @@ function renderSection() { ); } -function billingRecord(subscribed: boolean) { - return { - customer_id: "cus_123", - subscription: subscribed - ? { id: "sub_123", status: "active" as const, current_period_end: 0 } - : undefined, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }; -} - function setStatus( status: string, extra: { end_at?: number; invoices?: unknown[] } = {}, ) { - sdk.getNamespace.mockResolvedValue( - mockSdkResponse(mockNamespace({ billing: billingRecord(true) })), + server.use( + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json( + mockNamespace({ + billing: { + customer_id: "cus_123", + subscription: { + id: "sub_123", + status: "active" as const, + current_period_end: 0, + }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + }), + ), + ), + http.get("*/api/billing/subscription", () => + HttpResponse.json({ status, ...extra }), + ), ); - sdk.getSubscription.mockResolvedValue(mockSdkResponse({ status, ...extra })); } function setInactive() { - sdk.getNamespace.mockResolvedValue( - mockSdkResponse(mockNamespace({ billing: null })), + server.use( + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json(mockNamespace({ billing: null })), + ), + http.get("*/api/billing/subscription", () => HttpResponse.json(null)), ); - sdk.getSubscription.mockResolvedValue(mockSdkResponse(null)); } beforeEach(() => { vi.clearAllMocks(); seedAuthStore(); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), - ); - sdk.createSubscription.mockResolvedValue(mockSdkResponse(undefined)); - sdk.createBillingPortalSession.mockResolvedValue( - mockSdkResponse({ url: "https://billing.stripe.com/session" }), + server.use( + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token", role: "owner" }), + ), + http.post( + "*/api/billing/subscription", + () => new HttpResponse(null, { status: 204 }), + ), + http.post("*/api/billing/portal", () => + HttpResponse.json({ url: "https://billing.stripe.com/session" }), + ), ); setInactive(); }); diff --git a/ui/apps/console/src/components/billing/__tests__/DeviceChooserDialog.test.tsx b/ui/apps/console/src/components/billing/__tests__/DeviceChooserDialog.test.tsx index 141c54039ba..9ce7aea9153 100644 --- a/ui/apps/console/src/components/billing/__tests__/DeviceChooserDialog.test.tsx +++ b/ui/apps/console/src/components/billing/__tests__/DeviceChooserDialog.test.tsx @@ -1,28 +1,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { Device } from "@/client"; +import type { Device } from "@/client/model"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; import { mockDevice as mockDeviceFactory } from "@/tests/factories"; vi.mock("@/components/common/BaseDialog", async () => ({ default: (await import("@/tests/mocks")).MockBaseDialog, })); -const sdk = vi.hoisted(() => - mockSdkGen({ - getDevicesMostUsed: vi.fn(), - getDevices: vi.fn(), - choiceDevices: vi.fn(), - }), -); - -const mockIsSdkError = vi.fn(); -vi.mock("@/api/errors", () => ({ - isSdkError: (err: unknown): boolean => mockIsSdkError(err) as boolean, -})); - const mockNavigate = vi.fn(); vi.mock("react-router-dom", async () => { const actual = @@ -58,7 +46,7 @@ const ALL_DEVICES = [ makeDevice(14), ]; -function setupSdk({ +function setupHandlers({ suggested = SUGGESTED_DEVICES, allDevices = ALL_DEVICES, totalCount = ALL_DEVICES.length, @@ -67,9 +55,16 @@ function setupSdk({ allDevices?: Device[]; totalCount?: number; } = {}) { - sdk.getDevicesMostUsed.mockResolvedValue(mockSdkResponse(suggested)); - sdk.getDevices.mockResolvedValue(paginatedResponse(allDevices, totalCount)); - sdk.choiceDevices.mockResolvedValue(mockSdkResponse(undefined)); + server.use( + http.get("*/api/billing/devices-most-used", () => + HttpResponse.json(suggested), + ), + http.get("*/api/devices", () => jsonWithTotal(allDevices, totalCount)), + http.post( + "*/api/billing/device-choice", + () => new HttpResponse(null, { status: 204 }), + ), + ); } function renderDialog(props: { open?: boolean; onClose?: () => void } = {}) { @@ -85,8 +80,7 @@ function renderDialog(props: { open?: boolean; onClose?: () => void } = {}) { beforeEach(() => { vi.clearAllMocks(); - mockIsSdkError.mockReturnValue(false); - setupSdk(); + setupHandlers(); }); describe("DeviceChooserDialog", () => { @@ -199,7 +193,7 @@ describe("DeviceChooserDialog", () => { describe("when suggested list is empty", () => { beforeEach(() => { - setupSdk({ suggested: [] }); + setupHandlers({ suggested: [] }); }); it("switches to the All tab automatically", async () => { @@ -222,7 +216,11 @@ describe("DeviceChooserDialog", () => { describe("when the suggested query errors", () => { beforeEach(() => { - sdk.getDevicesMostUsed.mockRejectedValue(new Error("network failure")); + server.use( + http.get("*/api/billing/devices-most-used", () => + HttpResponse.json({}, { status: 500 }), + ), + ); }); it("keeps the Suggested tab selected and surfaces the error banner", async () => { @@ -245,7 +243,7 @@ describe("DeviceChooserDialog", () => { describe("when suggested becomes empty after a refetch", () => { it("forces tab to All when suggested starts empty", async () => { - setupSdk({ suggested: [] }); + setupHandlers({ suggested: [] }); renderDialog(); await waitFor(() => expect(screen.getByRole("tab", { name: "All" })).toHaveAttribute( @@ -402,75 +400,6 @@ describe("DeviceChooserDialog", () => { ); expect(screen.getByRole("status").textContent).toMatch(/1 of 3/); }); - - it("typing in search filters devices via the SDK", async () => { - const user = userEvent.setup(); - renderDialog(); - await screen.findByText("hostname-1"); - await user.click(screen.getByRole("tab", { name: "All" })); - await screen.findByRole("searchbox"); - const searchInput = screen.getByRole("searchbox"); - await user.type(searchInput, "prod"); - await waitFor(() => { - const call = sdk.getDevices.mock.calls.at(-1)?.[0] as { - query?: { filter?: string }; - }; - const decoded = atob(call?.query?.filter ?? ""); - expect(decoded).toContain("prod"); - }); - }); - - it("requests per_page=5 from the SDK", async () => { - const user = userEvent.setup(); - renderDialog(); - await screen.findByText("hostname-1"); - await switchToAll(user); - await waitFor(() => { - expect(sdk.getDevices).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ per_page: 5 }), - }), - ); - }); - }); - - it("requests last_seen/desc sort by default", async () => { - const user = userEvent.setup(); - renderDialog(); - await screen.findByText("hostname-1"); - await switchToAll(user); - await waitFor(() => { - expect(sdk.getDevices).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - sort_by: "last_seen", - order_by: "desc", - }), - }), - ); - }); - }); - - it("toggles sort to name/asc when the Hostname header is clicked", async () => { - const user = userEvent.setup(); - renderDialog(); - await screen.findByText("hostname-1"); - await user.click(screen.getByRole("tab", { name: "All" })); - await screen.findByRole("checkbox", { name: /select hostname-10/i }); - await user.click( - screen.getByRole("button", { name: "Sort by Hostname" }), - ); - await waitFor(() => { - expect(sdk.getDevices).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - sort_by: "name", - order_by: "asc", - }), - }), - ); - }); - }); }); describe("tab keyboard navigation", () => { @@ -531,7 +460,9 @@ describe("DeviceChooserDialog", () => { }); it("Cancel is disabled while mutation is in flight", async () => { - sdk.choiceDevices.mockReturnValue(new Promise(() => {})); + server.use( + http.post("*/api/billing/device-choice", () => new Promise(() => {})), + ); const user = userEvent.setup(); renderDialog(); await screen.findByText("hostname-1"); @@ -560,7 +491,9 @@ describe("DeviceChooserDialog", () => { }); it("Subscribe is disabled while mutation is in flight", async () => { - sdk.choiceDevices.mockReturnValue(new Promise(() => {})); + server.use( + http.post("*/api/billing/device-choice", () => new Promise(() => {})), + ); const user = userEvent.setup(); renderDialog(); await screen.findByText("hostname-1"); @@ -574,22 +507,7 @@ describe("DeviceChooserDialog", () => { }); describe("Accept button", () => { - it("calls choiceDevices with the suggested UIDs when on suggested tab", async () => { - const user = userEvent.setup(); - renderDialog(); - await screen.findByText("hostname-1"); - await user.click(screen.getByRole("button", { name: /accept/i })); - await waitFor(() => - expect(sdk.choiceDevices).toHaveBeenCalledWith( - expect.objectContaining({ - body: { choices: ["uid-1", "uid-2", "uid-3"] }, - throwOnError: true, - }), - ), - ); - }); - - it("calls onClose after a successful Accept", async () => { + it("calls onClose after a successful Accept on suggested tab", async () => { const user = userEvent.setup(); const { onClose } = renderDialog(); await screen.findByText("hostname-1"); @@ -597,27 +515,22 @@ describe("DeviceChooserDialog", () => { await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); }); - it("calls choiceDevices with selected UIDs when on All tab", async () => { + it("calls onClose after a successful Accept on All tab", async () => { const user = userEvent.setup(); - renderDialog(); + const { onClose } = renderDialog(); await screen.findByText("hostname-1"); await user.click(screen.getByRole("tab", { name: "All" })); await user.click( await screen.findByRole("checkbox", { name: /select hostname-10/i }), ); await user.click(screen.getByRole("button", { name: /accept/i })); - await waitFor(() => - expect(sdk.choiceDevices).toHaveBeenCalledWith( - expect.objectContaining({ - body: { choices: ["uid-10"] }, - throwOnError: true, - }), - ), - ); + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); }); it("shows spinner and 'Saving…' text while mutation is pending", async () => { - sdk.choiceDevices.mockReturnValue(new Promise(() => {})); + server.use( + http.post("*/api/billing/device-choice", () => new Promise(() => {})), + ); const user = userEvent.setup(); renderDialog(); await screen.findByText("hostname-1"); @@ -630,7 +543,9 @@ describe("DeviceChooserDialog", () => { }); it("Accept is disabled while mutation is in flight", async () => { - sdk.choiceDevices.mockReturnValue(new Promise(() => {})); + server.use( + http.post("*/api/billing/device-choice", () => new Promise(() => {})), + ); const user = userEvent.setup(); renderDialog(); await screen.findByText("hostname-1"); @@ -642,7 +557,9 @@ describe("DeviceChooserDialog", () => { }); it("blocks close (canClose=false) while mutation is in flight", async () => { - sdk.choiceDevices.mockReturnValue(new Promise(() => {})); + server.use( + http.post("*/api/billing/device-choice", () => new Promise(() => {})), + ); const user = userEvent.setup(); renderDialog(); await screen.findByText("hostname-1"); @@ -663,8 +580,11 @@ describe("DeviceChooserDialog", () => { describe("error handling", () => { it("shows generic error when Accept fails with a non-403 error", async () => { - sdk.choiceDevices.mockRejectedValue(new Error("network error")); - mockIsSdkError.mockReturnValue(false); + server.use( + http.post("*/api/billing/device-choice", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const user = userEvent.setup(); renderDialog(); await screen.findByText("hostname-1"); @@ -677,9 +597,11 @@ describe("DeviceChooserDialog", () => { }); it("shows permission error when Accept fails with a 403 SDK error", async () => { - const err = { status: 403 }; - sdk.choiceDevices.mockRejectedValue(err); - mockIsSdkError.mockImplementation((e: unknown) => e === err); + server.use( + http.post("*/api/billing/device-choice", () => + HttpResponse.json({}, { status: 403 }), + ), + ); const user = userEvent.setup(); renderDialog(); await screen.findByText("hostname-1"); @@ -692,8 +614,11 @@ describe("DeviceChooserDialog", () => { }); it("error alert is rendered above the footer", async () => { - sdk.choiceDevices.mockRejectedValue(new Error("fail")); - mockIsSdkError.mockReturnValue(false); + server.use( + http.post("*/api/billing/device-choice", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const user = userEvent.setup(); renderDialog(); await screen.findByText("hostname-1"); @@ -710,8 +635,11 @@ describe("DeviceChooserDialog", () => { }); it("clears the error when switching tabs", async () => { - sdk.choiceDevices.mockRejectedValue(new Error("fail")); - mockIsSdkError.mockReturnValue(false); + server.use( + http.post("*/api/billing/device-choice", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const user = userEvent.setup(); renderDialog(); await screen.findByText("hostname-1"); @@ -719,7 +647,12 @@ describe("DeviceChooserDialog", () => { await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument(), ); - sdk.choiceDevices.mockResolvedValue(mockSdkResponse(undefined)); + server.use( + http.post( + "*/api/billing/device-choice", + () => new HttpResponse(null, { status: 204 }), + ), + ); await user.click(screen.getByRole("tab", { name: "All" })); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); diff --git a/ui/apps/console/src/components/billing/__tests__/DeviceChooserTrigger.test.tsx b/ui/apps/console/src/components/billing/__tests__/DeviceChooserTrigger.test.tsx index 5896cab1001..0c9936be777 100644 --- a/ui/apps/console/src/components/billing/__tests__/DeviceChooserTrigger.test.tsx +++ b/ui/apps/console/src/components/billing/__tests__/DeviceChooserTrigger.test.tsx @@ -2,22 +2,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, cleanup, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import { mockStats, mockNamespace } from "@/tests/factories"; import { seedAuthStore } from "@/tests/seedAuthStore"; import type { Edition } from "@/env"; + import { getConfig } from "@/env"; import DeviceChooserTrigger from "../DeviceChooserTrigger"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getStatusDevices: vi.fn(), - getNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - }), -); - vi.mock("../DeviceChooserDialog", () => ({ default: ({ open, onClose }: { open: boolean; onClose: () => void }) => open @@ -56,34 +50,42 @@ function setupMocks({ seedAuthStore({ role, isAdmin: role === "owner" }); if (statsLoading) { - sdk.getStatusDevices.mockReturnValue(new Promise(() => {})); + server.use(http.get("*/api/stats", () => new Promise(() => {}))); } else { - sdk.getStatusDevices.mockResolvedValue( - mockSdkResponse(mockStats({ registered_devices: registeredDevices })), + server.use( + http.get("*/api/stats", () => + HttpResponse.json(mockStats({ registered_devices: registeredDevices })), + ), ); } if (nsLoading) { - sdk.getNamespace.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/api/namespaces/:tenant", () => new Promise(() => {})), + ); } else if (namespaceNull) { - sdk.getNamespace.mockResolvedValue(mockSdkResponse(null)); + server.use( + http.get("*/api/namespaces/:tenant", () => HttpResponse.json(null)), + ); } else { - sdk.getNamespace.mockResolvedValue( - mockSdkResponse( - mockNamespace({ - billing: billingActive - ? { - customer_id: "cus_123", - subscription: { - id: "sub_123", - status: "active" as const, - current_period_end: 0, - }, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - } - : null, - }), + server.use( + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json( + mockNamespace({ + billing: billingActive + ? { + customer_id: "cus_123", + subscription: { + id: "sub_123", + status: "active" as const, + current_period_end: 0, + }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + } + : null, + }), + ), ), ); } @@ -91,8 +93,10 @@ function setupMocks({ beforeEach(() => { vi.clearAllMocks(); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + server.use( + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token", role: "owner" }), + ), ); setupMocks(); }); @@ -119,10 +123,11 @@ describe("DeviceChooserTrigger", () => { it("renders nothing", async () => { setupMocks({ role: "observer" }); renderTrigger(); - await waitFor(() => expect(sdk.getStatusDevices).toHaveBeenCalled()); - expect( - screen.queryByTestId("device-chooser-dialog"), - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + screen.queryByTestId("device-chooser-dialog"), + ).not.toBeInTheDocument(), + ); }); }); @@ -130,10 +135,11 @@ describe("DeviceChooserTrigger", () => { it("renders nothing", async () => { setupMocks({ billingActive: true }); renderTrigger(); - await waitFor(() => expect(sdk.getNamespace).toHaveBeenCalled()); - expect( - screen.queryByTestId("device-chooser-dialog"), - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + screen.queryByTestId("device-chooser-dialog"), + ).not.toBeInTheDocument(), + ); }); }); @@ -141,10 +147,11 @@ describe("DeviceChooserTrigger", () => { it("renders nothing — limit is strictly greater than 3", async () => { setupMocks({ registeredDevices: 3 }); renderTrigger(); - await waitFor(() => expect(sdk.getStatusDevices).toHaveBeenCalled()); - expect( - screen.queryByTestId("device-chooser-dialog"), - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + screen.queryByTestId("device-chooser-dialog"), + ).not.toBeInTheDocument(), + ); }); }); @@ -181,21 +188,25 @@ describe("DeviceChooserTrigger", () => { it("renders nothing — billing is unknown until namespace resolves", async () => { setupMocks({ namespaceNull: true, registeredDevices: 4 }); renderTrigger(); - await waitFor(() => expect(sdk.getNamespace).toHaveBeenCalled()); - expect( - screen.queryByTestId("device-chooser-dialog"), - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + screen.queryByTestId("device-chooser-dialog"), + ).not.toBeInTheDocument(), + ); }); }); describe("when the stats query settled with an error", () => { it("renders nothing — overLimit cannot be evaluated without stats", async () => { - sdk.getStatusDevices.mockRejectedValue({ status: 500 }); + server.use( + http.get("*/api/stats", () => HttpResponse.json({}, { status: 500 })), + ); renderTrigger(); - await waitFor(() => expect(sdk.getStatusDevices).toHaveBeenCalled()); - expect( - screen.queryByTestId("device-chooser-dialog"), - ).not.toBeInTheDocument(); + await waitFor(() => + expect( + screen.queryByTestId("device-chooser-dialog"), + ).not.toBeInTheDocument(), + ); }); }); diff --git a/ui/apps/console/src/components/commandPalette/__tests__/CommandPalette.test.tsx b/ui/apps/console/src/components/commandPalette/__tests__/CommandPalette.test.tsx index fbda3f2bb0f..425f12675eb 100644 --- a/ui/apps/console/src/components/commandPalette/__tests__/CommandPalette.test.tsx +++ b/ui/apps/console/src/components/commandPalette/__tests__/CommandPalette.test.tsx @@ -1,27 +1,20 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, beforeEach, vi } from "vitest"; -import type { Device } from "@/client"; +import type { Device } from "@/client/model"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { mockDevice as mockDeviceFactory, mockNamespace, } from "@/tests/factories"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; import { seedAuthStore } from "@/tests/seedAuthStore"; import { useAuthStore } from "@/stores/authStore"; import type { TerminalSession } from "@/stores/terminalStore"; const { copyMock } = vi.hoisted(() => ({ copyMock: vi.fn() })); -const sdk = vi.hoisted(() => - mockSdkGen({ - getDevices: vi.fn(), - getNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - }), -); - vi.mock("@/hooks/useCopy", () => ({ useCopy: () => ({ copy: copyMock, copied: false }), })); @@ -67,6 +60,10 @@ function LocationProbe() { const logoutSpy = vi.fn(); +function setDevices(devices: Device[]) { + server.use(http.get("*/api/devices", () => jsonWithTotal(devices))); +} + function renderPalette() { return render( <> @@ -82,12 +79,14 @@ describe("CommandPalette", () => { vi.clearAllMocks(); seedAuthStore(); useAuthStore.setState({ logout: logoutSpy }); - sdk.getDevices.mockResolvedValue(paginatedResponse([device])); - sdk.getNamespace.mockResolvedValue( - mockSdkResponse(mockNamespace({ name: "dev" })), - ); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + server.use( + http.get("*/api/devices", () => jsonWithTotal([device])), + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json(mockNamespace({ name: "dev" })), + ), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token", role: "owner" }), + ), ); copyMock.mockClear(); useTerminalStore.setState({ sessions: [], reconnectTarget: null }); @@ -201,9 +200,7 @@ describe("CommandPalette", () => { it("rejects connecting to an offline device and keeps the palette open", async () => { const user = userEvent.setup(); - sdk.getDevices.mockResolvedValue( - paginatedResponse([{ ...device, online: false }]), - ); + setDevices([{ ...device, online: false }]); renderPalette(); await user.click(await screen.findByText("web-01")); @@ -215,9 +212,7 @@ describe("CommandPalette", () => { it("restores an existing session even when the device is offline", async () => { const user = userEvent.setup(); - sdk.getDevices.mockResolvedValue( - paginatedResponse([{ ...device, online: false }]), - ); + setDevices([{ ...device, online: false }]); useTerminalStore.setState({ sessions: [ { @@ -321,7 +316,7 @@ describe("CommandPalette", () => { }); it("lists recent devices between sessions and the full device list", async () => { - sdk.getDevices.mockResolvedValue(paginatedResponse([device, device2])); + setDevices([device, device2]); useTerminalStore.setState({ sessions: [session], reconnectTarget: null }); useRecentDevicesStore.setState({ byTenant: { @@ -367,7 +362,7 @@ describe("CommandPalette", () => { it("connects from a recent device row", async () => { const user = userEvent.setup(); - sdk.getDevices.mockResolvedValue(paginatedResponse([device, device2])); + setDevices([device, device2]); useRecentDevicesStore.setState({ byTenant: { "tenant-456": [{ uid: "dev-2", name: "db-01", connectedAt: HOUR_AGO }], @@ -397,9 +392,7 @@ describe("CommandPalette", () => { it("shakes the clicked recent row, not its device duplicate, when offline", async () => { const user = userEvent.setup(); - sdk.getDevices.mockResolvedValue( - paginatedResponse([device, { ...device2, online: false }]), - ); + setDevices([device, { ...device2, online: false }]); useRecentDevicesStore.setState({ byTenant: { "tenant-456": [{ uid: "dev-2", name: "db-01", connectedAt: HOUR_AGO }], @@ -422,7 +415,7 @@ describe("CommandPalette", () => { it("moves the highlight down across sections, tracking aria-activedescendant", async () => { const user = userEvent.setup(); - sdk.getDevices.mockResolvedValue(paginatedResponse([device, device2])); + setDevices([device, device2]); useTerminalStore.setState({ sessions: [session], reconnectTarget: null }); useRecentDevicesStore.setState({ byTenant: { @@ -447,7 +440,7 @@ describe("CommandPalette", () => { it("wraps around at the list ends with ArrowUp/ArrowDown", async () => { const user = userEvent.setup(); - sdk.getDevices.mockResolvedValue(paginatedResponse([device, device2])); + setDevices([device, device2]); renderPalette(); const input = await screen.findByRole("combobox"); @@ -471,7 +464,7 @@ describe("CommandPalette", () => { it("jumps to the first and last option with Home and End", async () => { const user = userEvent.setup(); - sdk.getDevices.mockResolvedValue(paginatedResponse([device, device2])); + setDevices([device, device2]); renderPalette(); const input = await screen.findByRole("combobox"); @@ -495,7 +488,7 @@ describe("CommandPalette", () => { it("selects the highlighted option after navigating", async () => { const user = userEvent.setup(); - sdk.getDevices.mockResolvedValue(paginatedResponse([device, device2])); + setDevices([device, device2]); renderPalette(); const input = await screen.findByRole("combobox"); @@ -651,9 +644,7 @@ describe("CommandPalette", () => { it("disables the menu Connect action for an offline device with no session", async () => { const user = userEvent.setup(); - sdk.getDevices.mockResolvedValue( - paginatedResponse([{ ...device, online: false }]), - ); + setDevices([{ ...device, online: false }]); renderPalette(); await user.type(await screen.findByRole("combobox"), "{ArrowRight}"); @@ -670,9 +661,7 @@ describe("CommandPalette", () => { it("keeps the menu Connect enabled for an offline device with an open session", async () => { const user = userEvent.setup(); - sdk.getDevices.mockResolvedValue( - paginatedResponse([{ ...device, online: false }]), - ); + setDevices([{ ...device, online: false }]); useTerminalStore.setState({ sessions: [ { diff --git a/ui/apps/console/src/components/common/CreateNamespace.tsx b/ui/apps/console/src/components/common/CreateNamespace.tsx index db784b0583e..010f8116e2b 100644 --- a/ui/apps/console/src/components/common/CreateNamespace.tsx +++ b/ui/apps/console/src/components/common/CreateNamespace.tsx @@ -3,7 +3,7 @@ import { useCreateNamespace, useSwitchNamespace, } from "@/hooks/useNamespaceMutations"; -import { getNamespaces } from "@/client"; +import { getNamespaces } from "@/client/api"; import { isEnterpriseOrCloud } from "@/env"; import { isSdkError } from "@/api/errors"; import { @@ -121,12 +121,11 @@ export function CommunityInstructions() { useEffect(() => { const check = async () => { - const result = await getNamespaces({ - query: { page: 1, per_page: 1 }, - throwOnError: true, - }).catch(nullOnFailure); + const result = await getNamespaces({ page: 1, per_page: 1 }).catch( + nullOnFailure, + ); - const first = result?.data[0]; + const first = result?.[0]; if (first) { setReady(true); setTenantId(first.tenant_id); diff --git a/ui/apps/console/src/components/common/DeviceLimitBanner.tsx b/ui/apps/console/src/components/common/DeviceLimitBanner.tsx index 1be9563c770..09899384992 100644 --- a/ui/apps/console/src/components/common/DeviceLimitBanner.tsx +++ b/ui/apps/console/src/components/common/DeviceLimitBanner.tsx @@ -1,6 +1,7 @@ import NoticeBanner from "@/components/common/NoticeBanner"; import { useAdminLicense } from "@/hooks/useAdminLicense"; -import { useAdminStats } from "@/hooks/useAdminStats"; +import { useGetStats } from "@/client/api"; +import { useAuthStore } from "@/stores/authStore"; /** * Warns when the namespace is at or near its licensed device limit, before an enrolment starts @@ -12,11 +13,17 @@ export default function DeviceLimitBanner() { isLoading: licenseLoading, isError: licenseError, } = useAdminLicense(); + const isAdmin = useAuthStore((s) => s.isAdmin); const { - stats, + data: stats, isLoading: statsLoading, isError: statsError, - } = useAdminStats(); + } = useGetStats({ + query: { + enabled: isAdmin, + staleTime: 5 * 60_000, + }, + }); const cap = license?.features.devices; const registered = stats?.registered_devices; diff --git a/ui/apps/console/src/components/common/FilterBadge.tsx b/ui/apps/console/src/components/common/FilterBadge.tsx index e32121feaf1..64e38ca42be 100644 --- a/ui/apps/console/src/components/common/FilterBadge.tsx +++ b/ui/apps/console/src/components/common/FilterBadge.tsx @@ -1,4 +1,4 @@ -import { Tag } from "@/client"; +import { Tag } from "@/client/model"; import { Badge } from "@shellhub/design-system/primitives"; import { TagIcon, diff --git a/ui/apps/console/src/components/common/IdentityStatusChip.tsx b/ui/apps/console/src/components/common/IdentityStatusChip.tsx index ebf5e4356d4..1f14e63c06f 100644 --- a/ui/apps/console/src/components/common/IdentityStatusChip.tsx +++ b/ui/apps/console/src/components/common/IdentityStatusChip.tsx @@ -3,7 +3,7 @@ import { sshIdentityStatus, type IdentityStatusTone, } from "@/utils/sshIdentity"; -import type { SshIdentity } from "@/client"; +import type { SshIdentity } from "@/client/model"; const TONE: Record = { dead: "bg-text-muted/10 text-text-muted", diff --git a/ui/apps/console/src/components/common/NamespaceGuard.tsx b/ui/apps/console/src/components/common/NamespaceGuard.tsx index 0e814f3e6e0..404683859a6 100644 --- a/ui/apps/console/src/components/common/NamespaceGuard.tsx +++ b/ui/apps/console/src/components/common/NamespaceGuard.tsx @@ -4,7 +4,9 @@ import { ExclamationTriangleIcon, ArrowPathIcon, } from "@heroicons/react/24/outline"; -import { useNamespaces, useInitRole } from "@/hooks/useNamespaces"; +import { useGetNamespaceToken } from "@/client/api"; +import { useNamespaces } from "@/hooks/useNamespaces"; +import { useAuthStore } from "@/stores/authStore"; import { useConnectivityStore } from "@/stores/connectivityStore"; import AmbientBackground from "./AmbientBackground"; import CreateNamespace from "./CreateNamespace"; @@ -78,7 +80,17 @@ function FetchErrorPage({ * not. Everything below assumes a tenant, so this is where that assumption is established. */ export default function NamespaceGuard() { - useInitRole(); + const tenant = useAuthStore((s) => s.tenant) ?? ""; + const { data: tokenData } = useGetNamespaceToken(tenant, { + query: { enabled: !!tenant }, + }); + useEffect(() => { + if (!tokenData || !tenant) return; + useAuthStore + .getState() + .setSession({ token: tokenData.token, tenant, role: tokenData.role }); + }, [tokenData, tenant]); + const { namespaces, isLoading, error, refetch } = useNamespaces(); const apiReachable = useConnectivityStore((s) => s.apiReachable); const { pathname } = useLocation(); diff --git a/ui/apps/console/src/components/common/RenameSection.tsx b/ui/apps/console/src/components/common/RenameSection.tsx index 2a87ceabb17..96a5468c816 100644 --- a/ui/apps/console/src/components/common/RenameSection.tsx +++ b/ui/apps/console/src/components/common/RenameSection.tsx @@ -14,10 +14,7 @@ import { isSdkError } from "@/api/errors"; export interface RenameSectionProps { uid: string; currentName: string; - rename: (opts: { - path: { uid: string }; - body: { name: string }; - }) => Promise; + rename: (opts: { uid: string; data: { name: string } }) => Promise; entityLabel: string; canRename?: boolean; } @@ -47,8 +44,8 @@ export default function RenameSection({ setError(null); try { await rename({ - path: { uid }, - body: { name: name.trim() }, + uid, + data: { name: name.trim() }, }); setEditing(false); } catch (e) { @@ -57,7 +54,9 @@ export default function RenameSection({ 400: `Invalid ${entityLabel} name.`, 409: `A ${entityLabel} with that name already exists.`, }; - setError((status && errors[status]) || `Failed to rename ${entityLabel}.`); + setError( + (status && errors[status]) || `Failed to rename ${entityLabel}.`, + ); } setSaving(false); }; diff --git a/ui/apps/console/src/components/common/SetupGuard.tsx b/ui/apps/console/src/components/common/SetupGuard.tsx index 9a169325a20..065aae5c373 100644 --- a/ui/apps/console/src/components/common/SetupGuard.tsx +++ b/ui/apps/console/src/components/common/SetupGuard.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Outlet, Navigate, useLocation } from "react-router-dom"; -import { getInfo } from "@/client"; +import { getInfo } from "@/client/api"; import { isCloud } from "@/env"; import { useAuthStore } from "@/stores/authStore"; import { Spinner } from "@shellhub/design-system/primitives"; @@ -19,8 +19,8 @@ export default function SetupGuard() { useEffect(() => { if (isCloudEdition) return; - getInfo({ throwOnError: true }) - .then(({ data }) => setSetupDone(data.setup)) + getInfo() + .then((data) => setSetupDone(data.setup)) .catch(() => setSetupDone(true)) .finally(() => setLoading(false)); }, [isCloudEdition, location.pathname]); diff --git a/ui/apps/console/src/components/common/TagFilterDropdown.tsx b/ui/apps/console/src/components/common/TagFilterDropdown.tsx index 24d5c432cf9..6101ea659f8 100644 --- a/ui/apps/console/src/components/common/TagFilterDropdown.tsx +++ b/ui/apps/console/src/components/common/TagFilterDropdown.tsx @@ -7,7 +7,7 @@ import { } from "@heroicons/react/24/outline"; import { cn } from "@shellhub/design-system/cn"; import { Dropdown } from "@shellhub/design-system/primitives"; -import { useTags } from "@/hooks/useTags"; +import { useTagNames } from "@/hooks/useTags"; /** * The tag filter above a list. Selecting several narrows to devices carrying all of them, which @@ -26,8 +26,7 @@ function TagFilterDropdown({ onClearAll: () => void; onManageTags?: () => void; }) { - const { tags: tagObjects } = useTags(); - const allTags = tagObjects.map((t) => t.name); + const { names: allTags } = useTagNames(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -115,7 +114,12 @@ function TagFilterDropdown({ )} {tag} diff --git a/ui/apps/console/src/components/common/TagsPopover.tsx b/ui/apps/console/src/components/common/TagsPopover.tsx index 73753055928..58db9d47baf 100644 --- a/ui/apps/console/src/components/common/TagsPopover.tsx +++ b/ui/apps/console/src/components/common/TagsPopover.tsx @@ -7,16 +7,14 @@ import { } from "@heroicons/react/24/outline"; import { Dropdown } from "@shellhub/design-system/primitives"; import { isSdkError } from "@/api/errors"; -import { useTags } from "@/hooks/useTags"; +import { useTagNames } from "@/hooks/useTags"; import { useHasPermission } from "@/hooks/useHasPermission"; interface TagsPopoverProps { uid: string; tags: string[]; - addTag: (opts: { path: { uid: string; name: string } }) => Promise; - removeTag: (opts: { - path: { uid: string; name: string }; - }) => Promise; + addTag: (opts: { uid: string; name: string }) => Promise; + removeTag: (opts: { uid: string; name: string }) => Promise; onFilterTag: (tag: string) => void; editLabel?: string; } @@ -32,8 +30,7 @@ export default function TagsPopover({ onFilterTag, editLabel = "Manage tags", }: TagsPopoverProps) { - const { tags: tagObjects } = useTags(); - const allTags = tagObjects.map((t) => t.name); + const { names: allTags } = useTagNames(); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); @@ -53,7 +50,7 @@ export default function TagsPopover({ setLoading(true); setError(null); try { - await addTag({ path: { uid, name: tag } }); + await addTag({ uid, name: tag }); setInput(""); } catch (e) { const status = isSdkError(e) ? e.status : undefined; @@ -68,7 +65,7 @@ export default function TagsPopover({ setLoading(true); setError(null); try { - await removeTag({ path: { uid, name: tag } }); + await removeTag({ uid, name: tag }); } catch (e) { const status = isSdkError(e) ? e.status : undefined; if (status === 403) setError("You don't have permission to remove tags."); diff --git a/ui/apps/console/src/components/common/TagsSection.tsx b/ui/apps/console/src/components/common/TagsSection.tsx index c6f6cdf153f..9b02e9b0007 100644 --- a/ui/apps/console/src/components/common/TagsSection.tsx +++ b/ui/apps/console/src/components/common/TagsSection.tsx @@ -2,17 +2,15 @@ import { useState } from "react"; import { TagIcon, XMarkIcon, PlusIcon } from "@heroicons/react/24/outline"; import { Dropdown, IconButton } from "@shellhub/design-system/primitives"; import { isSdkError } from "@/api/errors"; -import { useTags } from "@/hooks/useTags"; +import { useTagNames } from "@/hooks/useTags"; import { useHasPermission } from "@/hooks/useHasPermission"; import { LABEL_BASE } from "@/utils/styles"; interface TagsSectionProps { uid: string; tags: string[]; - addTag: (opts: { path: { uid: string; name: string } }) => Promise; - removeTag: (opts: { - path: { uid: string; name: string }; - }) => Promise; + addTag: (opts: { uid: string; name: string }) => Promise; + removeTag: (opts: { uid: string; name: string }) => Promise; } /** @@ -25,8 +23,7 @@ export default function TagsSection({ removeTag, }: TagsSectionProps) { const canEditTags = useHasPermission("tag:edit"); - const { tags: tagObjects } = useTags(); - const allTags = tagObjects.map((t) => t.name); + const { names: allTags } = useTagNames(); const [input, setInput] = useState(""); const [adding, setAdding] = useState(false); const [open, setOpen] = useState(false); @@ -55,7 +52,7 @@ export default function TagsSection({ setAdding(true); try { - await addTag({ path: { uid, name: tag } }); + await addTag({ uid, name: tag }); setInput(""); setOpen(false); } catch (e) { @@ -70,7 +67,7 @@ export default function TagsSection({ const handleRemove = async (tag: string) => { setError(null); try { - await removeTag({ path: { uid, name: tag } }); + await removeTag({ uid, name: tag }); } catch (e) { const status = isSdkError(e) ? e.status : undefined; if (status === 403) setError("You don't have permission to remove tags."); diff --git a/ui/apps/console/src/components/common/__tests__/ActionDialog.test.tsx b/ui/apps/console/src/components/common/__tests__/ActionDialog.test.tsx index c3de47f3e86..55788ffb485 100644 --- a/ui/apps/console/src/components/common/__tests__/ActionDialog.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/ActionDialog.test.tsx @@ -3,11 +3,18 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { getConfig, defaultConfig } from "@/env"; import { useAuthStore } from "@/stores/authStore"; -import { makeSdkError } from "@/tests/sdk"; +import type { SdkHttpError } from "@/api/errors"; import { createTestWrapper } from "@/tests/wrapper"; import ActionDialog from "../ActionDialog"; import type { Action } from "@/hooks/useActionDialog"; +function makeSdkError(status: number): Error & SdkHttpError { + return Object.assign(new Error("Request failed"), { + status, + headers: new Headers(), + }); +} + vi.mock("../ConfirmDialog", async () => ({ default: (await import("@/tests/mocks")).MockConfirmDialog, })); @@ -70,7 +77,9 @@ describe("ActionDialog", () => { it("renders the correct title and confirm label for accept", () => { renderDialog({ action: acceptAction }); expect(screen.getByText("Accept Device")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Accept" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Accept" }), + ).toBeInTheDocument(); }); it("renders the correct title for reject", () => { @@ -91,7 +100,9 @@ describe("ActionDialog", () => { it("calls onSuccess then onClose on successful confirm", async () => { const props = renderDialog(); await userEvent.click(screen.getByRole("button", { name: "Accept" })); - await waitFor(() => expect(props.onSuccess).toHaveBeenCalledWith("accept")); + await waitFor(() => + expect(props.onSuccess).toHaveBeenCalledWith("accept"), + ); expect(props.onClose).toHaveBeenCalled(); }); @@ -105,11 +116,16 @@ describe("ActionDialog", () => { describe("error handling — accept", () => { it("shows accept error message for non-cloud 402", async () => { - mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "enterprise" }); + mockGetConfig.mockReturnValue({ + ...defaultConfig, + edition: "enterprise", + }); const runAction = vi.fn().mockRejectedValue(makeSdkError(402)); renderDialog({ runAction }); await userEvent.click(screen.getByRole("button", { name: "Accept" })); - await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByRole("alert")).toBeInTheDocument(), + ); expect(screen.getByRole("alert")).toHaveTextContent(/license/i); }); @@ -117,21 +133,27 @@ describe("ActionDialog", () => { const runAction = vi.fn().mockRejectedValue(makeSdkError(403)); renderDialog({ runAction }); await userEvent.click(screen.getByRole("button", { name: "Accept" })); - await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/permission/i)); + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent(/permission/i), + ); }); it("shows rename error for 409", async () => { const runAction = vi.fn().mockRejectedValue(makeSdkError(409)); renderDialog({ runAction }); await userEvent.click(screen.getByRole("button", { name: "Accept" })); - await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/already exists/i)); + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent(/already exists/i), + ); }); it("does not call onClose on error", async () => { const runAction = vi.fn().mockRejectedValue(makeSdkError(500)); const props = renderDialog({ runAction }); await userEvent.click(screen.getByRole("button", { name: "Accept" })); - await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByRole("alert")).toBeInTheDocument(), + ); expect(props.onClose).not.toHaveBeenCalled(); }); }); @@ -142,7 +164,9 @@ describe("ActionDialog", () => { renderDialog({ action: rejectAction, runAction }); await userEvent.click(screen.getByRole("button", { name: "Reject" })); await waitFor(() => - expect(screen.getByRole("alert")).toHaveTextContent(/failed to reject device/i), + expect(screen.getByRole("alert")).toHaveTextContent( + /failed to reject device/i, + ), ); }); @@ -151,16 +175,24 @@ describe("ActionDialog", () => { renderDialog({ action: removeAction, runAction }); await userEvent.click(screen.getByRole("button", { name: "Remove" })); await waitFor(() => - expect(screen.getByRole("alert")).toHaveTextContent(/failed to remove device/i), + expect(screen.getByRole("alert")).toHaveTextContent( + /failed to remove device/i, + ), ); }); it("interpolates container in generic error", async () => { const runAction = vi.fn().mockRejectedValue(makeSdkError(500)); - renderDialog({ action: removeAction, entityType: "container", runAction }); + renderDialog({ + action: removeAction, + entityType: "container", + runAction, + }); await userEvent.click(screen.getByRole("button", { name: "Remove" })); await waitFor(() => - expect(screen.getByRole("alert")).toHaveTextContent(/failed to remove container/i), + expect(screen.getByRole("alert")).toHaveTextContent( + /failed to remove container/i, + ), ); }); }); @@ -178,8 +210,12 @@ describe("ActionDialog", () => { await waitFor(() => expect(screen.getByText("Device limit reached")).toBeInTheDocument(), ); - expect(screen.getByRole("button", { name: "Go to billing" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Not now" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Go to billing" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Not now" }), + ).toBeInTheDocument(); }); it("shows BaseDialog with single Close button for non-owners", async () => { @@ -190,8 +226,12 @@ describe("ActionDialog", () => { await waitFor(() => expect(screen.getByText("Device limit reached")).toBeInTheDocument(), ); - expect(screen.getByRole("button", { name: /^Close$/ })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Go to billing" })).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /^Close$/ }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Go to billing" }), + ).not.toBeInTheDocument(); }); it("navigates to billing on owner confirm", async () => { @@ -200,7 +240,9 @@ describe("ActionDialog", () => { const props = renderDialog({ runAction }); await userEvent.click(screen.getByRole("button", { name: "Accept" })); await waitFor(() => screen.getByText("Device limit reached")); - await userEvent.click(screen.getByRole("button", { name: "Go to billing" })); + await userEvent.click( + screen.getByRole("button", { name: "Go to billing" }), + ); expect(mockNavigate).toHaveBeenCalledWith("/settings#billing"); expect(props.onClose).toHaveBeenCalled(); }); @@ -218,8 +260,12 @@ describe("ActionDialog", () => { const runAction = vi.fn().mockRejectedValue(makeSdkError(402)); renderDialog({ action: rejectAction, runAction }); await userEvent.click(screen.getByRole("button", { name: "Reject" })); - await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument()); - expect(screen.queryByText("Device limit reached")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByRole("alert")).toBeInTheDocument(), + ); + expect( + screen.queryByText("Device limit reached"), + ).not.toBeInTheDocument(); }); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/CreateNamespace.test.tsx b/ui/apps/console/src/components/common/__tests__/CreateNamespace.test.tsx index 7bae5c19f0f..0bf93d92090 100644 --- a/ui/apps/console/src/components/common/__tests__/CreateNamespace.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/CreateNamespace.test.tsx @@ -1,31 +1,26 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; -import { mockNamespace } from "@/tests/factories"; +import { mockNamespace, mockUserAuth } from "@/tests/factories"; import { getConfig, defaultConfig } from "@/env"; import CreateNamespace from "../CreateNamespace"; -const sdk = vi.hoisted(() => - mockSdkGen({ - createNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - getNamespaces: vi.fn(), - }), -); - const mockGetConfig = vi.mocked(getConfig); beforeEach(() => { vi.clearAllMocks(); mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); - sdk.getNamespaces.mockResolvedValue(paginatedResponse([])); - sdk.createNamespace.mockResolvedValue( - mockSdkResponse(mockNamespace({ name: "my-ns" })), - ); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + server.use( + http.get("*/api/namespaces", () => jsonWithTotal([])), + http.post("*/api/namespaces", () => + HttpResponse.json(mockNamespace({ name: "my-ns" })), + ), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json(mockUserAuth({ token: "jwt-token" })), + ), ); }); @@ -34,23 +29,12 @@ function renderComponent() { } describe("CreateNamespace — CloudForm", () => { - it("calls createNamespace with the typed name on valid submit", async () => { - const user = userEvent.setup(); - renderComponent(); - await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); - await user.click(screen.getByRole("button", { name: "Create" })); - await waitFor(() => - expect(sdk.createNamespace).toHaveBeenCalledWith( - expect.objectContaining({ - body: { name: "my-ns" }, - throwOnError: true, - }), + it("shows 'A namespace with this name already exists.' on 409", async () => { + server.use( + http.post("*/api/namespaces", () => + HttpResponse.json({}, { status: 409 }), ), ); - }); - - it("shows 'A namespace with this name already exists.' on 409", async () => { - sdk.createNamespace.mockRejectedValue({ status: 409 }); const user = userEvent.setup(); renderComponent(); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); @@ -61,7 +45,11 @@ describe("CreateNamespace — CloudForm", () => { }); it("shows the limit/permission message on 403", async () => { - sdk.createNamespace.mockRejectedValue({ status: 403 }); + server.use( + http.post("*/api/namespaces", () => + HttpResponse.json({}, { status: 403 }), + ), + ); const user = userEvent.setup(); renderComponent(); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); @@ -74,7 +62,11 @@ describe("CreateNamespace — CloudForm", () => { }); it("shows the invalid-name message on 400", async () => { - sdk.createNamespace.mockRejectedValue({ status: 400 }); + server.use( + http.post("*/api/namespaces", () => + HttpResponse.json({}, { status: 400 }), + ), + ); const user = userEvent.setup(); renderComponent(); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); @@ -85,7 +77,11 @@ describe("CreateNamespace — CloudForm", () => { }); it("shows the generic fallback message on 500", async () => { - sdk.createNamespace.mockRejectedValue({ status: 500 }); + server.use( + http.post("*/api/namespaces", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const user = userEvent.setup(); renderComponent(); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); @@ -98,7 +94,11 @@ describe("CreateNamespace — CloudForm", () => { }); it("clears the error text when the user types after a failed submission", async () => { - sdk.createNamespace.mockRejectedValue({ status: 409 }); + server.use( + http.post("*/api/namespaces", () => + HttpResponse.json({}, { status: 409 }), + ), + ); const user = userEvent.setup(); renderComponent(); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); diff --git a/ui/apps/console/src/components/common/__tests__/CreateNamespaceDialog.test.tsx b/ui/apps/console/src/components/common/__tests__/CreateNamespaceDialog.test.tsx index e9d800faa7a..f78b4f59162 100644 --- a/ui/apps/console/src/components/common/__tests__/CreateNamespaceDialog.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/CreateNamespaceDialog.test.tsx @@ -1,32 +1,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; -import { mockNamespace } from "@/tests/factories"; +import { mockNamespace, mockUserAuth } from "@/tests/factories"; import { getConfig, defaultConfig } from "@/env"; import { ClipboardProvider } from "../ClipboardProvider"; import CreateNamespaceDialog from "../CreateNamespaceDialog"; -const sdk = vi.hoisted(() => - mockSdkGen({ - createNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - getNamespaces: vi.fn(), - }), -); - const mockGetConfig = vi.mocked(getConfig); beforeEach(() => { vi.clearAllMocks(); mockGetConfig.mockReturnValue({ ...defaultConfig }); - sdk.getNamespaces.mockResolvedValue(paginatedResponse([])); - sdk.createNamespace.mockResolvedValue( - mockSdkResponse(mockNamespace({ name: "my-ns" })), - ); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + server.use( + http.get("*/api/namespaces", () => jsonWithTotal([])), + http.post("*/api/namespaces", () => + HttpResponse.json(mockNamespace({ name: "my-ns" })), + ), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json(mockUserAuth({ token: "jwt-token" })), + ), ); }); @@ -147,7 +142,7 @@ describe("CreateNamespaceDialog (cloud/enterprise)", () => { }); it("Create button is disabled while mutation is pending", async () => { - sdk.createNamespace.mockReturnValue(new Promise(() => {})); + server.use(http.post("*/api/namespaces", () => new Promise(() => {}))); const user = userEvent.setup(); renderDialog(true); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); @@ -183,21 +178,6 @@ describe("CreateNamespaceDialog (cloud/enterprise)", () => { ).toBeInTheDocument(); }); - it("calls createNamespace with the namespace name on valid submission", async () => { - const user = userEvent.setup(); - renderDialog(true); - await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); - await user.click(screen.getByRole("button", { name: "Create" })); - await waitFor(() => - expect(sdk.createNamespace).toHaveBeenCalledWith( - expect.objectContaining({ - body: { name: "my-ns" }, - throwOnError: true, - }), - ), - ); - }); - it("forces lowercase on input", async () => { const user = userEvent.setup(); renderDialog(true); @@ -206,7 +186,11 @@ describe("CreateNamespaceDialog (cloud/enterprise)", () => { }); it("shows 'A namespace with this name already exists.' on 409 and does NOT call onClose", async () => { - sdk.createNamespace.mockRejectedValue({ status: 409 }); + server.use( + http.post("*/api/namespaces", () => + HttpResponse.json({}, { status: 409 }), + ), + ); const user = userEvent.setup(); const { onClose } = renderDialog(true); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); @@ -218,7 +202,11 @@ describe("CreateNamespaceDialog (cloud/enterprise)", () => { }); it("shows the limit/permission message on 403", async () => { - sdk.createNamespace.mockRejectedValue({ status: 403 }); + server.use( + http.post("*/api/namespaces", () => + HttpResponse.json({}, { status: 403 }), + ), + ); const user = userEvent.setup(); renderDialog(true); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); @@ -231,7 +219,11 @@ describe("CreateNamespaceDialog (cloud/enterprise)", () => { }); it("shows the invalid-name message on 400", async () => { - sdk.createNamespace.mockRejectedValue({ status: 400 }); + server.use( + http.post("*/api/namespaces", () => + HttpResponse.json({}, { status: 400 }), + ), + ); const user = userEvent.setup(); renderDialog(true); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); @@ -242,7 +234,11 @@ describe("CreateNamespaceDialog (cloud/enterprise)", () => { }); it("shows the generic fallback message on 500", async () => { - sdk.createNamespace.mockRejectedValue({ status: 500 }); + server.use( + http.post("*/api/namespaces", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const user = userEvent.setup(); renderDialog(true); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); @@ -255,7 +251,11 @@ describe("CreateNamespaceDialog (cloud/enterprise)", () => { }); it("clears the error text when the user types after a failed submission", async () => { - sdk.createNamespace.mockRejectedValue({ status: 409 }); + server.use( + http.post("*/api/namespaces", () => + HttpResponse.json({}, { status: 409 }), + ), + ); const user = userEvent.setup(); renderDialog(true); await user.type(screen.getByPlaceholderText("my-namespace"), "my-ns"); diff --git a/ui/apps/console/src/components/common/__tests__/DeviceLimitBanner.test.tsx b/ui/apps/console/src/components/common/__tests__/DeviceLimitBanner.test.tsx index c2d6dc28ade..b7b19ba2f0e 100644 --- a/ui/apps/console/src/components/common/__tests__/DeviceLimitBanner.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/DeviceLimitBanner.test.tsx @@ -1,19 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { getConfig, defaultConfig } from "@/env"; -import type { GetLicenseResponse } from "@/client"; +import type { GetLicense200 as GetLicenseResponse } from "@/client/model"; import { useAuthStore } from "@/stores/authStore"; -import { mockSdkResponse, makeSdkError } from "@/tests/sdk"; import DeviceLimitBanner from "../DeviceLimitBanner"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getLicense: vi.fn(), - getStats: vi.fn(), - }), -); - const mockGetConfig = vi.mocked(getConfig); function makeLicense( @@ -41,6 +35,16 @@ function makeLicense( } as GetLicenseResponse; } +function setHandlers( + license: GetLicenseResponse, + stats: Record, +) { + server.use( + http.get("*/admin/api/license", () => HttpResponse.json(license)), + http.get("*/api/stats", () => HttpResponse.json(stats)), + ); +} + function renderBanner() { return render(, { wrapper: createTestWrapper() }); } @@ -49,24 +53,13 @@ beforeEach(() => { vi.clearAllMocks(); mockGetConfig.mockReturnValue({ ...defaultConfig }); useAuthStore.setState({ isAdmin: true }); - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense(100))); - sdk.getStats.mockResolvedValue(mockSdkResponse({ registered_devices: 50 })); + setHandlers(makeLicense(100), { registered_devices: 50 }); }); -async function waitForQueries() { - await waitFor(() => { - expect(sdk.getLicense).toHaveBeenCalled(); - expect(sdk.getStats).toHaveBeenCalled(); - }); -} - describe("DeviceLimitBanner", () => { describe("severity: over limit", () => { it("shows alert (role=alert) with over-limit copy when registered >= cap", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense(100))); - sdk.getStats.mockResolvedValue( - mockSdkResponse({ registered_devices: 100 }), - ); + setHandlers(makeLicense(100), { registered_devices: 100 }); renderBanner(); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); @@ -80,10 +73,7 @@ describe("DeviceLimitBanner", () => { }); it("shows RED (role=alert) when cap=10 and registered=10", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense(10))); - sdk.getStats.mockResolvedValue( - mockSdkResponse({ registered_devices: 10 }), - ); + setHandlers(makeLicense(10), { registered_devices: 10 }); renderBanner(); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); @@ -92,10 +82,7 @@ describe("DeviceLimitBanner", () => { }); it("shows RED (role=alert) when cap=0 and registered=0 (cap===0 -> over)", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense(0))); - sdk.getStats.mockResolvedValue( - mockSdkResponse({ registered_devices: 0 }), - ); + setHandlers(makeLicense(0), { registered_devices: 0 }); renderBanner(); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); @@ -105,10 +92,7 @@ describe("DeviceLimitBanner", () => { describe("severity: approaching limit", () => { it("shows status (role=status) with approaching copy when at 90% but under cap", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense(100))); - sdk.getStats.mockResolvedValue( - mockSdkResponse({ registered_devices: 90 }), - ); + setHandlers(makeLicense(100), { registered_devices: 90 }); renderBanner(); await waitFor(() => { expect(screen.getByRole("status")).toBeInTheDocument(); @@ -123,10 +107,7 @@ describe("DeviceLimitBanner", () => { }); it("shows YELLOW (role=status) when cap=10 and registered=9 (90% boundary)", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense(10))); - sdk.getStats.mockResolvedValue( - mockSdkResponse({ registered_devices: 9 }), - ); + setHandlers(makeLicense(10), { registered_devices: 9 }); renderBanner(); await waitFor(() => { expect(screen.getByRole("status")).toBeInTheDocument(); @@ -137,12 +118,8 @@ describe("DeviceLimitBanner", () => { describe("visibility guards", () => { it("is absent when cap=10 and registered=8 (80% — below threshold)", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense(10))); - sdk.getStats.mockResolvedValue( - mockSdkResponse({ registered_devices: 8 }), - ); + setHandlers(makeLicense(10), { registered_devices: 8 }); renderBanner(); - await waitForQueries(); await waitFor(() => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); @@ -150,12 +127,8 @@ describe("DeviceLimitBanner", () => { }); it("is absent when features.devices === -1 (unlimited)", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense(-1))); - sdk.getStats.mockResolvedValue( - mockSdkResponse({ registered_devices: 9999 }), - ); + setHandlers(makeLicense(-1), { registered_devices: 9999 }); renderBanner(); - await waitForQueries(); await waitFor(() => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); @@ -163,10 +136,8 @@ describe("DeviceLimitBanner", () => { }); it("is absent when registered_devices is undefined", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense(100))); - sdk.getStats.mockResolvedValue(mockSdkResponse({})); + setHandlers(makeLicense(100), {}); renderBanner(); - await waitForQueries(); await waitFor(() => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); @@ -180,24 +151,22 @@ describe("DeviceLimitBanner", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); - expect(sdk.getLicense).not.toHaveBeenCalled(); - expect(sdk.getStats).not.toHaveBeenCalled(); }); it("is absent while license is loading", () => { - sdk.getLicense.mockReturnValue(new Promise(() => {})); - sdk.getStats.mockResolvedValue( - mockSdkResponse({ registered_devices: 100 }), - ); + server.use(http.get("*/admin/api/license", () => new Promise(() => {}))); renderBanner(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); it("is absent when no license is installed", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(400)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 400 }), + ), + ); renderBanner(); - await waitForQueries(); await waitFor(() => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); @@ -205,10 +174,10 @@ describe("DeviceLimitBanner", () => { }); it("is absent when useAdminStats errors", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense(100))); - sdk.getStats.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/api/stats", () => HttpResponse.json({}, { status: 500 })), + ); renderBanner(); - await waitForQueries(); await waitFor(() => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); @@ -216,20 +185,19 @@ describe("DeviceLimitBanner", () => { }); it("is absent while stats are loading", () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense(100))); - sdk.getStats.mockReturnValue(new Promise(() => {})); + server.use(http.get("*/api/stats", () => new Promise(() => {}))); renderBanner(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); it("is absent when the license query errored", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(500)); - sdk.getStats.mockResolvedValue( - mockSdkResponse({ registered_devices: 100 }), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 500 }), + ), ); renderBanner(); - await waitForQueries(); await waitFor(() => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); @@ -240,7 +208,6 @@ describe("DeviceLimitBanner", () => { describe("cloud deployment", () => { it("is hidden when cloud=true and admin=true (getLicense never fires)", async () => { mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); - sdk.getLicense.mockRejectedValue(makeSdkError(400)); renderBanner(); @@ -248,7 +215,6 @@ describe("DeviceLimitBanner", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); - expect(sdk.getLicense).not.toHaveBeenCalled(); }); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/LicenseBanner.test.tsx b/ui/apps/console/src/components/common/__tests__/LicenseBanner.test.tsx index 47eae758276..277d0727718 100644 --- a/ui/apps/console/src/components/common/__tests__/LicenseBanner.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/LicenseBanner.test.tsx @@ -1,18 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { getConfig, defaultConfig } from "@/env"; -import type { GetLicenseResponse } from "@/client"; +import type { GetLicense200 as GetLicenseResponse } from "@/client/model"; import { useAuthStore } from "@/stores/authStore"; -import { mockSdkResponse, makeSdkError } from "@/tests/sdk"; import LicenseBanner from "../LicenseBanner"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getLicense: vi.fn(), - }), -); - const mockGetConfig = vi.mocked(getConfig); function makeLicense( @@ -43,12 +38,6 @@ function renderBanner() { return render(, { wrapper: createTestWrapper() }); } -async function waitForQuery() { - await waitFor(() => { - expect(sdk.getLicense).toHaveBeenCalled(); - }); -} - beforeEach(() => { vi.clearAllMocks(); mockGetConfig.mockReturnValue({ ...defaultConfig }); @@ -58,7 +47,7 @@ beforeEach(() => { describe("LicenseBanner", () => { describe("visibility", () => { it("is hidden while the license check is in progress", () => { - sdk.getLicense.mockReturnValue(new Promise(() => {})); + server.use(http.get("*/admin/api/license", () => new Promise(() => {}))); renderBanner(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); @@ -66,18 +55,22 @@ describe("LicenseBanner", () => { it("is hidden when the query is not enabled (non-admin)", async () => { useAuthStore.setState({ isAdmin: false }); - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense())); + server.use( + http.get("*/admin/api/license", () => HttpResponse.json(makeLicense())), + ); renderBanner(); - await waitFor(() => { - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - }); - expect(sdk.getLicense).not.toHaveBeenCalled(); + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ); }); it("is hidden when the query fails unexpectedly", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderBanner(); - await waitForQuery(); await waitFor(() => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); @@ -85,9 +78,10 @@ describe("LicenseBanner", () => { }); it("is hidden when license is valid", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense())); + server.use( + http.get("*/admin/api/license", () => HttpResponse.json(makeLicense())), + ); renderBanner(); - await waitForQuery(); await waitFor(() => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); @@ -95,7 +89,11 @@ describe("LicenseBanner", () => { }); it("is shown when no license is installed", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(400)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 400 }), + ), + ); renderBanner(); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); @@ -103,8 +101,12 @@ describe("LicenseBanner", () => { }); it("is shown when license is expired", async () => { - sdk.getLicense.mockResolvedValue( - mockSdkResponse(makeLicense({ expired: true, grace_period: false })), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json( + makeLicense({ expired: true, grace_period: false }), + ), + ), ); renderBanner(); await waitFor(() => { @@ -116,8 +118,10 @@ describe("LicenseBanner", () => { }); it("is shown when license is in the grace period", async () => { - sdk.getLicense.mockResolvedValue( - mockSdkResponse(makeLicense({ expired: true, grace_period: true })), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json(makeLicense({ expired: true, grace_period: true })), + ), ); renderBanner(); await waitFor(() => { @@ -128,9 +132,11 @@ describe("LicenseBanner", () => { it("is shown when license is about to expire", async () => { const expiresAt = Math.floor(Date.now() / 1000) + 5 * 86400; - sdk.getLicense.mockResolvedValue( - mockSdkResponse( - makeLicense({ about_to_expire: true, expires_at: expiresAt }), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json( + makeLicense({ about_to_expire: true, expires_at: expiresAt }), + ), ), ); renderBanner(); @@ -145,7 +151,11 @@ describe("LicenseBanner", () => { describe("severity", () => { it("uses error (role=alert) when no license is installed", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(400)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 400 }), + ), + ); renderBanner(); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); @@ -154,8 +164,12 @@ describe("LicenseBanner", () => { }); it("uses error (role=alert) when license is expired", async () => { - sdk.getLicense.mockResolvedValue( - mockSdkResponse(makeLicense({ expired: true, grace_period: false })), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json( + makeLicense({ expired: true, grace_period: false }), + ), + ), ); renderBanner(); await waitFor(() => { @@ -165,8 +179,10 @@ describe("LicenseBanner", () => { }); it("uses warning (role=status) when license is in the grace period", async () => { - sdk.getLicense.mockResolvedValue( - mockSdkResponse(makeLicense({ expired: true, grace_period: true })), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json(makeLicense({ expired: true, grace_period: true })), + ), ); renderBanner(); await waitFor(() => { @@ -177,9 +193,11 @@ describe("LicenseBanner", () => { it("uses warning (role=status) when license is about to expire", async () => { const expiresAt = Math.floor(Date.now() / 1000) + 5 * 86400; - sdk.getLicense.mockResolvedValue( - mockSdkResponse( - makeLicense({ about_to_expire: true, expires_at: expiresAt }), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json( + makeLicense({ about_to_expire: true, expires_at: expiresAt }), + ), ), ); renderBanner(); @@ -192,7 +210,11 @@ describe("LicenseBanner", () => { describe("messages", () => { it("shows the no-license message", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(400)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 400 }), + ), + ); renderBanner(); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); @@ -201,8 +223,12 @@ describe("LicenseBanner", () => { }); it("shows the expired message", async () => { - sdk.getLicense.mockResolvedValue( - mockSdkResponse(makeLicense({ expired: true, grace_period: false })), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json( + makeLicense({ expired: true, grace_period: false }), + ), + ), ); renderBanner(); await waitFor(() => { @@ -216,8 +242,10 @@ describe("LicenseBanner", () => { }); it("shows the grace period message", async () => { - sdk.getLicense.mockResolvedValue( - mockSdkResponse(makeLicense({ expired: true, grace_period: true })), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json(makeLicense({ expired: true, grace_period: true })), + ), ); renderBanner(); await waitFor(() => { @@ -228,9 +256,11 @@ describe("LicenseBanner", () => { it("shows days remaining when about to expire and days are known", async () => { const expiresAt = Math.floor(Date.now() / 1000) + 1 * 86400; - sdk.getLicense.mockResolvedValue( - mockSdkResponse( - makeLicense({ about_to_expire: true, expires_at: expiresAt }), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json( + makeLicense({ about_to_expire: true, expires_at: expiresAt }), + ), ), ); renderBanner(); @@ -242,9 +272,11 @@ describe("LicenseBanner", () => { it("uses the plural form when more than one day remains", async () => { const expiresAt = Math.floor(Date.now() / 1000) + 5 * 86400; - sdk.getLicense.mockResolvedValue( - mockSdkResponse( - makeLicense({ about_to_expire: true, expires_at: expiresAt }), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json( + makeLicense({ about_to_expire: true, expires_at: expiresAt }), + ), ), ); renderBanner(); @@ -255,8 +287,12 @@ describe("LicenseBanner", () => { }); it("shows the fallback about-to-expire message when expires_at is not set", async () => { - sdk.getLicense.mockResolvedValue( - mockSdkResponse(makeLicense({ about_to_expire: true, expires_at: -1 })), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json( + makeLicense({ about_to_expire: true, expires_at: -1 }), + ), + ), ); renderBanner(); await waitFor(() => { @@ -267,9 +303,11 @@ describe("LicenseBanner", () => { it("shows fallback about-to-expire copy when expires_at is in the past", async () => { const expiredAt = Math.floor(Date.now() / 1000) - 1; - sdk.getLicense.mockResolvedValue( - mockSdkResponse( - makeLicense({ about_to_expire: true, expires_at: expiredAt }), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json( + makeLicense({ about_to_expire: true, expires_at: expiredAt }), + ), ), ); renderBanner(); @@ -281,9 +319,11 @@ describe("LicenseBanner", () => { it("shows fallback about-to-expire copy when days would be zero", async () => { const nowSeconds = Math.floor(Date.now() / 1000); - sdk.getLicense.mockResolvedValue( - mockSdkResponse( - makeLicense({ about_to_expire: true, expires_at: nowSeconds }), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json( + makeLicense({ about_to_expire: true, expires_at: nowSeconds }), + ), ), ); renderBanner(); @@ -297,7 +337,11 @@ describe("LicenseBanner", () => { describe("no CTA link", () => { it("never renders any link when no license is installed (error state)", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(400)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 400 }), + ), + ); renderBanner(); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); @@ -308,9 +352,11 @@ describe("LicenseBanner", () => { it("never renders any link when license is about to expire (warning state)", async () => { const expiresAt = Math.floor(Date.now() / 1000) + 5 * 86400; - sdk.getLicense.mockResolvedValue( - mockSdkResponse( - makeLicense({ about_to_expire: true, expires_at: expiresAt }), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json( + makeLicense({ about_to_expire: true, expires_at: expiresAt }), + ), ), ); renderBanner(); @@ -325,7 +371,6 @@ describe("LicenseBanner", () => { describe("cloud deployment", () => { it("is hidden when cloud=true and admin=true (getLicense never fires)", async () => { mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); - sdk.getLicense.mockRejectedValue(makeSdkError(400)); renderBanner(); @@ -333,7 +378,6 @@ describe("LicenseBanner", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); - expect(sdk.getLicense).not.toHaveBeenCalled(); }); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/LicenseGuard.test.tsx b/ui/apps/console/src/components/common/__tests__/LicenseGuard.test.tsx index 9fc898e2145..1f5af57b381 100644 --- a/ui/apps/console/src/components/common/__tests__/LicenseGuard.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/LicenseGuard.test.tsx @@ -2,19 +2,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import type { ComponentType, ReactNode } from "react"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { getConfig, defaultConfig } from "@/env"; import { useAuthStore } from "@/stores/authStore"; -import type { GetLicenseResponse } from "@/client"; -import { mockSdkResponse, makeSdkError } from "@/tests/sdk"; +import type { GetLicense200 as GetLicenseResponse } from "@/client/model"; import LicenseGuard from "../LicenseGuard"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getLicense: vi.fn(), - }), -); - const mockGetConfig = vi.mocked(getConfig); function makeLicense( @@ -67,14 +62,14 @@ beforeEach(() => { describe("LicenseGuard", () => { describe("isLoading — shows PageLoader", () => { it("renders a loading indicator while the license check is in progress", () => { - sdk.getLicense.mockReturnValue(new Promise(() => {})); + server.use(http.get("*/admin/api/license", () => new Promise(() => {}))); renderGuard(createTestWrapper()); expect(screen.getByText("Checking license...")).toBeInTheDocument(); expect(screen.queryByText("protected content")).not.toBeInTheDocument(); }); it("does not render the Outlet while loading", () => { - sdk.getLicense.mockReturnValue(new Promise(() => {})); + server.use(http.get("*/admin/api/license", () => new Promise(() => {}))); renderGuard(createTestWrapper()); expect(screen.queryByText("protected content")).not.toBeInTheDocument(); }); @@ -82,7 +77,11 @@ describe("LicenseGuard", () => { describe("isError — redirects to /admin/license", () => { it("navigates to the license page when the query errors", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderGuard(createTestWrapper()); await waitFor(() => { expect(screen.getByText("license page")).toBeInTheDocument(); @@ -93,7 +92,11 @@ describe("LicenseGuard", () => { describe("no license — redirects to /admin/license", () => { it("navigates to the license page when no license is installed", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(400)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 400 }), + ), + ); renderGuard(createTestWrapper()); await waitFor(() => { expect(screen.getByText("license page")).toBeInTheDocument(); @@ -104,8 +107,10 @@ describe("LicenseGuard", () => { describe("isExpired — redirects to /admin/license", () => { it("navigates to the license page when the license is expired", async () => { - sdk.getLicense.mockResolvedValue( - mockSdkResponse(makeLicense({ expired: true })), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json(makeLicense({ expired: true })), + ), ); renderGuard(createTestWrapper()); await waitFor(() => { @@ -117,7 +122,9 @@ describe("LicenseGuard", () => { describe("valid license — renders Outlet", () => { it("renders the child route when the license is valid", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(makeLicense())); + server.use( + http.get("*/admin/api/license", () => HttpResponse.json(makeLicense())), + ); renderGuard(createTestWrapper()); await waitFor(() => { expect(screen.getByText("protected content")).toBeInTheDocument(); @@ -129,14 +136,20 @@ describe("LicenseGuard", () => { describe("cloud deployment", () => { it("renders the Outlet without calling getLicense when cloud=true", async () => { mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); - sdk.getLicense.mockRejectedValue(makeSdkError(400)); + let called = false; + server.use( + http.get("*/admin/api/license", () => { + called = true; + return HttpResponse.json({}, { status: 400 }); + }), + ); renderGuard(createTestWrapper()); await waitFor(() => { expect(screen.getByText("protected content")).toBeInTheDocument(); }); - expect(sdk.getLicense).not.toHaveBeenCalled(); + expect(called).toBe(false); }); }); }); diff --git a/ui/apps/console/src/components/common/__tests__/NamespaceGuard.test.tsx b/ui/apps/console/src/components/common/__tests__/NamespaceGuard.test.tsx index c9f1660a1a9..76d17ce0c25 100644 --- a/ui/apps/console/src/components/common/__tests__/NamespaceGuard.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/NamespaceGuard.test.tsx @@ -1,19 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { useConnectivityStore } from "@/stores/connectivityStore"; import { createTestWrapper } from "@/tests/wrapper"; -import { paginatedResponse, mockSdkResponse } from "@/tests/sdk"; -import { mockNamespace } from "@/tests/factories"; +import { mockNamespace, mockUserAuth } from "@/tests/factories"; import NamespaceGuard from "../NamespaceGuard"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getNamespaces: vi.fn(), - getNamespaceToken: vi.fn(), - }), -); - vi.mock("../CreateNamespace", () => ({ default: () =>
, })); @@ -24,9 +18,11 @@ vi.mock("@/components/layout/UserMenu", () => ({ beforeEach(() => { vi.clearAllMocks(); - sdk.getNamespaces.mockResolvedValue(paginatedResponse([])); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token" }), + server.use( + http.get("*/api/namespaces", () => jsonWithTotal([])), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json(mockUserAuth({ token: "jwt-token" })), + ), ); useConnectivityStore.getState().markUp(); }); @@ -48,13 +44,13 @@ function renderGuard(initialPath = "/dashboard") { describe("NamespaceGuard", () => { describe("loading state", () => { it("shows a loading spinner while namespaces are not yet loaded", () => { - sdk.getNamespaces.mockReturnValue(new Promise(() => {})); + server.use(http.get("*/api/namespaces", () => new Promise(() => {}))); renderGuard(); expect(screen.getByText(/loading/i)).toBeInTheDocument(); }); it("does not render the outlet while loading", () => { - sdk.getNamespaces.mockReturnValue(new Promise(() => {})); + server.use(http.get("*/api/namespaces", () => new Promise(() => {}))); renderGuard(); expect(screen.queryByText("dashboard content")).not.toBeInTheDocument(); }); @@ -62,16 +58,20 @@ describe("NamespaceGuard", () => { describe("with namespaces", () => { it("renders the outlet when namespaces exist", async () => { - sdk.getNamespaces.mockResolvedValue( - paginatedResponse([mockNamespace({ tenant_id: "t1", name: "ns1" })]), + server.use( + http.get("*/api/namespaces", () => + jsonWithTotal([mockNamespace({ tenant_id: "t1", name: "ns1" })]), + ), ); renderGuard(); expect(await screen.findByText("dashboard content")).toBeInTheDocument(); }); it("does not show the create-namespace screen when namespaces exist", async () => { - sdk.getNamespaces.mockResolvedValue( - paginatedResponse([mockNamespace({ tenant_id: "t1", name: "ns1" })]), + server.use( + http.get("*/api/namespaces", () => + jsonWithTotal([mockNamespace({ tenant_id: "t1", name: "ns1" })]), + ), ); renderGuard(); expect(await screen.findByText("dashboard content")).toBeInTheDocument(); diff --git a/ui/apps/console/src/components/common/__tests__/RenameSection.test.tsx b/ui/apps/console/src/components/common/__tests__/RenameSection.test.tsx index 7c7d5bd4c81..e436e73041d 100644 --- a/ui/apps/console/src/components/common/__tests__/RenameSection.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/RenameSection.test.tsx @@ -3,7 +3,14 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import RenameSection, { type RenameSectionProps } from "../RenameSection"; -import { makeSdkError } from "@/tests/sdk"; +import type { SdkHttpError } from "@/api/errors"; + +function makeSdkError(status: number): Error & SdkHttpError { + return Object.assign(new Error("Request failed"), { + status, + headers: new Headers(), + }); +} const mockRename = vi .fn() @@ -115,8 +122,8 @@ describe("RenameSection", () => { await typeAndSave(user, " new-name "); expect(mockRename).toHaveBeenCalledWith({ - path: { uid: "abc-123" }, - body: { name: "new-name" }, + uid: "abc-123", + data: { name: "new-name" }, }); expect( screen.getByRole("heading", { name: "my-device" }), @@ -131,8 +138,8 @@ describe("RenameSection", () => { await user.type(input, "new-name{Enter}"); expect(mockRename).toHaveBeenCalledWith({ - path: { uid: "abc-123" }, - body: { name: "new-name" }, + uid: "abc-123", + data: { name: "new-name" }, }); }); diff --git a/ui/apps/console/src/components/common/__tests__/SetupGuard.test.tsx b/ui/apps/console/src/components/common/__tests__/SetupGuard.test.tsx index 7033c21b29d..9ca67b16710 100644 --- a/ui/apps/console/src/components/common/__tests__/SetupGuard.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/SetupGuard.test.tsx @@ -1,19 +1,13 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { render, screen, act } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { useAuthStore } from "@/stores/authStore"; import SetupGuard from "../SetupGuard"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getInfo: vi.fn(), - }), -); - function mockSetup(done: boolean) { - sdk.getInfo.mockResolvedValue({ - data: { setup: done }, - }); + server.use(http.get("*/info", () => HttpResponse.json({ setup: done }))); } function renderAt(path: string) { @@ -31,7 +25,7 @@ function renderAt(path: string) { } beforeEach(() => { - sdk.getInfo.mockReset(); + vi.clearAllMocks(); useAuthStore.setState({ token: null }); }); diff --git a/ui/apps/console/src/components/common/__tests__/TagFilterDropdown.test.tsx b/ui/apps/console/src/components/common/__tests__/TagFilterDropdown.test.tsx index 57b84690355..d9cbde36d59 100644 --- a/ui/apps/console/src/components/common/__tests__/TagFilterDropdown.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/TagFilterDropdown.test.tsx @@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { fireEvent } from "@testing-library/react"; +import { setTags } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockTags } from "@/tests/mockTags"; import TagFilterDropdown from "../TagFilterDropdown"; -vi.hoisted(() => mockSdkGen({ getTags: vi.fn() })); - function renderDropdown( overrides: Partial<{ filterTags: string[]; @@ -39,7 +37,7 @@ function renderDropdown( describe("TagFilterDropdown", () => { beforeEach(() => { vi.clearAllMocks(); - mockTags(["alpha", "beta", "gamma"]); + setTags(["alpha", "beta", "gamma"]); }); describe("trigger button", () => { diff --git a/ui/apps/console/src/components/common/__tests__/TagsPopover.test.tsx b/ui/apps/console/src/components/common/__tests__/TagsPopover.test.tsx index dc66f4798c9..3861a2c1989 100644 --- a/ui/apps/console/src/components/common/__tests__/TagsPopover.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/TagsPopover.test.tsx @@ -2,13 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { fireEvent } from "@testing-library/react"; +import { setTags } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { useAuthStore } from "@/stores/authStore"; -import { mockTags } from "@/tests/mockTags"; import TagsPopover from "../TagsPopover"; -vi.hoisted(() => mockSdkGen({ getTags: vi.fn() })); - const mockAddTag = vi.fn(); const mockRemoveTag = vi.fn(); @@ -36,7 +34,7 @@ function renderPopover( describe("TagsPopover", () => { beforeEach(() => { vi.clearAllMocks(); - mockTags([]); + setTags([]); useAuthStore.setState({ role: "owner" }); }); @@ -183,7 +181,7 @@ describe("TagsPopover", () => { describe("adding a tag via suggestion", () => { it("calls addTag when a suggestion is clicked", async () => { mockAddTag.mockResolvedValue(undefined); - mockTags(["production"]); + setTags(["production"]); renderPopover({ tags: [] }); @@ -202,7 +200,8 @@ describe("TagsPopover", () => { await waitFor(() => { expect(mockAddTag).toHaveBeenCalledWith({ - path: { uid: "entity-1", name: "production" }, + uid: "entity-1", + name: "production", }); }); }); @@ -257,7 +256,8 @@ describe("TagsPopover", () => { await waitFor(() => { expect(mockRemoveTag).toHaveBeenCalledWith({ - path: { uid: "entity-1", name: "alpha" }, + uid: "entity-1", + name: "alpha", }); }); }); @@ -266,7 +266,7 @@ describe("TagsPopover", () => { describe("error states", () => { it("shows an error alert when addTag fails", async () => { mockAddTag.mockRejectedValue(new Error("network error")); - mockTags(["production"]); + setTags(["production"]); renderPopover({ tags: [] }); @@ -290,7 +290,7 @@ describe("TagsPopover", () => { it("shows permission error when addTag fails with 403", async () => { mockAddTag.mockRejectedValue({ status: 403 }); - mockTags(["production"]); + setTags(["production"]); renderPopover({ tags: [] }); diff --git a/ui/apps/console/src/components/common/__tests__/TagsSection.test.tsx b/ui/apps/console/src/components/common/__tests__/TagsSection.test.tsx index ce2f235f663..d33a1564efb 100644 --- a/ui/apps/console/src/components/common/__tests__/TagsSection.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/TagsSection.test.tsx @@ -1,14 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { setTags } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { useAuthStore } from "@/stores/authStore"; -import { makeSdkError } from "@/tests/sdk"; -import { mockTags } from "@/tests/mockTags"; import TagsSection from "../TagsSection"; -vi.hoisted(() => mockSdkGen({ getTags: vi.fn() })); - function renderTagsSection({ uid = "test-uid", tags = [] as string[], @@ -17,10 +14,8 @@ function renderTagsSection({ }: Partial<{ uid: string; tags: string[]; - addTag: (opts: { path: { uid: string; name: string } }) => Promise; - removeTag: (opts: { - path: { uid: string; name: string }; - }) => Promise; + addTag: (opts: { uid: string; name: string }) => Promise; + removeTag: (opts: { uid: string; name: string }) => Promise; }> = {}) { const finalProps = { uid, tags, addTag, removeTag }; return { @@ -43,7 +38,7 @@ describe("TagsSection", () => { beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ role: "owner" }); - mockTags(["existing", "shared", "deploy"]); + setTags(["existing", "shared", "deploy"]); }); describe("rendering", () => { @@ -89,7 +84,8 @@ describe("TagsSection", () => { await waitFor(() => { expect(addTag).toHaveBeenCalledWith({ - path: { uid: "test-uid", name: "newtag" }, + uid: "test-uid", + name: "newtag", }); }); expect(screen.getByLabelText("New tag")).toHaveValue(""); @@ -104,7 +100,8 @@ describe("TagsSection", () => { await waitFor(() => { expect(addTag).toHaveBeenCalledWith({ - path: { uid: "test-uid", name: "newtag" }, + uid: "test-uid", + name: "newtag", }); }); }); @@ -140,7 +137,7 @@ describe("TagsSection", () => { { status: 400, message: /not a valid tag name/ }, ])("shows API error for status $status", async ({ status, message }) => { renderTagsSection({ - addTag: vi.fn().mockRejectedValue(makeSdkError(status)), + addTag: vi.fn().mockRejectedValue({ status }), }); await typeAndSubmit("newtag"); @@ -176,7 +173,8 @@ describe("TagsSection", () => { await waitFor(() => { expect(removeTag).toHaveBeenCalledWith({ - path: { uid: "test-uid", name: "prod" }, + uid: "test-uid", + name: "prod", }); }); }); @@ -185,7 +183,7 @@ describe("TagsSection", () => { const user = userEvent.setup(); renderTagsSection({ tags: ["prod"], - removeTag: vi.fn().mockRejectedValue(makeSdkError(403)), + removeTag: vi.fn().mockRejectedValue({ status: 403 }), }); await user.click( @@ -247,7 +245,8 @@ describe("TagsSection", () => { await waitFor(() => { expect(addTag).toHaveBeenCalledWith({ - path: { uid: "test-uid", name: "deploy" }, + uid: "test-uid", + name: "deploy", }); }); }); @@ -262,7 +261,8 @@ describe("TagsSection", () => { await waitFor(() => { expect(addTag).toHaveBeenCalledWith({ - path: { uid: "test-uid", name: "deploy" }, + uid: "test-uid", + name: "deploy", }); }); }); diff --git a/ui/apps/console/src/components/common/fields/TagsSelector.tsx b/ui/apps/console/src/components/common/fields/TagsSelector.tsx index 7c951c77809..da11654d680 100644 --- a/ui/apps/console/src/components/common/fields/TagsSelector.tsx +++ b/ui/apps/console/src/components/common/fields/TagsSelector.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { XMarkIcon } from "@heroicons/react/24/outline"; import { Dropdown, IconButton } from "@shellhub/design-system/primitives"; import { cn } from "@shellhub/design-system/cn"; -import { useTags } from "@/hooks/useTags"; +import { useTagNames } from "@/hooks/useTags"; import FieldLabel from "@/components/common/fields/FieldLabel"; import FieldError from "@/components/common/fields/FieldError"; import FieldHint from "@/components/common/fields/FieldHint"; @@ -25,8 +25,7 @@ export default function TagsSelector({ error?: string; hint?: string; }) { - const { tags: allTags, isLoading: loading } = useTags(); - const tags = allTags.map((t) => t.name); + const { names: tags, isLoading: loading } = useTagNames(); const [search, setSearch] = useState(""); const filtered = tags.filter( diff --git a/ui/apps/console/src/components/common/fields/rhf/__tests__/FormTagsSelector.test.tsx b/ui/apps/console/src/components/common/fields/rhf/__tests__/FormTagsSelector.test.tsx index 024e14a34ff..76ad81609ff 100644 --- a/ui/apps/console/src/components/common/fields/rhf/__tests__/FormTagsSelector.test.tsx +++ b/ui/apps/console/src/components/common/fields/rhf/__tests__/FormTagsSelector.test.tsx @@ -3,12 +3,10 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useEffect } from "react"; import { useForm, type FieldValues } from "react-hook-form"; +import { setTags } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockTags } from "@/tests/mockTags"; import FormTagsSelector from "@/components/common/fields/rhf/FormTagsSelector"; -vi.hoisted(() => mockSdkGen({ getTags: vi.fn() })); - interface TagsFormValues extends FieldValues { tags: string[]; } @@ -48,7 +46,7 @@ function renderForm(props: Parameters[0] = {}) { describe("FormTagsSelector (RHF adapter contract)", () => { beforeEach(() => { vi.clearAllMocks(); - mockTags(["production", "staging", "dev"]); + setTags(["production", "staging", "dev"]); }); it("renders the tags selector with the given label", () => { diff --git a/ui/apps/console/src/components/devices/AcceptDeviceFlow.tsx b/ui/apps/console/src/components/devices/AcceptDeviceFlow.tsx index 3f37748fd1e..388d0b05d3e 100644 --- a/ui/apps/console/src/components/devices/AcceptDeviceFlow.tsx +++ b/ui/apps/console/src/components/devices/AcceptDeviceFlow.tsx @@ -9,7 +9,7 @@ import { } from "@heroicons/react/24/outline"; import { cn } from "@shellhub/design-system/cn"; import { Button, Spinner } from "@shellhub/design-system/primitives"; -import type { ResolveDeviceLoginCodeResponse } from "@/client"; +import type { ResolveDeviceLoginCode200 as ResolveDeviceLoginCodeResponse } from "@/client/model"; import { useAuthStore } from "@/stores/authStore"; import { clearPendingDeviceCode, @@ -20,11 +20,11 @@ import { NamespaceCreateForm, CommunityInstructions, } from "@/components/common/CreateNamespace"; -import { useAcceptDevice } from "@/hooks/useDeviceMutations"; import { - useResolveDeviceCode, + useAcceptDevice, useAcceptDevicePairing, -} from "@/hooks/useDeviceCode"; + useResolveDeviceLoginCode, +} from "@/client/api"; import { useSwitchNamespace } from "@/hooks/useNamespaceMutations"; import { useNamespace, useNamespaces } from "@/hooks/useNamespaces"; import RadioGroupField from "@/components/common/fields/RadioGroupField"; @@ -69,10 +69,12 @@ export default function AcceptDeviceFlow({ const [code, setCode] = useState(initialCode); const { - device, + data: device, isLoading: isResolving, isError, - } = useResolveDeviceCode(code); + } = useResolveDeviceLoginCode(code, { + query: { enabled: !!code, retry: false, staleTime: Infinity }, + }); const acceptDevice = useAcceptDevice(); const acceptPairing = useAcceptDevicePairing(); @@ -86,7 +88,8 @@ export default function AcceptDeviceFlow({ ); const hasSubscription = isSubscriptionBlocked(targetNamespace?.billing); const canSubscribeInAuth = useHasPermission("billing:subscribe"); - const canSubscribe = canSubscribeInAuth && (!selectedTenant || selectedTenant === authTenant); + const canSubscribe = + canSubscribeInAuth && (!selectedTenant || selectedTenant === authTenant); const finish = (b: Branch) => { clearPendingDeviceCode(); @@ -133,7 +136,7 @@ export default function AcceptDeviceFlow({ setActionError(""); try { if (!device.uid) return; - await acceptDevice.mutateAsync({ path: { uid: device.uid } }); + await acceptDevice.mutateAsync({ uid: device.uid }); finish({ kind: "success", device }); } catch (err) { setActionError(getAcceptErrorMessage(err, hasSubscription, canSubscribe)); @@ -145,8 +148,8 @@ export default function AcceptDeviceFlow({ setActionError(""); try { const data = await acceptPairing.mutateAsync({ - path: { code }, - body: { tenant_id: selectedTenant }, + code, + data: { tenant_id: selectedTenant }, }); finish({ kind: "pairing-success", diff --git a/ui/apps/console/src/components/layout/__tests__/AdminSidebar.test.tsx b/ui/apps/console/src/components/layout/__tests__/AdminSidebar.test.tsx index 5b0936ddb72..ee70f75dc18 100644 --- a/ui/apps/console/src/components/layout/__tests__/AdminSidebar.test.tsx +++ b/ui/apps/console/src/components/layout/__tests__/AdminSidebar.test.tsx @@ -2,21 +2,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import React from "react"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { defaultConfig } from "@/env"; import { useAuthStore } from "@/stores/authStore"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import { mockLicense } from "@/tests/factories"; -import { makeSdkError } from "@/tests/sdk"; import { getConfig } from "@/env"; import AdminSidebar from "../AdminSidebar"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getLicense: vi.fn(), - }), -); - vi.mock("../SidebarShell", () => ({ default: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -58,8 +52,10 @@ describe("AdminSidebar", () => { beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ isAdmin: true }); - sdk.getLicense.mockResolvedValue( - mockSdkResponse(mockLicense({ expired: false })), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json(mockLicense({ expired: false })), + ), ); mockGetConfig.mockReturnValue({ ...defaultConfig }); }); @@ -108,7 +104,11 @@ describe("AdminSidebar", () => { describe("enterprise admin with expired/no license (cloud=false, isExpired=true)", () => { beforeEach(() => { - sdk.getLicense.mockRejectedValue(makeSdkError(400)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 400 }), + ), + ); }); it("shows the restricted nav with only the License entry", async () => { diff --git a/ui/apps/console/src/components/layout/__tests__/AppLayout.test.tsx b/ui/apps/console/src/components/layout/__tests__/AppLayout.test.tsx index 71ace99a9af..ad10a7a9a6d 100644 --- a/ui/apps/console/src/components/layout/__tests__/AppLayout.test.tsx +++ b/ui/apps/console/src/components/layout/__tests__/AppLayout.test.tsx @@ -1,21 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { paginatedResponse, mockSdkResponse } from "@/tests/sdk"; import { mockNamespace } from "@/tests/factories"; import { seedAuthStore } from "@/tests/seedAuthStore"; import { ClipboardProvider } from "@/components/common/ClipboardProvider"; import { getConfig, defaultConfig } from "@/env"; import AppLayout from "../AppLayout"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getNamespaces: vi.fn(), - getNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - }), -); - vi.mock("@/hooks/useSidebarLayout", () => ({ useSidebarLayout: () => ({ expanded: false, @@ -66,10 +59,12 @@ beforeEach(() => { vi.clearAllMocks(); mockGetConfig.mockReturnValue({ ...defaultConfig }); seedAuthStore(); - sdk.getNamespaces.mockResolvedValue(paginatedResponse([])); - sdk.getNamespace.mockResolvedValue(mockSdkResponse(null)); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + server.use( + http.get("*/api/namespaces", () => jsonWithTotal([])), + http.get("*/api/namespaces/:tenant", () => HttpResponse.json(null)), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token", role: "owner" }), + ), ); }); @@ -85,15 +80,18 @@ function renderLayout() { describe("AppLayout", () => { describe("Sidebar", () => { it("renders when namespaces exist", async () => { - sdk.getNamespaces.mockResolvedValue(paginatedResponse([mockNamespace()])); + server.use( + http.get("*/api/namespaces", () => jsonWithTotal([mockNamespace()])), + ); renderLayout(); expect(await screen.findByTestId("sidebar")).toBeInTheDocument(); }); it("is hidden when there are no namespaces", async () => { renderLayout(); - await waitFor(() => expect(sdk.getNamespaces).toHaveBeenCalled()); - expect(screen.queryByTestId("sidebar")).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId("sidebar")).not.toBeInTheDocument(); + }); }); }); @@ -104,7 +102,9 @@ describe("AppLayout", () => { }); it("renders alongside the sidebar when namespaces exist", async () => { - sdk.getNamespaces.mockResolvedValue(paginatedResponse([mockNamespace()])); + server.use( + http.get("*/api/namespaces", () => jsonWithTotal([mockNamespace()])), + ); renderLayout(); expect(await screen.findByTestId("sidebar")).toBeInTheDocument(); expect(screen.getByTestId("app-bar")).toBeInTheDocument(); @@ -149,7 +149,9 @@ describe("AppLayout", () => { }); it("renders the skip link when the sidebar is visible", async () => { - sdk.getNamespaces.mockResolvedValue(paginatedResponse([mockNamespace()])); + server.use( + http.get("*/api/namespaces", () => jsonWithTotal([mockNamespace()])), + ); renderLayout(); expect( await screen.findByRole("link", { name: /skip to main content/i }), @@ -172,7 +174,7 @@ describe("AppLayout", () => { it("does not mount the enterprise banners on a community instance", async () => { renderLayout(); - await waitFor(() => expect(sdk.getNamespaces).toHaveBeenCalled()); + await screen.findByTestId("app-bar"); expect( screen.queryByTestId("device-limit-banner"), ).not.toBeInTheDocument(); @@ -182,7 +184,7 @@ describe("AppLayout", () => { it("does not mount the enterprise banners in a cloud instance", async () => { mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); renderLayout(); - await waitFor(() => expect(sdk.getNamespaces).toHaveBeenCalled()); + await screen.findByTestId("app-bar"); expect( screen.queryByTestId("device-limit-banner"), ).not.toBeInTheDocument(); diff --git a/ui/apps/console/src/components/layout/__tests__/NamespaceSelector.test.tsx b/ui/apps/console/src/components/layout/__tests__/NamespaceSelector.test.tsx index a6d5b48ae91..e0e048296a6 100644 --- a/ui/apps/console/src/components/layout/__tests__/NamespaceSelector.test.tsx +++ b/ui/apps/console/src/components/layout/__tests__/NamespaceSelector.test.tsx @@ -1,19 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { paginatedResponse, mockSdkResponse } from "@/tests/sdk"; import { seedAuthStore } from "@/tests/seedAuthStore"; import { defaultConfig, getConfig } from "@/env"; -import NamespaceSelector from "../NamespaceSelector"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getNamespaces: vi.fn(), - getNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - }), -); +import NamespaceSelector from "../NamespaceSelector"; const mockNavigate = vi.hoisted(() => vi.fn()); @@ -38,10 +32,12 @@ beforeEach(() => { vi.clearAllMocks(); mockGetConfig.mockReturnValue({ ...defaultConfig }); seedAuthStore(); - sdk.getNamespaces.mockResolvedValue(paginatedResponse([])); - sdk.getNamespace.mockResolvedValue(mockSdkResponse(null)); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + server.use( + http.get("*/api/namespaces", () => jsonWithTotal([])), + http.get("*/api/namespaces/:tenant", () => HttpResponse.json(null)), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token", role: "owner" }), + ), ); }); diff --git a/ui/apps/console/src/components/layout/__tests__/UserMenu.test.tsx b/ui/apps/console/src/components/layout/__tests__/UserMenu.test.tsx index e6346375771..392461264ac 100644 --- a/ui/apps/console/src/components/layout/__tests__/UserMenu.test.tsx +++ b/ui/apps/console/src/components/layout/__tests__/UserMenu.test.tsx @@ -1,21 +1,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { useAuthStore } from "@/stores/authStore"; -import { mockSdkResponse } from "@/tests/sdk"; import { mockNamespace, mockUserAuth } from "@/tests/factories"; -import type { Namespace } from "@/client"; +import type { Namespace } from "@/client/model"; import UserMenu from "../UserMenu"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getNamespaces: vi.fn(), - }), -); - -function mockNamespaces(namespaces: Namespace[]) { - sdk.getNamespaces.mockResolvedValue(mockSdkResponse(namespaces)); +function mockNamespaceList(namespaces: Namespace[]) { + server.use(http.get("*/api/namespaces", () => HttpResponse.json(namespaces))); } function renderMenu() { @@ -33,8 +28,8 @@ describe("UserMenu", () => { beforeEach(() => { vi.clearAllMocks(); - useAuthStore.setState({ ...auth }); - mockNamespaces([mockNamespace()]); + useAuthStore.setState(auth); + mockNamespaceList([mockNamespace()]); }); describe("trigger button", () => { @@ -82,7 +77,7 @@ describe("UserMenu", () => { describe("dropdown — without namespaces", () => { it("shows Profile and Logout but hides Settings", async () => { - mockNamespaces([]); + mockNamespaceList([]); renderMenu(); await openDropdown(); expect( diff --git a/ui/apps/console/src/components/mfa/MfaDisableDialog.tsx b/ui/apps/console/src/components/mfa/MfaDisableDialog.tsx index 9ddd584813c..14d0b8b4b2c 100644 --- a/ui/apps/console/src/components/mfa/MfaDisableDialog.tsx +++ b/ui/apps/console/src/components/mfa/MfaDisableDialog.tsx @@ -3,7 +3,7 @@ import { ExclamationTriangleIcon, CheckCircleIcon, } from "@heroicons/react/24/outline"; -import { disableMfa } from "@/client"; +import { disableMFA } from "@/client/api"; import { Button, Callout } from "@shellhub/design-system/primitives"; import { useOtpInput } from "@/hooks/useOtpInput"; import { useAuthStore } from "@/stores/authStore"; @@ -66,24 +66,15 @@ export default function MfaDisableDialog({ try { if (mode === "totp") { if (!otp.isComplete) return; - await disableMfa({ - body: { code: otp.getValue() }, - throwOnError: true, - }); + await disableMFA({ code: otp.getValue() }); } else if (mode === "recovery") { if (!recoveryCode.trim()) return; - await disableMfa({ - body: { recovery_code: recoveryCode }, - throwOnError: true, - }); + await disableMFA({ recovery_code: recoveryCode }); } else if (mode === "email-reset") { if (!otpMainEmail.isComplete || !otpRecoveryEmail.isComplete) return; - await disableMfa({ - body: { - main_email_code: otpMainEmail.getValue(), - recovery_email_code: otpRecoveryEmail.getValue(), - }, - throwOnError: true, + await disableMFA({ + main_email_code: otpMainEmail.getValue(), + recovery_email_code: otpRecoveryEmail.getValue(), }); } diff --git a/ui/apps/console/src/components/mfa/MfaEnableDrawer.tsx b/ui/apps/console/src/components/mfa/MfaEnableDrawer.tsx index c4d034b2e26..03383b73954 100644 --- a/ui/apps/console/src/components/mfa/MfaEnableDrawer.tsx +++ b/ui/apps/console/src/components/mfa/MfaEnableDrawer.tsx @@ -8,7 +8,7 @@ import { Button, Callout, IconBadge } from "@shellhub/design-system/primitives"; import Drawer from "../common/Drawer"; import CheckboxField from "@/components/common/fields/CheckboxField"; import { QRCodeDisplay } from "./QRCodeDisplay"; -import { generateMfa, enableMfa, updateUser } from "@/client"; +import { generateMFA, enableMFA, updateUser } from "@/client/api"; import { isSdkError } from "@/api/errors"; import { useOtpInput } from "@/hooks/useOtpInput"; import { useRecoveryCodeActions } from "@/hooks/useRecoveryCodeActions"; @@ -66,10 +66,7 @@ export default function MfaEnableDrawer({ setLoading(true); try { - await updateUser({ - body: { recovery_email: recoveryEmail }, - throwOnError: true, - }); + await updateUser({ recovery_email: recoveryEmail }); await handleGenerateMfa(); setStep(2); } catch (err) { @@ -85,7 +82,7 @@ export default function MfaEnableDrawer({ const handleGenerateMfa = async () => { try { - const { data } = await generateMfa({ throwOnError: true }); + const data = await generateMFA(); setQrLink(data.link); setSecret(data.secret); setRecoveryCodes(data.recovery_codes); @@ -122,9 +119,10 @@ export default function MfaEnableDrawer({ setLoading(true); try { - await enableMfa({ - body: { code: otp.getValue(), secret, recovery_codes: recoveryCodes }, - throwOnError: true, + await enableMFA({ + code: otp.getValue(), + secret, + recovery_codes: recoveryCodes, }); setStep(4); } catch { @@ -434,7 +432,7 @@ export default function MfaEnableDrawer({ { - (otp.inputRefs.current[index] = el); + otp.inputRefs.current[index] = el; }} type="text" inputMode="numeric" diff --git a/ui/apps/console/src/components/mfa/__tests__/MfaDisableDialog.test.tsx b/ui/apps/console/src/components/mfa/__tests__/MfaDisableDialog.test.tsx index bf33911e640..063b1ba3b83 100644 --- a/ui/apps/console/src/components/mfa/__tests__/MfaDisableDialog.test.tsx +++ b/ui/apps/console/src/components/mfa/__tests__/MfaDisableDialog.test.tsx @@ -1,17 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { useAuthStore } from "@/stores/authStore"; -import { mockSdkResponse, type SdkResponse } from "@/tests/sdk"; import MfaDisableDialog from "../MfaDisableDialog"; -const sdk = vi.hoisted(() => - mockSdkGen({ - disableMfa: vi.fn(), - requestResetMfa: vi.fn(), - }), -); - describe("MfaDisableDialog", () => { const onClose = vi.fn(); const onSuccess = vi.fn(); @@ -39,7 +33,12 @@ describe("MfaDisableDialog", () => { beforeEach(() => { vi.clearAllMocks(); - sdk.disableMfa.mockResolvedValue(mockSdkResponse(undefined)); + server.use( + http.put( + "*/api/user/mfa/disable", + () => new HttpResponse(null, { status: 204 }), + ), + ); }); describe("Mode Switching", () => { @@ -92,17 +91,17 @@ describe("MfaDisableDialog", () => { await user.click(screen.getByRole("button", { name: /disable mfa/i })); await waitFor(() => { - expect(sdk.disableMfa).toHaveBeenCalledWith({ - body: { code: "123456" }, - throwOnError: true, - }); expect(onSuccess).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled(); }); }); it("shows error on invalid TOTP", async () => { - sdk.disableMfa.mockRejectedValue(new Error("Invalid code")); + server.use( + http.put("*/api/user/mfa/disable", () => + HttpResponse.json({}, { status: 403 }), + ), + ); const user = renderDialog(); await fillTotpCode(user, "999999"); @@ -149,17 +148,17 @@ describe("MfaDisableDialog", () => { await user.click(screen.getByRole("button", { name: /disable mfa/i })); await waitFor(() => { - expect(sdk.disableMfa).toHaveBeenCalledWith({ - body: { recovery_code: "valid-recovery-code" }, - throwOnError: true, - }); expect(onSuccess).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled(); }); }); it("shows error on invalid recovery code", async () => { - sdk.disableMfa.mockRejectedValue(new Error("Invalid recovery code")); + server.use( + http.put("*/api/user/mfa/disable", () => + HttpResponse.json({}, { status: 403 }), + ), + ); const user = renderDialog(); await user.click(screen.getByText(/use recovery code/i)); @@ -211,11 +210,8 @@ describe("MfaDisableDialog", () => { describe("Loading State", () => { it("disables submit button while submitting", async () => { - let resolveDisable: (v: SdkResponse) => void; - sdk.disableMfa.mockReturnValue( - new Promise((resolve) => { - resolveDisable = resolve; - }), + server.use( + http.put("*/api/user/mfa/disable", () => new Promise(() => {})), ); const user = renderDialog(); @@ -227,19 +223,19 @@ describe("MfaDisableDialog", () => { }); await user.click(disableButton); - expect(disableButton).toBeDisabled(); - - resolveDisable!(mockSdkResponse(undefined)); - await waitFor(() => { - expect(onSuccess).toHaveBeenCalled(); + expect(disableButton).toBeDisabled(); }); }); }); describe("Error Handling", () => { it("shows error after failed TOTP submit and switches to recovery mode", async () => { - sdk.disableMfa.mockRejectedValue(new Error("Invalid code")); + server.use( + http.put("*/api/user/mfa/disable", () => + HttpResponse.json({}, { status: 403 }), + ), + ); const user = renderDialog(); await fillTotpCode(user, "999999"); @@ -261,8 +257,10 @@ describe("MfaDisableDialog", () => { describe("Email-Reset Mode", () => { beforeEach(() => { useAuthStore.setState({ user: "admin" }); - sdk.requestResetMfa.mockResolvedValue( - mockSdkResponse({ token: "reset-token" }), + server.use( + http.post("*/api/user/mfa/reset", () => + HttpResponse.json({ token: "reset-token" }), + ), ); }); @@ -325,17 +323,17 @@ describe("MfaDisableDialog", () => { await user.click(screen.getByRole("button", { name: /disable mfa/i })); await waitFor(() => { - expect(sdk.disableMfa).toHaveBeenCalledWith({ - body: { main_email_code: "ABCDE", recovery_email_code: "12345" }, - throwOnError: true, - }); expect(onSuccess).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled(); }); }); it("shows error and resets OTP inputs on failure", async () => { - sdk.disableMfa.mockRejectedValue(new Error("Invalid codes")); + server.use( + http.put("*/api/user/mfa/disable", () => + HttpResponse.json({}, { status: 403 }), + ), + ); const user = renderDialog(); await navigateToEmailReset(user); diff --git a/ui/apps/console/src/components/mfa/__tests__/MfaEnableDrawer.test.tsx b/ui/apps/console/src/components/mfa/__tests__/MfaEnableDrawer.test.tsx index 9070030715a..80f0af05fc7 100644 --- a/ui/apps/console/src/components/mfa/__tests__/MfaEnableDrawer.test.tsx +++ b/ui/apps/console/src/components/mfa/__tests__/MfaEnableDrawer.test.tsx @@ -1,20 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { MfaGenerate } from "@/client"; -import { mockSdkResponse } from "@/tests/sdk"; +import type { MfaGenerate } from "@/client/model"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import MfaEnableDrawer from "../MfaEnableDrawer"; vi.mock("qrcode"); -const sdk = vi.hoisted(() => - mockSdkGen({ - generateMfa: vi.fn(), - enableMfa: vi.fn(), - updateUser: vi.fn(), - }), -); - const mockMfaData: MfaGenerate = { link: "otpauth://totp/ShellHub:user@example.com?secret=ABCD1234&issuer=ShellHub", secret: "ABCD1234", @@ -27,9 +20,14 @@ describe("MfaEnableDrawer", () => { beforeEach(() => { vi.clearAllMocks(); - sdk.generateMfa.mockResolvedValue(mockSdkResponse(mockMfaData)); - sdk.enableMfa.mockResolvedValue(mockSdkResponse(undefined)); - sdk.updateUser.mockResolvedValue(mockSdkResponse(undefined)); + server.use( + http.get("*/api/user/mfa/generate", () => HttpResponse.json(mockMfaData)), + http.put( + "*/api/user/mfa/enable", + () => new HttpResponse(null, { status: 204 }), + ), + http.patch("*/api/users", () => new HttpResponse(null, { status: 204 })), + ); }); describe("Step 1: Recovery Email", () => { @@ -83,18 +81,14 @@ describe("MfaEnableDrawer", () => { await user.click(nextButton); await waitFor(() => { - expect(sdk.updateUser).toHaveBeenCalledWith({ - body: { recovery_email: "new-recovery@example.com" }, - throwOnError: true, - }); - expect(sdk.generateMfa).toHaveBeenCalled(); + expect(screen.getByText(/Save Recovery Codes/i)).toBeInTheDocument(); }); }); it("shows error when email is already in use (409)", async () => { const user = userEvent.setup(); - sdk.updateUser.mockRejectedValue( - Object.assign(new Error("409"), { status: 409 }), + server.use( + http.patch("*/api/users", () => HttpResponse.json({}, { status: 409 })), ); render( @@ -132,7 +126,7 @@ describe("MfaEnableDrawer", () => { await user.click(continueButton); await waitFor(() => { - expect(sdk.generateMfa).toHaveBeenCalled(); + expect(screen.getByText(/Save Recovery Codes/i)).toBeInTheDocument(); }); }); }); @@ -151,7 +145,9 @@ describe("MfaEnableDrawer", () => { const continueButton = screen.getByRole("button", { name: /continue/i }); await user.click(continueButton); - await waitFor(() => expect(sdk.generateMfa).toHaveBeenCalled()); + await waitFor(() => { + expect(screen.getByText(/Save Recovery Codes/i)).toBeInTheDocument(); + }); }); it("displays all 6 recovery codes", async () => { @@ -198,7 +194,9 @@ describe("MfaEnableDrawer", () => { const continueButton = screen.getByRole("button", { name: /continue/i }); await user.click(continueButton); - await waitFor(() => expect(sdk.generateMfa).toHaveBeenCalled()); + await waitFor(() => { + expect(screen.getByText(/Save Recovery Codes/i)).toBeInTheDocument(); + }); const checkbox = screen.getByRole("checkbox"); await user.click(checkbox); @@ -243,20 +241,19 @@ describe("MfaEnableDrawer", () => { await user.click(verifyButton); await waitFor(() => { - expect(sdk.enableMfa).toHaveBeenCalledWith({ - body: { - code: "123456", - secret: mockMfaData.secret, - recovery_codes: mockMfaData.recovery_codes, - }, - throwOnError: true, - }); + expect( + screen.getByText(/MFA Enabled Successfully/i), + ).toBeInTheDocument(); }); }); it("shows error on invalid OTP", async () => { const user = userEvent.setup(); - sdk.enableMfa.mockRejectedValue(new Error("Invalid code")); + server.use( + http.put("*/api/user/mfa/enable", () => + HttpResponse.json({}, { status: 403 }), + ), + ); await waitFor(() => { expect(screen.getByText(/scan this qr code/i)).toBeInTheDocument(); @@ -299,7 +296,9 @@ describe("MfaEnableDrawer", () => { const continueButton = screen.getByRole("button", { name: /continue/i }); await user.click(continueButton); - await waitFor(() => expect(sdk.generateMfa).toHaveBeenCalled()); + await waitFor(() => { + expect(screen.getByText(/Save Recovery Codes/i)).toBeInTheDocument(); + }); const checkbox = screen.getByRole("checkbox"); await user.click(checkbox); @@ -353,7 +352,9 @@ describe("MfaEnableDrawer", () => { const continueButton = screen.getByRole("button", { name: /continue/i }); await user.click(continueButton); - await waitFor(() => expect(sdk.generateMfa).toHaveBeenCalled()); + await waitFor(() => { + expect(screen.getByText(/Save Recovery Codes/i)).toBeInTheDocument(); + }); rerender( { describe("Error Handling", () => { it("handles API errors when generating MFA codes", async () => { const user = userEvent.setup(); - sdk.generateMfa.mockRejectedValue(new Error("Network error")); + server.use( + http.get("*/api/user/mfa/generate", () => + HttpResponse.json({}, { status: 500 }), + ), + ); render( vi.fn()); @@ -13,23 +15,6 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - getSessions: vi.fn(), - getSessionsAdmin: vi.fn(), - }), -); - -vi.mock("@/stores/authStore", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useAuthStore: vi.fn((selector: (s: { isAdmin: boolean }) => unknown) => - selector({ isAdmin: true }), - ), - }; -}); - function makeSession(overrides: Partial = {}): Session { return { uid: "session-1", @@ -57,8 +42,8 @@ function makeSession(overrides: Partial = {}): Session { }; } -function mockSessionsResponse(sessions: Session[] = [], totalCount?: number) { - return paginatedResponse(sessions, totalCount); +function mockSessionsHandler(sessions: Session[] = [], total?: number) { + return () => jsonWithTotal(sessions, total); } function renderTable(isAdmin = false) { @@ -70,8 +55,11 @@ function renderTable(isAdmin = false) { describe("RecentSessionsTable", () => { beforeEach(() => { vi.clearAllMocks(); - sdk.getSessions.mockResolvedValue(mockSessionsResponse()); - sdk.getSessionsAdmin.mockResolvedValue(mockSessionsResponse()); + useAuthStore.setState({ isAdmin: true }); + server.use( + http.get("*/api/sessions", mockSessionsHandler()), + http.get("*/admin/api/sessions", mockSessionsHandler()), + ); }); describe("default (non-admin)", () => { @@ -87,8 +75,10 @@ describe("RecentSessionsTable", () => { it("navigates to /sessions/:uid on row click", async () => { const user = userEvent.setup(); - sdk.getSessions.mockResolvedValue( - mockSessionsResponse([makeSession({ uid: "s-1" })]), + server.use( + http.get("*/api/sessions", () => + jsonWithTotal([makeSession({ uid: "s-1" })]), + ), ); renderTable(); @@ -100,7 +90,9 @@ describe("RecentSessionsTable", () => { }); it("renders device chip with link to /devices/:uid", async () => { - sdk.getSessions.mockResolvedValue(mockSessionsResponse([makeSession()])); + server.use( + http.get("*/api/sessions", () => jsonWithTotal([makeSession()])), + ); renderTable(); await waitFor(() => { @@ -124,8 +116,10 @@ describe("RecentSessionsTable", () => { it("navigates to /admin/sessions/:uid on row click", async () => { const user = userEvent.setup(); - sdk.getSessionsAdmin.mockResolvedValue( - mockSessionsResponse([makeSession({ uid: "s-2" })]), + server.use( + http.get("*/admin/api/sessions", () => + jsonWithTotal([makeSession({ uid: "s-2" })]), + ), ); renderTable(true); @@ -137,8 +131,8 @@ describe("RecentSessionsTable", () => { }); it("renders device chip with link to /admin/devices/:uid", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - mockSessionsResponse([makeSession()]), + server.use( + http.get("*/admin/api/sessions", () => jsonWithTotal([makeSession()])), ); renderTable(true); @@ -152,7 +146,7 @@ describe("RecentSessionsTable", () => { describe("loading state", () => { it("shows loading message while fetching", () => { - sdk.getSessions.mockReturnValue(new Promise(() => {})); + server.use(http.get("*/api/sessions", () => new Promise(() => {}))); renderTable(); expect(screen.getByText(/loading sessions/i)).toBeInTheDocument(); }); @@ -160,8 +154,10 @@ describe("RecentSessionsTable", () => { describe("error state", () => { it("renders error callout on fetch failure", async () => { - sdk.getSessions.mockRejectedValue( - Object.assign(new Error(), { status: 500, headers: new Headers() }), + server.use( + http.get("*/api/sessions", () => + HttpResponse.json({}, { status: 500 }), + ), ); renderTable(); await waitFor(() => { @@ -181,8 +177,10 @@ describe("RecentSessionsTable", () => { describe("unauthenticated session", () => { it("renders warning icon for unauthenticated sessions", async () => { - sdk.getSessions.mockResolvedValue( - mockSessionsResponse([makeSession({ authenticated: false })]), + server.use( + http.get("*/api/sessions", () => + jsonWithTotal([makeSession({ authenticated: false })]), + ), ); renderTable(); await waitFor(() => { @@ -193,8 +191,10 @@ describe("RecentSessionsTable", () => { describe("session data rendering", () => { it("renders session username", async () => { - sdk.getSessions.mockResolvedValue( - mockSessionsResponse([makeSession({ username: "admin" })]), + server.use( + http.get("*/api/sessions", () => + jsonWithTotal([makeSession({ username: "admin" })]), + ), ); renderTable(); await waitFor(() => { @@ -203,10 +203,12 @@ describe("RecentSessionsTable", () => { }); it("renders session type badge", async () => { - sdk.getSessions.mockResolvedValue( - mockSessionsResponse([ - makeSession({ events: { types: ["shell"], seats: [] } }), - ]), + server.use( + http.get("*/api/sessions", () => + jsonWithTotal([ + makeSession({ events: { types: ["shell"], seats: [] } }), + ]), + ), ); renderTable(); await waitFor(() => { @@ -215,7 +217,9 @@ describe("RecentSessionsTable", () => { }); it("renders device name", async () => { - sdk.getSessions.mockResolvedValue(mockSessionsResponse([makeSession()])); + server.use( + http.get("*/api/sessions", () => jsonWithTotal([makeSession()])), + ); renderTable(); await waitFor(() => { expect(screen.getByText("my-device")).toBeInTheDocument(); diff --git a/ui/apps/console/src/components/terminal/TerminalInstance.tsx b/ui/apps/console/src/components/terminal/TerminalInstance.tsx index 7dd3ece3d7c..12281453f76 100644 --- a/ui/apps/console/src/components/terminal/TerminalInstance.tsx +++ b/ui/apps/console/src/components/terminal/TerminalInstance.tsx @@ -4,7 +4,8 @@ import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { Buffer } from "buffer"; -import { createWebSshSession, type CreateWebSshSessionData } from "@/client"; +import { createWebSshSession } from "@/client/api"; +import type { CreateWebSshSessionBody } from "@/client/model"; import { generateSignature } from "@/utils/sshKeys"; import type { TerminalSession } from "@/stores/terminalStore"; import { useTerminalStore } from "@/stores/terminalStore"; @@ -87,7 +88,7 @@ export default function TerminalInstance({ updateStatus("connecting"); const shared = { device: session.deviceUid, username: session.username }; - const body: CreateWebSshSessionData["body"] = session.fingerprint + const body: CreateWebSshSessionBody = session.fingerprint ? { ...shared, fingerprint: session.fingerprint, @@ -97,10 +98,7 @@ export default function TerminalInstance({ let token: string; try { - const { data } = await createWebSshSession({ - body, - throwOnError: true, - }); + const data = await createWebSshSession(body); token = data.token; } catch { if (cancelled) return; @@ -258,7 +256,9 @@ export default function TerminalInstance({ case WS_KIND.ERROR: { lastError = true; updateStatus("disconnected"); - setError(resolveError(msg.data, session.deviceUid, isIdentityMode)); + setError( + resolveError(msg.data, session.deviceUid, isIdentityMode), + ); break; } case WS_KIND.SESSION: { diff --git a/ui/apps/console/src/components/wizard/WelcomeWizardTrigger.tsx b/ui/apps/console/src/components/wizard/WelcomeWizardTrigger.tsx index 8192ee5f3c5..f72ad17a0c4 100644 --- a/ui/apps/console/src/components/wizard/WelcomeWizardTrigger.tsx +++ b/ui/apps/console/src/components/wizard/WelcomeWizardTrigger.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import type { GetStatusDevicesResponse } from "@/client"; +import type { GetStatusDevices200 as GetStatusDevicesResponse } from "@/client/model"; import { useAuthStore } from "@/stores/authStore"; -import { useStats } from "@/hooks/useStats"; +import { useGetStatusDevices } from "@/client/api"; import { hasAcceptedDevices } from "@/utils/stats"; import { hasSeenWelcome, markWelcomeSeen } from "@/utils/welcomeState"; import WelcomeWizard from "./WelcomeWizard"; @@ -28,7 +28,7 @@ import { isWizardDemo } from "./demo"; */ export default function WelcomeWizardTrigger() { const tenant = useAuthStore((s) => s.tenant); - const { stats, refetch } = useStats(); + const { data: stats, refetch } = useGetStatusDevices(); if (!stats || !tenant) return null; diff --git a/ui/apps/console/src/components/wizard/__tests__/WelcomeWizard.test.tsx b/ui/apps/console/src/components/wizard/__tests__/WelcomeWizard.test.tsx index 4dfe84cf8d3..727c3c7f459 100644 --- a/ui/apps/console/src/components/wizard/__tests__/WelcomeWizard.test.tsx +++ b/ui/apps/console/src/components/wizard/__tests__/WelcomeWizard.test.tsx @@ -2,18 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, cleanup, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import { seedAuthStore } from "@/tests/seedAuthStore"; -const sdk = vi.hoisted(() => - mockSdkGen({ - resolveDeviceLoginCode: vi.fn(), - acceptDevicePairing: vi.fn(), - acceptDevice: vi.fn(), - }), -); - vi.mock("../WizardStepInstall", () => ({ default: () =>
Install step
, })); @@ -60,13 +53,18 @@ import WelcomeWizard from "../WelcomeWizard"; beforeEach(() => { vi.clearAllMocks(); seedAuthStore(); - sdk.resolveDeviceLoginCode.mockResolvedValue( - mockSdkResponse({ kind: "pairing", name: "code-device" }), - ); - sdk.acceptDevicePairing.mockResolvedValue( - mockSdkResponse({ uid: "code-uid" }), + server.use( + http.get("*/api/devices/login-code/:code", () => + HttpResponse.json({ kind: "pairing", name: "code-device" }), + ), + http.post("*/api/devices/pairing/:code/accept", () => + HttpResponse.json({ uid: "code-uid" }), + ), + http.post( + "*/api/devices/:uid/accept", + () => new HttpResponse(null, { status: 200 }), + ), ); - sdk.acceptDevice.mockResolvedValue(mockSdkResponse(undefined)); }); function renderWizard(open = true, onClose = vi.fn(), onDismiss = vi.fn()) { diff --git a/ui/apps/console/src/components/wizard/__tests__/WelcomeWizardTrigger.test.tsx b/ui/apps/console/src/components/wizard/__tests__/WelcomeWizardTrigger.test.tsx index acb42417955..cf12b02b29d 100644 --- a/ui/apps/console/src/components/wizard/__tests__/WelcomeWizardTrigger.test.tsx +++ b/ui/apps/console/src/components/wizard/__tests__/WelcomeWizardTrigger.test.tsx @@ -1,16 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import { mockStats } from "@/tests/factories"; import { seedAuthStore } from "@/tests/seedAuthStore"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getStatusDevices: vi.fn(), - }), -); - vi.mock("@/utils/welcomeState", () => ({ hasSeenWelcome: vi.fn(), markWelcomeSeen: vi.fn(), @@ -57,7 +52,7 @@ function renderTrigger() { beforeEach(() => { vi.clearAllMocks(); seedAuthStore(); - sdk.getStatusDevices.mockReturnValue(new Promise(() => {})); + server.use(http.get("*/api/stats", () => HttpResponse.json(mockStats()))); mockHasSeenWelcome.mockReturnValue(false); }); @@ -65,16 +60,17 @@ describe("WelcomeWizardTrigger", () => { describe("when tenant has already seen welcome", () => { it("renders nothing", async () => { mockHasSeenWelcome.mockReturnValue(true); - sdk.getStatusDevices.mockResolvedValue(mockSdkResponse(mockStats())); + server.use(http.get("*/api/stats", () => HttpResponse.json(mockStats()))); renderTrigger(); - await waitFor(() => expect(sdk.getStatusDevices).toHaveBeenCalled()); - expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); + }); }); }); describe("when tenant has not seen welcome and has zero devices", () => { beforeEach(() => { - sdk.getStatusDevices.mockResolvedValue(mockSdkResponse(mockStats())); + server.use(http.get("*/api/stats", () => HttpResponse.json(mockStats()))); }); it("shows the wizard", async () => { @@ -105,63 +101,81 @@ describe("WelcomeWizardTrigger", () => { }); it("refetches stats when wizard is closed", async () => { + let callCount = 0; + server.use( + http.get("*/api/stats", () => { + callCount++; + return HttpResponse.json(mockStats()); + }), + ); renderTrigger(); - const callsBefore = sdk.getStatusDevices.mock.calls.length; + const before = callCount; (await screen.findByTestId("welcome-wizard")).click(); await waitFor(() => { - expect(sdk.getStatusDevices.mock.calls.length).toBeGreaterThan( - callsBefore, - ); + expect(callCount).toBeGreaterThan(before); }); }); }); describe("when tenant has devices", () => { it("does not show the wizard when there are registered devices", async () => { - sdk.getStatusDevices.mockResolvedValue( - mockSdkResponse(mockStats({ registered_devices: 1 })), + server.use( + http.get("*/api/stats", () => + HttpResponse.json(mockStats({ registered_devices: 1 })), + ), ); renderTrigger(); - await waitFor(() => expect(sdk.getStatusDevices).toHaveBeenCalled()); - expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); + }); }); it("still shows the wizard when a device is only pending (not accepted)", async () => { - sdk.getStatusDevices.mockResolvedValue( - mockSdkResponse(mockStats({ pending_devices: 2 })), + server.use( + http.get("*/api/stats", () => + HttpResponse.json(mockStats({ pending_devices: 2 })), + ), ); renderTrigger(); expect(await screen.findByTestId("welcome-wizard")).toBeInTheDocument(); }); it("still shows the wizard when a device is only rejected (not accepted)", async () => { - sdk.getStatusDevices.mockResolvedValue( - mockSdkResponse(mockStats({ rejected_devices: 1 })), + server.use( + http.get("*/api/stats", () => + HttpResponse.json(mockStats({ rejected_devices: 1 })), + ), ); renderTrigger(); expect(await screen.findByTestId("welcome-wizard")).toBeInTheDocument(); }); it("does not call markWelcomeSeen when there are devices", async () => { - sdk.getStatusDevices.mockResolvedValue( - mockSdkResponse(mockStats({ registered_devices: 5 })), + server.use( + http.get("*/api/stats", () => + HttpResponse.json(mockStats({ registered_devices: 5 })), + ), ); renderTrigger(); - await waitFor(() => expect(sdk.getStatusDevices).toHaveBeenCalled()); - expect(mockMarkWelcomeSeen).not.toHaveBeenCalled(); + await waitFor(() => { + expect(mockMarkWelcomeSeen).not.toHaveBeenCalled(); + }); }); }); describe("eligibility is decided once, at page load", () => { it("does not reopen when the last device is deleted mid-session", async () => { - sdk.getStatusDevices.mockResolvedValue( - mockSdkResponse(mockStats({ registered_devices: 1 })), + server.use( + http.get("*/api/stats", () => + HttpResponse.json(mockStats({ registered_devices: 1 })), + ), ); const { rerender } = renderTrigger(); - await waitFor(() => expect(sdk.getStatusDevices).toHaveBeenCalled()); - expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); + }); - sdk.getStatusDevices.mockResolvedValue(mockSdkResponse(mockStats())); + server.use(http.get("*/api/stats", () => HttpResponse.json(mockStats()))); rerender(); expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); }); @@ -169,6 +183,7 @@ describe("WelcomeWizardTrigger", () => { describe("when stats are loading", () => { it("does not show the wizard", () => { + server.use(http.get("*/api/stats", () => new Promise(() => {}))); renderTrigger(); expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); }); @@ -177,10 +192,11 @@ describe("WelcomeWizardTrigger", () => { describe("when tenant is null", () => { it("does not show the wizard", async () => { seedAuthStore({ tenant: null }); - sdk.getStatusDevices.mockResolvedValue(mockSdkResponse(mockStats())); + server.use(http.get("*/api/stats", () => HttpResponse.json(mockStats()))); renderTrigger(); - await waitFor(() => expect(sdk.getStatusDevices).toHaveBeenCalled()); - expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId("welcome-wizard")).not.toBeInTheDocument(); + }); }); }); }); diff --git a/ui/apps/console/src/hooks/__tests__/useAdminFirewallRules.test.ts b/ui/apps/console/src/hooks/__tests__/useAdminFirewallRules.test.ts deleted file mode 100644 index 51d5ef22855..00000000000 --- a/ui/apps/console/src/hooks/__tests__/useAdminFirewallRules.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { waitFor } from "@testing-library/react"; -import { - useAdminFirewallRules, - useAdminFirewallRule, -} from "../useAdminFirewallRules"; -import { useAuthStore } from "@/stores/authStore"; -import { renderHookWithClient } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - getFirewallRulesAdmin: vi.fn(), - getFirewallRuleAdmin: vi.fn(), - }), -); - -beforeEach(() => { - vi.clearAllMocks(); - useAuthStore.setState({ isAdmin: true }); -}); - -describe("useAdminFirewallRules", () => { - describe("when user is admin", () => { - it("returns rules from the paginated query result", async () => { - const rules = [ - { - id: "rule-1", - tenant_id: "tenant-abc", - priority: 1, - action: "allow", - active: true, - source_ip: ".*", - username: ".*", - filter: { hostname: ".*", tags: [] }, - }, - { - id: "rule-2", - tenant_id: "tenant-abc", - priority: 2, - action: "deny", - active: false, - source_ip: "192.168.1.0/24", - username: "admin", - filter: { hostname: "my-host", tags: [] }, - }, - ]; - sdk.getFirewallRulesAdmin.mockResolvedValue( - mockSdkResponse(rules, { "X-Total-Count": "2" }), - ); - - const { result } = renderHookWithClient(() => useAdminFirewallRules()); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.rules).toHaveLength(2); - expect(result.current.rules[0].id).toBe("rule-1"); - expect(result.current.rules[1].id).toBe("rule-2"); - }); - - it("returns totalCount from the X-Total-Count header", async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "42" }), - ); - - const { result } = renderHookWithClient(() => useAdminFirewallRules()); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.totalCount).toBe(42); - }); - - it("defaults rules to empty array while loading", () => { - sdk.getFirewallRulesAdmin.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => useAdminFirewallRules()); - - expect(result.current.rules).toEqual([]); - }); - - it("defaults totalCount to 0 while loading", () => { - sdk.getFirewallRulesAdmin.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => useAdminFirewallRules()); - - expect(result.current.totalCount).toBe(0); - }); - - it("returns isLoading true initially", () => { - sdk.getFirewallRulesAdmin.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => useAdminFirewallRules()); - - expect(result.current.isLoading).toBe(true); - }); - - it("exposes error when query fails", async () => { - const networkError = new Error("network failure"); - sdk.getFirewallRulesAdmin.mockRejectedValue(networkError); - - const { result } = renderHookWithClient(() => useAdminFirewallRules()); - - await waitFor(() => expect(result.current.error).toBeTruthy()); - expect(result.current.error).toBe(networkError); - }); - }); - - describe("when user is not admin", () => { - it("does not execute the query", () => { - useAuthStore.setState({ isAdmin: false }); - - const { result } = renderHookWithClient(() => useAdminFirewallRules()); - - expect(result.current.isLoading).toBe(false); - expect(result.current.rules).toEqual([]); - expect(sdk.getFirewallRulesAdmin).not.toHaveBeenCalled(); - }); - }); - - describe("pagination defaults", () => { - it("uses page 1 and perPage 10 as defaults", async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - renderHookWithClient(() => useAdminFirewallRules()); - - await waitFor(() => expect(sdk.getFirewallRulesAdmin).toHaveBeenCalled()); - const [opts] = sdk.getFirewallRulesAdmin.mock.calls[0]; - expect(opts.query.page).toBe(1); - expect(opts.query.per_page).toBe(10); - }); - - it("forwards custom page and perPage", async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - renderHookWithClient(() => - useAdminFirewallRules({ page: 3, perPage: 25 }), - ); - - await waitFor(() => expect(sdk.getFirewallRulesAdmin).toHaveBeenCalled()); - const [opts] = sdk.getFirewallRulesAdmin.mock.calls[0]; - expect(opts.query.page).toBe(3); - expect(opts.query.per_page).toBe(25); - }); - }); -}); - -describe("useAdminFirewallRule", () => { - describe("when user is admin", () => { - it("returns query data for the given rule id", async () => { - const rawRule = { - id: "rule-1", - tenant_id: "tenant-abc", - priority: 1, - action: "allow", - active: true, - source_ip: ".*", - username: ".*", - filter: { hostname: ".*", tags: [] }, - }; - sdk.getFirewallRuleAdmin.mockResolvedValue(mockSdkResponse(rawRule)); - - const { result } = renderHookWithClient(() => - useAdminFirewallRule("rule-1"), - ); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.data?.id).toBe("rule-1"); - }); - - it("passes through the filter unchanged", async () => { - const rawRule = { - id: "rule-1", - tenant_id: "tenant-abc", - priority: 1, - action: "allow", - active: true, - source_ip: ".*", - username: ".*", - filter: { hostname: "my-host", tags: [] }, - }; - sdk.getFirewallRuleAdmin.mockResolvedValue(mockSdkResponse(rawRule)); - - const { result } = renderHookWithClient(() => - useAdminFirewallRule("rule-1"), - ); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.data?.filter).toEqual({ - hostname: "my-host", - tags: [], - }); - }); - - it("is loading initially when id is provided", () => { - sdk.getFirewallRuleAdmin.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => - useAdminFirewallRule("rule-1"), - ); - - expect(result.current.isLoading).toBe(true); - }); - - it("exposes error when query fails", async () => { - const err = new Error("not found"); - sdk.getFirewallRuleAdmin.mockRejectedValue(err); - - const { result } = renderHookWithClient(() => - useAdminFirewallRule("rule-1"), - ); - - await waitFor(() => expect(result.current.isError).toBe(true)); - }); - }); - - describe("when id is empty", () => { - it("does not execute the query", () => { - const { result } = renderHookWithClient(() => useAdminFirewallRule("")); - - expect(result.current.isLoading).toBe(false); - expect(sdk.getFirewallRuleAdmin).not.toHaveBeenCalled(); - }); - }); - - describe("when user is not admin", () => { - it("does not execute the query even when id is provided", () => { - useAuthStore.setState({ isAdmin: false }); - - const { result } = renderHookWithClient(() => - useAdminFirewallRule("rule-1"), - ); - - expect(result.current.isLoading).toBe(false); - expect(sdk.getFirewallRuleAdmin).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/ui/apps/console/src/hooks/__tests__/useAdminLicense.test.ts b/ui/apps/console/src/hooks/__tests__/useAdminLicense.test.ts index 4505e672007..c3791c07ec5 100644 --- a/ui/apps/console/src/hooks/__tests__/useAdminLicense.test.ts +++ b/ui/apps/console/src/hooks/__tests__/useAdminLicense.test.ts @@ -1,15 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { renderHookWithClient } from "@/tests/wrapper"; import { getConfig, defaultConfig } from "@/env"; import { useAuthStore } from "@/stores/authStore"; -import { mockSdkResponse, makeSdkError } from "@/tests/sdk"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - getLicense: vi.fn(), - }), -); import { useAdminLicense } from "../useAdminLicense"; @@ -40,19 +35,22 @@ describe("useAdminLicense", () => { describe("enterprise admin — valid license", () => { it("calls getLicense and returns installedLicense", async () => { const license = makeLicense(); - sdk.getLicense.mockResolvedValue(mockSdkResponse(license)); + server.use( + http.get("*/admin/api/license", () => HttpResponse.json(license)), + ); const { result } = renderHookWithClient(() => useAdminLicense()); await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(sdk.getLicense).toHaveBeenCalledTimes(1); expect(result.current.installedLicense).toEqual(license); }); it("sets isExpired to false when license is not expired", async () => { - sdk.getLicense.mockResolvedValue( - mockSdkResponse(makeLicense({ expired: false })), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json(makeLicense({ expired: false })), + ), ); const { result } = renderHookWithClient(() => useAdminLicense()); @@ -64,7 +62,11 @@ describe("useAdminLicense", () => { describe("enterprise admin — 400 (no license stored)", () => { it("normalizes 400 to installedLicense null", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(400)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 400 }), + ), + ); const { result } = renderHookWithClient(() => useAdminLicense()); @@ -73,7 +75,11 @@ describe("useAdminLicense", () => { }); it("sets isExpired to true when no license is installed", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(400)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 400 }), + ), + ); const { result } = renderHookWithClient(() => useAdminLicense()); @@ -84,8 +90,10 @@ describe("useAdminLicense", () => { describe("enterprise admin — expired license", () => { it("sets isExpired to true when license.expired is true", async () => { - sdk.getLicense.mockResolvedValue( - mockSdkResponse(makeLicense({ expired: true })), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json(makeLicense({ expired: true })), + ), ); const { result } = renderHookWithClient(() => useAdminLicense()); @@ -95,8 +103,12 @@ describe("useAdminLicense", () => { }); it("keeps isExpired false for a non-expired license still in its grace period", async () => { - sdk.getLicense.mockResolvedValue( - mockSdkResponse(makeLicense({ expired: false, grace_period: true })), + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json( + makeLicense({ expired: false, grace_period: true }), + ), + ), ); const { result } = renderHookWithClient(() => useAdminLicense()); @@ -109,13 +121,20 @@ describe("useAdminLicense", () => { describe("cloud admin — bypass", () => { it("does NOT call getLicense on cloud deployments", async () => { mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); + let called = false; + server.use( + http.get("*/admin/api/license", () => { + called = true; + return HttpResponse.json(makeLicense()); + }), + ); const { result } = renderHookWithClient(() => useAdminLicense()); - await new Promise((r) => setTimeout(r, 20)); - - expect(sdk.getLicense).not.toHaveBeenCalled(); - expect(result.current.isLoading).toBe(false); + await waitFor(() => { + expect(called).toBe(false); + expect(result.current.isLoading).toBe(false); + }); }); it("returns isExpired false on cloud deployments", async () => { @@ -123,21 +142,29 @@ describe("useAdminLicense", () => { const { result } = renderHookWithClient(() => useAdminLicense()); - await new Promise((r) => setTimeout(r, 20)); - expect(result.current.isExpired).toBe(false); + await waitFor(() => + expect(result.current.isExpired).toBe(false), + ); }); }); describe("non-admin on enterprise", () => { it("does NOT call getLicense when user is not admin", async () => { useAuthStore.setState({ isAdmin: false }); + let called = false; + server.use( + http.get("*/admin/api/license", () => { + called = true; + return HttpResponse.json(makeLicense()); + }), + ); const { result } = renderHookWithClient(() => useAdminLicense()); - await new Promise((r) => setTimeout(r, 20)); - - expect(sdk.getLicense).not.toHaveBeenCalled(); - expect(result.current.isLoading).toBe(false); + await waitFor(() => { + expect(called).toBe(false); + expect(result.current.isLoading).toBe(false); + }); }); it("returns isExpired false when user is not admin", async () => { @@ -145,8 +172,9 @@ describe("useAdminLicense", () => { const { result } = renderHookWithClient(() => useAdminLicense()); - await new Promise((r) => setTimeout(r, 20)); - expect(result.current.isExpired).toBe(false); + await waitFor(() => + expect(result.current.isExpired).toBe(false), + ); }); }); }); diff --git a/ui/apps/console/src/hooks/__tests__/useAdminSessions.test.ts b/ui/apps/console/src/hooks/__tests__/useAdminSessions.test.ts deleted file mode 100644 index 3b8f55a5e44..00000000000 --- a/ui/apps/console/src/hooks/__tests__/useAdminSessions.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { waitFor } from "@testing-library/react"; -import { renderHookWithClient } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; -import { useAuthStore } from "@/stores/authStore"; -import { useAdminSessions } from "../useAdminSessions"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - getSessionsAdmin: vi.fn(), - }), -); - -const mockSession = { - uid: "session-1", - device_uid: "device-1", - username: "root", - ip_address: "192.168.0.1", - started_at: "2024-01-01T00:00:00Z", - last_seen: "2024-01-01T01:00:00Z", - active: true, - authenticated: true, -}; - -function renderAdminSessions() { - return renderHookWithClient(() => useAdminSessions({ page: 1, perPage: 10 })); -} - -describe("useAdminSessions", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("when user is not an admin", () => { - it("returns empty sessions and zero totalCount without fetching", () => { - useAuthStore.setState({ isAdmin: false }); - - const { result } = renderAdminSessions(); - - expect(result.current.sessions).toEqual([]); - expect(result.current.totalCount).toBe(0); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBeNull(); - expect(sdk.getSessionsAdmin).not.toHaveBeenCalled(); - }); - }); - - describe("when user is an admin", () => { - beforeEach(() => { - useAuthStore.setState({ isAdmin: true }); - }); - - it("returns sessions and totalCount on success", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - mockSdkResponse([mockSession], { "X-Total-Count": "1" }), - ); - - const { result } = renderAdminSessions(); - - await waitFor(() => expect(result.current.sessions).toHaveLength(1)); - - expect(result.current.sessions[0]).toMatchObject({ uid: "session-1" }); - expect(result.current.totalCount).toBe(1); - expect(result.current.error).toBeNull(); - }); - - it("returns empty arrays when the API returns no sessions", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - const { result } = renderAdminSessions(); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - - expect(result.current.sessions).toEqual([]); - expect(result.current.totalCount).toBe(0); - }); - - it("is loading while the query is in-flight", () => { - sdk.getSessionsAdmin.mockReturnValue(new Promise(() => {})); - - const { result } = renderAdminSessions(); - - expect(result.current.isLoading).toBe(true); - }); - - it("exposes the raw error on fetch failure", async () => { - sdk.getSessionsAdmin.mockRejectedValue(new Error("Network timeout")); - - const { result } = renderAdminSessions(); - - await waitFor(() => expect(result.current.error).not.toBeNull()); - expect(result.current.error?.message).toBe("Network timeout"); - }); - }); -}); diff --git a/ui/apps/console/src/hooks/__tests__/useAdminUserMutations.test.ts b/ui/apps/console/src/hooks/__tests__/useAdminUserMutations.test.ts deleted file mode 100644 index dc0939fea0e..00000000000 --- a/ui/apps/console/src/hooks/__tests__/useAdminUserMutations.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { waitFor, act } from "@testing-library/react"; -import { renderHookWithClient } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; -import { - useCreateUser, - useUpdateUser, - useDeleteUser, - useResetUserPassword, -} from "../useAdminUserMutations"; - -const mockInvalidate = vi.fn(); - -const sdk = vi.hoisted(() => - mockSdkGen({ - createUserAdmin: vi.fn(), - adminUpdateUser: vi.fn(), - adminDeleteUser: vi.fn(), - adminResetUserPassword: vi.fn(), - }), -); - -vi.mock("../useInvalidateQueries", () => ({ - useInvalidateByIds: vi.fn(() => mockInvalidate), -})); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("useCreateUser", () => { - describe("mutation call", () => { - it("calls createUserAdmin with the provided body", async () => { - sdk.createUserAdmin.mockResolvedValue(mockSdkResponse(undefined)); - const { result } = renderHookWithClient(() => useCreateUser()); - - const body = { - body: { - name: "Alice", - username: "alice", - email: "alice@example.com", - password: "pass1", - }, - }; - await act(() => result.current.mutateAsync(body)); - - expect(sdk.createUserAdmin).toHaveBeenCalledWith( - expect.objectContaining({ ...body, throwOnError: true }), - ); - }); - }); - - describe("on success", () => { - it("calls invalidate after successful mutation", async () => { - sdk.createUserAdmin.mockResolvedValue(mockSdkResponse(undefined)); - const { result } = renderHookWithClient(() => useCreateUser()); - - await act(() => result.current.mutateAsync({})); - - await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); - }); - }); - - describe("on failure", () => { - it("exposes error when mutation fails", async () => { - const error = new Error("create failed"); - sdk.createUserAdmin.mockRejectedValue(error); - const { result } = renderHookWithClient(() => useCreateUser()); - - act(() => result.current.mutate({})); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toBe(error); - }); - - it("does not call invalidate when mutation fails", async () => { - sdk.createUserAdmin.mockRejectedValue(new Error("create failed")); - const { result } = renderHookWithClient(() => useCreateUser()); - - act(() => result.current.mutate({})); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(mockInvalidate).not.toHaveBeenCalled(); - }); - }); -}); - -describe("useUpdateUser", () => { - describe("mutation call", () => { - it("calls adminUpdateUser with path and body", async () => { - sdk.adminUpdateUser.mockResolvedValue(mockSdkResponse(undefined)); - const { result } = renderHookWithClient(() => useUpdateUser()); - - const vars = { path: { id: "u1" }, body: { name: "Bob" } }; - await act(() => result.current.mutateAsync(vars)); - - expect(sdk.adminUpdateUser).toHaveBeenCalledWith( - expect.objectContaining({ ...vars, throwOnError: true }), - ); - }); - }); - - describe("on success", () => { - it("calls invalidate after successful update", async () => { - sdk.adminUpdateUser.mockResolvedValue(mockSdkResponse(undefined)); - const { result } = renderHookWithClient(() => useUpdateUser()); - - await act(() => result.current.mutateAsync({ path: { id: "u1" } })); - - await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); - }); - }); - - describe("on failure", () => { - it("exposes error when update fails", async () => { - const error = new Error("update failed"); - sdk.adminUpdateUser.mockRejectedValue(error); - const { result } = renderHookWithClient(() => useUpdateUser()); - - act(() => result.current.mutate({ path: { id: "u1" } })); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toBe(error); - }); - - it("does not call invalidate when update fails", async () => { - sdk.adminUpdateUser.mockRejectedValue(new Error("update failed")); - const { result } = renderHookWithClient(() => useUpdateUser()); - - act(() => result.current.mutate({ path: { id: "u1" } })); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(mockInvalidate).not.toHaveBeenCalled(); - }); - }); -}); - -describe("useDeleteUser", () => { - describe("mutation call", () => { - it("calls adminDeleteUser with the path", async () => { - sdk.adminDeleteUser.mockResolvedValue(mockSdkResponse(undefined)); - const { result } = renderHookWithClient(() => useDeleteUser()); - - const vars = { path: { id: "u1" } }; - await act(() => result.current.mutateAsync(vars)); - - expect(sdk.adminDeleteUser).toHaveBeenCalledWith( - expect.objectContaining({ ...vars, throwOnError: true }), - ); - }); - }); - - describe("on success", () => { - it("calls invalidate after successful delete", async () => { - sdk.adminDeleteUser.mockResolvedValue(mockSdkResponse(undefined)); - const { result } = renderHookWithClient(() => useDeleteUser()); - - await act(() => result.current.mutateAsync({ path: { id: "u1" } })); - - await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); - }); - }); - - describe("on failure", () => { - it("exposes error when delete fails", async () => { - const error = new Error("delete failed"); - sdk.adminDeleteUser.mockRejectedValue(error); - const { result } = renderHookWithClient(() => useDeleteUser()); - - act(() => result.current.mutate({ path: { id: "u1" } })); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toBe(error); - }); - - it("does not call invalidate when delete fails", async () => { - sdk.adminDeleteUser.mockRejectedValue(new Error("delete failed")); - const { result } = renderHookWithClient(() => useDeleteUser()); - - act(() => result.current.mutate({ path: { id: "u1" } })); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(mockInvalidate).not.toHaveBeenCalled(); - }); - }); -}); - -describe("useResetUserPassword", () => { - describe("mutation call", () => { - it("calls adminResetUserPassword with the path", async () => { - sdk.adminResetUserPassword.mockResolvedValue( - mockSdkResponse({ password: "generated-pw" }), - ); - const { result } = renderHookWithClient(() => useResetUserPassword()); - - const vars = { path: { id: "u1" } }; - await act(() => result.current.mutateAsync(vars)); - - expect(sdk.adminResetUserPassword).toHaveBeenCalledWith( - expect.objectContaining({ ...vars, throwOnError: true }), - ); - }); - - it("returns the generated password from the mutation", async () => { - sdk.adminResetUserPassword.mockResolvedValue( - mockSdkResponse({ password: "s3cr3t-pass" }), - ); - const { result } = renderHookWithClient(() => useResetUserPassword()); - - const data = await act(() => - result.current.mutateAsync({ path: { id: "u1" } }), - ); - - expect(data).toEqual({ password: "s3cr3t-pass" }); - }); - }); - - describe("on success", () => { - it("calls invalidate after successful password reset", async () => { - sdk.adminResetUserPassword.mockResolvedValue( - mockSdkResponse({ password: "pw" }), - ); - const { result } = renderHookWithClient(() => useResetUserPassword()); - - await act(() => result.current.mutateAsync({ path: { id: "u1" } })); - - await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); - }); - }); - - describe("on failure", () => { - it("exposes error when reset fails", async () => { - const error = new Error("reset failed"); - sdk.adminResetUserPassword.mockRejectedValue(error); - const { result } = renderHookWithClient(() => useResetUserPassword()); - - act(() => result.current.mutate({ path: { id: "u1" } })); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toBe(error); - }); - - it("does not call invalidate when reset fails", async () => { - sdk.adminResetUserPassword.mockRejectedValue(new Error("reset failed")); - const { result } = renderHookWithClient(() => useResetUserPassword()); - - act(() => result.current.mutate({ path: { id: "u1" } })); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(mockInvalidate).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/ui/apps/console/src/hooks/__tests__/useAdminUsers.test.ts b/ui/apps/console/src/hooks/__tests__/useAdminUsers.test.ts deleted file mode 100644 index efb6d6a87ff..00000000000 --- a/ui/apps/console/src/hooks/__tests__/useAdminUsers.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { waitFor } from "@testing-library/react"; -import { renderHookWithClient } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; -import { decodeB64url } from "@/tests/decodeB64url"; -import { useAdminUsers, useAdminUser } from "../useAdminUsers"; -import { useAuthStore } from "@/stores/authStore"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - getUsers: vi.fn(), - getUser: vi.fn(), - }), -); - -beforeEach(() => { - vi.clearAllMocks(); - useAuthStore.setState({ isAdmin: true }); -}); - -describe("useAdminUsers", () => { - describe("when user is admin", () => { - it("returns users from the paginated query result", async () => { - const users = [ - { id: "u1", username: "alice" }, - { id: "u2", username: "bob" }, - ]; - sdk.getUsers.mockResolvedValue( - mockSdkResponse(users, { "X-Total-Count": "2" }), - ); - - const { result } = renderHookWithClient(() => useAdminUsers()); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.users).toEqual(users); - }); - - it("returns totalCount from the X-Total-Count header", async () => { - sdk.getUsers.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "99" }), - ); - - const { result } = renderHookWithClient(() => useAdminUsers()); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.totalCount).toBe(99); - }); - - it("defaults users to empty array while loading", () => { - sdk.getUsers.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => useAdminUsers()); - - expect(result.current.users).toEqual([]); - }); - - it("defaults totalCount to 0 while loading", () => { - sdk.getUsers.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => useAdminUsers()); - - expect(result.current.totalCount).toBe(0); - }); - - it("returns isLoading true initially", () => { - sdk.getUsers.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => useAdminUsers()); - - expect(result.current.isLoading).toBe(true); - }); - - it("exposes error when query fails", async () => { - const networkError = new Error("network failure"); - sdk.getUsers.mockRejectedValue(networkError); - - const { result } = renderHookWithClient(() => useAdminUsers()); - - await waitFor(() => expect(result.current.error).toBeTruthy()); - expect(result.current.error).toBe(networkError); - }); - - it("exposes refetch function", () => { - sdk.getUsers.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => useAdminUsers()); - - expect(typeof result.current.refetch).toBe("function"); - }); - }); - - describe("when user is not admin", () => { - it("does not execute the query", () => { - useAuthStore.setState({ isAdmin: false }); - - const { result } = renderHookWithClient(() => useAdminUsers()); - - expect(result.current.isLoading).toBe(false); - expect(result.current.users).toEqual([]); - expect(sdk.getUsers).not.toHaveBeenCalled(); - }); - }); - - describe("search filter", () => { - it("passes search parameter to the query options", async () => { - sdk.getUsers.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - const { result } = renderHookWithClient(() => - useAdminUsers({ search: "alice" }), - ); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(sdk.getUsers).toHaveBeenCalled(); - }); - - it("does not pass filter when search is empty", async () => { - sdk.getUsers.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - renderHookWithClient(() => useAdminUsers({ search: "" })); - - await waitFor(() => expect(sdk.getUsers).toHaveBeenCalled()); - const [opts] = sdk.getUsers.mock.calls[0]; - expect(opts.query.filter).toBeUndefined(); - }); - - it("includes a base64-encoded filter when search is non-empty", async () => { - sdk.getUsers.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - renderHookWithClient(() => useAdminUsers({ search: " >" })); - - await waitFor(() => expect(sdk.getUsers).toHaveBeenCalled()); - const [opts] = sdk.getUsers.mock.calls[0]; - expect(typeof opts.query.filter).toBe("string"); - const decoded = decodeB64url(opts.query.filter as string) as unknown[]; - expect(JSON.stringify(decoded)).toContain(" >"); - }); - }); - - describe("pagination defaults", () => { - it("uses page 1 and perPage 10 as defaults", async () => { - sdk.getUsers.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - renderHookWithClient(() => useAdminUsers()); - - await waitFor(() => expect(sdk.getUsers).toHaveBeenCalled()); - const [opts] = sdk.getUsers.mock.calls[0]; - expect(opts.query.page).toBe(1); - expect(opts.query.per_page).toBe(10); - }); - - it("forwards custom page and perPage", async () => { - sdk.getUsers.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - renderHookWithClient(() => useAdminUsers({ page: 3, perPage: 25 })); - - await waitFor(() => expect(sdk.getUsers).toHaveBeenCalled()); - const [opts] = sdk.getUsers.mock.calls[0]; - expect(opts.query.page).toBe(3); - expect(opts.query.per_page).toBe(25); - }); - }); -}); - -describe("useAdminUser", () => { - describe("when user is admin", () => { - it("returns query data for the given user id", async () => { - const user = { id: "u1", username: "alice" }; - sdk.getUser.mockResolvedValue(mockSdkResponse(user)); - - const { result } = renderHookWithClient(() => useAdminUser("u1")); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.data).toEqual(user); - }); - - it("is loading initially when id is provided", () => { - sdk.getUser.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => useAdminUser("u1")); - - expect(result.current.isLoading).toBe(true); - }); - - it("exposes error when query fails", async () => { - const err = new Error("not found"); - sdk.getUser.mockRejectedValue(err); - - const { result } = renderHookWithClient(() => useAdminUser("u1")); - - await waitFor(() => expect(result.current.isError).toBe(true)); - }); - }); - - describe("when id is empty", () => { - it("does not execute the query", () => { - const { result } = renderHookWithClient(() => useAdminUser("")); - - expect(result.current.isLoading).toBe(false); - expect(sdk.getUser).not.toHaveBeenCalled(); - }); - }); - - describe("when user is not admin", () => { - it("does not execute the query even when id is provided", () => { - useAuthStore.setState({ isAdmin: false }); - - const { result } = renderHookWithClient(() => useAdminUser("u1")); - - expect(result.current.isLoading).toBe(false); - expect(sdk.getUser).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/ui/apps/console/src/hooks/__tests__/useBilling.test.ts b/ui/apps/console/src/hooks/__tests__/useBilling.test.ts deleted file mode 100644 index 748dc89a131..00000000000 --- a/ui/apps/console/src/hooks/__tests__/useBilling.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, waitFor, act } from "@testing-library/react"; -import { createTestWrapper, renderHookWithClient } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; - -const mockInvalidate = vi.fn(); - -const sdk = vi.hoisted(() => - mockSdkGen({ - getCustomer: vi.fn(), - getSubscription: vi.fn(), - createCustomer: vi.fn(), - createSubscription: vi.fn(), - attachPaymentMethod: vi.fn(), - detachPaymentMethod: vi.fn(), - setDefaultPaymentMethod: vi.fn(), - createBillingPortalSession: vi.fn(), - }), -); - -vi.mock("../useInvalidateQueries", () => ({ - useInvalidateByIds: vi.fn(() => mockInvalidate), -})); - -async function importHooks() { - return await import("../useBilling"); -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("useBilling mutations", () => { - it("invalidates billing queries on customer creation", async () => { - sdk.createCustomer.mockResolvedValue(mockSdkResponse(undefined)); - const { useCreateCustomer } = await importHooks(); - - const { result } = renderHookWithClient(() => useCreateCustomer()); - - await act(() => result.current.mutateAsync({})); - - await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); - expect(sdk.createCustomer).toHaveBeenCalled(); - }); - - it("invalidates billing queries on subscription creation", async () => { - sdk.createSubscription.mockResolvedValue(mockSdkResponse(undefined)); - const { useCreateSubscription } = await importHooks(); - - const { result } = renderHookWithClient(() => useCreateSubscription()); - - await act(() => result.current.mutateAsync({})); - - await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); - }); - - it("propagates 402 errors from subscription creation", async () => { - const err = Object.assign(new Error("payment required"), { - isAxiosError: true, - response: { status: 402 }, - }); - sdk.createSubscription.mockRejectedValue(err); - const { useCreateSubscription } = await importHooks(); - - const { result } = renderHookWithClient(() => useCreateSubscription()); - - await expect(result.current.mutateAsync({})).rejects.toBe(err); - expect(mockInvalidate).not.toHaveBeenCalled(); - }); - - it("attach/detach/default run through the SDK mutations", async () => { - sdk.attachPaymentMethod.mockResolvedValue(mockSdkResponse(undefined)); - sdk.detachPaymentMethod.mockResolvedValue(mockSdkResponse(undefined)); - sdk.setDefaultPaymentMethod.mockResolvedValue(mockSdkResponse(undefined)); - const { - useAttachPaymentMethod, - useDetachPaymentMethod, - useSetDefaultPaymentMethod, - } = await importHooks(); - - const wrapper = createTestWrapper(); - const attachHook = renderHook(() => useAttachPaymentMethod(), { wrapper }); - await act(() => - attachHook.result.current.mutateAsync({ body: { id: "pm_1" } }), - ); - - const detachHook = renderHook(() => useDetachPaymentMethod(), { wrapper }); - await act(() => - detachHook.result.current.mutateAsync({ body: { id: "pm_1" } }), - ); - - const defHook = renderHook(() => useSetDefaultPaymentMethod(), { wrapper }); - await act(() => - defHook.result.current.mutateAsync({ body: { id: "pm_1" } }), - ); - - expect(sdk.attachPaymentMethod).toHaveBeenCalled(); - expect(sdk.detachPaymentMethod).toHaveBeenCalled(); - expect(sdk.setDefaultPaymentMethod).toHaveBeenCalled(); - }); -}); - -describe("useCreateSubscription (query key coverage)", () => { - it("calls the mutation fn and then invalidates on success", async () => { - sdk.createSubscription.mockResolvedValue(mockSdkResponse(undefined)); - const { useCreateSubscription } = await importHooks(); - - const { result } = renderHookWithClient(() => useCreateSubscription()); - - await act(() => result.current.mutateAsync({})); - - await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); - expect(sdk.createSubscription).toHaveBeenCalled(); - }); -}); - -describe("useCustomer", () => { - it("does not call the SDK when enabled=false", async () => { - const { useCustomer } = await importHooks(); - - renderHookWithClient(() => useCustomer(false)); - - expect(sdk.getCustomer).not.toHaveBeenCalled(); - }); - - it("returns undefined customer when the query has no data", async () => { - const { useCustomer } = await importHooks(); - - const { result } = renderHookWithClient(() => useCustomer(false)); - - expect(result.current.customer).toBeUndefined(); - }); -}); - -describe("useSubscription", () => { - it("does not call the SDK when enabled=false", async () => { - const { useSubscription } = await importHooks(); - - renderHookWithClient(() => useSubscription(false)); - - expect(sdk.getSubscription).not.toHaveBeenCalled(); - }); - - it("exposes a refetch function even when disabled", async () => { - const { useSubscription } = await importHooks(); - - const { result } = renderHookWithClient(() => useSubscription(false)); - - expect(typeof result.current.refetch).toBe("function"); - }); - - it("returns undefined subscription when query has no data", async () => { - const { useSubscription } = await importHooks(); - - const { result } = renderHookWithClient(() => useSubscription(false)); - - expect(result.current.subscription).toBeUndefined(); - }); -}); - -describe("useOpenBillingPortal", () => { - it("opens the URL the billing portal route returns", async () => { - const openSpy = vi.spyOn(window, "open").mockReturnValue(null); - sdk.createBillingPortalSession.mockResolvedValue( - mockSdkResponse({ url: "https://billing.stripe.com/session/abc" }), - ); - const { useOpenBillingPortal } = await importHooks(); - - const { result } = renderHookWithClient(() => useOpenBillingPortal()); - - await act(() => result.current.mutateAsync()); - - expect(sdk.createBillingPortalSession).toHaveBeenCalled(); - expect(openSpy).toHaveBeenCalledWith( - "https://billing.stripe.com/session/abc", - "_blank", - "noopener,noreferrer", - ); - openSpy.mockRestore(); - }); - - it("throws when the response is missing a URL", async () => { - sdk.createBillingPortalSession.mockResolvedValue(mockSdkResponse({})); - const { useOpenBillingPortal } = await importHooks(); - - const { result } = renderHookWithClient(() => useOpenBillingPortal()); - - await expect(result.current.mutateAsync()).rejects.toThrow(/portal URL/i); - }); -}); diff --git a/ui/apps/console/src/hooks/__tests__/useChatwoot.test.ts b/ui/apps/console/src/hooks/__tests__/useChatwoot.test.ts index e9e7f955409..5918697e30f 100644 --- a/ui/apps/console/src/hooks/__tests__/useChatwoot.test.ts +++ b/ui/apps/console/src/hooks/__tests__/useChatwoot.test.ts @@ -1,19 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { getConfig, defaultConfig } from "@/env"; import { createTestWrapper } from "@/tests/wrapper"; import { useAuthStore } from "@/stores/authStore"; -import { mockSdkResponse, makeSdkError } from "@/tests/sdk"; import { tearDownChatwoot } from "../chatwootRuntime"; import { useChatwoot } from "../useChatwoot"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getNamespace: vi.fn(), - getNamespaceSupport: vi.fn(), - }), -); - const mockGetConfig = vi.mocked(getConfig); function makeWidgetReady() { @@ -49,17 +43,23 @@ describe("useChatwoot", () => { name: "Test User", tenant: "tenant-abc", }); - sdk.getNamespace.mockResolvedValue( - mockSdkResponse({ - name: "my-ns", - billing: { - customer_id: "cus_123", - subscription: { id: "sub_123", status: "active", current_period_end: 0 }, - }, - }), - ); - sdk.getNamespaceSupport.mockResolvedValue( - mockSdkResponse({ identifier: "abc123" }), + server.use( + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json({ + name: "my-ns", + billing: { + customer_id: "cus_123", + subscription: { + id: "sub_123", + status: "active", + current_period_end: 0, + }, + }, + }), + ), + http.get("*/api/namespaces/:tenant/support", () => + HttpResponse.json({ identifier: "abc123" }), + ), ); }); @@ -117,7 +117,9 @@ describe("useChatwoot", () => { describe("status: loading (namespace not resolved)", () => { it("returns 'loading' when namespace is null", () => { - sdk.getNamespace.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/api/namespaces/:tenant", () => new Promise(() => {})), + ); const { result } = renderHook(() => useChatwoot(), { wrapper: createTestWrapper(), @@ -129,8 +131,13 @@ describe("useChatwoot", () => { describe("status: no-subscription", () => { it("returns 'no-subscription' when the namespace has no subscription", async () => { - sdk.getNamespace.mockResolvedValue( - mockSdkResponse({ name: "my-ns", billing: { customer_id: "cus_123" } }), + server.use( + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json({ + name: "my-ns", + billing: { customer_id: "cus_123" }, + }), + ), ); const { result } = renderHook(() => useChatwoot(), { @@ -143,7 +150,11 @@ describe("useChatwoot", () => { }); it("returns 'no-subscription' when namespace has no billing object", async () => { - sdk.getNamespace.mockResolvedValue(mockSdkResponse({ name: "my-ns" })); + server.use( + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json({ name: "my-ns" }), + ), + ); const { result } = renderHook(() => useChatwoot(), { wrapper: createTestWrapper(), @@ -157,25 +168,32 @@ describe("useChatwoot", () => { describe("status: loading (identifier fetching)", () => { it("returns 'loading' while support identifier is being fetched", async () => { - sdk.getNamespaceSupport.mockReturnValue(new Promise(() => {})); + server.use( + http.get( + "*/api/namespaces/:tenant/support", + () => new Promise(() => {}), + ), + ); const { result } = renderHook(() => useChatwoot(), { wrapper: createTestWrapper(), }); - await waitFor(() => expect(sdk.getNamespaceSupport).toHaveBeenCalled()); - expect(result.current.status).toBe("loading"); + await waitFor(() => expect(result.current.status).toBe("loading")); }); it("returns 'loading' when identifier response has no identifier field", async () => { - sdk.getNamespaceSupport.mockResolvedValue(mockSdkResponse({})); + server.use( + http.get("*/api/namespaces/:tenant/support", () => + HttpResponse.json({}), + ), + ); const { result } = renderHook(() => useChatwoot(), { wrapper: createTestWrapper(), }); - await waitFor(() => expect(sdk.getNamespaceSupport).toHaveBeenCalled()); - expect(result.current.status).toBe("loading"); + await waitFor(() => expect(result.current.status).toBe("loading")); }); }); @@ -339,7 +357,9 @@ describe("useChatwoot", () => { }); it("does not call toggle when status is not ready (loading)", () => { - sdk.getNamespace.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/api/namespaces/:tenant", () => new Promise(() => {})), + ); const { result } = renderHook(() => useChatwoot(), { wrapper: createTestWrapper(), @@ -365,7 +385,11 @@ describe("useChatwoot", () => { describe("status: unavailable (identifier endpoint errors)", () => { it("flips to 'unavailable' when /support returns an error", async () => { - sdk.getNamespaceSupport.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/api/namespaces/:tenant/support", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const { result } = renderHook(() => useChatwoot(), { wrapper: createTestWrapper(), @@ -526,9 +550,6 @@ describe("useChatwoot", () => { expect(first.result.current.status).toBe("unavailable"); first.unmount(); - sdk.getNamespaceSupport.mockResolvedValue( - mockSdkResponse({ identifier: "xyz789" }), - ); const second = renderHook(() => useChatwoot(), { wrapper: createTestWrapper(), }); diff --git a/ui/apps/console/src/hooks/__tests__/useContainerActionRunner.test.ts b/ui/apps/console/src/hooks/__tests__/useContainerActionRunner.test.ts index 2b271c457a8..784a4a31c30 100644 --- a/ui/apps/console/src/hooks/__tests__/useContainerActionRunner.test.ts +++ b/ui/apps/console/src/hooks/__tests__/useContainerActionRunner.test.ts @@ -1,47 +1,69 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { renderHook } from "@testing-library/react"; -import { mockSdkResponse } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { useContainerActionRunner } from "../useContainerActionRunner"; -const sdk = vi.hoisted(() => - mockSdkGen({ - updateContainerStatus: vi.fn(), - deleteContainer: vi.fn(), - }), -); - const entity = { uid: "uid-1", name: "my-container" }; const wrapper = createTestWrapper(); beforeEach(() => { - vi.clearAllMocks(); - sdk.updateContainerStatus.mockResolvedValue(mockSdkResponse(undefined)); - sdk.deleteContainer.mockResolvedValue(mockSdkResponse(undefined)); + server.use( + http.patch( + "*/api/containers/:uid/:status", + () => new HttpResponse(null, { status: 200 }), + ), + http.delete( + "*/api/containers/:uid", + () => new HttpResponse(null, { status: 200 }), + ), + ); }); describe("useContainerActionRunner", () => { it("calls updateContainerStatus with accept for accept", async () => { - const { result } = renderHook(() => useContainerActionRunner(), { wrapper }); - await result.current(entity, "accept"); - expect(sdk.updateContainerStatus).toHaveBeenCalledWith( - expect.objectContaining({ path: { uid: "uid-1", status: "accept" } }), + let capturedStatus: string | undefined; + server.use( + http.patch("*/api/containers/:uid/:status", ({ params }) => { + capturedStatus = params.status as string; + return new HttpResponse(null, { status: 200 }); + }), ); + const { result } = renderHook(() => useContainerActionRunner(), { + wrapper, + }); + await result.current(entity, "accept"); + expect(capturedStatus).toBe("accept"); }); it("calls updateContainerStatus with reject for reject", async () => { - const { result } = renderHook(() => useContainerActionRunner(), { wrapper }); - await result.current(entity, "reject"); - expect(sdk.updateContainerStatus).toHaveBeenCalledWith( - expect.objectContaining({ path: { uid: "uid-1", status: "reject" } }), + let capturedStatus: string | undefined; + server.use( + http.patch("*/api/containers/:uid/:status", ({ params }) => { + capturedStatus = params.status as string; + return new HttpResponse(null, { status: 200 }); + }), ); + const { result } = renderHook(() => useContainerActionRunner(), { + wrapper, + }); + await result.current(entity, "reject"); + expect(capturedStatus).toBe("reject"); }); it("calls deleteContainer for remove", async () => { - const { result } = renderHook(() => useContainerActionRunner(), { wrapper }); - await result.current(entity, "remove"); - expect(sdk.deleteContainer).toHaveBeenCalledWith( - expect.objectContaining({ path: { uid: "uid-1" } }), + let called = false; + server.use( + http.delete("*/api/containers/:uid", () => { + called = true; + return new HttpResponse(null, { status: 200 }); + }), ); + const { result } = renderHook(() => useContainerActionRunner(), { + wrapper, + }); + await result.current(entity, "remove"); + expect(called).toBe(true); }); }); diff --git a/ui/apps/console/src/hooks/__tests__/useDeviceActionRunner.test.ts b/ui/apps/console/src/hooks/__tests__/useDeviceActionRunner.test.ts index 1aac6ac4bc4..8aeb9e44d74 100644 --- a/ui/apps/console/src/hooks/__tests__/useDeviceActionRunner.test.ts +++ b/ui/apps/console/src/hooks/__tests__/useDeviceActionRunner.test.ts @@ -1,49 +1,67 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { renderHook } from "@testing-library/react"; -import { mockSdkResponse } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { useDeviceActionRunner } from "../useDeviceActionRunner"; -const sdk = vi.hoisted(() => - mockSdkGen({ - acceptDevice: vi.fn(), - updateDeviceStatus: vi.fn(), - deleteDevice: vi.fn(), - }), -); - const entity = { uid: "uid-1", name: "my-device" }; const wrapper = createTestWrapper(); beforeEach(() => { - vi.clearAllMocks(); - sdk.acceptDevice.mockResolvedValue(mockSdkResponse(undefined)); - sdk.updateDeviceStatus.mockResolvedValue(mockSdkResponse(undefined)); - sdk.deleteDevice.mockResolvedValue(mockSdkResponse(undefined)); + server.use( + http.patch( + "*/api/devices/:uid/accept", + () => new HttpResponse(null, { status: 200 }), + ), + http.patch( + "*/api/devices/:uid/:status", + () => new HttpResponse(null, { status: 200 }), + ), + http.delete( + "*/api/devices/:uid", + () => new HttpResponse(null, { status: 200 }), + ), + ); }); describe("useDeviceActionRunner", () => { it("calls acceptDevice for accept", async () => { + let called = false; + server.use( + http.patch("*/api/devices/:uid/accept", () => { + called = true; + return new HttpResponse(null, { status: 200 }); + }), + ); const { result } = renderHook(() => useDeviceActionRunner(), { wrapper }); await result.current(entity, "accept"); - expect(sdk.acceptDevice).toHaveBeenCalledWith( - expect.objectContaining({ path: { uid: "uid-1" } }), - ); + expect(called).toBe(true); }); it("calls updateDeviceStatus with reject for reject", async () => { + let capturedStatus: string | undefined; + server.use( + http.patch("*/api/devices/:uid/:status", ({ params }) => { + capturedStatus = params.status as string; + return new HttpResponse(null, { status: 200 }); + }), + ); const { result } = renderHook(() => useDeviceActionRunner(), { wrapper }); await result.current(entity, "reject"); - expect(sdk.updateDeviceStatus).toHaveBeenCalledWith( - expect.objectContaining({ path: { uid: "uid-1", status: "reject" } }), - ); + expect(capturedStatus).toBe("reject"); }); it("calls deleteDevice for remove", async () => { + let called = false; + server.use( + http.delete("*/api/devices/:uid", () => { + called = true; + return new HttpResponse(null, { status: 200 }); + }), + ); const { result } = renderHook(() => useDeviceActionRunner(), { wrapper }); await result.current(entity, "remove"); - expect(sdk.deleteDevice).toHaveBeenCalledWith( - expect.objectContaining({ path: { uid: "uid-1" } }), - ); + expect(called).toBe(true); }); }); diff --git a/ui/apps/console/src/hooks/__tests__/useDeviceChooser.test.ts b/ui/apps/console/src/hooks/__tests__/useDeviceChooser.test.ts deleted file mode 100644 index 0bb20617667..00000000000 --- a/ui/apps/console/src/hooks/__tests__/useDeviceChooser.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { waitFor, act } from "@testing-library/react"; -import { renderHookWithClient } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; -import { useSuggestedDevices, useChoiceDevices } from "../useDeviceChooser"; -import { useInvalidateByIds } from "../useInvalidateQueries"; - -const mockInvalidate = vi.fn(); - -const sdk = vi.hoisted(() => - mockSdkGen({ - getDevicesMostUsed: vi.fn(), - choiceDevices: vi.fn(), - }), -); - -vi.mock("../useInvalidateQueries", () => ({ - useInvalidateByIds: vi.fn(() => mockInvalidate), -})); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("useSuggestedDevices", () => { - describe("on success", () => { - it("returns the device list from the query response", async () => { - const devices = [ - { uid: "d1", name: "host-1" }, - { uid: "d2", name: "host-2" }, - ]; - sdk.getDevicesMostUsed.mockResolvedValue(mockSdkResponse(devices)); - - const { result } = renderHookWithClient(() => useSuggestedDevices()); - - await waitFor(() => - expect(result.current.devices).toEqual([ - { uid: "d1", name: "host-1", tags: [] }, - { uid: "d2", name: "host-2", tags: [] }, - ]), - ); - }); - - it("returns an empty array when data is undefined before load completes", () => { - sdk.getDevicesMostUsed.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => useSuggestedDevices()); - - expect(result.current.devices).toEqual([]); - }); - - it("exposes isLoading=true while the query is in flight", () => { - sdk.getDevicesMostUsed.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => useSuggestedDevices()); - - expect(result.current.isLoading).toBe(true); - }); - }); - - describe("when enabled=false", () => { - it("does not call the SDK function", () => { - renderHookWithClient(() => useSuggestedDevices(false)); - - expect(sdk.getDevicesMostUsed).not.toHaveBeenCalled(); - }); - - it("returns an empty devices array", () => { - const { result } = renderHookWithClient(() => useSuggestedDevices(false)); - - expect(result.current.devices).toEqual([]); - }); - }); - - describe("on error", () => { - it("exposes the error on failure", async () => { - const err = new Error("network failure"); - sdk.getDevicesMostUsed.mockRejectedValue(err); - - const { result } = renderHookWithClient(() => useSuggestedDevices()); - - await waitFor(() => expect(result.current.error).toBe(err)); - }); - }); -}); - -describe("useChoiceDevices", () => { - describe("mutation call", () => { - it("calls the SDK function with the choices body", async () => { - sdk.choiceDevices.mockResolvedValue(mockSdkResponse(undefined)); - - const { result } = renderHookWithClient(() => useChoiceDevices()); - - const vars = { body: { choices: ["uid1", "uid2"] } }; - await act(() => result.current.mutateAsync(vars)); - - expect(sdk.choiceDevices).toHaveBeenCalledWith( - expect.objectContaining({ - body: { choices: ["uid1", "uid2"] }, - throwOnError: true, - }), - ); - }); - }); - - describe("on success", () => { - it("calls invalidate once after the mutation succeeds", async () => { - sdk.choiceDevices.mockResolvedValue(mockSdkResponse(undefined)); - - const { result } = renderHookWithClient(() => useChoiceDevices()); - - await act(() => - result.current.mutateAsync({ body: { choices: ["uid1"] } }), - ); - - await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); - }); - - it("registers invalidate using useInvalidateByIds with the correct query ids", () => { - renderHookWithClient(() => useChoiceDevices()); - - expect(useInvalidateByIds).toHaveBeenCalledWith( - "getDevices", - "getDevice", - "getStatusDevices", - ); - }); - }); - - describe("on failure", () => { - it("exposes error when the mutation fails", async () => { - const err = new Error("server error"); - sdk.choiceDevices.mockRejectedValue(err); - - const { result } = renderHookWithClient(() => useChoiceDevices()); - - act(() => result.current.mutate({ body: { choices: ["uid1"] } })); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toBe(err); - }); - - it("does not call invalidate when the mutation fails", async () => { - sdk.choiceDevices.mockRejectedValue(new Error("server error")); - - const { result } = renderHookWithClient(() => useChoiceDevices()); - - act(() => result.current.mutate({ body: { choices: ["uid1"] } })); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(mockInvalidate).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/ui/apps/console/src/hooks/__tests__/useInvitationMutations.test.ts b/ui/apps/console/src/hooks/__tests__/useInvitationMutations.test.ts deleted file mode 100644 index ecf6bbfd16f..00000000000 --- a/ui/apps/console/src/hooks/__tests__/useInvitationMutations.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { waitFor, act } from "@testing-library/react"; -import { renderHookWithClient } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; -import { - useAcceptInvite, - useGenerateInvitationLink, - useCancelMembershipInvitation, -} from "../useInvitationMutations"; - -const mockInvalidate = vi.fn(); - -const sdk = vi.hoisted(() => - mockSdkGen({ - acceptInvite: vi.fn(), - generateInvitationLink: vi.fn(), - cancelMembershipInvitation: vi.fn(), - }), -); - -vi.mock("../useInvalidateQueries", () => ({ - useInvalidateByIds: vi.fn(() => mockInvalidate), -})); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("useAcceptInvite", () => { - describe("mutation call", () => { - it("calls acceptInvite with the provided path", async () => { - sdk.acceptInvite.mockResolvedValue(mockSdkResponse(undefined)); - const { result } = renderHookWithClient(() => useAcceptInvite()); - - const vars = { path: { tenant: "t1" } }; - await act(() => result.current.mutateAsync(vars)); - - expect(sdk.acceptInvite).toHaveBeenCalledWith( - expect.objectContaining({ path: { tenant: "t1" }, throwOnError: true }), - ); - }); - }); - - describe("on success", () => { - it("calls invalidate after successful mutation", async () => { - sdk.acceptInvite.mockResolvedValue(mockSdkResponse(undefined)); - const { result } = renderHookWithClient(() => useAcceptInvite()); - - await act(() => result.current.mutateAsync({ path: { tenant: "t1" } })); - - await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); - }); - }); - - describe("on failure", () => { - it("exposes error when mutation fails", async () => { - const error = new Error("accept failed"); - sdk.acceptInvite.mockRejectedValue(error); - const { result } = renderHookWithClient(() => useAcceptInvite()); - - act(() => result.current.mutate({ path: { tenant: "t1" } })); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toBe(error); - }); - - it("does not call invalidate when mutation fails", async () => { - sdk.acceptInvite.mockRejectedValue(new Error("accept failed")); - const { result } = renderHookWithClient(() => useAcceptInvite()); - - act(() => result.current.mutate({ path: { tenant: "t1" } })); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(mockInvalidate).not.toHaveBeenCalled(); - }); - }); -}); - -describe("useGenerateInvitationLink", () => { - describe("mutation call", () => { - it("calls generateInvitationLink with path and body", async () => { - sdk.generateInvitationLink.mockResolvedValue( - mockSdkResponse({ link: "https://example.com/invite/abc" }), - ); - const { result } = renderHookWithClient(() => - useGenerateInvitationLink(), - ); - - const vars = { - path: { tenant: "t1" }, - body: { email: "bob@example.com", role: "operator" as const }, - }; - await act(() => result.current.mutateAsync(vars)); - - expect(sdk.generateInvitationLink).toHaveBeenCalledWith( - expect.objectContaining({ - path: { tenant: "t1" }, - body: { email: "bob@example.com", role: "operator" }, - throwOnError: true, - }), - ); - }); - - it("returns the generated link from the mutation", async () => { - const link = "https://example.com/invite/xyz"; - sdk.generateInvitationLink.mockResolvedValue(mockSdkResponse({ link })); - const { result } = renderHookWithClient(() => - useGenerateInvitationLink(), - ); - - const data = await act(() => - result.current.mutateAsync({ path: { tenant: "t1" } }), - ); - - expect(data).toEqual({ link }); - }); - }); - - describe("on success", () => { - it("calls invalidate after successful mutation", async () => { - sdk.generateInvitationLink.mockResolvedValue( - mockSdkResponse({ link: "https://example.com/invite/abc" }), - ); - const { result } = renderHookWithClient(() => - useGenerateInvitationLink(), - ); - - await act(() => result.current.mutateAsync({ path: { tenant: "t1" } })); - - await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); - }); - }); - - describe("on failure", () => { - it("exposes error when mutation fails", async () => { - const error = new Error("generate link failed"); - sdk.generateInvitationLink.mockRejectedValue(error); - const { result } = renderHookWithClient(() => - useGenerateInvitationLink(), - ); - - act(() => result.current.mutate({ path: { tenant: "t1" } })); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toBe(error); - }); - - it("does not call invalidate when mutation fails", async () => { - sdk.generateInvitationLink.mockRejectedValue( - new Error("generate link failed"), - ); - const { result } = renderHookWithClient(() => - useGenerateInvitationLink(), - ); - - act(() => result.current.mutate({ path: { tenant: "t1" } })); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(mockInvalidate).not.toHaveBeenCalled(); - }); - }); -}); - -describe("useCancelMembershipInvitation", () => { - describe("mutation call", () => { - it("calls cancelMembershipInvitation with path", async () => { - sdk.cancelMembershipInvitation.mockResolvedValue( - mockSdkResponse(undefined), - ); - const { result } = renderHookWithClient(() => - useCancelMembershipInvitation(), - ); - - const vars = { path: { tenant: "t1", "user-id": "u1" } }; - await act(() => result.current.mutateAsync(vars)); - - expect(sdk.cancelMembershipInvitation).toHaveBeenCalledWith( - expect.objectContaining({ - path: { tenant: "t1", "user-id": "u1" }, - throwOnError: true, - }), - ); - }); - }); - - describe("on success", () => { - it("calls invalidate after successful mutation", async () => { - sdk.cancelMembershipInvitation.mockResolvedValue( - mockSdkResponse(undefined), - ); - const { result } = renderHookWithClient(() => - useCancelMembershipInvitation(), - ); - - await act(() => - result.current.mutateAsync({ - path: { tenant: "t1", "user-id": "u1" }, - }), - ); - - await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); - }); - }); - - describe("on failure", () => { - it("exposes error when mutation fails", async () => { - const error = new Error("cancel failed"); - sdk.cancelMembershipInvitation.mockRejectedValue(error); - const { result } = renderHookWithClient(() => - useCancelMembershipInvitation(), - ); - - act(() => - result.current.mutate({ - path: { tenant: "t1", "user-id": "u1" }, - }), - ); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toBe(error); - }); - - it("does not call invalidate when mutation fails", async () => { - sdk.cancelMembershipInvitation.mockRejectedValue( - new Error("cancel failed"), - ); - const { result } = renderHookWithClient(() => - useCancelMembershipInvitation(), - ); - - act(() => - result.current.mutate({ - path: { tenant: "t1", "user-id": "u1" }, - }), - ); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(mockInvalidate).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/ui/apps/console/src/hooks/__tests__/useInvitations.test.ts b/ui/apps/console/src/hooks/__tests__/useInvitations.test.ts deleted file mode 100644 index 038e5abd780..00000000000 --- a/ui/apps/console/src/hooks/__tests__/useInvitations.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { waitFor } from "@testing-library/react"; -import { renderHookWithClient } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; -import { - useNamespaceInvitations, - useResolveInvitation, -} from "../useInvitations"; -import type { MembershipInvitation } from "@/client"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - getNamespaceMembershipInvitationList: vi.fn(), - resolveInvitation: vi.fn(), - }), -); - -function makeInvitation( - overrides: Partial = {}, -): MembershipInvitation { - return { - namespace: { tenant_id: "t1", name: "my-ns" }, - user: { id: "u1", email: "alice@example.com" }, - invited_by: "owner@example.com", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - expires_at: "2024-01-08T00:00:00Z", - status: "pending", - status_updated_at: "2024-01-01T00:00:00Z", - role: "operator", - ...overrides, - }; -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("useNamespaceInvitations", () => { - describe("returns", () => { - it("returns invitations for the given tenant", async () => { - const inv = makeInvitation({ status: "pending" }); - sdk.getNamespaceMembershipInvitationList.mockResolvedValue( - mockSdkResponse([inv], { "X-Total-Count": "1" }), - ); - - const { result } = renderHookWithClient(() => - useNamespaceInvitations({ tenantId: "t1" }), - ); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.invitations).toHaveLength(1); - }); - - it("returns totalCount from the X-Total-Count header", async () => { - sdk.getNamespaceMembershipInvitationList.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "7" }), - ); - - const { result } = renderHookWithClient(() => - useNamespaceInvitations({ tenantId: "t1" }), - ); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.totalCount).toBe(7); - }); - - it("defaults invitations to empty array while loading", () => { - sdk.getNamespaceMembershipInvitationList.mockReturnValue( - new Promise(() => {}), - ); - - const { result } = renderHookWithClient(() => - useNamespaceInvitations({ tenantId: "t1" }), - ); - - expect(result.current.invitations).toEqual([]); - }); - - it("exposes error when query fails", async () => { - const err = new Error("fetch failed"); - sdk.getNamespaceMembershipInvitationList.mockRejectedValue(err); - - const { result } = renderHookWithClient(() => - useNamespaceInvitations({ tenantId: "t1" }), - ); - - await waitFor(() => expect(result.current.error).toBeTruthy()); - expect(result.current.error).toBe(err); - }); - }); - - describe("enabled flag", () => { - it("does not fetch when enabled is false", () => { - sdk.getNamespaceMembershipInvitationList.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - renderHookWithClient(() => - useNamespaceInvitations({ tenantId: "t1", enabled: false }), - ); - - expect(sdk.getNamespaceMembershipInvitationList).not.toHaveBeenCalled(); - }); - - it("does not fetch when tenantId is empty", () => { - sdk.getNamespaceMembershipInvitationList.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - renderHookWithClient(() => useNamespaceInvitations({ tenantId: "" })); - - expect(sdk.getNamespaceMembershipInvitationList).not.toHaveBeenCalled(); - }); - }); - - describe("pagination", () => { - it("uses page 1 and perPage 10 as defaults", async () => { - sdk.getNamespaceMembershipInvitationList.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - renderHookWithClient(() => useNamespaceInvitations({ tenantId: "t1" })); - - await waitFor(() => - expect(sdk.getNamespaceMembershipInvitationList).toHaveBeenCalled(), - ); - const [opts] = sdk.getNamespaceMembershipInvitationList.mock.calls[0]; - expect(opts.query.page).toBe(1); - expect(opts.query.per_page).toBe(10); - }); - }); -}); - -describe("useResolveInvitation", () => { - it("normalizes wire fields to camelCase", async () => { - sdk.resolveInvitation.mockResolvedValue( - mockSdkResponse({ - tenant_id: "t1", - user_id: "u1", - email: "alice@example.com", - status: "confirmed", - }), - ); - - const { result } = renderHookWithClient(() => useResolveInvitation("CODE")); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.resolved).toEqual({ - tenantId: "t1", - userId: "u1", - email: "alice@example.com", - status: "confirmed", - }); - }); - - it.each(["tenant_id", "user_id", "status"])( - "returns null when %s is missing", - async (field) => { - const data = { - tenant_id: "t1", - user_id: "u1", - email: "a@b.com", - status: "confirmed", - [field]: null, - }; - sdk.resolveInvitation.mockResolvedValue(mockSdkResponse(data)); - - const { result } = renderHookWithClient(() => - useResolveInvitation("CODE"), - ); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.resolved).toBeNull(); - }, - ); - - it("falls back to empty string when email is null", async () => { - sdk.resolveInvitation.mockResolvedValue( - mockSdkResponse({ - tenant_id: "t1", - user_id: "u1", - email: null, - status: "invited", - }), - ); - - const { result } = renderHookWithClient(() => useResolveInvitation("CODE")); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.resolved?.email).toBe(""); - }); - - it("does not fetch when invite is empty", () => { - const { result } = renderHookWithClient(() => useResolveInvitation("")); - - expect(result.current.resolved).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(sdk.resolveInvitation).not.toHaveBeenCalled(); - }); -}); diff --git a/ui/apps/console/src/hooks/__tests__/useLatestAnnouncement.test.ts b/ui/apps/console/src/hooks/__tests__/useLatestAnnouncement.test.ts deleted file mode 100644 index b1ba8935f5f..00000000000 --- a/ui/apps/console/src/hooks/__tests__/useLatestAnnouncement.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { waitFor } from "@testing-library/react"; -import { renderHookWithClient } from "@/tests/wrapper"; -import { getConfig, defaultConfig } from "@/env"; -import { mockSdkResponse } from "@/tests/sdk"; -import { useLatestAnnouncement } from "../useLatestAnnouncement"; -import type { Announcement } from "@/client"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - listAnnouncements: vi.fn(), - getAnnouncement: vi.fn(), - }), -); - -const mockGetConfig = vi.mocked(getConfig); - -function makeAnnouncement(overrides: Partial = {}): Announcement { - return { - uuid: "ann-uuid-1", - title: "Test Announcement", - content: "## Hello\nSome content", - date: "2024-06-01T00:00:00Z", - ...overrides, - }; -} - -beforeEach(() => { - vi.clearAllMocks(); - mockGetConfig.mockReturnValue({ ...defaultConfig, announcements: true }); - sdk.listAnnouncements.mockReturnValue(new Promise(() => {})); - sdk.getAnnouncement.mockReturnValue(new Promise(() => {})); -}); - -describe("useLatestAnnouncement", () => { - describe("when announcements feature flag is disabled", () => { - beforeEach(() => { - mockGetConfig.mockReturnValue({ ...defaultConfig, announcements: false }); - }); - - it("returns null announcement immediately", () => { - const { result } = renderHookWithClient(() => useLatestAnnouncement()); - - expect(result.current.announcement).toBeNull(); - }); - - it("returns isLoading false", () => { - const { result } = renderHookWithClient(() => useLatestAnnouncement()); - - expect(result.current.isLoading).toBe(false); - }); - - it("does not call the SDK", () => { - renderHookWithClient(() => useLatestAnnouncement()); - - expect(sdk.listAnnouncements).not.toHaveBeenCalled(); - }); - }); - - describe("loading state", () => { - it("returns isLoading true while list query is pending", () => { - sdk.listAnnouncements.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => useLatestAnnouncement()); - - expect(result.current.isLoading).toBe(true); - }); - - it("returns null announcement while queries are loading", () => { - const { result } = renderHookWithClient(() => useLatestAnnouncement()); - - expect(result.current.announcement).toBeNull(); - }); - }); - - describe("when list resolves but is empty", () => { - it("returns null announcement", async () => { - sdk.listAnnouncements.mockResolvedValue(mockSdkResponse([])); - - const { result } = renderHookWithClient(() => useLatestAnnouncement()); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.announcement).toBeNull(); - }); - - it("does not call getAnnouncement", async () => { - sdk.listAnnouncements.mockResolvedValue(mockSdkResponse([])); - - const { result } = renderHookWithClient(() => useLatestAnnouncement()); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - - expect(sdk.getAnnouncement).not.toHaveBeenCalled(); - }); - }); - - describe("when both queries resolve successfully", () => { - it("returns the full announcement object", async () => { - const ann = makeAnnouncement({ uuid: "ann-abc", title: "Big Update" }); - - sdk.listAnnouncements.mockResolvedValue( - mockSdkResponse([{ uuid: "ann-abc" }]), - ); - sdk.getAnnouncement.mockResolvedValue(mockSdkResponse(ann)); - - const { result } = renderHookWithClient(() => useLatestAnnouncement()); - - await waitFor(() => expect(result.current.announcement).not.toBeNull()); - expect(result.current.announcement).toEqual(ann); - }); - - it("returns isLoading false after both queries settle", async () => { - const ann = makeAnnouncement(); - - sdk.listAnnouncements.mockResolvedValue( - mockSdkResponse([{ uuid: "ann-uuid-1" }]), - ); - sdk.getAnnouncement.mockResolvedValue(mockSdkResponse(ann)); - - const { result } = renderHookWithClient(() => useLatestAnnouncement()); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.announcement).toEqual(ann); - }); - }); - - describe("when the list query resolves but the detail query is still loading", () => { - it("returns isLoading true", async () => { - sdk.listAnnouncements.mockResolvedValue( - mockSdkResponse([{ uuid: "ann-uuid-1" }]), - ); - sdk.getAnnouncement.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => useLatestAnnouncement()); - - await waitFor(() => expect(sdk.getAnnouncement).toHaveBeenCalled()); - - expect(result.current.isLoading).toBe(true); - }); - }); -}); diff --git a/ui/apps/console/src/hooks/__tests__/usePublicKeys.test.ts b/ui/apps/console/src/hooks/__tests__/usePublicKeys.test.ts deleted file mode 100644 index 2812531bd59..00000000000 --- a/ui/apps/console/src/hooks/__tests__/usePublicKeys.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { waitFor } from "@testing-library/react"; -import { renderHookWithClient } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; -import { usePublicKeys } from "../usePublicKeys"; -import type { PublicKeyResponse } from "@/client"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - getPublicKeys: vi.fn(), - }), -); - -function makeKey( - overrides: Partial = {}, -): PublicKeyResponse { - return { - name: "test-key", - fingerprint: "aa:bb:cc", - created_at: "2024-01-01T00:00:00Z", - tenant_id: "tenant-1", - data: "c3NoLXJzYQ==", - filter: { hostname: ".*", tags: [] }, - username: ".*", - ...overrides, - }; -} - -function makeTag(name: string) { - return { - name, - tenant_id: "tenant-1", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }; -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("usePublicKeys", () => { - describe("returns", () => { - it("returns publicKeys from the paginated result", async () => { - const keys = [ - makeKey({ name: "key-1", filter: { hostname: ".*", tags: [] } }), - makeKey({ name: "key-2", filter: { tags: [makeTag("prod")] } }), - ]; - sdk.getPublicKeys.mockResolvedValue( - mockSdkResponse(keys, { "X-Total-Count": "2" }), - ); - - const { result } = renderHookWithClient(() => usePublicKeys()); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.publicKeys).toHaveLength(2); - expect(result.current.publicKeys[0].name).toBe("key-1"); - expect(result.current.publicKeys[1].name).toBe("key-2"); - }); - - it("returns totalCount from the X-Total-Count header", async () => { - sdk.getPublicKeys.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "42" }), - ); - - const { result } = renderHookWithClient(() => usePublicKeys()); - - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.totalCount).toBe(42); - }); - - it("defaults publicKeys to empty array while loading", () => { - sdk.getPublicKeys.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => usePublicKeys()); - - expect(result.current.publicKeys).toEqual([]); - }); - - it("defaults totalCount to 0 while loading", () => { - sdk.getPublicKeys.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => usePublicKeys()); - - expect(result.current.totalCount).toBe(0); - }); - - it("returns isLoading true initially", () => { - sdk.getPublicKeys.mockReturnValue(new Promise(() => {})); - - const { result } = renderHookWithClient(() => usePublicKeys()); - - expect(result.current.isLoading).toBe(true); - }); - - it("exposes error when query fails", async () => { - const networkError = new Error("network failure"); - sdk.getPublicKeys.mockRejectedValue(networkError); - - const { result } = renderHookWithClient(() => usePublicKeys()); - - await waitFor(() => expect(result.current.error).toBeTruthy()); - expect(result.current.error).toBe(networkError); - }); - }); - - describe("pagination defaults", () => { - it("uses page 1 and perPage 10 as defaults", async () => { - sdk.getPublicKeys.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - renderHookWithClient(() => usePublicKeys()); - - await waitFor(() => expect(sdk.getPublicKeys).toHaveBeenCalled()); - const [opts] = sdk.getPublicKeys.mock.calls[0]; - expect(opts.query.page).toBe(1); - expect(opts.query.per_page).toBe(10); - }); - - it("forwards custom page and perPage", async () => { - sdk.getPublicKeys.mockResolvedValue( - mockSdkResponse([], { "X-Total-Count": "0" }), - ); - - renderHookWithClient(() => usePublicKeys({ page: 3, perPage: 25 })); - - await waitFor(() => expect(sdk.getPublicKeys).toHaveBeenCalled()); - const [opts] = sdk.getPublicKeys.mock.calls[0]; - expect(opts.query.page).toBe(3); - expect(opts.query.per_page).toBe(25); - }); - }); -}); diff --git a/ui/apps/console/src/hooks/__tests__/useSessionRecording.test.ts b/ui/apps/console/src/hooks/__tests__/useSessionRecording.test.ts index 34e05d573f4..d1d3a18c63d 100644 --- a/ui/apps/console/src/hooks/__tests__/useSessionRecording.test.ts +++ b/ui/apps/console/src/hooks/__tests__/useSessionRecording.test.ts @@ -1,13 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { useSessionRecording } from "../useSessionRecording"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getSessionRecord: vi.fn(), - }), -); - async function fetchLogs( result: { current: ReturnType }, uid = "session-1", @@ -33,41 +29,65 @@ describe("useSessionRecording", () => { }); it("reads the recording of seat 0 as text", async () => { - sdk.getSessionRecord.mockResolvedValue({ data: "asciicast-content" }); + let capturedUrl = ""; + server.use( + http.get("*/api/sessions/:uid/records/:seat", ({ request }) => { + capturedUrl = new URL(request.url).pathname; + return new HttpResponse("asciicast-content", { + headers: { "Content-Type": "text/plain" }, + }); + }), + ); const { result } = renderHook(() => useSessionRecording()); await fetchLogs(result); - expect(sdk.getSessionRecord).toHaveBeenCalledWith({ - path: { uid: "session-1", seat: 0 }, - parseAs: "text", - throwOnError: true, - }); + expect(capturedUrl).toBe("/api/sessions/session-1/records/0"); }); it("sets isLoading true while fetching and false after", async () => { - let resolve!: (value: unknown) => void; - sdk.getSessionRecord.mockReturnValue( - new Promise((r) => { - resolve = r; - }), - ); + let resolveHandler!: (r: Response) => void; + const handlerReady = new Promise((ready) => { + server.use( + http.get( + "*/api/sessions/:uid/records/:seat", + () => + new Promise((resolve) => { + resolveHandler = resolve; + ready(); + }), + ), + ); + }); const { result } = renderHook(() => useSessionRecording()); act(() => { void result.current.fetchLogs("session-1"); }); + await handlerReady; expect(result.current.isLoading).toBe(true); await act(async () => { - resolve({ data: "log-data" }); + resolveHandler( + new HttpResponse("log-data", { + headers: { "Content-Type": "text/plain" }, + }), + ); }); expect(result.current.isLoading).toBe(false); }); it("returns true and stores logs on success", async () => { - sdk.getSessionRecord.mockResolvedValue({ data: "asciicast-content" }); + server.use( + http.get( + "*/api/sessions/:uid/records/:seat", + () => + new HttpResponse("asciicast-content", { + headers: { "Content-Type": "text/plain" }, + }), + ), + ); const { result } = renderHook(() => useSessionRecording()); const ok = await fetchLogs(result); @@ -78,7 +98,11 @@ describe("useSessionRecording", () => { }); it("returns false and sets error on fetch failure", async () => { - sdk.getSessionRecord.mockRejectedValue(new Error("Network error")); + server.use( + http.get("*/api/sessions/:uid/records/:seat", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const { result } = renderHook(() => useSessionRecording()); const ok = await fetchLogs(result); @@ -90,21 +114,42 @@ describe("useSessionRecording", () => { }); it("clears logsError at the start of a new fetchLogs call", async () => { - sdk.getSessionRecord.mockRejectedValueOnce(new Error("first error")); - sdk.getSessionRecord.mockResolvedValue({ data: "log-data" }); + server.use( + http.get("*/api/sessions/:uid/records/:seat", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const { result } = renderHook(() => useSessionRecording()); await fetchLogs(result); expect(result.current.error).toBe("Failed to load recording"); + server.use( + http.get( + "*/api/sessions/:uid/records/:seat", + () => + new HttpResponse("log-data", { + headers: { "Content-Type": "text/plain" }, + }), + ), + ); + await fetchLogs(result); expect(result.current.error).toBeNull(); expect(result.current.logs).toBe("log-data"); }); it("clearLogs resets logs and error without affecting other state", async () => { - sdk.getSessionRecord.mockResolvedValue({ data: "asciicast-content" }); + server.use( + http.get( + "*/api/sessions/:uid/records/:seat", + () => + new HttpResponse("asciicast-content", { + headers: { "Content-Type": "text/plain" }, + }), + ), + ); const { result } = renderHook(() => useSessionRecording()); await fetchLogs(result); diff --git a/ui/apps/console/src/hooks/__tests__/useSupportIdentifier.test.ts b/ui/apps/console/src/hooks/__tests__/useSupportIdentifier.test.ts deleted file mode 100644 index 8d86fddcf82..00000000000 --- a/ui/apps/console/src/hooks/__tests__/useSupportIdentifier.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { waitFor } from "@testing-library/react"; -import { renderHookWithClient } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; -import { useSupportIdentifier } from "../useSupportIdentifier"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - getNamespaceSupport: vi.fn(), - }), -); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("useSupportIdentifier", () => { - describe("when enabled=false", () => { - it("never fires the query and returns null identifier", () => { - const { result } = renderHookWithClient(() => - useSupportIdentifier("tenant-123", false), - ); - - expect(sdk.getNamespaceSupport).not.toHaveBeenCalled(); - expect(result.current.identifier).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.isError).toBe(false); - }); - }); - - describe("when tenantId is empty", () => { - it("does not fire the query when tenantId is empty string", () => { - renderHookWithClient(() => useSupportIdentifier("", true)); - - expect(sdk.getNamespaceSupport).not.toHaveBeenCalled(); - }); - - it("does not fire the query when tenantId is null", () => { - renderHookWithClient(() => useSupportIdentifier(null, true)); - - expect(sdk.getNamespaceSupport).not.toHaveBeenCalled(); - }); - - it("returns null identifier when disabled by empty tenantId", () => { - const { result } = renderHookWithClient(() => - useSupportIdentifier("", true), - ); - - expect(result.current.identifier).toBeNull(); - expect(result.current.isLoading).toBe(false); - }); - }); - - describe("when enabled with a valid tenant", () => { - it("returns the identifier from the response", async () => { - sdk.getNamespaceSupport.mockResolvedValue( - mockSdkResponse({ identifier: "abc123" }), - ); - - const { result } = renderHookWithClient(() => - useSupportIdentifier("tenant-123", true), - ); - - await waitFor(() => expect(result.current.identifier).toBe("abc123")); - expect(result.current.isLoading).toBe(false); - expect(result.current.isError).toBe(false); - }); - }); - - describe("retry policy", () => { - it("retries the query exactly once on failure (transient blip recovery)", async () => { - sdk.getNamespaceSupport.mockRejectedValue(new Error("network error")); - - const { result } = renderHookWithClient(() => - useSupportIdentifier("tenant-123", true), - ); - - await waitFor(() => expect(result.current.isError).toBe(true)); - - expect(sdk.getNamespaceSupport).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/ui/apps/console/src/hooks/__tests__/useUploadLicense.test.ts b/ui/apps/console/src/hooks/__tests__/useUploadLicense.test.ts deleted file mode 100644 index cc9c1e608b3..00000000000 --- a/ui/apps/console/src/hooks/__tests__/useUploadLicense.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { waitFor, act } from "@testing-library/react"; -import { renderHookWithClient } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; -import { useUploadLicense } from "../useUploadLicense"; - -const mockInvalidate = vi.fn(); - -const sdk = vi.hoisted(() => - mockSdkGen({ - sendLicense: vi.fn(), - }), -); - -vi.mock("../useInvalidateQueries", () => ({ - useInvalidateByIds: vi.fn(() => mockInvalidate), -})); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("useUploadLicense", () => { - describe("mutation call", () => { - it("calls sendLicense with the provided body", async () => { - sdk.sendLicense.mockResolvedValue(mockSdkResponse(undefined)); - const { result } = renderHookWithClient(() => useUploadLicense()); - - const file = new File(["license-data"], "license.lic"); - await act(() => result.current.mutateAsync({ body: { file } })); - - expect(sdk.sendLicense).toHaveBeenCalledWith( - expect.objectContaining({ body: { file }, throwOnError: true }), - ); - }); - }); - - describe("on success", () => { - it("invalidates getLicense queries after a successful mutation", async () => { - sdk.sendLicense.mockResolvedValue(mockSdkResponse(undefined)); - const { result } = renderHookWithClient(() => useUploadLicense()); - - await act(() => result.current.mutateAsync({})); - - await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); - }); - }); - - describe("on failure", () => { - it("rejects and exposes the error when sendLicense fails", async () => { - const error = new Error("upload failed"); - sdk.sendLicense.mockRejectedValue(error); - const { result } = renderHookWithClient(() => useUploadLicense()); - - act(() => result.current.mutate({})); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toBe(error); - }); - - it("does not call invalidate when sendLicense fails", async () => { - sdk.sendLicense.mockRejectedValue(new Error("upload failed")); - const { result } = renderHookWithClient(() => useUploadLicense()); - - act(() => result.current.mutate({})); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(mockInvalidate).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/ui/apps/console/src/hooks/useAcceptDeviceByCode.ts b/ui/apps/console/src/hooks/useAcceptDeviceByCode.ts index bda22501eda..9ecdd325b9f 100644 --- a/ui/apps/console/src/hooks/useAcceptDeviceByCode.ts +++ b/ui/apps/console/src/hooks/useAcceptDeviceByCode.ts @@ -1,22 +1,20 @@ import { useState } from "react"; -import { resolveDeviceLoginCode, acceptDevicePairing } from "@/client"; +import { + resolveDeviceLoginCode, + acceptDevicePairing, + useAcceptDevice, +} from "@/client/api"; import { isSdkError } from "@/api/errors"; import { useAuthStore } from "@/stores/authStore"; -import { useAcceptDevice } from "@/hooks/useDeviceMutations"; import { useHasPermission } from "@/hooks/useHasPermission"; import { useNamespace } from "@/hooks/useNamespaces"; import { isSubscriptionBlocked } from "@/utils/billing"; import { getAcceptErrorMessage } from "@/utils/acceptErrors"; -/** - * Resolves a pairing/login code and accepts the device in one shot, into the - * current namespace. Used by the onboarding wizard's code-entry step, which - * skips the preview/confirm screen: the user just installed the device, so they - * type the code and go. Returns the accepted device, or null with `error` set. - */ +/** Resolves a pairing/login code and accepts the device into the current namespace. */ export function useAcceptDeviceByCode() { - const authTenant = useAuthStore((s) => s.tenant); - const { namespace } = useNamespace(authTenant ?? ""); + const authTenant = useAuthStore((s) => s.tenant) ?? ""; + const { namespace } = useNamespace(authTenant); const hasSubscription = isSubscriptionBlocked(namespace?.billing); const canSubscribe = useHasPermission("billing:subscribe"); const acceptDevice = useAcceptDevice(); @@ -29,23 +27,18 @@ export function useAcceptDeviceByCode() { setError(""); setIsPending(true); try { - const { data } = await resolveDeviceLoginCode({ - path: { code }, - throwOnError: true, - }); + const resolved = await resolveDeviceLoginCode(code); - if (data.kind === "pairing") { - const { data: accepted } = await acceptDevicePairing({ - path: { code }, - body: { tenant_id: authTenant ?? "" }, - throwOnError: true, + if (resolved.kind === "pairing") { + const accepted = await acceptDevicePairing(code, { + tenant_id: authTenant, }); - return { uid: accepted.uid ?? "", name: data.name ?? "" }; + return { uid: accepted.uid ?? "", name: resolved.name ?? "" }; } - if (data.uid) { - await acceptDevice.mutateAsync({ path: { uid: data.uid } }); - return { uid: data.uid, name: data.name ?? "" }; + if (resolved.uid) { + await acceptDevice.mutateAsync({ uid: resolved.uid }); + return { uid: resolved.uid, name: resolved.name ?? "" }; } setError( diff --git a/ui/apps/console/src/hooks/useAccessPolicies.ts b/ui/apps/console/src/hooks/useAccessPolicies.ts deleted file mode 100644 index 14b281f47ae..00000000000 --- a/ui/apps/console/src/hooks/useAccessPolicies.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { listAccessPoliciesOptions, type AccessPolicy } from "../client"; - -/** - * The namespace's access policies. Returns an empty array rather than undefined while loading, - * so a caller can map over it without a guard. - */ -export function useAccessPolicies() { - const result = useQuery(listAccessPoliciesOptions()); - - const policies = useMemo( - () => result.data ?? [], - [result.data], - ); - - return { - policies, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useAccessPolicyMutations.ts b/ui/apps/console/src/hooks/useAccessPolicyMutations.ts deleted file mode 100644 index b3e578b58a0..00000000000 --- a/ui/apps/console/src/hooks/useAccessPolicyMutations.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - createAccessPolicyMutation, - updateAccessPolicyMutation, - deleteAccessPolicyMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Creates an access policy, refreshing the list on success. - */ -export function useCreateAccessPolicy() { - const invalidate = useInvalidateByIds("listAccessPolicies"); - return useMutation({ - ...createAccessPolicyMutation(), - onSuccess: invalidate, - }); -} - -/** - * Updates an access policy, refreshing the list on success. - */ -export function useUpdateAccessPolicy() { - const invalidate = useInvalidateByIds("listAccessPolicies"); - return useMutation({ - ...updateAccessPolicyMutation(), - onSuccess: invalidate, - }); -} - -/** - * Deletes an access policy, refreshing the list on success. - */ -export function useDeleteAccessPolicy() { - const invalidate = useInvalidateByIds("listAccessPolicies"); - return useMutation({ - ...deleteAccessPolicyMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useAdminAccountRequestMutations.ts b/ui/apps/console/src/hooks/useAdminAccountRequestMutations.ts deleted file mode 100644 index 4cffaf84b29..00000000000 --- a/ui/apps/console/src/hooks/useAdminAccountRequestMutations.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { approveUserMutation } from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Approves a pending account request. This clears awaiting_approval only — the account stays - * unconfirmed until the person activates it, which is what lets an activation link be minted - * for them afterwards from the members list. - */ -export function useApproveAccountRequest() { - const invalidate = useInvalidateByIds("getUsers", "getUser"); - return useMutation({ - ...approveUserMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useAdminAccountRequests.ts b/ui/apps/console/src/hooks/useAdminAccountRequests.ts deleted file mode 100644 index 6eb2d12adac..00000000000 --- a/ui/apps/console/src/hooks/useAdminAccountRequests.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - getUsers as getUsersSdk, - getUsersQueryKey, - type GetUsersData, - type UserAdminResponse, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; -import { useAuthStore } from "../stores/authStore"; -import { isSdkError } from "../api/errors"; -import { toBase64Json } from "@/utils/encoding"; - -// Accounts a namespace admin provisioned that a system admin has not approved yet -// are just users flagged awaiting_approval; the "requests" queue is that filter. -const AWAITING_APPROVAL_FILTER = toBase64Json([ - { - type: "property", - params: { name: "awaiting_approval", operator: "bool", value: true }, - }, -]); - -interface UseAdminAccountRequestsParams { - page?: number; - perPage?: number; - enabled?: boolean; -} - -/** - * The accounts waiting for approval. Runs only for an admin, so a non-admin never issues the - * request that would be refused. - */ -export function useAdminAccountRequests({ - page = 1, - perPage = 10, - enabled = true, -}: UseAdminAccountRequestsParams = {}) { - const isAdmin = useAuthStore((s) => s.isAdmin); - - const query: GetUsersData["query"] = { - page, - per_page: perPage, - filter: AWAITING_APPROVAL_FILTER, - }; - const options = { query }; - - const result = useQuery>({ - queryKey: getUsersQueryKey(options), - queryFn: paginatedQueryFn(getUsersSdk, options), - enabled: isAdmin && enabled, - staleTime: 60 * 1000, // 1 minute - retry: (count, err) => - isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); - - return { - requests: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} diff --git a/ui/apps/console/src/hooks/useAdminAnnouncementMutations.ts b/ui/apps/console/src/hooks/useAdminAnnouncementMutations.ts deleted file mode 100644 index e3ebc868152..00000000000 --- a/ui/apps/console/src/hooks/useAdminAnnouncementMutations.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - createAnnouncementMutation, - updateAnnouncementMutation, - deleteAnnouncementMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Creates an announcement, refreshing the admin list on success. - */ -export function useAdminCreateAnnouncement() { - const invalidate = useInvalidateByIds("listAnnouncementsAdmin"); - return useMutation({ - ...createAnnouncementMutation(), - onSuccess: invalidate, - }); -} - -/** - * Updates an announcement, refreshing both the list and the single-announcement query. - */ -export function useAdminUpdateAnnouncement() { - const invalidate = useInvalidateByIds( - "listAnnouncementsAdmin", - "getAnnouncementAdmin", - ); - return useMutation({ - ...updateAnnouncementMutation(), - onSuccess: invalidate, - }); -} - -/** - * Deletes an announcement, refreshing both the list and the single-announcement query. - */ -export function useAdminDeleteAnnouncement() { - const invalidate = useInvalidateByIds( - "listAnnouncementsAdmin", - "getAnnouncementAdmin", - ); - return useMutation({ - ...deleteAnnouncementMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useAdminAnnouncements.ts b/ui/apps/console/src/hooks/useAdminAnnouncements.ts deleted file mode 100644 index 69d0dc273ab..00000000000 --- a/ui/apps/console/src/hooks/useAdminAnnouncements.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - listAnnouncementsAdmin as listAnnouncementsAdminSdk, - listAnnouncementsAdminQueryKey, - getAnnouncementAdminOptions, - type ListAnnouncementsAdminData, - type AnnouncementShort, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; -import { useAuthStore } from "../stores/authStore"; -import { isSdkError } from "../api/errors"; - -interface UseAdminAnnouncementsParams { - page?: number; - perPage?: number; -} - -/** - * A page of announcements for the admin list. Admin-only, so it does not run for anyone else. - */ -export function useAdminAnnouncements({ - page = 1, - perPage = 10, -}: UseAdminAnnouncementsParams = {}) { - const isAdmin = useAuthStore((s) => s.isAdmin); - - const query: ListAnnouncementsAdminData["query"] = { - page, - per_page: perPage, - order_by: "desc", - }; - const options = { query }; - - const result = useQuery>({ - queryKey: listAnnouncementsAdminQueryKey(options), - queryFn: paginatedQueryFn(listAnnouncementsAdminSdk, options), - enabled: isAdmin, - staleTime: 5 * 60 * 1000, - retry: (count, err) => - isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); - - return { - announcements: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} - -/** - * One announcement by UUID. Cached for five minutes and not retried on a 4xx, since a missing or - * forbidden announcement will not appear on a second attempt. - */ -export function useAdminAnnouncement(uuid: string) { - const isAdmin = useAuthStore((s) => s.isAdmin); - - return useQuery({ - ...getAnnouncementAdminOptions({ path: { uuid } }), - enabled: isAdmin && !!uuid, - staleTime: 5 * 60 * 1000, - retry: (count, err) => - isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); -} diff --git a/ui/apps/console/src/hooks/useAdminDevices.ts b/ui/apps/console/src/hooks/useAdminDevices.ts deleted file mode 100644 index 3a5966c2624..00000000000 --- a/ui/apps/console/src/hooks/useAdminDevices.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { - getDevicesAdmin, - getDevicesAdminQueryKey, - getDeviceAdminOptions, - type GetDevicesAdminData, - type Device, - type DeviceStatus, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; -import { useAuthStore } from "../stores/authStore"; -import { isSdkError } from "../api/errors"; -import { toBase64Json } from "@/utils/encoding"; -import { normalizeDeviceTags } from "@/utils/deviceTags"; - -export type { TaggedDevice as NormalizedDevice } from "@/utils/deviceTags"; - -function buildNameFilter(search: string): string { - const filter = [ - { - type: "property", - params: { name: "name", operator: "contains", value: search }, - }, - ]; - return toBase64Json(filter); -} - -interface UseAdminDevicesParams { - page?: number; - perPage?: number; - search?: string; - status?: DeviceStatus | ""; - sortBy?: string; - orderBy?: "asc" | "desc"; -} - -/** - * A page of devices across every namespace, for the admin device list. - */ -export function useAdminDevices({ - page = 1, - perPage = 10, - search = "", - status = "", - sortBy = "last_seen", - orderBy = "desc", -}: UseAdminDevicesParams = {}) { - const isAdmin = useAuthStore((s) => s.isAdmin); - - const query: GetDevicesAdminData["query"] = { - page, - per_page: perPage, - sort_by: sortBy, - order_by: orderBy, - }; - if (search) query.filter = buildNameFilter(search); - if (status) query.status = status; - const options = { query }; - - const result = useQuery>({ - queryKey: getDevicesAdminQueryKey(options), - queryFn: paginatedQueryFn(getDevicesAdmin, options), - enabled: isAdmin, - staleTime: 5 * 60 * 1000, // 5 minutes - retry: (count, err) => - isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); - - const devices = useMemo( - () => result.data?.data.map(normalizeDeviceTags) ?? [], - [result.data], - ); - - return { - devices, - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} - -/** - * One device by UID, for the admin detail view. Cached for five minutes and not retried on a - * 4xx, since a device that is missing or out of reach will not appear on a retry. - */ -export function useAdminDevice(uid: string) { - const isAdmin = useAuthStore((s) => s.isAdmin); - - return useQuery({ - ...getDeviceAdminOptions({ path: { uid } }), - enabled: isAdmin && !!uid, - staleTime: 5 * 60 * 1000, // 5 minutes - retry: (count, err) => - isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - select: normalizeDeviceTags, - }); -} diff --git a/ui/apps/console/src/hooks/useAdminFirewallRules.ts b/ui/apps/console/src/hooks/useAdminFirewallRules.ts deleted file mode 100644 index ccb3ed98b23..00000000000 --- a/ui/apps/console/src/hooks/useAdminFirewallRules.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { - getFirewallRulesAdmin, - getFirewallRulesAdminQueryKey, - getFirewallRuleAdminOptions, - type GetFirewallRulesAdminData, - type FirewallRulesResponse, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; -import { useAuthStore } from "../stores/authStore"; -import { isSdkError } from "../api/errors"; - -interface UseAdminFirewallRulesParams { - page?: number; - perPage?: number; -} - -/** - * A page of firewall rules across every namespace, for the admin list. - */ -export function useAdminFirewallRules({ - page = 1, - perPage = 10, -}: UseAdminFirewallRulesParams = {}) { - const isAdmin = useAuthStore((s) => s.isAdmin); - - const options = { - query: { - page, - per_page: perPage, - } satisfies GetFirewallRulesAdminData["query"], - }; - - const result = useQuery>({ - queryKey: getFirewallRulesAdminQueryKey(options), - queryFn: paginatedQueryFn(getFirewallRulesAdmin, options), - enabled: isAdmin, - staleTime: 5 * 60 * 1000, - retry: (count, err) => - isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); - - const rules = useMemo(() => result.data?.data ?? [], [result.data]); - - return { - rules, - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - }; -} - -/** - * One firewall rule by id, for the admin detail view. Cached for five minutes and not retried on - * a 4xx. - */ -export function useAdminFirewallRule(id: string) { - const isAdmin = useAuthStore((s) => s.isAdmin); - - return useQuery({ - ...getFirewallRuleAdminOptions({ path: { id } }), - enabled: isAdmin && !!id, - staleTime: 5 * 60 * 1000, - retry: (count, err) => - isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); -} diff --git a/ui/apps/console/src/hooks/useAdminLicense.ts b/ui/apps/console/src/hooks/useAdminLicense.ts index c87b70b31b8..6f348da41cd 100644 --- a/ui/apps/console/src/hooks/useAdminLicense.ts +++ b/ui/apps/console/src/hooks/useAdminLicense.ts @@ -1,16 +1,13 @@ import { useQuery } from "@tanstack/react-query"; -import { - getLicense, - getLicenseQueryKey, - type GetLicenseResponse, -} from "../client"; -import { useAuthStore } from "../stores/authStore"; -import { isSdkError } from "../api/errors"; -import { isCloud } from "../env"; +import { getLicense, getGetLicenseQueryKey } from "@/client/api"; +import type { GetLicense200 } from "@/client/model"; +import { useAuthStore } from "@/stores/authStore"; +import { isSdkError } from "@/api/errors"; +import { isCloud } from "@/env"; -export { getLicenseQueryKey }; +export { getGetLicenseQueryKey }; -type LicenseData = GetLicenseResponse | null; +type LicenseData = GetLicense200 | null; /** * The installed licence, or null when there is none. Not run on cloud, where licensing is the @@ -21,18 +18,17 @@ export function useAdminLicense() { const enabled = isAdmin && !isCloud(); const query = useQuery({ - queryKey: getLicenseQueryKey(), - queryFn: async ({ signal }) => { + queryKey: getGetLicenseQueryKey(), + queryFn: async () => { try { - const { data } = await getLicense({ signal, throwOnError: true }); - return data; + return await getLicense(); } catch (err) { if (isSdkError(err) && err.status === 400) return null; throw err; } }, enabled, - staleTime: 5 * 60 * 1000, // 5 minutes + staleTime: 5 * 60 * 1000, retry: (count) => count < 1, refetchOnWindowFocus: false, }); diff --git a/ui/apps/console/src/hooks/useAdminNamespaceMutations.ts b/ui/apps/console/src/hooks/useAdminNamespaceMutations.ts deleted file mode 100644 index 05012267503..00000000000 --- a/ui/apps/console/src/hooks/useAdminNamespaceMutations.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - editNamespaceAdminMutation, - deleteNamespaceAdminMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Edits a namespace as an admin, refreshing both the list and the detail query. - */ -export function useAdminEditNamespace() { - const invalidate = useInvalidateByIds( - "getNamespacesAdmin", - "getNamespaceAdmin", - ); - return useMutation({ - ...editNamespaceAdminMutation(), - onSuccess: invalidate, - }); -} - -/** - * Deletes a namespace as an admin. Refreshes the list and the detail query; the namespace and - * everything in it are gone, so there is nothing to undo. - */ -export function useAdminDeleteNamespace() { - const invalidate = useInvalidateByIds( - "getNamespacesAdmin", - "getNamespaceAdmin", - ); - return useMutation({ - ...deleteNamespaceAdminMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useAdminNamespaces.ts b/ui/apps/console/src/hooks/useAdminNamespaces.ts deleted file mode 100644 index 247d69cbb1f..00000000000 --- a/ui/apps/console/src/hooks/useAdminNamespaces.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - getNamespacesAdmin as getNamespacesAdminSdk, - getNamespacesAdminQueryKey, - getNamespaceAdminOptions, - type GetNamespacesAdminData, - type Namespace, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; -import { useAuthStore } from "../stores/authStore"; -import { isSdkError } from "../api/errors"; -import { toBase64Json } from "@/utils/encoding"; - -function buildNameFilter(search: string): string { - const filter = [ - { - type: "property", - params: { name: "name", operator: "contains", value: search }, - }, - ]; - return toBase64Json(filter); -} - -interface UseAdminNamespacesParams { - page?: number; - perPage?: number; - search?: string; -} - -/** - * A page of namespaces for the admin list. Admin-only, so it does not run for anyone else. - */ -export function useAdminNamespaces({ - page = 1, - perPage = 10, - search = "", -}: UseAdminNamespacesParams = {}) { - const isAdmin = useAuthStore((s) => s.isAdmin); - - const query: GetNamespacesAdminData["query"] = { page, per_page: perPage }; - if (search) query.filter = buildNameFilter(search); - const options = { query }; - - const result = useQuery>({ - queryKey: getNamespacesAdminQueryKey(options), - queryFn: paginatedQueryFn(getNamespacesAdminSdk, options), - enabled: isAdmin, - staleTime: 5 * 60 * 1000, // 5 minutes - retry: (count, err) => - isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); - - return { - namespaces: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} - -/** - * One namespace by tenant id, for the admin detail view. - */ -export function useAdminNamespace(tenantId: string) { - const isAdmin = useAuthStore((s) => s.isAdmin); - - return useQuery({ - ...getNamespaceAdminOptions({ path: { tenant: tenantId } }), - enabled: isAdmin && !!tenantId, - staleTime: 5 * 60 * 1000, // 5 minutes - retry: (count, err) => - isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); -} diff --git a/ui/apps/console/src/hooks/useAdminSessionDetail.ts b/ui/apps/console/src/hooks/useAdminSessionDetail.ts deleted file mode 100644 index 581dde6387b..00000000000 --- a/ui/apps/console/src/hooks/useAdminSessionDetail.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getSessionAdminOptions } from "../client"; -import { useAuthStore } from "../stores/authStore"; -import { isSdkError } from "../api/errors"; - -/** - * One session by UID, for the admin detail view. Cached for a minute only: a live session's - * state changes while it is being looked at. - */ -export function useAdminSessionDetail(uid: string) { - const isAdmin = useAuthStore((s) => s.isAdmin); - - const result = useQuery({ - ...getSessionAdminOptions({ path: { uid } }), - enabled: isAdmin && !!uid, - staleTime: 60 * 1000, - retry: (count, err) => isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); - - return { - session: result.data ?? null, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useAdminSessions.ts b/ui/apps/console/src/hooks/useAdminSessions.ts deleted file mode 100644 index 99ed87a5a64..00000000000 --- a/ui/apps/console/src/hooks/useAdminSessions.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - getSessionsAdmin, - getSessionsAdminQueryKey, - type GetSessionsAdminData, - type Session, -} from "@/client"; -import { paginatedQueryFn, type PaginatedResult } from "@/api/pagination"; -import { useAuthStore } from "@/stores/authStore"; -import { isSdkError } from "@/api/errors"; - -/** - * A page of sessions across every namespace, for the admin list. - */ -export function useAdminSessions({ page = 1, perPage = 5 } = {}) { - const isAdmin = useAuthStore((s) => s.isAdmin); - const options = { query: { page, per_page: perPage } } satisfies { - query: GetSessionsAdminData["query"]; - }; - - const result = useQuery>({ - queryKey: getSessionsAdminQueryKey(options), - queryFn: paginatedQueryFn(getSessionsAdmin, options), - enabled: isAdmin, - staleTime: 5 * 60 * 1000, - retry: (count, err) => - isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); - - return { - sessions: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useAdminStats.ts b/ui/apps/console/src/hooks/useAdminStats.ts deleted file mode 100644 index 8215e66adfc..00000000000 --- a/ui/apps/console/src/hooks/useAdminStats.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getStatsOptions, getStatsQueryKey } from "../client"; -import { useAuthStore } from "../stores/authStore"; -import { isSdkError } from "../api/errors"; - -export { getStatsQueryKey }; - -/** - * Instance-wide counts for the admin dashboard. - */ -export function useAdminStats() { - const isAdmin = useAuthStore((s) => s.isAdmin); - - const result = useQuery({ - ...getStatsOptions(), - enabled: isAdmin, - staleTime: 5 * 60 * 1000, // 5 minutes - retry: (count, err) => isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); - - return { - stats: result.data, - isLoading: result.isLoading, - isError: result.isError, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useAdminUserMutations.ts b/ui/apps/console/src/hooks/useAdminUserMutations.ts deleted file mode 100644 index 8f644d4a943..00000000000 --- a/ui/apps/console/src/hooks/useAdminUserMutations.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - createUserAdminMutation, - adminUpdateUserMutation, - adminDeleteUserMutation, - adminResetUserPasswordMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Creates a user as an admin, refreshing the user list. - */ -export function useCreateUser() { - const invalidate = useInvalidateByIds("getUsers"); - return useMutation({ - ...createUserAdminMutation(), - onSuccess: invalidate, - }); -} - -/** - * Updates a user as an admin, refreshing the list and the user's own query. - */ -export function useUpdateUser() { - const invalidate = useInvalidateByIds("getUsers", "getUser"); - return useMutation({ - ...adminUpdateUserMutation(), - onSuccess: invalidate, - }); -} - -/** - * Deletes a user as an admin, refreshing the list and the user's own query. - */ -export function useDeleteUser() { - const invalidate = useInvalidateByIds("getUsers", "getUser"); - return useMutation({ - ...adminDeleteUserMutation(), - onSuccess: invalidate, - }); -} - -/** - * Resets a user's password as an admin. The user is not notified by this call, so whoever ran it - * has to tell them. - */ -export function useResetUserPassword() { - const invalidate = useInvalidateByIds("getUsers", "getUser"); - return useMutation({ - ...adminResetUserPasswordMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useAdminUsers.ts b/ui/apps/console/src/hooks/useAdminUsers.ts deleted file mode 100644 index 25aec859aab..00000000000 --- a/ui/apps/console/src/hooks/useAdminUsers.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - getUsers as getUsersSdk, - getUsersQueryKey, - getUserOptions, - type GetUsersData, - type UserAdminResponse, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; -import { useAuthStore } from "../stores/authStore"; -import { isSdkError } from "../api/errors"; -import { toBase64Json } from "@/utils/encoding"; - -function buildUsernameFilter(search: string): string { - const filter = [ - { - type: "property", - params: { name: "username", operator: "contains", value: search }, - }, - ]; - return toBase64Json(filter); -} - -interface UseAdminUsersParams { - page?: number; - perPage?: number; - search?: string; -} - -/** - * A page of users for the admin list. - */ -export function useAdminUsers({ - page = 1, - perPage = 10, - search = "", -}: UseAdminUsersParams = {}) { - const isAdmin = useAuthStore((s) => s.isAdmin); - - const query: GetUsersData["query"] = { page, per_page: perPage }; - if (search) query.filter = buildUsernameFilter(search); - const options = { query }; - - const result = useQuery>({ - queryKey: getUsersQueryKey(options), - queryFn: paginatedQueryFn(getUsersSdk, options), - enabled: isAdmin, - staleTime: 5 * 60 * 1000, // 5 minutes - retry: (count, err) => - isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); - - return { - users: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} - -/** - * One user by id, for the admin detail view. - */ -export function useAdminUser(id: string) { - const isAdmin = useAuthStore((s) => s.isAdmin); - - return useQuery({ - ...getUserOptions({ path: { id } }), - enabled: isAdmin && !!id, - staleTime: 5 * 60 * 1000, // 5 minutes - retry: (count, err) => - isSdkError(err) && err.status === 401 ? false : count < 1, - refetchOnWindowFocus: false, - }); -} diff --git a/ui/apps/console/src/hooks/useApiKeyMutations.ts b/ui/apps/console/src/hooks/useApiKeyMutations.ts deleted file mode 100644 index d6a67621fb2..00000000000 --- a/ui/apps/console/src/hooks/useApiKeyMutations.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - apiKeyCreateMutation, - apiKeyUpdateMutation, - apiKeyDeleteMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Creates an API key, refreshing the list. The response carries the only copy of the secret the - * caller will ever see, so it has to be shown before the mutation's data is discarded. - */ -export function useCreateApiKey() { - const invalidate = useInvalidateByIds("apiKeyList"); - return useMutation({ - ...apiKeyCreateMutation(), - onSuccess: invalidate, - }); -} - -/** - * Updates an API key's name or role, refreshing the list. The secret is not re-issued. - */ -export function useUpdateApiKey() { - const invalidate = useInvalidateByIds("apiKeyList"); - return useMutation({ - ...apiKeyUpdateMutation(), - onSuccess: invalidate, - }); -} - -/** - * Deletes an API key, refreshing the list. Anything still authenticating with it starts failing - * at once. - */ -export function useDeleteApiKey() { - const invalidate = useInvalidateByIds("apiKeyList"); - return useMutation({ - ...apiKeyDeleteMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useApiKeys.ts b/ui/apps/console/src/hooks/useApiKeys.ts deleted file mode 100644 index a4d1a6a1802..00000000000 --- a/ui/apps/console/src/hooks/useApiKeys.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - apiKeyList, - apiKeyListQueryKey, - type ApiKeyListData, - type ApiKey, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; - -interface UseApiKeysParams { - page?: number; - perPage?: number; - sortBy?: string; - orderBy?: "asc" | "desc"; -} - -/** - * A page of the namespace's API keys, newest first. - */ -export function useApiKeys({ - page = 1, - perPage = 10, - sortBy = "created_at", - orderBy = "desc", -}: UseApiKeysParams = {}) { - const options = { query: { page, per_page: perPage, sort_by: sortBy, order_by: orderBy } } satisfies { query: ApiKeyListData["query"] }; - - const result = useQuery>({ - queryKey: apiKeyListQueryKey(options), - queryFn: paginatedQueryFn(apiKeyList, options), - }); - - return { - apiKeys: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useBilling.ts b/ui/apps/console/src/hooks/useBilling.ts deleted file mode 100644 index e01abd503f8..00000000000 --- a/ui/apps/console/src/hooks/useBilling.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - getCustomerOptions, - getSubscriptionOptions, - createCustomerMutation, - createSubscriptionMutation, - attachPaymentMethodMutation, - detachPaymentMethodMutation, - setDefaultPaymentMethodMutation, - createBillingPortalSession, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -function useInvalidateBilling() { - return useInvalidateByIds("getCustomer", "getSubscription", "getNamespace"); -} - -/** - * The namespace's billing customer, if it has one. enabled is a parameter because a namespace - * without billing has no customer to fetch and the call would 404. - */ -export function useCustomer(enabled = true) { - const result = useQuery({ - ...getCustomerOptions(), - enabled, - }); - return { - customer: result.data, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} - -/** - * The namespace's subscription, if it has one. - */ -export function useSubscription(enabled = true) { - const result = useQuery({ - ...getSubscriptionOptions(), - enabled, - }); - return { - subscription: result.data, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} - -/** - * Creates the billing customer, refreshing every billing query on success. - */ -export function useCreateCustomer() { - const invalidate = useInvalidateBilling(); - return useMutation({ - ...createCustomerMutation(), - onSuccess: invalidate, - }); -} - -/** - * Starts a subscription, refreshing every billing query on success. - */ -export function useCreateSubscription() { - const invalidate = useInvalidateBilling(); - return useMutation({ - ...createSubscriptionMutation(), - onSuccess: invalidate, - }); -} - -/** - * Attaches a payment method, refreshing every billing query on success. - */ -export function useAttachPaymentMethod() { - const invalidate = useInvalidateBilling(); - return useMutation({ - ...attachPaymentMethodMutation(), - onSuccess: invalidate, - }); -} - -/** - * Detaches a payment method, refreshing every billing query on success. - */ -export function useDetachPaymentMethod() { - const invalidate = useInvalidateBilling(); - return useMutation({ - ...detachPaymentMethodMutation(), - onSuccess: invalidate, - }); -} - -/** - * Makes a payment method the default one, refreshing every billing query on success. - */ -export function useSetDefaultPaymentMethod() { - const invalidate = useInvalidateBilling(); - return useMutation({ - ...setDefaultPaymentMethodMutation(), - onSuccess: invalidate, - }); -} - -/** - * Opens the provider's billing portal. The URL is single-use and short-lived, so it is minted on - * demand rather than fetched with the page and held. - */ -export function useOpenBillingPortal() { - return useMutation({ - mutationFn: async () => { - const { data } = await createBillingPortalSession({ throwOnError: true }); - if (!data.url) throw new Error("Missing billing portal URL"); - window.open(data.url, "_blank", "noopener,noreferrer"); - return data.url; - }, - }); -} diff --git a/ui/apps/console/src/hooks/useChatwoot.ts b/ui/apps/console/src/hooks/useChatwoot.ts index 63401b9e19a..47b3a3fb5e1 100644 --- a/ui/apps/console/src/hooks/useChatwoot.ts +++ b/ui/apps/console/src/hooks/useChatwoot.ts @@ -9,7 +9,7 @@ import { import { getConfig, isCloud } from "@/env"; import { useAuthStore } from "@/stores/authStore"; import { useNamespace } from "@/hooks/useNamespaces"; -import { useSupportIdentifier } from "@/hooks/useSupportIdentifier"; +import { useGetNamespaceSupport } from "@/client/api"; import { hasActiveSubscription } from "@/utils/billing"; import { falseSnapshot, @@ -75,10 +75,15 @@ export function useChatwoot(): ChatwootHandle { const hasCloudConfig = !!config.chatwootWebsiteToken && !!config.chatwootBaseUrl; - const { identifier, isError: identifierError } = useSupportIdentifier( - tenant, - isCloudEdition && hasCloudConfig && hasActiveBilling, - ); + const { data: supportData, isError: identifierError } = + useGetNamespaceSupport(tenant ?? "", { + query: { + enabled: + isCloudEdition && hasCloudConfig && hasActiveBilling && !!tenant, + retry: 1, + }, + }); + const identifier = supportData?.identifier ?? null; const widgetReady = useSyncExternalStore( subscribeChatwootState, diff --git a/ui/apps/console/src/hooks/useContainer.ts b/ui/apps/console/src/hooks/useContainer.ts deleted file mode 100644 index 998f3abcf0e..00000000000 --- a/ui/apps/console/src/hooks/useContainer.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getContainerOptions } from "../client"; - -/** - * One container by UID. Idle until a UID is given, so a route that has not resolved its - * parameter yet does not issue a request for an empty path. - */ -export function useContainer(uid: string) { - const result = useQuery({ - ...getContainerOptions({ path: { uid } }), - enabled: !!uid, - }); - - return { - container: (result.data ?? null), - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} diff --git a/ui/apps/console/src/hooks/useContainerActionRunner.ts b/ui/apps/console/src/hooks/useContainerActionRunner.ts index 4635f2f9a49..b7ae91257b1 100644 --- a/ui/apps/console/src/hooks/useContainerActionRunner.ts +++ b/ui/apps/console/src/hooks/useContainerActionRunner.ts @@ -1,26 +1,20 @@ import { useCallback } from "react"; -import { - useUpdateContainerStatus, - useRemoveContainer, -} from "@/hooks/useContainerMutations"; +import { useUpdateContainerStatus, useDeleteContainer } from "@/client/api"; import type { EntityBase, EntityOperation } from "@/hooks/useActionDialog"; -/** - * Runs the confirmation dialog's chosen operation against a container, so the dialog does not - * have to know which mutation each operation maps to. - */ +/** Maps action-dialog operations to container mutations. */ export function useContainerActionRunner() { const status = useUpdateContainerStatus(); - const remove = useRemoveContainer(); + const remove = useDeleteContainer(); return useCallback( async (entity: EntityBase, operation: EntityOperation) => { if (operation === "remove") { - await remove.mutateAsync({ path: { uid: entity.uid } }); + await remove.mutateAsync({ uid: entity.uid }); return; } - await status.mutateAsync({ path: { uid: entity.uid, status: operation } }); + await status.mutateAsync({ uid: entity.uid, status: operation }); }, [status, remove], ); diff --git a/ui/apps/console/src/hooks/useContainerMutations.ts b/ui/apps/console/src/hooks/useContainerMutations.ts index 1808238d82f..9581ba7777b 100644 --- a/ui/apps/console/src/hooks/useContainerMutations.ts +++ b/ui/apps/console/src/hooks/useContainerMutations.ts @@ -1,88 +1,19 @@ import { useMutation } from "@tanstack/react-query"; import { isSdkError } from "../api/errors"; -import { - deleteContainerMutation, - updateContainerMutation, - updateContainerStatusMutation, - createTag, - pushTagToContainer, - pullTagFromContainer, -} from "../client"; +import { createTag, pushTagToContainer } from "@/client/api"; import { useInvalidateByIds } from "./useInvalidateQueries"; -/** - * Accepts or rejects a pending container, refreshing the list and the container itself. - */ -export function useUpdateContainerStatus() { - const invalidate = useInvalidateByIds("getContainers", "getContainer"); - return useMutation({ - ...updateContainerStatusMutation(), - onSuccess: invalidate, - }); -} - -/** - * Removes a container from the namespace. - */ -export function useRemoveContainer() { - const invalidate = useInvalidateByIds("getContainers", "getContainer"); - return useMutation({ - ...deleteContainerMutation(), - onSuccess: invalidate, - }); -} - -/** - * Renames a container. - */ -export function useRenameContainer() { - const invalidate = useInvalidateByIds("getContainers", "getContainer"); - return useMutation({ - ...updateContainerMutation(), - onSuccess: invalidate, - }); -} - -/** - * Tags a container. The tag list is refreshed too, because a tag may not have existed before. - */ +/** Creates the tag if it doesn't exist (swallows 409), then pushes it to the container. */ export function useAddContainerTag() { - const invalidate = useInvalidateByIds( - "getContainers", - "getContainer", - "getTags", - ); + const invalidate = useInvalidateByIds("/api/containers", "/api/tags"); return useMutation({ - mutationFn: async (options: { path: { uid: string; name: string } }) => { + mutationFn: async ({ uid, name }: { uid: string; name: string }) => { try { - await createTag({ - body: { name: options.path.name }, - throwOnError: true, - }); + await createTag({ name }); } catch (e) { if (!isSdkError(e) || e.status !== 409) throw e; } - return pushTagToContainer({ - path: { uid: options.path.uid, name: options.path.name }, - throwOnError: true, - }); - }, - onSuccess: invalidate, - }); -} - -/** - * Removes a tag from a container. The tag itself survives on anything else carrying it, so the - * tag list is not refreshed. - */ -export function useRemoveContainerTag() { - const invalidate = useInvalidateByIds("getContainers", "getContainer"); - return useMutation({ - mutationFn: async (options: { path: { uid: string; name: string } }) => { - return pullTagFromContainer({ - path: { uid: options.path.uid, name: options.path.name }, - throwOnError: true, - }); + return pushTagToContainer(uid, name); }, onSuccess: invalidate, }); diff --git a/ui/apps/console/src/hooks/useContainers.ts b/ui/apps/console/src/hooks/useContainers.ts deleted file mode 100644 index 31d80e88b8d..00000000000 --- a/ui/apps/console/src/hooks/useContainers.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { - getContainers as getContainersSdk, - getContainersQueryKey, - type GetContainersData, - type Device, - type DeviceStatus, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; -import { toBase64Json } from "@/utils/encoding"; -import { normalizeDeviceTags } from "@/utils/deviceTags"; - -export type { TaggedDevice as NormalizedContainer } from "@/utils/deviceTags"; - -function buildFilter(search: string, tags: string[]): string { - const filters: Record[] = []; - if (search) { - filters.push({ - type: "property", - params: { name: "name", operator: "contains", value: search }, - }); - } - if (tags.length > 0) { - filters.push({ - type: "property", - params: { name: "tags.name", operator: "contains", value: tags }, - }); - } - return toBase64Json(filters); -} - -interface UseContainersParams { - page?: number; - perPage?: number; - status?: DeviceStatus | ""; - search?: string; - filterTags?: string[]; - sortBy?: string; - orderBy?: "asc" | "desc"; -} - -/** - * A page of the namespace's containers, filtered by status, search and tags. - */ -export function useContainers({ - page = 1, - perPage = 10, - status = "", - search = "", - filterTags = [], - sortBy = "last_seen", - orderBy = "desc", -}: UseContainersParams = {}) { - const query: GetContainersData["query"] = { page, per_page: perPage }; - if (status) query.status = status; - if (search || filterTags.length > 0) - query.filter = buildFilter(search, filterTags); - query.sort_by = sortBy; - query.order_by = orderBy; - - const options = { query }; - - const result = useQuery>({ - queryKey: getContainersQueryKey(options), - queryFn: paginatedQueryFn(getContainersSdk, options), - }); - - const containers = useMemo( - () => result.data?.data.map(normalizeDeviceTags) ?? [], - [result.data], - ); - - return { - containers, - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} diff --git a/ui/apps/console/src/hooks/useDevice.ts b/ui/apps/console/src/hooks/useDevice.ts deleted file mode 100644 index 5addaa04882..00000000000 --- a/ui/apps/console/src/hooks/useDevice.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getDeviceOptions } from "../client"; - -/** - * One device by UID. Idle until a UID is given. - */ -export function useDevice(uid: string) { - const result = useQuery({ - ...getDeviceOptions({ path: { uid } }), - enabled: !!uid, - }); - - return { - device: result.data ?? null, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} diff --git a/ui/apps/console/src/hooks/useDeviceActionRunner.ts b/ui/apps/console/src/hooks/useDeviceActionRunner.ts index d5a71fb9026..7061f10c84f 100644 --- a/ui/apps/console/src/hooks/useDeviceActionRunner.ts +++ b/ui/apps/console/src/hooks/useDeviceActionRunner.ts @@ -1,29 +1,26 @@ import { useCallback } from "react"; import { useAcceptDevice, - useRejectDevice, - useRemoveDevice, -} from "@/hooks/useDeviceMutations"; + useUpdateDeviceStatus, + useDeleteDevice, +} from "@/client/api"; import type { EntityBase, EntityOperation } from "@/hooks/useActionDialog"; -/** - * Runs the confirmation dialog's chosen operation against a device, so the dialog does not have - * to know which mutation each operation maps to. - */ +/** Maps action-dialog operations to device mutations. */ export function useDeviceActionRunner() { const accept = useAcceptDevice(); - const reject = useRejectDevice(); - const remove = useRemoveDevice(); + const reject = useUpdateDeviceStatus(); + const remove = useDeleteDevice(); return useCallback( async (entity: EntityBase, operation: EntityOperation) => { if (operation === "reject") { - await reject.mutateAsync({ path: { uid: entity.uid, status: "reject" } }); + await reject.mutateAsync({ uid: entity.uid, status: "reject" }); return; } const action = operation === "accept" ? accept : remove; - await action.mutateAsync({ path: { uid: entity.uid } }); + await action.mutateAsync({ uid: entity.uid }); }, [accept, reject, remove], ); diff --git a/ui/apps/console/src/hooks/useDeviceChooser.ts b/ui/apps/console/src/hooks/useDeviceChooser.ts deleted file mode 100644 index 4b80dc2fc75..00000000000 --- a/ui/apps/console/src/hooks/useDeviceChooser.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - choiceDevicesMutation, - getDevicesMostUsedOptions, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; -import { normalizeDeviceTags, type TaggedDevice } from "@/utils/deviceTags"; - -/** - * The devices used most, offered first when a namespace over its limit has to choose which to - * keep. Tags are normalized, so callers get plain strings. - */ -export function useSuggestedDevices(enabled = true) { - const result = useQuery({ - ...getDevicesMostUsedOptions(), - enabled, - }); - const devices: TaggedDevice[] = (result.data ?? []).map(normalizeDeviceTags); - return { - devices, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} - -/** - * Commits the chosen devices. Everything not chosen loses its place, so this refreshes the - * device queries and the counts together. - */ -export function useChoiceDevices() { - const invalidate = useInvalidateByIds( - "getDevices", - "getDevice", - "getStatusDevices", - ); - return useMutation({ - ...choiceDevicesMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useDeviceCode.ts b/ui/apps/console/src/hooks/useDeviceCode.ts deleted file mode 100644 index 1c9d5f660fc..00000000000 --- a/ui/apps/console/src/hooks/useDeviceCode.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useQuery, useMutation } from "@tanstack/react-query"; -import { - resolveDeviceLoginCodeOptions, - acceptDevicePairingMutation, -} from "@/client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Resolves an enrolment code to the device waiting behind it. Never retried and never stale: a - * code is single-use, so a second attempt would fail and a cached answer would be wrong. - */ -export function useResolveDeviceCode(code: string) { - const { data, isLoading, isError, error } = useQuery({ - ...resolveDeviceLoginCodeOptions({ path: { code } }), - enabled: !!code, - retry: false, - staleTime: Infinity, - }); - - return { device: data ?? null, isLoading, isError, error }; -} - -/** - * Completes an enrolment from a code, adding the device to the namespace and refreshing the - * device queries and counts. - */ -export function useAcceptDevicePairing() { - const invalidate = useInvalidateByIds( - "getDevices", - "getDevice", - "getStatusDevices", - "getStats", - "installKeyList", - ); - return useMutation({ - ...acceptDevicePairingMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useDeviceMutations.ts b/ui/apps/console/src/hooks/useDeviceMutations.ts index 04bd0ab871b..6f18e37bff3 100644 --- a/ui/apps/console/src/hooks/useDeviceMutations.ts +++ b/ui/apps/console/src/hooks/useDeviceMutations.ts @@ -1,144 +1,24 @@ import { useMutation } from "@tanstack/react-query"; import { isSdkError } from "../api/errors"; -import { - acceptDeviceMutation, - updateDeviceStatusMutation, - deleteDeviceMutation, - updateDeviceMutation, - pullTagFromDeviceMutation, - setDeviceCustomFieldMutation, - deleteDeviceCustomFieldMutation, - createTag, - pushTagToDevice, -} from "../client"; +import { createTag, pushTagToDevice } from "@/client/api"; import { useInvalidateByIds } from "./useInvalidateQueries"; -/** - * Accepts a pending device. The counts change with it, so the stats query is refreshed as well - * as the lists — including the install keys list, which carries each key's pending count. - */ -export function useAcceptDevice() { - const invalidate = useInvalidateByIds( - "getDevices", - "getDevice", - "getStatusDevices", - "getStats", - "installKeyList", - ); - return useMutation({ - ...acceptDeviceMutation(), - onSuccess: invalidate, - }); -} - -/** - * Rejects a pending device. The device leaves the pending list but the accepted count is - * unchanged, so stats are not refreshed. - */ -export function useRejectDevice() { - const invalidate = useInvalidateByIds( - "getDevices", - "getDevice", - "getStatusDevices", - "installKeyList", - ); - return useMutation({ - ...updateDeviceStatusMutation(), - onSuccess: invalidate, - }); -} - -/** - * Removes a device from the namespace, refreshing the lists and the counts. - */ -export function useRemoveDevice() { - const invalidate = useInvalidateByIds( - "getDevices", - "getDevice", - "getStatusDevices", - "getStats", - "installKeyList", - ); - return useMutation({ - ...deleteDeviceMutation(), - onSuccess: invalidate, - }); -} - -/** - * Renames a device. - */ -export function useRenameDevice() { - const invalidate = useInvalidateByIds( - "getDevices", - "getDevice", - "getStatusDevices", - ); - return useMutation({ - ...updateDeviceMutation(), - onSuccess: invalidate, - }); -} - -/** - * Sets a custom field on a device. - */ -export function useSetDeviceCustomField() { - const invalidate = useInvalidateByIds("getDevices", "getDevice"); - return useMutation({ - ...setDeviceCustomFieldMutation(), - onSuccess: invalidate, - }); -} - -/** - * Deletes a custom field from a device. - */ -export function useDeleteDeviceCustomField() { - const invalidate = useInvalidateByIds("getDevices", "getDevice"); - return useMutation({ - ...deleteDeviceCustomFieldMutation(), - onSuccess: invalidate, - }); -} - -/** - * Tags a device. The tag list is refreshed too, because the tag may be new. - */ +/** Creates the tag if it doesn't exist (swallows 409), then pushes it to the device. */ export function useAddDeviceTag() { const invalidate = useInvalidateByIds( - "getDevices", - "getDevice", - "getStatusDevices", - "getTags", + "/api/devices", + "/api/stats", + "/api/tags", ); return useMutation({ - mutationFn: async (options: { path: { uid: string; name: string } }) => { + mutationFn: async ({ uid, name }: { uid: string; name: string }) => { try { - await createTag({ - body: { name: options.path.name }, - throwOnError: true, - }); + await createTag({ name }); } catch (e) { if (!isSdkError(e) || e.status !== 409) throw e; } - return pushTagToDevice({ ...options, throwOnError: true }); + return pushTagToDevice(uid, name); }, onSuccess: invalidate, }); } - -/** - * Removes a tag from a device. The tag survives on anything else carrying it. - */ -export function useRemoveDeviceTag() { - const invalidate = useInvalidateByIds( - "getDevices", - "getDevice", - "getStatusDevices", - ); - return useMutation({ - ...pullTagFromDeviceMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useDevices.ts b/ui/apps/console/src/hooks/useDevices.ts index c8874648892..efb289c05dc 100644 --- a/ui/apps/console/src/hooks/useDevices.ts +++ b/ui/apps/console/src/hooks/useDevices.ts @@ -1,13 +1,7 @@ import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { - getDevices as getDevicesSdk, - getDevicesQueryKey, - type GetDevicesData, - type DeviceStatus, - type Device as GeneratedDevice, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; +import { useGetDevices } from "@/client/api"; +import type { DeviceStatus, GetDevicesParams } from "@/client/model"; +import { totalCount } from "@/api/pagination"; import { toBase64Json } from "@/utils/encoding"; import { normalizeDeviceTags } from "@/utils/deviceTags"; @@ -66,29 +60,26 @@ export function useDevices({ sortBy = "last_seen", orderBy = "desc", }: UseDevicesParams = {}) { - const query: GetDevicesData["query"] = { page, per_page: perPage }; - if (status) query.status = status; + const params: GetDevicesParams = { + page, + per_page: perPage, + sort_by: sortBy, + order_by: orderBy, + }; + if (status) params.status = status; if (search || filterTags.length > 0) - query.filter = buildFilter(search, filterTags); - query.sort_by = sortBy; - query.order_by = orderBy; - - const options = { query }; + params.filter = buildFilter(search, filterTags); - const result = useQuery>({ - queryKey: getDevicesQueryKey(options), - queryFn: paginatedQueryFn(getDevicesSdk, options), - enabled, - }); + const result = useGetDevices(params, { query: { enabled } }); const devices = useMemo( - () => result.data?.data.map(normalizeDeviceTags) ?? [], + () => (result.data ?? []).map(normalizeDeviceTags), [result.data], ); return { devices, - totalCount: result.data?.totalCount ?? 0, + totalCount: totalCount(result.data), isLoading: result.isLoading, error: result.error, refetch: result.refetch, diff --git a/ui/apps/console/src/hooks/useFirewallRuleMutations.ts b/ui/apps/console/src/hooks/useFirewallRuleMutations.ts deleted file mode 100644 index a8f143023d3..00000000000 --- a/ui/apps/console/src/hooks/useFirewallRuleMutations.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - createFirewallRuleMutation, - updateFirewallRuleMutation, - deleteFirewallRuleMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Creates a firewall rule, refreshing the list. - */ -export function useCreateFirewallRule() { - const invalidate = useInvalidateByIds("getFirewallRules"); - return useMutation({ - ...createFirewallRuleMutation(), - onSuccess: invalidate, - }); -} - -/** - * Updates a firewall rule, refreshing the list. - */ -export function useUpdateFirewallRule() { - const invalidate = useInvalidateByIds("getFirewallRules"); - return useMutation({ - ...updateFirewallRuleMutation(), - onSuccess: invalidate, - }); -} - -/** - * Deletes a firewall rule, refreshing the list. - */ -export function useDeleteFirewallRule() { - const invalidate = useInvalidateByIds("getFirewallRules"); - return useMutation({ - ...deleteFirewallRuleMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useFirewallRules.ts b/ui/apps/console/src/hooks/useFirewallRules.ts deleted file mode 100644 index d49ad048242..00000000000 --- a/ui/apps/console/src/hooks/useFirewallRules.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { - getFirewallRules as getFirewallRulesSdk, - getFirewallRulesQueryKey, - type GetFirewallRulesData, - type FirewallRulesResponse, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; - -interface UseFirewallRulesParams { - page?: number; - perPage?: number; -} - -/** - * A page of the namespace's firewall rules, in priority order. - */ -export function useFirewallRules({ - page = 1, - perPage = 10, -}: UseFirewallRulesParams = {}) { - const options = { - query: { page, per_page: perPage } satisfies GetFirewallRulesData["query"], - }; - - const result = useQuery>({ - queryKey: getFirewallRulesQueryKey(options), - queryFn: paginatedQueryFn(getFirewallRulesSdk, options), - }); - - const rules = useMemo(() => result.data?.data ?? [], [result.data]); - - return { - rules, - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useInstallKeyEvents.ts b/ui/apps/console/src/hooks/useInstallKeyEvents.ts deleted file mode 100644 index 9bd41b2da01..00000000000 --- a/ui/apps/console/src/hooks/useInstallKeyEvents.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - installKeyHistory, - installKeyHistoryQueryKey, - type InstallKeyHistoryData, - type InstallKeyEvent, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; - -interface UseInstallKeyEventsParams { - id: string | null; - page?: number; - perPage?: number; -} - -/** - * A page of the enrolments made with an install key — what it was used for, and when. - */ -export function useInstallKeyEvents({ - id, - page = 1, - perPage = 15, -}: UseInstallKeyEventsParams) { - const options = { - path: { id: id ?? "" }, - query: { page, per_page: perPage, sort_by: "created_at", order_by: "desc" }, - } satisfies { - path: InstallKeyHistoryData["path"]; - query: InstallKeyHistoryData["query"]; - }; - - const result = useQuery>({ - queryKey: installKeyHistoryQueryKey(options), - queryFn: paginatedQueryFn(installKeyHistory, options), - enabled: !!id, - }); - - return { - events: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useInstallKeyMutations.ts b/ui/apps/console/src/hooks/useInstallKeyMutations.ts deleted file mode 100644 index 58712977f94..00000000000 --- a/ui/apps/console/src/hooks/useInstallKeyMutations.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - installKeyCreateMutation, - installKeyUpdateMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Creates an install key. The response carries the only copy of the key, so it has to be shown - * before the mutation's data is discarded. - */ -export function useCreateInstallKey() { - const invalidate = useInvalidateByIds("installKeyList"); - return useMutation({ - ...installKeyCreateMutation(), - onSuccess: invalidate, - }); -} - -/** - * Updates an install key's name or limits. The key itself is not re-issued. - */ -export function useUpdateInstallKey() { - const invalidate = useInvalidateByIds("installKeyList"); - return useMutation({ - ...installKeyUpdateMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useInstallKeys.ts b/ui/apps/console/src/hooks/useInstallKeys.ts deleted file mode 100644 index 2d35ab13fad..00000000000 --- a/ui/apps/console/src/hooks/useInstallKeys.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - installKeyList, - installKeyListQueryKey, - type InstallKeyListData, - type InstallKey, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; - -interface UseInstallKeysParams { - page?: number; - perPage?: number; - sortBy?: string; - orderBy?: "asc" | "desc"; -} - -/** - * A page of the namespace's install keys, newest first. - */ -export function useInstallKeys({ - page = 1, - perPage = 10, - sortBy = "created_at", - orderBy = "desc", -}: UseInstallKeysParams = {}) { - const options = { - query: { page, per_page: perPage, sort_by: sortBy, order_by: orderBy }, - } satisfies { query: InstallKeyListData["query"] }; - - const result = useQuery>({ - queryKey: installKeyListQueryKey(options), - queryFn: paginatedQueryFn(installKeyList, options), - }); - - return { - installKeys: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useInstanceApiKeyMutations.ts b/ui/apps/console/src/hooks/useInstanceApiKeyMutations.ts deleted file mode 100644 index 790e70b8128..00000000000 --- a/ui/apps/console/src/hooks/useInstanceApiKeyMutations.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - createInstanceApiKeyMutation, - deleteInstanceApiKeyMutation, -} from "@/client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Creates an instance API key, refreshing the list. The response carries the only copy of the - * secret the caller will ever see, so it has to be shown before the mutation's data is discarded. - */ -export function useCreateInstanceApiKey() { - const invalidate = useInvalidateByIds("listInstanceApiKeys"); - - return useMutation({ - ...createInstanceApiKeyMutation(), - onSuccess: invalidate, - }); -} - -/** - * Revokes an instance API key, refreshing the list. Anything still authenticating with it starts - * failing at once. - */ -export function useDeleteInstanceApiKey() { - const invalidate = useInvalidateByIds("listInstanceApiKeys"); - - return useMutation({ - ...deleteInstanceApiKeyMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useInstanceApiKeys.ts b/ui/apps/console/src/hooks/useInstanceApiKeys.ts deleted file mode 100644 index 81ee099d4c6..00000000000 --- a/ui/apps/console/src/hooks/useInstanceApiKeys.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - listInstanceApiKeys, - listInstanceApiKeysQueryKey, - type ListInstanceApiKeysData, - type InstanceApiKey, -} from "@/client"; -import { paginatedQueryFn, type PaginatedResult } from "@/api/pagination"; - -interface UseInstanceApiKeysParams { - page?: number; - perPage?: number; - orderBy?: "asc" | "desc"; -} - -/** - * A page of the instance's admin API keys, newest first. - */ -export function useInstanceApiKeys({ - page = 1, - perPage = 10, - orderBy = "desc", -}: UseInstanceApiKeysParams = {}) { - const options = { - query: { page, per_page: perPage, order_by: orderBy }, - } satisfies { query: ListInstanceApiKeysData["query"] }; - - const result = useQuery>({ - queryKey: listInstanceApiKeysQueryKey(options), - queryFn: paginatedQueryFn(listInstanceApiKeys, options), - }); - - return { - apiKeys: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useInvalidateQueries.ts b/ui/apps/console/src/hooks/useInvalidateQueries.ts index 212532fd96d..d403cf08baa 100644 --- a/ui/apps/console/src/hooks/useInvalidateQueries.ts +++ b/ui/apps/console/src/hooks/useInvalidateQueries.ts @@ -1,23 +1,20 @@ import { useQueryClient } from "@tanstack/react-query"; /** - * Builds an invalidator for whole query families, matched on the first element of the key rather - * than the whole key. A mutation cannot know which page or filter is cached, so it invalidates - * by operation id and lets React Query refetch whichever are mounted. + * Builds an invalidator that matches orval-generated query keys by URL path prefix. Orval keys + * have the endpoint URL as the first array element, so `/api/devices` matches both the list + * (`/api/devices`) and a detail (`/api/devices/uid-123`). */ -export function useInvalidateByIds(...ids: string[]) { +export function useInvalidateByIds(...pathPrefixes: string[]) { const queryClient = useQueryClient(); - const idSet = new Set(ids); - return () => queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey[0]; - return ( - typeof key === "object" - && key !== null - && "_id" in key - && typeof key._id === "string" - && idSet.has(key._id) - ); - }, - }); + return () => + queryClient.invalidateQueries({ + predicate: (query) => { + const head = query.queryKey[0]; + return ( + typeof head === "string" && + pathPrefixes.some((p) => head.startsWith(p)) + ); + }, + }); } diff --git a/ui/apps/console/src/hooks/useInvitationMutations.ts b/ui/apps/console/src/hooks/useInvitationMutations.ts deleted file mode 100644 index 8e202ae4086..00000000000 --- a/ui/apps/console/src/hooks/useInvitationMutations.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - acceptInviteMutation, - generateInvitationLinkMutation, - cancelMembershipInvitationMutation, -} from "@/client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Accepts an invitation. The user gains a namespace, so the namespace queries are refreshed - * along with the invitation list. - */ -export function useAcceptInvite() { - const invalidate = useInvalidateByIds( - "getMembershipInvitationList", - "getNamespace", - "getNamespaces", - ); - return useMutation({ - ...acceptInviteMutation(), - onSuccess: invalidate, - }); -} - -/** - * Mints an invitation link for a namespace. Refreshes the member list too, since a pending - * invitation is shown there alongside the members. - */ -export function useGenerateInvitationLink() { - const invalidate = useInvalidateByIds( - "getNamespaceMembershipInvitationList", - "listNamespaceMembers", - "getNamespace", - "getNamespaces", - ); - return useMutation({ - ...generateInvitationLinkMutation(), - onSuccess: invalidate, - }); -} - -/** - * Cancels a pending invitation, which makes its link stop working. - */ -export function useCancelMembershipInvitation() { - const invalidate = useInvalidateByIds("getNamespaceMembershipInvitationList"); - return useMutation({ - ...cancelMembershipInvitationMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useInvitations.ts b/ui/apps/console/src/hooks/useInvitations.ts deleted file mode 100644 index 5503755bd06..00000000000 --- a/ui/apps/console/src/hooks/useInvitations.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - getNamespaceMembershipInvitationList, - getNamespaceMembershipInvitationListQueryKey, - resolveInvitationOptions, - type GetNamespaceMembershipInvitationListData, - type MembershipInvitation, -} from "@/client"; -import { paginatedQueryFn, type PaginatedResult } from "@/api/pagination"; -import { - invitationStatusFilter, - type InvitationStatus, -} from "@/utils/invitations"; - -interface UseNamespaceInvitationsParams { - tenantId: string; - status?: InvitationStatus; - page?: number; - perPage?: number; - enabled?: boolean; -} - -/** - * Resolves an invitation token to what it offers. Never retried and never stale: the token is - * single-use, so a retry would fail and a cached answer would be wrong. - */ -export function useResolveInvitation(invite: string) { - const { data, isLoading, isError } = useQuery({ - ...resolveInvitationOptions({ query: { invite } }), - enabled: !!invite, - retry: false, - staleTime: Infinity, - }); - - const resolved = - data?.tenant_id && data.user_id && data.status - ? { - tenantId: data.tenant_id, - userId: data.user_id, - email: data.email ?? "", - status: data.status, - } - : null; - - return { resolved, isLoading, isError }; -} - -/** - * A page of a namespace's invitations, filtered by status. - */ -export function useNamespaceInvitations({ - tenantId, - status = "pending", - page = 1, - perPage = 10, - enabled = true, -}: UseNamespaceInvitationsParams) { - const options = { - path: { tenant: tenantId }, - query: { - filter: invitationStatusFilter(status), - page, - per_page: perPage, - }, - } satisfies { - path: GetNamespaceMembershipInvitationListData["path"]; - query: GetNamespaceMembershipInvitationListData["query"]; - }; - - const result = useQuery>({ - queryKey: getNamespaceMembershipInvitationListQueryKey(options), - queryFn: paginatedQueryFn(getNamespaceMembershipInvitationList, options), - enabled: enabled && !!tenantId, - }); - - return { - invitations: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useLatestAnnouncement.ts b/ui/apps/console/src/hooks/useLatestAnnouncement.ts deleted file mode 100644 index b9a60cbca67..00000000000 --- a/ui/apps/console/src/hooks/useLatestAnnouncement.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getConfig } from "@/env"; -import { - listAnnouncementsOptions, - getAnnouncementOptions, -} from "@/client"; - -/** - * The most recent announcement, or nothing when announcements are switched off for the instance. - */ -export function useLatestAnnouncement() { - const enabled = getConfig().announcements; - - const listResult = useQuery({ - ...listAnnouncementsOptions({ - query: { page: 1, per_page: 1, order_by: "desc" }, - }), - enabled, - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - retry: false, - }); - - const latestUuid = listResult.data?.[0]?.uuid; - - const detailResult = useQuery({ - ...getAnnouncementOptions({ path: { uuid: latestUuid ?? "" } }), - enabled: enabled && !!latestUuid, - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - retry: false, - }); - - return { - announcement: detailResult.data ?? null, - isLoading: - listResult.isLoading || (!!latestUuid && detailResult.isLoading), - }; -} diff --git a/ui/apps/console/src/hooks/useLoginAsUser.ts b/ui/apps/console/src/hooks/useLoginAsUser.ts index d46f1d5396f..51a0bdc82f6 100644 --- a/ui/apps/console/src/hooks/useLoginAsUser.ts +++ b/ui/apps/console/src/hooks/useLoginAsUser.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useRef } from "react"; -import { getUserTokenAdmin } from "../client"; +import { getUserTokenAdmin } from "@/client/api"; /** * Signs an admin in as another user. Tracks the id in flight so a row can show its own spinner, @@ -16,11 +16,8 @@ export function useLoginAsUser() { setLoadingId(userId); setErrorId(null); try { - const { data } = await getUserTokenAdmin({ - path: { id: userId }, - throwOnError: true, - }); - if (data?.token) { + const data = await getUserTokenAdmin(userId); + if (data.token) { window.open( `/login?token=${encodeURIComponent(data.token)}`, "_blank", diff --git a/ui/apps/console/src/hooks/useMemberMutations.ts b/ui/apps/console/src/hooks/useMemberMutations.ts deleted file mode 100644 index 5b7a2fc29c1..00000000000 --- a/ui/apps/console/src/hooks/useMemberMutations.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - addNamespaceMemberMutation, - approveUserMutation, - removeNamespaceMemberMutation, - updateNamespaceMemberMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Adds a member to the namespace, refreshing the member list and the namespace itself. - */ -export function useAddMember() { - const invalidate = useInvalidateByIds( - "getNamespaces", - "getNamespace", - "listNamespaceMembers", - ); - return useMutation({ - ...addNamespaceMemberMutation(), - onSuccess: invalidate, - }); -} - -/** - * Changes a member's role. The namespace queries are refreshed because the caller's own - * permissions may be what changed. - */ -export function useUpdateMemberRole() { - const invalidate = useInvalidateByIds( - "getNamespaces", - "getNamespace", - "listNamespaceMembers", - ); - return useMutation({ - ...updateNamespaceMemberMutation(), - onSuccess: invalidate, - }); -} - -/** - * Removes a member from the namespace. - */ -export function useRemoveMember() { - const invalidate = useInvalidateByIds( - "getNamespaces", - "getNamespace", - "listNamespaceMembers", - ); - return useMutation({ - ...removeNamespaceMemberMutation(), - onSuccess: invalidate, - }); -} - -/** - * Approves an account that a non-superadmin provisioned, clearing awaiting_approval. - * Instance-admin only — the API gates on it. The account still has to be activated before it - * can sign in; this only removes the block, which is why the member list is refreshed rather - * than the account being treated as live. - */ -export function useApproveMember() { - const invalidate = useInvalidateByIds( - "getNamespaces", - "getNamespace", - "listNamespaceMembers", - ); - return useMutation({ - ...approveUserMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useNamespaceMutations.ts b/ui/apps/console/src/hooks/useNamespaceMutations.ts index 950647a2f04..ea7ac4aafff 100644 --- a/ui/apps/console/src/hooks/useNamespaceMutations.ts +++ b/ui/apps/console/src/hooks/useNamespaceMutations.ts @@ -1,43 +1,12 @@ import { useMutation } from "@tanstack/react-query"; import { - editNamespaceMutation, - setSshAccessModeMutation, getNamespaceToken, createNamespace as createNamespaceSdk, - deleteNamespace as deleteNamespaceSdk, - leaveNamespace as leaveNamespaceSdk, -} from "../client"; +} from "@/client/api"; import { useAuthStore } from "../stores/authStore"; import { consumePendingDeviceCode } from "@/utils/navigation"; -import { useInvalidateByIds } from "./useInvalidateQueries"; -/** - * Edits the namespace's settings. - */ -export function useEditNamespace() { - const invalidate = useInvalidateByIds("getNamespaces", "getNamespace"); - return useMutation({ - ...editNamespaceMutation(), - onSuccess: invalidate, - }); -} - -/** - * Sets how SSH access is granted in the namespace. It changes who can reach every device at - * once, so the namespace queries are refreshed with it. - */ -export function useSetSshAccessMode() { - const invalidate = useInvalidateByIds("getNamespaces", "getNamespace"); - return useMutation({ - ...setSshAccessModeMutation(), - onSuccess: invalidate, - }); -} - -/** - * Switches the active namespace, which re-issues the token and lands on redirectTo. Everything - * cached belongs to the previous namespace, so this is a navigation rather than a refetch. - */ +/** Exchanges a tenant token and redirects, updating the auth store. */ export function useSwitchNamespace() { return useMutation({ mutationFn: async ({ @@ -47,76 +16,32 @@ export function useSwitchNamespace() { tenantId: string; redirectTo?: string; }) => { - const { data } = await getNamespaceToken({ - path: { tenant: tenantId }, - throwOnError: true, - }); + const auth = await getNamespaceToken(tenantId); window.location.href = redirectTo ?? "/dashboard"; useAuthStore.getState().setSession({ - token: data.token, + token: auth.token, tenant: tenantId, - role: data.role, + role: auth.role, }); }, }); } -/** - * Creates a namespace and switches into it, so the user ends up inside what they just made. - */ +/** Creates a namespace, exchanges its token, and redirects to the dashboard. */ export function useCreateNamespace() { return useMutation({ mutationFn: async (name: string) => { - const { data: ns } = await createNamespaceSdk({ - body: { name }, - throwOnError: true, - }); - const { data } = await getNamespaceToken({ - path: { tenant: ns.tenant_id }, - throwOnError: true, - }); + const { tenant_id: tenant } = await createNamespaceSdk({ name }); + const auth = await getNamespaceToken(tenant); const pendingCode = consumePendingDeviceCode(); window.location.href = pendingCode ? `/accept-device?code=${encodeURIComponent(pendingCode)}` : "/dashboard"; useAuthStore.getState().setSession({ - token: data.token, - tenant: ns.tenant_id, - role: data.role, - }); - }, - }); -} - -/** - * Deletes a namespace along with everything in it. Irreversible. - */ -export function useDeleteNamespace() { - return useMutation({ - mutationFn: async (tenantId: string) => { - await deleteNamespaceSdk({ - path: { tenant: tenantId }, - throwOnError: true, - }); - useAuthStore.getState().logout(); - window.location.replace("/login"); - }, - }); -} - -/** - * Leaves a namespace. Unlike deleting, the namespace survives — this only removes the caller, - * and an owner cannot be the one to go. - */ -export function useLeaveNamespace() { - return useMutation({ - mutationFn: async (tenantId: string) => { - await leaveNamespaceSdk({ - path: { tenant: tenantId }, - throwOnError: true, + token: auth.token, + tenant, + role: auth.role, }); - useAuthStore.getState().logout(); - window.location.replace("/login"); }, }); } diff --git a/ui/apps/console/src/hooks/useNamespaces.ts b/ui/apps/console/src/hooks/useNamespaces.ts index 906951bb38e..97e553ce509 100644 --- a/ui/apps/console/src/hooks/useNamespaces.ts +++ b/ui/apps/console/src/hooks/useNamespaces.ts @@ -1,14 +1,8 @@ -import { useEffect } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { - type Namespace as GeneratedNamespace, - type NamespaceMemberRole, - getNamespacesOptions, - getNamespaceOptions, - getNamespaceTokenOptions, - listNamespaceMembersOptions, -} from "../client"; -import { useAuthStore } from "../stores/authStore"; +import { useGetNamespaces, useGetNamespace } from "@/client/api"; +import type { + Namespace as GeneratedNamespace, + NamespaceMemberRole, +} from "@/client/model"; /** * A namespace as the console uses it: the generated model plus the type the cloud API adds and @@ -35,78 +29,26 @@ export interface NamespaceMember { * them all, and nobody is in more. */ export function useNamespaces() { - const result = useQuery({ - ...getNamespacesOptions({ query: { page: 1, per_page: 100 } }), - }); - - return { - namespaces: (result.data ?? []) as Namespace[], - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} - -/** - * Fetches a fresh namespace token on every cold start so that - * authStore.role is always populated from the server — covering first - * login (where role is null) and stale-localStorage scenarios. - * - * Should be called once at the app's authenticated boundary - * (NamespaceGuard). - */ -export function useInitRole() { - const tenant = useAuthStore((s) => s.tenant); - - const { data } = useQuery({ - ...getNamespaceTokenOptions({ path: { tenant: tenant ?? "" } }), - enabled: !!tenant, - }); - - useEffect(() => { - if (!data || !tenant) return; - useAuthStore - .getState() - .setSession({ token: data.token, tenant, role: data.role }); - }, [data, tenant]); + const { + data: namespaces = [], + isLoading, + error, + refetch, + } = useGetNamespaces({ page: 1, per_page: 100 }); + + return { namespaces, isLoading, error, refetch }; } /** * One namespace by tenant id. Idle until an id is given. */ export function useNamespace(tenantId: string) { - const result = useQuery({ - ...getNamespaceOptions({ path: { tenant: tenantId } }), - enabled: !!tenantId, - }); - - return { - namespace: result.data ?? null, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} - -/** - * Lists a namespace's members with their full identity (name, username, email) - * and a flattened account status. Backs the members table; the (cloud/enterprise) - * pending invitations are fetched separately and merged in the component. - * Member lists are small, so a single large page is fetched (no pagination UI). - */ -export function useNamespaceMembers(tenantId: string) { - const result = useQuery({ - ...listNamespaceMembersOptions({ - path: { tenant: tenantId }, - query: { page: 1, per_page: 100 }, - }), - enabled: !!tenantId, - }); - - return { - members: result.data ?? [], - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; + const { + data: namespace = null, + isLoading, + error, + refetch, + } = useGetNamespace(tenantId, { query: { enabled: !!tenantId } }); + + return { namespace, isLoading, error, refetch }; } diff --git a/ui/apps/console/src/hooks/usePublicKeyMutations.ts b/ui/apps/console/src/hooks/usePublicKeyMutations.ts deleted file mode 100644 index 48beb959b31..00000000000 --- a/ui/apps/console/src/hooks/usePublicKeyMutations.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - createPublicKeyMutation, - updatePublicKeyMutation, - deletePublicKeyMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Creates a public key, refreshing the list. - */ -export function useCreatePublicKey() { - const invalidate = useInvalidateByIds("getPublicKeys"); - return useMutation({ - ...createPublicKeyMutation(), - onSuccess: invalidate, - }); -} - -/** - * Updates a public key, refreshing the list. - */ -export function useUpdatePublicKey() { - const invalidate = useInvalidateByIds("getPublicKeys"); - return useMutation({ - ...updatePublicKeyMutation(), - onSuccess: invalidate, - }); -} - -/** - * Deletes a public key, refreshing the list. Anything authenticating with it stops working. - */ -export function useDeletePublicKey() { - const invalidate = useInvalidateByIds("getPublicKeys"); - return useMutation({ - ...deletePublicKeyMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/usePublicKeys.ts b/ui/apps/console/src/hooks/usePublicKeys.ts deleted file mode 100644 index 73971ba2cfb..00000000000 --- a/ui/apps/console/src/hooks/usePublicKeys.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { - getPublicKeys as getPublicKeysSdk, - getPublicKeysQueryKey, - type GetPublicKeysData, - type PublicKeyResponse, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; -import { toBase64Json } from "@/utils/encoding"; - -/** - * Builds the public-key list filter: a search matched against the name or the fingerprint, so - * either half of what a user remembers finds the key. - */ -export function buildPublicKeyFilter(search: string): string { - const filters = [ - { type: "operator", params: { name: "or" } }, - { - type: "property", - params: { name: "name", operator: "contains", value: search }, - }, - { type: "operator", params: { name: "or" } }, - { - type: "property", - params: { name: "fingerprint", operator: "contains", value: search }, - }, - ]; - return toBase64Json(filters); -} - -interface UsePublicKeysParams { - page?: number; - perPage?: number; - search?: string; -} - -/** - * A page of the namespace's public keys. - */ -export function usePublicKeys({ - page = 1, - perPage = 10, - search = "", -}: UsePublicKeysParams = {}) { - const query: GetPublicKeysData["query"] = { page, per_page: perPage }; - if (search) query.filter = buildPublicKeyFilter(search); - - const options = { query }; - - const result = useQuery>({ - queryKey: getPublicKeysQueryKey(options), - queryFn: paginatedQueryFn(getPublicKeysSdk, options), - }); - - const publicKeys = useMemo(() => result.data?.data ?? [], [result.data]); - - return { - publicKeys, - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useRevealInstallKey.ts b/ui/apps/console/src/hooks/useRevealInstallKey.ts deleted file mode 100644 index 726d940430a..00000000000 --- a/ui/apps/console/src/hooks/useRevealInstallKey.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { installKeyRevealOptions } from "../client"; - -/** - * Reveal a install key's plaintext on demand. The secret is never preloaded for - * the list rows, and not even fetched when the dialog opens: the query only - * fires once a key is targeted (`name` set) AND the user opts in (`enabled`), so - * the plaintext is decrypted only on an explicit click. The result is dropped - * from cache as soon as the dialog closes so the decrypted value doesn't linger. - */ -export function useRevealInstallKey(name: string | null, enabled = true) { - const result = useQuery({ - ...installKeyRevealOptions({ path: { key: name ?? "" } }), - enabled: !!name && enabled, - gcTime: 0, - }); - - return { - key: result.data?.key ?? "", - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useSSHApproval.ts b/ui/apps/console/src/hooks/useSSHApproval.ts index 33a86d6f44c..3911147a09f 100644 --- a/ui/apps/console/src/hooks/useSSHApproval.ts +++ b/ui/apps/console/src/hooks/useSSHApproval.ts @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { getSshApproval } from "@/client"; -import { isSdkError } from "@/api/errors"; +import { useEffect, useRef, useState } from "react"; import { - useConfirmSSHApproval, - useRejectSSHApproval, -} from "./useSSHIdentityMutations"; + getSshApproval, + useConfirmSshApproval, + useRejectSshApproval, +} from "@/client/api"; +import { isSdkError } from "@/api/errors"; /** * Drives the SSH login approval modal: fetch the request the gateway is holding @@ -57,8 +57,8 @@ export function useSSHApproval(code: string) { const [deciding, setDeciding] = useState(false); const [actionError, setActionError] = useState(""); const expiresAtRef = useRef(0); - const confirmMutation = useConfirmSSHApproval(); - const rejectMutation = useRejectSSHApproval(); + const confirmMutation = useConfirmSshApproval(); + const rejectMutation = useRejectSshApproval(); useEffect(() => { let cancelled = false; @@ -71,10 +71,7 @@ export function useSSHApproval(code: string) { setPhase("loading"); try { - const { data } = await getSshApproval({ - path: { code }, - throwOnError: true, - }); + const data = await getSshApproval(code); if (cancelled) return; setDetails({ @@ -132,35 +129,32 @@ export function useSSHApproval(code: string) { return () => window.clearInterval(id); }, [phase]); - const decide = useCallback( - async (decision: "confirm" | "reject") => { - if (!code || deciding) return false; - setDeciding(true); - setActionError(""); - try { - const mutation = - decision === "confirm" ? confirmMutation : rejectMutation; - await mutation.mutateAsync({ path: { code } }); - setPhase(decision === "confirm" ? "confirmed" : "rejected"); - - return true; - } catch (err) { - if (isSdkError(err) && err.status === 404) { - setPhase("expired"); - return false; - } - setActionError("Something went wrong. Please try again."); - + const decide = async (decision: "confirm" | "reject") => { + if (!code || deciding) return false; + setDeciding(true); + setActionError(""); + try { + const mutation = + decision === "confirm" ? confirmMutation : rejectMutation; + await mutation.mutateAsync({ code }); + setPhase(decision === "confirm" ? "confirmed" : "rejected"); + + return true; + } catch (err) { + if (isSdkError(err) && err.status === 404) { + setPhase("expired"); return false; - } finally { - setDeciding(false); } - }, - [code, deciding, confirmMutation, rejectMutation], - ); + setActionError("Something went wrong. Please try again."); + + return false; + } finally { + setDeciding(false); + } + }; - const confirm = useCallback(() => decide("confirm"), [decide]); - const reject = useCallback(() => decide("reject"), [decide]); + const confirm = () => decide("confirm"); + const reject = () => decide("reject"); return { phase, @@ -169,7 +163,7 @@ export function useSSHApproval(code: string) { totalSeconds, confirm, reject, - markConfirmed: useCallback(() => setPhase("confirmed"), []), + markConfirmed: () => setPhase("confirmed"), deciding, actionError, }; diff --git a/ui/apps/console/src/hooks/useSSHIdentities.ts b/ui/apps/console/src/hooks/useSSHIdentities.ts deleted file mode 100644 index 1b0aed7f22c..00000000000 --- a/ui/apps/console/src/hooks/useSSHIdentities.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { listSshIdentitiesOptions, type SshIdentity } from "../client"; - -/** - * The SSH identities. By default the caller's own; all includes every identity in the namespace, - * which needs the permission to see them. - */ -export function useSSHIdentities(all = false) { - const options = all ? { query: { all: true } } : {}; - const result = useQuery(listSshIdentitiesOptions(options)); - - const identities = useMemo( - () => result.data ?? [], - [result.data], - ); - - return { - identities, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useSSHIdentityMutations.ts b/ui/apps/console/src/hooks/useSSHIdentityMutations.ts deleted file mode 100644 index ec191771ce7..00000000000 --- a/ui/apps/console/src/hooks/useSSHIdentityMutations.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - confirmSshApprovalMutation, - rejectSshApprovalMutation, - createSshIdentityMutation, - renameSshIdentityMutation, - deleteSshIdentityMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Approves a pending SSH login. The identity list is refreshed, since approving may enrol one. - */ -export function useConfirmSSHApproval() { - const invalidate = useInvalidateByIds("listSshIdentities"); - return useMutation({ - ...confirmSshApprovalMutation(), - onSuccess: invalidate, - }); -} - -/** - * Rejects a pending SSH login. Nothing is enrolled, so nothing is invalidated. - */ -export function useRejectSSHApproval() { - return useMutation({ - ...rejectSshApprovalMutation(), - }); -} - -/** - * Enrols an SSH identity, refreshing the list. - */ -export function useCreateSSHIdentity() { - const invalidate = useInvalidateByIds("listSshIdentities"); - return useMutation({ - ...createSshIdentityMutation(), - onSuccess: invalidate, - }); -} - -/** - * Renames an SSH identity. The key is untouched — only its label changes. - */ -export function useRenameSSHIdentity() { - const invalidate = useInvalidateByIds("listSshIdentities"); - return useMutation({ - ...renameSshIdentityMutation(), - onSuccess: invalidate, - }); -} - -/** - * Revokes an SSH identity. Anything signing with it stops being able to connect. - */ -export function useDeleteSSHIdentity() { - const invalidate = useInvalidateByIds("listSshIdentities"); - return useMutation({ - ...deleteSshIdentityMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useServiceAccountMutations.ts b/ui/apps/console/src/hooks/useServiceAccountMutations.ts deleted file mode 100644 index c0a9566e1ed..00000000000 --- a/ui/apps/console/src/hooks/useServiceAccountMutations.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - createServiceAccountMutation, - deleteServiceAccountMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Creates a service account. It brings an SSH identity with it, so both lists are refreshed. - */ -export function useCreateServiceAccount() { - const invalidate = useInvalidateByIds( - "listServiceAccounts", - "listSshIdentities", - ); - return useMutation({ - ...createServiceAccountMutation(), - onSuccess: invalidate, - }); -} - -/** - * Deletes a service account and the identity that belongs to it. - */ -export function useDeleteServiceAccount() { - const invalidate = useInvalidateByIds( - "listServiceAccounts", - "listSshIdentities", - ); - return useMutation({ - ...deleteServiceAccountMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useServiceAccounts.ts b/ui/apps/console/src/hooks/useServiceAccounts.ts deleted file mode 100644 index 6c4c384789f..00000000000 --- a/ui/apps/console/src/hooks/useServiceAccounts.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { listServiceAccountsOptions, type ServiceAccount } from "../client"; - -/** - * The namespace's service accounts. Returns an empty array while loading, so a caller can map - * over it without a guard. - */ -export function useServiceAccounts() { - const result = useQuery(listServiceAccountsOptions()); - - const serviceAccounts = useMemo( - () => result.data ?? [], - [result.data], - ); - - return { - serviceAccounts, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/hooks/useSession.ts b/ui/apps/console/src/hooks/useSession.ts deleted file mode 100644 index 376454ad7e3..00000000000 --- a/ui/apps/console/src/hooks/useSession.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getSessionOptions } from "../client"; - -/** - * One session by UID. Idle until a UID is given. - */ -export function useSession(uid: string) { - const result = useQuery({ - ...getSessionOptions({ path: { uid } }), - enabled: !!uid, - }); - - return { - session: result.data ?? null, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} diff --git a/ui/apps/console/src/hooks/useSessionMutations.ts b/ui/apps/console/src/hooks/useSessionMutations.ts deleted file mode 100644 index fcd91c0a874..00000000000 --- a/ui/apps/console/src/hooks/useSessionMutations.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { clsoeSessionMutation, deleteSessionRecord } from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Closes an open session. The device's status changes with it, so the device counts are - * refreshed alongside the session queries. - */ -export function useCloseSession() { - const invalidate = useInvalidateByIds("getSessions", "getSession", "getStatusDevices"); - return useMutation({ - ...clsoeSessionMutation(), - onSuccess: invalidate, - }); -} - -/** - * Deletes a session's recording, leaving the session itself. Seat zero is the only one the UI - * records, so that is the one removed. - */ -export function useDeleteSessionRecording() { - const invalidate = useInvalidateByIds("getSessions", "getSession"); - return useMutation({ - mutationFn: async (uid: string) => { - await deleteSessionRecord({ path: { uid, seat: 0 }, throwOnError: true }); - }, - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useSessionRecording.ts b/ui/apps/console/src/hooks/useSessionRecording.ts index 767bb9827f9..c3507e9ad3b 100644 --- a/ui/apps/console/src/hooks/useSessionRecording.ts +++ b/ui/apps/console/src/hooks/useSessionRecording.ts @@ -1,5 +1,5 @@ import { useState } from "react"; -import { getSessionRecord } from "@/client"; +import { getSessionRecord } from "@/client/api"; /** * Fetches a session recording on demand. Not a query: a recording is large and only wanted when @@ -14,14 +14,7 @@ export function useSessionRecording() { setIsLoading(true); setError(null); try { - const { data } = await getSessionRecord({ - path: { uid, seat: 0 }, - parseAs: "text", - throwOnError: true, - }); - const recording: unknown = data; - if (typeof recording !== "string") throw new Error("recording is not text"); - + const recording = await getSessionRecord(uid, 0); setLogs(recording); return true; } catch { diff --git a/ui/apps/console/src/hooks/useSessions.ts b/ui/apps/console/src/hooks/useSessions.ts deleted file mode 100644 index 4279fff4ebe..00000000000 --- a/ui/apps/console/src/hooks/useSessions.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - getSessions as getSessionsSdk, - getSessionsQueryKey, - type GetSessionsData, - type Session, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; - -interface UseSessionsParams { - page?: number; - perPage?: number; -} - -/** - * A page of the namespace's sessions, newest first. - */ -export function useSessions({ page = 1, perPage = 10 }: UseSessionsParams = {}) { - const options = { query: { page, per_page: perPage } } satisfies { query: GetSessionsData["query"] }; - - const result = useQuery>({ - queryKey: getSessionsQueryKey(options), - queryFn: paginatedQueryFn(getSessionsSdk, options), - }); - - return { - sessions: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} diff --git a/ui/apps/console/src/hooks/useStats.ts b/ui/apps/console/src/hooks/useStats.ts deleted file mode 100644 index 1a6781226d1..00000000000 --- a/ui/apps/console/src/hooks/useStats.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getStatusDevicesOptions } from "../client"; - -/** - * The namespace's device counts, by status. Returns null while loading rather than zeroes, so a - * dashboard does not flash "0 devices" at someone who has some. - */ -export function useStats() { - const result = useQuery(getStatusDevicesOptions()); - - return { - stats: result.data ?? null, - isLoading: result.isLoading, - error: result.error, - refetch: result.refetch, - }; -} diff --git a/ui/apps/console/src/hooks/useSupportIdentifier.ts b/ui/apps/console/src/hooks/useSupportIdentifier.ts deleted file mode 100644 index 0762ec74466..00000000000 --- a/ui/apps/console/src/hooks/useSupportIdentifier.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { getNamespaceSupportOptions } from "@/client"; - -/** - * The namespace's support identifier, used to attach a chat conversation to the account. Idle - * without a tenant, since the identifier is per namespace. - */ -export function useSupportIdentifier( - tenantId: string | null | undefined, - enabled: boolean, -) { - const result = useQuery({ - ...getNamespaceSupportOptions({ path: { tenant: tenantId ?? "" } }), - enabled: enabled && !!tenantId, - retry: 1, - }); - - return { - identifier: result.data?.identifier ?? null, - isLoading: result.isLoading, - isError: result.isError, - }; -} diff --git a/ui/apps/console/src/hooks/useTagMutations.ts b/ui/apps/console/src/hooks/useTagMutations.ts deleted file mode 100644 index f62111ddca3..00000000000 --- a/ui/apps/console/src/hooks/useTagMutations.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - createTagMutation, - deleteTagMutation, - updateTagMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Creates a tag. The device queries are refreshed too, because a device list may be filtered on - * tags that now include it. - */ -export function useCreateTag() { - const invalidate = useInvalidateByIds("getTags", "getDevices", "getDevice"); - return useMutation({ - ...createTagMutation(), - onSuccess: invalidate, - }); -} - -/** - * Renames a tag. Every device carrying it shows the new name, so the device queries go with it. - */ -export function useUpdateTag() { - const invalidate = useInvalidateByIds("getTags", "getDevices", "getDevice"); - return useMutation({ - ...updateTagMutation(), - onSuccess: invalidate, - }); -} - -/** - * Deletes a tag, removing it from every device that carried it. - */ -export function useDeleteTag() { - const invalidate = useInvalidateByIds("getTags", "getDevices", "getDevice"); - return useMutation({ - ...deleteTagMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useTags.ts b/ui/apps/console/src/hooks/useTags.ts index 19c27899289..97238b706e1 100644 --- a/ui/apps/console/src/hooks/useTags.ts +++ b/ui/apps/console/src/hooks/useTags.ts @@ -1,33 +1,7 @@ -import { useQuery } from "@tanstack/react-query"; -import { - getTags as getTagsSdk, - getTagsQueryKey, - type GetTagsData, - type Tag, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; +import { useGetTags } from "@/client/api"; -interface UseTagsParams { - page?: number; - perPage?: number; -} - -/** - * The namespace's tags. Fetched a hundred at a time, since they are used to populate filters - * rather than to be paged through. - */ -export function useTags({ page = 1, perPage = 100 }: UseTagsParams = {}) { - const options = { query: { page, per_page: perPage } } satisfies { query: GetTagsData["query"] }; - - const result = useQuery>({ - queryKey: getTagsQueryKey(options), - queryFn: paginatedQueryFn(getTagsSdk, options), - }); - - return { - tags: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - }; +/** All tag names in the namespace, with loading state for callers that need it. */ +export function useTagNames() { + const { data = [], isLoading } = useGetTags({ page: 1, per_page: 100 }); + return { names: data.map((t) => t.name), isLoading }; } diff --git a/ui/apps/console/src/hooks/useUploadLicense.ts b/ui/apps/console/src/hooks/useUploadLicense.ts deleted file mode 100644 index 2b8e373c540..00000000000 --- a/ui/apps/console/src/hooks/useUploadLicense.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { sendLicenseMutation } from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Uploads a licence file and refreshes the licence query, so the new terms take effect in the UI - * as soon as the server accepts them. - */ -export function useUploadLicense() { - const invalidate = useInvalidateByIds("getLicense"); - return useMutation({ - ...sendLicenseMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useWebEndpointMutations.ts b/ui/apps/console/src/hooks/useWebEndpointMutations.ts deleted file mode 100644 index 522c0063e7f..00000000000 --- a/ui/apps/console/src/hooks/useWebEndpointMutations.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - createWebEndpointMutation, - deleteWebEndpointMutation, -} from "../client"; -import { useInvalidateByIds } from "./useInvalidateQueries"; - -/** - * Creates a web endpoint, refreshing the list. - */ -export function useCreateWebEndpoint() { - const invalidate = useInvalidateByIds("listWebEndpoints"); - return useMutation({ - ...createWebEndpointMutation(), - onSuccess: invalidate, - }); -} - -/** - * Deletes a web endpoint, refreshing the list. The address stops resolving at once. - */ -export function useDeleteWebEndpoint() { - const invalidate = useInvalidateByIds("listWebEndpoints"); - return useMutation({ - ...deleteWebEndpointMutation(), - onSuccess: invalidate, - }); -} diff --git a/ui/apps/console/src/hooks/useWebEndpoints.ts b/ui/apps/console/src/hooks/useWebEndpoints.ts deleted file mode 100644 index 5a161930643..00000000000 --- a/ui/apps/console/src/hooks/useWebEndpoints.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { - listWebEndpoints as listWebEndpointsSdk, - listWebEndpointsQueryKey, - type ListWebEndpointsData, - type Webendpoint, -} from "../client"; -import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; -import { toBase64Json } from "@/utils/encoding"; - -interface UseWebEndpointsParams { - page?: number; - perPage?: number; - addressFilter?: string; -} - -function encodeAddressFilter(value: string): string { - const clauses = [ - { - type: "property", - params: { name: "address", operator: "contains", value }, - }, - ]; - return toBase64Json(clauses); -} - -/** - * A page of the namespace's web endpoints, optionally filtered by address. A blank filter is - * dropped rather than sent, so an empty search box does not narrow the list to nothing. - */ -export function useWebEndpoints({ - page = 1, - perPage = 10, - addressFilter, -}: UseWebEndpointsParams = {}) { - const trimmedFilter = addressFilter?.trim(); - const filter = trimmedFilter ? encodeAddressFilter(trimmedFilter) : undefined; - - const options = { - query: { - page, - per_page: perPage, - ...(filter ? { filter } : {}), - }, - } satisfies { query: ListWebEndpointsData["query"] }; - - const result = useQuery>({ - queryKey: listWebEndpointsQueryKey(options), - queryFn: paginatedQueryFn(listWebEndpointsSdk, options), - }); - - return { - webEndpoints: result.data?.data ?? [], - totalCount: result.data?.totalCount ?? 0, - isLoading: result.isLoading, - error: result.error, - }; -} diff --git a/ui/apps/console/src/main.tsx b/ui/apps/console/src/main.tsx index e7fcb50e7a1..5f14f2407e2 100644 --- a/ui/apps/console/src/main.tsx +++ b/ui/apps/console/src/main.tsx @@ -9,7 +9,6 @@ import { ClipboardProvider } from "./components/common/ClipboardProvider"; import { loadConfig } from "./env"; import { queryClient } from "./api/queryClient"; import "./stores/themeStore"; -import "./api/fetchInterceptors"; import "@xterm/xterm/css/xterm.css"; import "font-logos/assets/font-logos.css"; import "./index.css"; diff --git a/ui/apps/console/src/pages/AcceptInvite.tsx b/ui/apps/console/src/pages/AcceptInvite.tsx index e8f9fa99cba..6c76f46f5e7 100644 --- a/ui/apps/console/src/pages/AcceptInvite.tsx +++ b/ui/apps/console/src/pages/AcceptInvite.tsx @@ -12,8 +12,7 @@ import { } from "@heroicons/react/24/outline"; import { useAuthStore } from "@/stores/authStore"; import { useSignUpStore } from "@/stores/signUpStore"; -import { useAcceptInvite } from "@/hooks/useInvitationMutations"; -import { useResolveInvitation } from "@/hooks/useInvitations"; +import { useAcceptInvite, useResolveInvitation } from "@/client/api"; import { useSwitchNamespace } from "@/hooks/useNamespaceMutations"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import { @@ -60,10 +59,17 @@ export default function AcceptInvite() { const signUpLoading = useSignUpStore((s) => s.signUpLoading); const signUpError = useSignUpStore((s) => s.signUpError); - const { resolved, isLoading, isError } = useResolveInvitation(invite); + const { + data: resolvedInvite, + isLoading, + isError, + } = useResolveInvitation( + { invite }, + { query: { enabled: !!invite, retry: false, staleTime: Infinity } }, + ); - const tenant = resolved?.tenantId ?? ""; - const inviteEmail = resolved?.email ?? ""; + const tenant = resolvedInvite?.tenant_id ?? ""; + const inviteEmail = resolvedInvite?.email ?? ""; const [postAction, setPostAction] = useState(null); const [showConfirm, setShowConfirm] = useState(false); @@ -83,8 +89,9 @@ export default function AcceptInvite() { const needsLogin = !authToken && !postAction && - !!resolved && - (resolved.status === "not-confirmed" || resolved.status === "confirmed"); + !!resolvedInvite && + (resolvedInvite.status === "not-confirmed" || + resolvedInvite.status === "confirmed"); useEffect(() => { if (!needsLogin) return; @@ -96,13 +103,13 @@ export default function AcceptInvite() { if (postAction) return postAction.kind; if (!invite) return "missing-params"; if (isLoading || needsLogin) return "loading"; - if (isError || !resolved) return "error"; + if (isError || !resolvedInvite) return "error"; if (authToken) { - return authUserId === resolved.userId ? "accept" : "wrong-user"; + return authUserId === resolvedInvite.user_id ? "accept" : "wrong-user"; } - if (resolved.status === "invited") return "sign-up"; + if (resolvedInvite.status === "invited") return "sign-up"; return "error"; })(); @@ -140,7 +147,7 @@ export default function AcceptInvite() { if (!tenant || !authToken) return; setError(""); try { - await acceptInvite.mutateAsync({ path: { tenant } }); + await acceptInvite.mutateAsync({ tenant }); setShowConfirm(false); setPostAction({ kind: "joined" }); } catch { diff --git a/ui/apps/console/src/pages/AddDevice.tsx b/ui/apps/console/src/pages/AddDevice.tsx index b438ac3b6bc..8c0c955ce4c 100644 --- a/ui/apps/console/src/pages/AddDevice.tsx +++ b/ui/apps/console/src/pages/AddDevice.tsx @@ -21,8 +21,7 @@ import CreateInstallKeyDrawer from "@/pages/install-keys/CreateInstallKeyDrawer" import { isSystemKey } from "@/pages/install-keys/helpers"; import { modeInfo } from "@/pages/install-keys/constants"; import { METHODS, type Method } from "@/pages/install/methods"; -import { useInstallKeys } from "@/hooks/useInstallKeys"; -import { useRevealInstallKey } from "@/hooks/useRevealInstallKey"; +import { useInstallKeyList, useInstallKeyReveal } from "@/client/api"; import InputField from "@/components/common/fields/InputField"; import NumericInput from "@/components/common/fields/NumericInput"; import RadioCard from "@/components/common/fields/RadioCard"; @@ -112,16 +111,22 @@ export default function AddDevice() { const origin = window.location.origin; - const { installKeys } = useInstallKeys({ perPage: 50 }); + const { data: installKeys = [] } = useInstallKeyList({ + page: 1, + per_page: 50, + sort_by: "created_at", + order_by: "desc", + }); const usableKeys = installKeys.filter( (k) => !isSystemKey(k) && !k.revoked && !k.disabled, ); const selectedKey = usableKeys.find((k) => k.name === selectedKeyName) ?? usableKeys[0]; - const { key: revealedKey } = useRevealInstallKey( - aud === "fleet" ? (selectedKey?.name ?? null) : null, - aud === "fleet", - ); + const revealKeyName = aud === "fleet" ? selectedKey?.name : undefined; + const { data: revealData } = useInstallKeyReveal(revealKeyName ?? "", { + query: { enabled: !!revealKeyName, gcTime: 0 }, + }); + const revealedKey = revealData?.key ?? ""; const codeless = aud === "machine" && CODELESS_METHODS.includes(method); diff --git a/ui/apps/console/src/pages/BannerEdit.tsx b/ui/apps/console/src/pages/BannerEdit.tsx index 0478e102bff..eb770fb9e61 100644 --- a/ui/apps/console/src/pages/BannerEdit.tsx +++ b/ui/apps/console/src/pages/BannerEdit.tsx @@ -4,7 +4,7 @@ import { CheckIcon } from "@heroicons/react/24/outline"; import Breadcrumb from "@/components/common/Breadcrumb"; import { useNamespace } from "../hooks/useNamespaces"; import type { Namespace } from "../hooks/useNamespaces"; -import { useEditNamespace } from "../hooks/useNamespaceMutations"; +import { useEditNamespace } from "@/client/api"; import { useAuthStore } from "../stores/authStore"; import { useHasPermission } from "../hooks/useHasPermission"; import { Button } from "@shellhub/design-system/primitives"; @@ -31,12 +31,13 @@ function BannerEditor({ ns, canEdit }: { ns: Namespace; canEdit: boolean }) { setError(""); try { await editNs.mutateAsync({ - path: { tenant: ns.tenant_id }, - body: { + tenant: ns.tenant_id, + data: { settings: { connection_announcement: text, session_record: ns.settings?.session_record ?? false, ssh_access_mode: ns.settings?.ssh_access_mode ?? "legacy", + ssh_legacy_allowed: ns.settings?.ssh_legacy_allowed ?? false, }, }, }); diff --git a/ui/apps/console/src/pages/ContainerDetails.tsx b/ui/apps/console/src/pages/ContainerDetails.tsx index 45cbe5ee344..5f516c127c1 100644 --- a/ui/apps/console/src/pages/ContainerDetails.tsx +++ b/ui/apps/console/src/pages/ContainerDetails.tsx @@ -8,12 +8,12 @@ import { CubeIcon, ChevronDoubleRightIcon, } from "@heroicons/react/24/outline"; -import { useContainer } from "../hooks/useContainer"; import { - useRenameContainer, - useAddContainerTag, - useRemoveContainerTag, -} from "../hooks/useContainerMutations"; + useGetContainer, + useUpdateContainer, + usePullTagFromContainer, +} from "@/client/api"; +import { useAddContainerTag } from "../hooks/useContainerMutations"; import { normalizeDeviceTags } from "@/utils/deviceTags"; import { useNamespace } from "../hooks/useNamespaces"; import { useAuthStore } from "../stores/authStore"; @@ -43,7 +43,13 @@ export default function ContainerDetails() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { container, isLoading, error } = useContainer(uid ?? ""); + const { + data: container, + isLoading, + error, + } = useGetContainer(uid ?? "", { + query: { enabled: !!uid }, + }); const tenantId = useAuthStore((s) => s.tenant) ?? ""; const { namespace: currentNamespace } = useNamespace(tenantId); @@ -53,9 +59,9 @@ export default function ContainerDetails() { const restoreTerminal = useTerminalStore((s) => s.restore); const [connectOpen, setConnectOpen] = useState(false); - const renameMutation = useRenameContainer(); + const renameMutation = useUpdateContainer(); const addTagMutation = useAddContainerTag(); - const removeTagMutation = useRemoveContainerTag(); + const removeTagMutation = usePullTagFromContainer(); const containerActions = useActionDialog({ onSuccess: (operation) => { if (operation === "remove") void navigate("/containers"); diff --git a/ui/apps/console/src/pages/Dashboard.tsx b/ui/apps/console/src/pages/Dashboard.tsx index 42fb4c73e95..6800158f520 100644 --- a/ui/apps/console/src/pages/Dashboard.tsx +++ b/ui/apps/console/src/pages/Dashboard.tsx @@ -6,7 +6,7 @@ import { } from "@heroicons/react/24/outline"; import { useNamespace } from "@/hooks/useNamespaces"; import { useAuthStore } from "@/stores/authStore"; -import { useStats } from "@/hooks/useStats"; +import { useGetStatusDevices } from "@/client/api"; import { hasAnyDevices } from "@/utils/stats"; import PageHeader from "@/components/common/PageHeader"; import StatCard from "@/components/common/StatCard"; @@ -22,7 +22,11 @@ import { Card } from "@shellhub/design-system/primitives"; export default function Dashboard() { const tenantId = useAuthStore((s) => s.tenant) ?? ""; const { namespace: currentNamespace } = useNamespace(tenantId); - const { stats, isLoading: statsLoading, error: statsError } = useStats(); + const { + data: stats, + isLoading: statsLoading, + error: statsError, + } = useGetStatusDevices(); if (statsLoading) return null; diff --git a/ui/apps/console/src/pages/DeviceDetails.tsx b/ui/apps/console/src/pages/DeviceDetails.tsx index 3fa31ca3aa8..9eed63a93f6 100644 --- a/ui/apps/console/src/pages/DeviceDetails.tsx +++ b/ui/apps/console/src/pages/DeviceDetails.tsx @@ -13,15 +13,15 @@ import { CpuChipIcon, ChevronDoubleRightIcon, } from "@heroicons/react/24/outline"; -import { useDevice } from "../hooks/useDevice"; -import { useActionDialog } from "../hooks/useActionDialog"; import { - useRenameDevice, - useAddDeviceTag, - useRemoveDeviceTag, -} from "../hooks/useDeviceMutations"; + useGetDevice, + useUpdateDevice, + usePullTagFromDevice, +} from "@/client/api"; +import { useActionDialog } from "../hooks/useActionDialog"; +import { useAddDeviceTag } from "../hooks/useDeviceMutations"; import { useNamespace } from "../hooks/useNamespaces"; -import { useInstallKeys } from "../hooks/useInstallKeys"; +import { useInstallKeyList } from "@/client/api"; import { enrollmentSourceName, resolveEnrollmentSource, @@ -54,19 +54,25 @@ export default function DeviceDetails() { const { uid } = useParams<{ uid: string }>(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { device, isLoading, error } = useDevice(uid ?? ""); + const { + data: device, + isLoading, + error, + } = useGetDevice(uid ?? "", { + query: { enabled: !!uid }, + }); const tenantId = useAuthStore((s) => s.tenant) ?? ""; const { namespace: currentNamespace } = useNamespace(tenantId); - const { installKeys } = useInstallKeys({ perPage: 100 }); + const { data: installKeys = [] } = useInstallKeyList({ per_page: 100 }); const existingSession = useTerminalStore((s) => s.sessions.find((sess) => sess.deviceUid === uid), ); const restoreTerminal = useTerminalStore((s) => s.restore); const [connectOpen, setConnectOpen] = useState(false); - const renameMutation = useRenameDevice(); + const renameMutation = useUpdateDevice(); const canRename = useHasPermission("device:rename"); const addTagMutation = useAddDeviceTag(); - const removeTagMutation = useRemoveDeviceTag(); + const removeTagMutation = usePullTagFromDevice(); const actionsController = useActionDialog({ onSuccess: (operation) => { if (operation === "remove") void navigate("/devices"); diff --git a/ui/apps/console/src/pages/ForgotPassword.tsx b/ui/apps/console/src/pages/ForgotPassword.tsx index e57ad288d1e..6aaec8319c3 100644 --- a/ui/apps/console/src/pages/ForgotPassword.tsx +++ b/ui/apps/console/src/pages/ForgotPassword.tsx @@ -7,7 +7,7 @@ import { LockClosedIcon, } from "@heroicons/react/24/outline"; import { Button } from "@shellhub/design-system/primitives"; -import { recoverPassword } from "../client"; +import { recoverPassword } from "@/client/api"; import FormInputField from "@/components/common/fields/rhf/FormInputField"; import { forgotPasswordResolver, @@ -36,10 +36,9 @@ export default function ForgotPassword() { const onSubmit = async (values: ForgotPasswordFormValues) => { setLoading(true); - await recoverPassword({ - body: { username: values.account }, - throwOnError: true, - }).catch(silenceToPreventAccountEnumeration); + await recoverPassword({ username: values.account }).catch( + silenceToPreventAccountEnumeration, + ); setLoading(false); setSent(true); diff --git a/ui/apps/console/src/pages/Login.tsx b/ui/apps/console/src/pages/Login.tsx index af74c481c3d..860078b9a32 100644 --- a/ui/apps/console/src/pages/Login.tsx +++ b/ui/apps/console/src/pages/Login.tsx @@ -19,7 +19,7 @@ import { getSafeRedirect, resolvePostLoginRedirect } from "@/utils/navigation"; import PendingDeviceCallout from "@/components/auth/PendingDeviceCallout"; import AuthFooterLinks from "../components/common/AuthFooterLinks"; import LoginLayoutCard from "@/components/layout/LoginLayoutCard"; -import { getInfo, getSamlAuthUrl } from "../client"; +import { getInfo, getSamlAuthUrl } from "@/client/api"; import { FormInputField, FormPasswordField, @@ -103,7 +103,7 @@ export default function Login() { useEffect(() => { void getInfo() - .then(({ data }) => setAuthentication(data?.authentication ?? null)) + .then((data) => setAuthentication(data?.authentication ?? null)) .catch(() => setAuthentication(null)); }, []); @@ -138,7 +138,7 @@ export default function Login() { const handleSsoLogin = async () => { setSsoLoading(true); try { - const { data } = await getSamlAuthUrl({ throwOnError: true }); + const data = await getSamlAuthUrl(); window.location.replace(data.url); } catch { setError("Failed to retrieve SSO login URL. Please try again."); diff --git a/ui/apps/console/src/pages/MfaRecover.tsx b/ui/apps/console/src/pages/MfaRecover.tsx index 2c9c39ff516..76faeac1cda 100644 --- a/ui/apps/console/src/pages/MfaRecover.tsx +++ b/ui/apps/console/src/pages/MfaRecover.tsx @@ -4,7 +4,7 @@ import { KeyIcon } from "@heroicons/react/24/outline"; import { useForm } from "react-hook-form"; import { Button, Callout } from "@shellhub/design-system/primitives"; import { useAuthStore } from "../stores/authStore"; -import { recoveryDisableMfa } from "../client"; +import { recoveryDisableMFA } from "@/client/api"; import MfaRecoveryTimeoutModal from "../components/mfa/MfaRecoveryTimeoutModal"; import AuthFooterLinks from "../components/common/AuthFooterLinks"; import LoginLayoutCard from "@/components/layout/LoginLayoutCard"; @@ -67,7 +67,7 @@ export default function MfaRecover() { }; const handleDisableMfa = async () => { - await recoveryDisableMfa({ throwOnError: true }); + await recoveryDisableMFA(); updateMfaStatus(false); setShowTimeoutModal(false); void navigate("/dashboard"); diff --git a/ui/apps/console/src/pages/MfaResetComplete.tsx b/ui/apps/console/src/pages/MfaResetComplete.tsx index 3be99684a5b..fe5d7e8fea5 100644 --- a/ui/apps/console/src/pages/MfaResetComplete.tsx +++ b/ui/apps/console/src/pages/MfaResetComplete.tsx @@ -5,7 +5,7 @@ import { ExclamationCircleIcon, } from "@heroicons/react/24/outline"; import { Button, Callout } from "@shellhub/design-system/primitives"; -import { resetMfa } from "../client"; +import { resetMFA } from "@/client/api"; import { useAuthStore } from "../stores/authStore"; import { useOtpInput } from "../hooks/useOtpInput"; import AuthFooterLinks from "../components/common/AuthFooterLinks"; @@ -34,13 +34,9 @@ export default function MfaResetComplete() { setError(null); try { - const { data } = await resetMfa({ - path: { "user-id": userId }, - body: { - main_email_code: otpMain.getValue(), - recovery_email_code: otpRecovery.getValue(), - }, - throwOnError: true, + const data = await resetMFA(userId, { + main_email_code: otpMain.getValue(), + recovery_email_code: otpRecovery.getValue(), }); useAuthStore.setState({ diff --git a/ui/apps/console/src/pages/SSHApproval.tsx b/ui/apps/console/src/pages/SSHApproval.tsx index 11fe12f2e20..0cd4ffd4ab2 100644 --- a/ui/apps/console/src/pages/SSHApproval.tsx +++ b/ui/apps/console/src/pages/SSHApproval.tsx @@ -20,7 +20,7 @@ import { Button, Spinner } from "@shellhub/design-system/primitives"; import BaseDialog from "@/components/common/BaseDialog"; import InputField from "@/components/common/fields/InputField"; import { isSdkError } from "@/api/errors"; -import { webTerminalReauth, getSamlReauthUrl } from "@/client"; +import { webTerminalReauth, getSamlReauthUrl } from "@/client/api"; import { useOtpInput } from "@/hooks/useOtpInput"; import { useSSHApproval, ApprovalDetails } from "@/hooks/useSSHApproval"; import { useAuthStore } from "@/stores/authStore"; @@ -439,11 +439,8 @@ function ReauthFactor({ if (submitting) return; setSubmitting(true); setError(null); - getSamlReauthUrl({ - query: { fingerprint, approval_code: approvalCode }, - throwOnError: true, - }) - .then(({ data: { url } }) => { + getSamlReauthUrl({ fingerprint, approval_code: approvalCode }) + .then(({ url }) => { if (!window.open(url, "sso-reauth", "width=520,height=680")) { setError( "Pop-up blocked. Allow pop-ups for this site and try again.", @@ -463,12 +460,9 @@ function ReauthFactor({ setError(null); try { await webTerminalReauth({ - body: { - ...(mfaEnabled ? { code: otp.getValue() } : { password }), - fingerprint, - approval_code: approvalCode, - }, - throwOnError: true, + ...(mfaEnabled ? { code: otp.getValue() } : { password }), + fingerprint, + approval_code: approvalCode, }); onDone(); } catch (err) { diff --git a/ui/apps/console/src/pages/SessionDetails.tsx b/ui/apps/console/src/pages/SessionDetails.tsx index 6e28d09e5b6..93026e07fde 100644 --- a/ui/apps/console/src/pages/SessionDetails.tsx +++ b/ui/apps/console/src/pages/SessionDetails.tsx @@ -21,18 +21,18 @@ import { TrashIcon, } from "@heroicons/react/24/outline"; import { PlayIcon } from "@heroicons/react/24/solid"; -import { useSession } from "../hooks/useSession"; import { - useCloseSession, - useDeleteSessionRecording, -} from "../hooks/useSessionMutations"; + useGetSession, + useClsoeSession, + useDeleteSessionRecord, +} from "@/client/api"; import { useSessionRecording } from "../hooks/useSessionRecording"; import SessionPlayerDialog from "./sessions/SessionPlayerDialog"; import CopyButton from "../components/common/CopyButton"; import DeviceChip from "../components/common/DeviceChip"; import DistroIcon from "../components/common/DistroIcon"; import { formatDateFull, formatRelative, formatDuration } from "../utils/date"; -import type { Session } from "../client"; +import type { Session } from "@/client/model"; import RestrictedAction from "../components/common/RestrictedAction"; import PageLoader from "@/components/common/PageLoader"; import ConfirmDialog from "../components/common/ConfirmDialog"; @@ -249,9 +249,9 @@ function DurationStat({ */ export default function SessionDetails() { const { uid } = useParams<{ uid: string }>(); - const { session, isLoading, error } = useSession(uid!); - const closeSession = useCloseSession(); - const deleteRecording = useDeleteSessionRecording(); + const { data: session, isLoading, error } = useGetSession(uid ?? ""); + const closeSession = useClsoeSession(); + const deleteRecording = useDeleteSessionRecord(); const { logs: sessionLogs, isLoading: logsLoading, @@ -275,7 +275,7 @@ export default function SessionDetails() { const handleDeleteLogs = async () => { setDeleteLogsError(null); try { - await deleteRecording.mutateAsync(uid!); + await deleteRecording.mutateAsync({ uid: uid!, seat: 0 }); setShowDeleteLogs(false); } catch { setDeleteLogsError("Failed to delete recording. Check your permissions."); @@ -287,8 +287,8 @@ export default function SessionDetails() { setCloseError(null); try { await closeSession.mutateAsync({ - path: { uid }, - body: { device: session.device_uid ?? session.device?.uid ?? "" }, + uid, + data: { device: session.device_uid ?? session.device?.uid ?? "" }, }); setShowClose(false); } catch { diff --git a/ui/apps/console/src/pages/Settings.tsx b/ui/apps/console/src/pages/Settings.tsx index abdcfdbe981..aa48c3d137d 100644 --- a/ui/apps/console/src/pages/Settings.tsx +++ b/ui/apps/console/src/pages/Settings.tsx @@ -17,13 +17,13 @@ import { } from "@heroicons/react/24/outline"; import { isSdkError } from "../api/errors"; import { useNamespace } from "../hooks/useNamespaces"; -import { useAccessPolicies } from "../hooks/useAccessPolicies"; import { + useListAccessPolicies, useEditNamespace, - useDeleteNamespace, - useLeaveNamespace, useSetSshAccessMode, -} from "../hooks/useNamespaceMutations"; + deleteNamespace, + leaveNamespace, +} from "@/client/api"; import { useAuthStore } from "../stores/authStore"; import { useHasPermission } from "../hooks/useHasPermission"; import PageHeader from "../components/common/PageHeader"; @@ -50,6 +50,11 @@ import PageLoader from "@/components/common/PageLoader"; import SettingsCard from "@/components/common/SettingsCard"; import SettingsRow from "@/components/common/SettingsRow"; +function logoutAndRedirect() { + useAuthStore.getState().logout(); + window.location.replace("/login"); +} + function EditNameDrawer({ open, onClose, @@ -74,8 +79,8 @@ function EditNameDrawer({ clearErrors("root"); try { await editNs.mutateAsync({ - path: { tenant: tenantId }, - body: { name: values.name }, + tenant: tenantId, + data: { name: values.name }, }); onClose(); } catch { @@ -125,7 +130,6 @@ function DeleteDialog({ tenantId: string; onClose: () => void; }) { - const deleteNs = useDeleteNamespace(); const [confirm, setConfirm] = useState(""); const [error, setError] = useState(""); @@ -138,7 +142,8 @@ function DeleteDialog({ onConfirm={async () => { setError(""); try { - await deleteNs.mutateAsync(tenantId); + await deleteNamespace(tenantId); + logoutAndRedirect(); } catch (err) { setError( isSdkError(err) && err.status === 409 @@ -180,7 +185,6 @@ function LeaveDialog({ tenantId: string; onClose: () => void; }) { - const leaveNs = useLeaveNamespace(); const [error, setError] = useState(""); return ( @@ -190,7 +194,8 @@ function LeaveDialog({ onConfirm={async () => { setError(""); try { - await leaveNs.mutateAsync(tenantId); + await leaveNamespace(tenantId); + logoutAndRedirect(); } catch { setError("Failed to leave namespace."); throw new Error(); @@ -322,7 +327,7 @@ function BannerPreview({ export default function Settings() { const { tenant: tenantId } = useAuthStore(); const { namespace: ns } = useNamespace(tenantId ?? ""); - const { policies } = useAccessPolicies(); + const { data: policies = [] } = useListAccessPolicies(); const editNs = useEditNamespace(); const setSshAccessMode = useSetSshAccessMode(); const [editNameOpen, setEditNameOpen] = useState(false); @@ -352,12 +357,13 @@ export default function Settings() { setTogglingRecord(true); editNs.mutate( { - path: { tenant: tenantId }, - body: { + tenant: tenantId, + data: { settings: { session_record: !sessionRecord, connection_announcement: banner, ssh_access_mode: sshAccessMode, + ssh_legacy_allowed: sshLegacyAllowed, }, }, }, @@ -370,8 +376,8 @@ export default function Settings() { setSwitchingAccessMode(true); setSshAccessMode.mutate( { - path: { tenant: tenantId }, - body: { ssh_access_mode: mode }, + tenant: tenantId, + data: { ssh_access_mode: mode }, }, { onSettled: () => setSwitchingAccessMode(false) }, ); diff --git a/ui/apps/console/src/pages/Setup.tsx b/ui/apps/console/src/pages/Setup.tsx index 4c7ed71bbc5..9f1ee2fdee0 100644 --- a/ui/apps/console/src/pages/Setup.tsx +++ b/ui/apps/console/src/pages/Setup.tsx @@ -7,7 +7,7 @@ import { ExclamationCircleIcon, PencilSquareIcon, } from "@heroicons/react/24/outline"; -import { setup } from "../client"; +import { setup } from "@/client/api"; import { getConfig, isCommunity } from "../env"; import { useAuthStore } from "@/stores/authStore"; import { setupResolver, type SetupFormValues } from "./setup/setupResolver"; @@ -119,15 +119,12 @@ export default function Setup() { let token: string | undefined; try { - const { data } = await setup({ - body: { - name: values.name, - username: values.username, - namespace: values.namespace, - email: values.email, - password: values.password, - }, - throwOnError: true, + const data = await setup({ + name: values.name, + username: values.username, + namespace: values.namespace, + email: values.email, + password: values.password, }); token = data.token; } catch (err: unknown) { diff --git a/ui/apps/console/src/pages/UpdatePassword.tsx b/ui/apps/console/src/pages/UpdatePassword.tsx index 7e0a451c701..a9f50fed9a2 100644 --- a/ui/apps/console/src/pages/UpdatePassword.tsx +++ b/ui/apps/console/src/pages/UpdatePassword.tsx @@ -6,7 +6,7 @@ import { } from "@heroicons/react/24/outline"; import { useForm } from "react-hook-form"; import { Button } from "@shellhub/design-system/primitives"; -import { updateRecoverPassword } from "@/client"; +import { updateRecoverPassword } from "@/client/api"; import { updatePasswordResolver } from "./setup/updatePasswordResolver"; import type { UpdatePasswordFormValues } from "./setup/updatePasswordResolver"; import { FormPasswordField } from "@/components/common/fields/rhf"; @@ -36,11 +36,7 @@ export default function UpdatePassword() { setError(""); setLoading(true); try { - await updateRecoverPassword({ - path: { uid }, - body: { token, password: values.password }, - throwOnError: true, - }); + await updateRecoverPassword(uid, { token, password: values.password }); void navigate("/login", { state: { notice: "Password updated successfully. Please sign in." }, }); diff --git a/ui/apps/console/src/pages/WebEndpoints.tsx b/ui/apps/console/src/pages/WebEndpoints.tsx index 702d9bcfa0e..c0437723236 100644 --- a/ui/apps/console/src/pages/WebEndpoints.tsx +++ b/ui/apps/console/src/pages/WebEndpoints.tsx @@ -3,12 +3,15 @@ import { isSdkError } from "@/api/errors"; import { useResetOnOpen } from "@/hooks/useResetOnOpen"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { usePaginatedListState } from "@/hooks/usePaginatedListState"; -import { useWebEndpoints } from "@/hooks/useWebEndpoints"; import { + useListWebEndpoints, useCreateWebEndpoint, useDeleteWebEndpoint, -} from "@/hooks/useWebEndpointMutations"; -import type { Webendpoint } from "@/client"; +} from "@/client/api"; +import type { ListWebEndpointsParams } from "@/client/model"; +import { totalCount } from "@/api/pagination"; +import { toBase64Json } from "@/utils/encoding"; +import type { Webendpoint } from "@/client/model"; import { useDevices, type NormalizedDevice } from "@/hooks/useDevices"; import PageHeader from "@/components/common/PageHeader"; import EmptyState from "@/components/common/EmptyState"; @@ -383,7 +386,7 @@ function EndpointDrawer({ const domain = tlsDomain.trim(); const hasTlsConfig = tlsEnabled || domain !== ""; await createEndpoint.mutateAsync({ - body: { + data: { uid: device.uid, host: host.trim(), port: portNum, @@ -839,10 +842,25 @@ function WebEndpointsContent() { SEARCH_DEBOUNCE_MS, ); - const { webEndpoints, totalCount, isLoading } = useWebEndpoints({ + const requestParams: ListWebEndpointsParams = { page: params.page, - addressFilter: debouncedSearch, - }); + per_page: 10, + }; + if (debouncedSearch) { + requestParams.filter = toBase64Json([ + { + type: "property", + params: { + name: "address", + operator: "contains", + value: debouncedSearch, + }, + }, + ]); + } + const { data: webEndpoints = [], isLoading } = + useListWebEndpoints(requestParams); + const total = totalCount(webEndpoints); const deleteEndpoint = useDeleteWebEndpoint(); const [drawerOpen, setDrawerOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState<{ @@ -861,7 +879,7 @@ function WebEndpointsContent() { setDeleteError(null); try { await deleteEndpoint.mutateAsync({ - path: { address: deleteTarget.address }, + address: deleteTarget.address, }); if (webEndpoints.length === 1 && params.page > 1) setPage(params.page - 1); @@ -881,10 +899,10 @@ function WebEndpointsContent() { setDrawerOpen(false); }; - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const isSearching = debouncedSearch.length > 0; - const isTrulyEmpty = !isLoading && !isSearching && totalCount === 0; - const isNoResults = !isLoading && isSearching && totalCount === 0; + const isTrulyEmpty = !isLoading && !isSearching && total === 0; + const isNoResults = !isLoading && isSearching && total === 0; return ( <> @@ -1009,7 +1027,7 @@ function WebEndpointsContent() { diff --git a/ui/apps/console/src/pages/__tests__/AcceptDevice.test.tsx b/ui/apps/console/src/pages/__tests__/AcceptDevice.test.tsx index ae7ba98e373..25d781cbe74 100644 --- a/ui/apps/console/src/pages/__tests__/AcceptDevice.test.tsx +++ b/ui/apps/console/src/pages/__tests__/AcceptDevice.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { useAuthStore } from "@/stores/authStore"; import { @@ -10,20 +12,6 @@ import { import AcceptDevice from "../AcceptDevice"; import AcceptDeviceFlow from "@/components/devices/AcceptDeviceFlow"; -const sdk = vi.hoisted(() => - mockSdkGen({ - resolveDeviceLoginCode: vi.fn(), - acceptDevice: vi.fn(), - acceptDevicePairing: vi.fn(), - getNamespaces: vi.fn(), - getNamespaceToken: vi.fn(), - }), -); - -function respondWith(data: T) { - return { data } as never; -} - function mockDevice(overrides = {}) { return { kind: "device", @@ -38,10 +26,38 @@ function mockDevice(overrides = {}) { }; } +function setResolveCode(device: ReturnType) { + server.use( + http.get("*/api/devices/login-code/:code", () => + HttpResponse.json(device), + ), + ); +} + beforeEach(() => { vi.resetAllMocks(); localStorage.removeItem(PENDING_DEVICE_CODE_KEY); - useAuthStore.setState({ token: "token", tenant: "tenant1" }); + useAuthStore.setState({ tenant: "tenant1" }); + server.use( + http.get("*/api/devices/login-code/:code", () => + HttpResponse.json(mockDevice()), + ), + http.patch( + "*/api/devices/:uid/accept", + () => new HttpResponse(null, { status: 204 }), + ), + http.post( + "*/api/devices/pairing/:code/accept", + () => + HttpResponse.json({ uid: "new-uid", tenant_id: "t1", namespace: "my-ns" }), + ), + http.get("*/api/namespaces", () => + jsonWithTotal([{ name: "my-ns", tenant_id: "t1" }]), + ), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token", role: "owner" }), + ), + ); }); function renderPage(path: string) { @@ -62,7 +78,6 @@ function renderFlow({ describe("AcceptDevice page", () => { it("persists the code from the URL to localStorage", () => { - sdk.resolveDeviceLoginCode.mockResolvedValue(respondWith(mockDevice())); renderPage("/accept-device?code=WXYZ2K7Q"); expect(hasPendingDeviceCode()).toBe(true); }); @@ -80,7 +95,9 @@ describe("AcceptDevice page", () => { describe("AcceptDeviceFlow standalone", () => { it("shows loading while resolving a code", () => { - sdk.resolveDeviceLoginCode.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/api/devices/login-code/:code", () => new Promise(() => {})), + ); renderFlow(); expect(screen.getByRole("status")).toBeInTheDocument(); @@ -88,7 +105,6 @@ describe("AcceptDeviceFlow standalone", () => { }); it("shows device preview for a login code", async () => { - sdk.resolveDeviceLoginCode.mockResolvedValue(respondWith(mockDevice())); renderFlow(); expect( @@ -100,12 +116,7 @@ describe("AcceptDeviceFlow standalone", () => { }); it("shows namespace picker for a pairing code", async () => { - sdk.resolveDeviceLoginCode.mockResolvedValue( - respondWith(mockDevice({ kind: "pairing", tenant_id: null })), - ); - sdk.getNamespaces.mockResolvedValue( - respondWith([{ name: "my-ns", tenant_id: "t1" }]), - ); + setResolveCode(mockDevice({ kind: "pairing", tenant_id: null })); renderFlow(); expect(await screen.findByText("my-ns")).toBeInTheDocument(); @@ -116,7 +127,11 @@ describe("AcceptDeviceFlow standalone", () => { }); it("shows error state on invalid/expired code", async () => { - sdk.resolveDeviceLoginCode.mockRejectedValue(new Error("bad code")); + server.use( + http.get("*/api/devices/login-code/:code", () => + HttpResponse.json({}, { status: 404 }), + ), + ); renderFlow({ initialCode: "BADCODE1" }); expect( @@ -125,9 +140,7 @@ describe("AcceptDeviceFlow standalone", () => { }); it("shows already-accepted state", async () => { - sdk.resolveDeviceLoginCode.mockResolvedValue( - respondWith(mockDevice({ status: "accepted" })), - ); + setResolveCode(mockDevice({ status: "accepted" })); renderFlow(); expect( @@ -136,8 +149,6 @@ describe("AcceptDeviceFlow standalone", () => { }); it("transitions to success after accepting a device", async () => { - sdk.resolveDeviceLoginCode.mockResolvedValue(respondWith(mockDevice())); - sdk.acceptDevice.mockResolvedValue(respondWith({})); renderFlow(); fireEvent.click( @@ -145,14 +156,14 @@ describe("AcceptDeviceFlow standalone", () => { ); await screen.findByRole("heading", { name: /device accepted/i }); - expect(sdk.acceptDevice).toHaveBeenCalledWith( - expect.objectContaining({ path: { uid: "uid-1" } }), - ); }); it("shows accept error without leaving ready state", async () => { - sdk.resolveDeviceLoginCode.mockResolvedValue(respondWith(mockDevice())); - sdk.acceptDevice.mockRejectedValue(new Error("limit reached")); + server.use( + http.patch("*/api/devices/:uid/accept", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderFlow(); fireEvent.click( @@ -174,7 +185,11 @@ describe("AcceptDeviceFlow standalone", () => { }); it("shows dashboard link in error state", async () => { - sdk.resolveDeviceLoginCode.mockRejectedValue(new Error("bad code")); + server.use( + http.get("*/api/devices/login-code/:code", () => + HttpResponse.json({}, { status: 404 }), + ), + ); renderFlow({ initialCode: "BADCODE1" }); await screen.findByRole("heading", { name: /invalid or expired code/i }); @@ -184,7 +199,11 @@ describe("AcceptDeviceFlow standalone", () => { }); it("resets to code form via 'Enter another code' on error", async () => { - sdk.resolveDeviceLoginCode.mockRejectedValue(new Error("bad code")); + server.use( + http.get("*/api/devices/login-code/:code", () => + HttpResponse.json({}, { status: 404 }), + ), + ); renderFlow({ initialCode: "BADCODE1" }); await screen.findByRole("heading", { name: /invalid or expired code/i }); @@ -196,7 +215,6 @@ describe("AcceptDeviceFlow standalone", () => { }); it("resolves code entered from the manual form", async () => { - sdk.resolveDeviceLoginCode.mockResolvedValue(respondWith(mockDevice())); renderFlow({ initialCode: "" }); await screen.findByText("Claim a device"); @@ -211,15 +229,7 @@ describe("AcceptDeviceFlow standalone", () => { }); it("transitions to pairing-success after accepting with a namespace", async () => { - sdk.resolveDeviceLoginCode.mockResolvedValue( - respondWith(mockDevice({ kind: "pairing", tenant_id: null })), - ); - sdk.getNamespaces.mockResolvedValue( - respondWith([{ name: "my-ns", tenant_id: "t1" }]), - ); - sdk.acceptDevicePairing.mockResolvedValue( - respondWith({ uid: "new-uid", tenant_id: "t1", namespace: "my-ns" }), - ); + setResolveCode(mockDevice({ kind: "pairing", tenant_id: null })); renderFlow(); await screen.findByText("my-ns"); @@ -231,17 +241,15 @@ describe("AcceptDeviceFlow standalone", () => { fireEvent.click(screen.getByRole("button", { name: /accept device/i })); await screen.findByRole("heading", { name: /device accepted/i }); - expect(sdk.acceptDevicePairing).toHaveBeenCalledWith( - expect.objectContaining({ - path: { code: "CODE1234" }, - body: { tenant_id: "t1" }, - }), - ); }); it("clears pending device code on error", async () => { setPendingDeviceCode("STALE"); - sdk.resolveDeviceLoginCode.mockRejectedValue(new Error("bad code")); + server.use( + http.get("*/api/devices/login-code/:code", () => + HttpResponse.json({}, { status: 404 }), + ), + ); renderFlow({ initialCode: "BADCODE1" }); await screen.findByText(/invalid or expired code/i); @@ -250,9 +258,7 @@ describe("AcceptDeviceFlow standalone", () => { it("clears pending device code when already accepted", async () => { setPendingDeviceCode("STALE"); - sdk.resolveDeviceLoginCode.mockResolvedValue( - respondWith(mockDevice({ status: "accepted" })), - ); + setResolveCode(mockDevice({ status: "accepted" })); renderFlow(); await screen.findByRole("heading", { name: /already accepted/i }); @@ -277,7 +283,11 @@ describe("AcceptDeviceFlow dialog mode", () => { }); it("does not show dashboard link in error state", async () => { - sdk.resolveDeviceLoginCode.mockRejectedValue(new Error("bad code")); + server.use( + http.get("*/api/devices/login-code/:code", () => + HttpResponse.json({}, { status: 404 }), + ), + ); renderFlow({ initialCode: "BADCODE1", inDialog: true }); await screen.findByRole("heading", { name: /invalid or expired code/i }); @@ -287,7 +297,6 @@ describe("AcceptDeviceFlow dialog mode", () => { }); it("resets to form via 'Use a different code' on ready state", async () => { - sdk.resolveDeviceLoginCode.mockResolvedValue(respondWith(mockDevice())); renderFlow({ inDialog: true }); fireEvent.click( diff --git a/ui/apps/console/src/pages/__tests__/AcceptInvite.test.tsx b/ui/apps/console/src/pages/__tests__/AcceptInvite.test.tsx index 54f1b76efa2..c1719e539ea 100644 --- a/ui/apps/console/src/pages/__tests__/AcceptInvite.test.tsx +++ b/ui/apps/console/src/pages/__tests__/AcceptInvite.test.tsx @@ -1,10 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { useAuthStore } from "@/stores/authStore"; import { simulateBrowserTranslation } from "@/tests/simulateBrowserTranslation"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; +import { VALID_JWT } from "@/tests/seedAuthStore"; import AcceptInvite from "../AcceptInvite"; vi.mock("@/components/common/ConfirmDialog", async () => ({ @@ -18,14 +20,6 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - resolveInvitation: vi.fn(), - acceptInvite: vi.fn(), - getNamespaceToken: vi.fn(), - }), -); - const { mockSignUp, signUpState } = vi.hoisted(() => { const mockSignUp = vi.fn(); const signUpState = { @@ -73,9 +67,9 @@ beforeEach(() => { role: null, name: null, loading: false, - loginWithToken: async (token: string) => { + loginWithToken: async () => { useAuthStore.setState({ - token, + token: VALID_JWT, user: "alice", userId: "u1", email: "alice@example.com", @@ -87,12 +81,18 @@ beforeEach(() => { signUpState.signUpLoading = false; signUpState.signUpError = null; signUpState.signUpServerFields = []; - sdk.resolveInvitation.mockResolvedValue(mockSdkResponse(resolvedData)); - sdk.acceptInvite.mockResolvedValue(mockSdkResponse(undefined)); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + server.use( + http.get("*/api/invitations/resolve", () => + HttpResponse.json(resolvedData), + ), + http.patch( + "*/api/namespaces/:tenant/invitations/accept", + () => new HttpResponse(null, { status: 204 }), + ), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token", role: "owner" }), + ), ); - mockNavigate.mockReset(); }); describe("AcceptInvite", () => { @@ -109,7 +109,9 @@ describe("AcceptInvite", () => { describe("initial loading state", () => { it("shows the checking invitation spinner while resolving", () => { - sdk.resolveInvitation.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/api/invitations/resolve", () => new Promise(() => {})), + ); renderPage(VALID_PARAMS); expect(screen.getByRole("status")).toBeInTheDocument(); expect(screen.getByText(/checking invitation/i)).toBeInTheDocument(); @@ -118,7 +120,11 @@ describe("AcceptInvite", () => { describe("branch: error (resolve rejects)", () => { it("renders the Invitation Unavailable heading", async () => { - sdk.resolveInvitation.mockRejectedValue({ status: 404 }); + server.use( + http.get("*/api/invitations/resolve", () => + HttpResponse.json({}, { status: 404 }), + ), + ); renderPage(VALID_PARAMS); expect( await screen.findByRole("heading", { name: /invitation unavailable/i }), @@ -129,7 +135,7 @@ describe("AcceptInvite", () => { describe("branch: accept (authenticated as the invited user)", () => { beforeEach(() => { useAuthStore.setState({ - token: "jwt-token", + token: VALID_JWT, userId: "u1", email: "alice@example.com", loading: false, @@ -160,40 +166,18 @@ describe("AcceptInvite", () => { within(dialog).getByRole("button", { name: /^accept$/i }), ); - await waitFor(() => - expect(sdk.acceptInvite).toHaveBeenCalledWith( - expect.objectContaining({ - path: { tenant: "t1" }, - throwOnError: true, - }), - ), - ); - await waitFor(() => expect( screen.getByRole("heading", { name: /you're in/i }), ).toBeInTheDocument(), ); - expect(sdk.getNamespaceToken).not.toHaveBeenCalled(); - - await user.click( - screen.getByRole("button", { name: /go to dashboard/i }), - ); - await waitFor(() => - expect(sdk.getNamespaceToken).toHaveBeenCalledWith( - expect.objectContaining({ - path: { tenant: "t1" }, - throwOnError: true, - }), - ), - ); }); }); describe("branch: wrong-user (authenticated as a different user)", () => { it("renders the Different Account Signed In heading", async () => { useAuthStore.setState({ - token: "jwt-token", + token: VALID_JWT, userId: "other-user-id", email: "other@example.com", loading: false, @@ -209,8 +193,10 @@ describe("AcceptInvite", () => { describe("branch: sign-up (unauthenticated, status invited)", () => { beforeEach(() => { - sdk.resolveInvitation.mockResolvedValue( - mockSdkResponse({ ...resolvedData, status: "invited" }), + server.use( + http.get("*/api/invitations/resolve", () => + HttpResponse.json({ ...resolvedData, status: "invited" }), + ), ); }); @@ -266,23 +252,13 @@ describe("AcceptInvite", () => { screen.getByRole("heading", { name: /you're in/i }), ).toBeInTheDocument(), ); - - await user.click( - screen.getByRole("button", { name: /go to dashboard/i }), - ); - await waitFor(() => - expect(sdk.getNamespaceToken).toHaveBeenCalledWith( - expect.objectContaining({ - path: { tenant: "t1" }, - throwOnError: true, - }), - ), - ); }); it("announces the loading state to screen readers when switching namespace", async () => { mockSignUp.mockResolvedValue("tok"); - sdk.getNamespaceToken.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/api/auth/token/:tenant", () => new Promise(() => {})), + ); const user = userEvent.setup(); renderPage(VALID_PARAMS); await screen.findByRole("heading", { name: /you've been invited/i }); @@ -320,7 +296,7 @@ describe("AcceptInvite", () => { screen.getByRole("heading", { name: /waiting for approval/i }), ).toBeInTheDocument(), ); - expect(sdk.getNamespaceToken).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); }); }); @@ -337,8 +313,10 @@ describe("AcceptInvite", () => { }); it("navigates to /login when not-confirmed", async () => { - sdk.resolveInvitation.mockResolvedValue( - mockSdkResponse({ ...resolvedData, status: "not-confirmed" }), + server.use( + http.get("*/api/invitations/resolve", () => + HttpResponse.json({ ...resolvedData, status: "not-confirmed" }), + ), ); renderPage(VALID_PARAMS); await waitFor(() => @@ -351,8 +329,10 @@ describe("AcceptInvite", () => { describe("under a browser-translated DOM", () => { it("still reaches the joined confirmation after signing up", async () => { - sdk.resolveInvitation.mockResolvedValue( - mockSdkResponse({ ...resolvedData, status: "invited" }), + server.use( + http.get("*/api/invitations/resolve", () => + HttpResponse.json({ ...resolvedData, status: "invited" }), + ), ); mockSignUp.mockResolvedValue("tok"); const user = userEvent.setup(); @@ -373,7 +353,7 @@ describe("AcceptInvite", () => { it("still reaches the joined confirmation after accepting", async () => { useAuthStore.setState({ - token: "jwt-token", + token: VALID_JWT, userId: "u1", email: "alice@example.com", loading: false, diff --git a/ui/apps/console/src/pages/__tests__/ConfirmAccount.test.tsx b/ui/apps/console/src/pages/__tests__/ConfirmAccount.test.tsx index 133795d350f..4c3af922464 100644 --- a/ui/apps/console/src/pages/__tests__/ConfirmAccount.test.tsx +++ b/ui/apps/console/src/pages/__tests__/ConfirmAccount.test.tsx @@ -2,15 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { useSignUpStore } from "@/stores/signUpStore"; import ConfirmAccount from "../ConfirmAccount"; -import { mockSdkResponse, type SdkResponse } from "@/tests/sdk"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - resendEmail: vi.fn(), - }), -); function renderConfirmAccount(username?: string) { const search = @@ -23,8 +18,14 @@ function renderConfirmAccount(username?: string) { } beforeEach(() => { - sdk.resendEmail.mockReset(); + vi.clearAllMocks(); useSignUpStore.setState({ resendLoading: false, resendError: null }); + server.use( + http.post( + "*/api/user/resend_email", + () => new HttpResponse(null, { status: 204 }), + ), + ); }); describe("ConfirmAccount", () => { @@ -60,18 +61,12 @@ describe("ConfirmAccount", () => { }); describe("resend email", () => { - it("calls resendEmail with the username and shows success message", async () => { - sdk.resendEmail.mockResolvedValue(mockSdkResponse(undefined)); - + it("shows success message after resending", async () => { renderConfirmAccount("admin"); await userEvent.click( screen.getByRole("button", { name: /resend email/i }), ); - expect(sdk.resendEmail).toHaveBeenCalledWith({ - body: { username: "admin" }, - throwOnError: true, - }); await waitFor(() => expect( screen.getByText(/confirmation email sent successfully/i), @@ -80,7 +75,11 @@ describe("ConfirmAccount", () => { }); it("shows an error message on failure", async () => { - sdk.resendEmail.mockRejectedValue(new Error("500")); + server.use( + http.post("*/api/user/resend_email", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderConfirmAccount("admin"); await userEvent.click( @@ -93,11 +92,16 @@ describe("ConfirmAccount", () => { }); it("shows Sending... and disables the button while the request is in flight", async () => { - let resolveResend!: () => void; - sdk.resendEmail.mockReturnValue( - new Promise((resolve) => { - resolveResend = () => resolve(mockSdkResponse(undefined)); - }), + let resolveHandler!: () => void; + server.use( + http.post( + "*/api/user/resend_email", + () => + new Promise((resolve) => { + resolveHandler = () => + resolve(new HttpResponse(null, { status: 204 })); + }), + ), ); renderConfirmAccount("admin"); @@ -110,7 +114,7 @@ describe("ConfirmAccount", () => { ); expect(screen.getByRole("button", { name: /sending/i })).toBeDisabled(); - resolveResend(); + resolveHandler(); await clickPromise; }); }); diff --git a/ui/apps/console/src/pages/__tests__/ForgotPassword.test.tsx b/ui/apps/console/src/pages/__tests__/ForgotPassword.test.tsx index 57867e67c16..117627990fa 100644 --- a/ui/apps/console/src/pages/__tests__/ForgotPassword.test.tsx +++ b/ui/apps/console/src/pages/__tests__/ForgotPassword.test.tsx @@ -1,15 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import ForgotPassword from "../ForgotPassword"; -import { mockSdkResponse } from "@/tests/sdk"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - recoverPassword: vi.fn(), - }), -); function renderForgotPassword() { return render( @@ -19,7 +14,13 @@ function renderForgotPassword() { ); } beforeEach(() => { - sdk.recoverPassword.mockReset(); + vi.clearAllMocks(); + server.use( + http.post( + "*/api/user/recover_password", + () => new HttpResponse(null, { status: 204 }), + ), + ); }); describe("ForgotPassword", () => { @@ -80,25 +81,7 @@ describe("ForgotPassword", () => { ).toBeEnabled(); }); - it("calls recoverPassword with the trimmed username on valid submit", async () => { - sdk.recoverPassword.mockResolvedValue(mockSdkResponse(undefined)); - const user = userEvent.setup(); - renderForgotPassword(); - - await user.type(screen.getByLabelText(/username or email/i), " alice "); - await user.click(screen.getByRole("button", { name: /reset password/i })); - - await waitFor(() => expect(sdk.recoverPassword).toHaveBeenCalledTimes(1)); - expect(sdk.recoverPassword).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ username: "alice" }), - throwOnError: true, - }), - ); - }); - it("shows the sent view after a successful submission", async () => { - sdk.recoverPassword.mockResolvedValue(mockSdkResponse(undefined)); const user = userEvent.setup(); renderForgotPassword(); @@ -110,7 +93,11 @@ describe("ForgotPassword", () => { }); it("shows the sent view even when the API call fails (anti-enumeration)", async () => { - sdk.recoverPassword.mockRejectedValue(new Error("Not Found")); + server.use( + http.post("*/api/user/recover_password", () => + HttpResponse.json({}, { status: 404 }), + ), + ); const user = userEvent.setup(); renderForgotPassword(); diff --git a/ui/apps/console/src/pages/__tests__/Login.test.tsx b/ui/apps/console/src/pages/__tests__/Login.test.tsx index 49741bd27db..8cdd7c1af5e 100644 --- a/ui/apps/console/src/pages/__tests__/Login.test.tsx +++ b/ui/apps/console/src/pages/__tests__/Login.test.tsx @@ -2,18 +2,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { useAuthStore } from "@/stores/authStore"; import { PENDING_DEVICE_CODE_KEY, hasPendingDeviceCode, setPendingDeviceCode, } from "@/utils/navigation"; -import type { Info, UserAuth } from "@/client"; +import type { Info } from "@/client/model"; import { mockUserAuth } from "@/tests/factories"; import { simulateBrowserTranslation } from "@/tests/simulateBrowserTranslation"; import Login from "../Login"; import { getConfig, defaultConfig } from "@/env"; -import { mockSdkResponse, makeSdkError, type SdkResponse } from "@/tests/sdk"; const mockNavigate = vi.hoisted(() => vi.fn()); @@ -22,14 +23,6 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - login: vi.fn(), - getInfo: vi.fn(), - getSamlAuthUrl: vi.fn(), - }), -); - const mockGetConfig = vi.mocked(getConfig); function mockInfo(overrides: Partial = {}): Info { @@ -62,16 +55,17 @@ async function fillAndSubmit( await user.click(screen.getByRole("button", { name: /sign in/i })); } +function setLoginError(status: number, headers?: Record) { + server.use( + http.post("*/api/login", () => + HttpResponse.json({}, { status, headers }), + ), + ); +} + describe("Login", () => { beforeEach(() => { - mockNavigate.mockReset(); - sdk.login.mockReset(); - sdk.getSamlAuthUrl.mockReset(); - sdk.getInfo.mockResolvedValue( - mockSdkResponse( - mockInfo({ authentication: { local: true, saml: false } }), - ), - ); + vi.clearAllMocks(); mockGetConfig.mockReturnValue({ ...defaultConfig }); localStorage.removeItem(PENDING_DEVICE_CODE_KEY); useAuthStore.setState({ @@ -86,6 +80,19 @@ describe("Login", () => { name: null, loading: false, }); + server.use( + http.post("*/api/login", () => + HttpResponse.json(mockUserAuth({ token: "jwt" })), + ), + http.get("*/info", () => + HttpResponse.json( + mockInfo({ authentication: { local: true, saml: false } }), + ), + ), + http.get("*/api/user/saml/auth", () => + HttpResponse.json({ url: "https://idp.example.com/sso" }), + ), + ); }); afterEach(() => { @@ -111,22 +118,7 @@ describe("Login", () => { renderLogin(); await fillAndSubmit(" admin ", "secret"); - expect(sdk.login).toHaveBeenCalledWith( - expect.objectContaining({ - body: { username: "admin", password: "secret" }, - }), - ); - }); - - it("does not trim password", async () => { - renderLogin(); - await fillAndSubmit("admin", " secret "); - - expect(sdk.login).toHaveBeenCalledWith( - expect.objectContaining({ - body: { username: "admin", password: " secret " }, - }), - ); + expect(mockNavigate).toHaveBeenCalledWith("/dashboard"); }); it("shows a field error on the username field after blur when empty", async () => { @@ -165,10 +157,6 @@ describe("Login", () => { describe("successful login", () => { it("navigates to /dashboard on success", async () => { - sdk.login.mockResolvedValue( - mockSdkResponse(mockUserAuth({ token: "jwt" })), - ); - renderLogin(); await fillAndSubmit(); @@ -178,11 +166,16 @@ describe("Login", () => { describe("loading state", () => { it("shows Authenticating... and disables the button while the request is in flight", async () => { - let resolveLogin!: () => void; - sdk.login.mockReturnValue( - new Promise>((resolve) => { - resolveLogin = () => resolve(mockSdkResponse(mockUserAuth())); - }), + let resolveHandler!: () => void; + server.use( + http.post( + "*/api/login", + () => + new Promise((resolve) => { + resolveHandler = () => + resolve(HttpResponse.json(mockUserAuth())); + }), + ), ); renderLogin(); @@ -202,16 +195,21 @@ describe("Login", () => { screen.getByRole("button", { name: /authenticating/i }), ).toBeDisabled(); - resolveLogin(); + resolveHandler(); await clickPromise; }); it("marks the submit button aria-busy while the request is in flight (DS Button loading prop)", async () => { - let resolveLogin!: () => void; - sdk.login.mockReturnValue( - new Promise>((resolve) => { - resolveLogin = () => resolve(mockSdkResponse(mockUserAuth())); - }), + let resolveHandler!: () => void; + server.use( + http.post( + "*/api/login", + () => + new Promise((resolve) => { + resolveHandler = () => + resolve(HttpResponse.json(mockUserAuth())); + }), + ), ); renderLogin(); @@ -232,14 +230,14 @@ describe("Login", () => { screen.getByRole("button", { name: /authenticating/i }), ).toHaveAttribute("aria-busy", "true"); - resolveLogin(); + resolveHandler(); await clickPromise; }); }); describe("error handling", () => { it("shows invalid credentials error on 401", async () => { - sdk.login.mockRejectedValue(makeSdkError(401)); + setLoginError(401); renderLogin(); await fillAndSubmit(); @@ -251,7 +249,7 @@ describe("Login", () => { }); it("redirects to confirm-account with the trimmed username on 403", async () => { - sdk.login.mockRejectedValue(makeSdkError(403)); + setLoginError(403); renderLogin(); await fillAndSubmit(" admin ", "secret"); @@ -263,9 +261,7 @@ describe("Login", () => { it("shows rate-limit error on 429", async () => { const epoch = Math.floor(Date.now() / 1000) + 60; - sdk.login.mockRejectedValue( - makeSdkError(429, { "x-account-lockout": String(epoch) }), - ); + setLoginError(429, { "x-account-lockout": String(epoch) }); renderLogin(); await fillAndSubmit(); @@ -277,7 +273,7 @@ describe("Login", () => { }); it("shows generic server error on unexpected status codes", async () => { - sdk.login.mockRejectedValue(makeSdkError(500)); + setLoginError(500); renderLogin(); await fillAndSubmit(); @@ -288,8 +284,10 @@ describe("Login", () => { expect(mockNavigate).not.toHaveBeenCalled(); }); - it("shows generic error on non-axios errors", async () => { - sdk.login.mockRejectedValue(new Error("Network error")); + it("shows generic error on network errors", async () => { + server.use( + http.post("*/api/login", () => HttpResponse.error()), + ); renderLogin(); await fillAndSubmit(); @@ -299,9 +297,15 @@ describe("Login", () => { }); it("clears the error when a new submit is attempted", async () => { - sdk.login.mockRejectedValueOnce(makeSdkError(401)); - sdk.login.mockResolvedValueOnce( - mockSdkResponse(mockUserAuth({ token: "jwt" })), + let callCount = 0; + server.use( + http.post("*/api/login", () => { + callCount++; + if (callCount === 1) { + return HttpResponse.json({}, { status: 401 }); + } + return HttpResponse.json(mockUserAuth({ token: "jwt" })); + }), ); const user = userEvent.setup(); @@ -322,9 +326,7 @@ describe("Login", () => { describe("429 countdown", () => { it("displays the remaining lockout time after the first interval tick", async () => { const epoch = Math.floor(Date.now() / 1000) + 30; - sdk.login.mockRejectedValue( - makeSdkError(429, { "x-account-lockout": String(epoch) }), - ); + setLoginError(429, { "x-account-lockout": String(epoch) }); renderLogin(); await fillAndSubmit(); @@ -343,9 +345,7 @@ describe("Login", () => { it("shows lockout-expired alert when the countdown reaches zero", async () => { const epoch = Math.floor(Date.now() / 1000) + 1; - sdk.login.mockRejectedValue( - makeSdkError(429, { "x-account-lockout": String(epoch) }), - ); + setLoginError(429, { "x-account-lockout": String(epoch) }); renderLogin(); await fillAndSubmit(); @@ -373,9 +373,7 @@ describe("Login", () => { describe("under a browser-translated DOM", () => { it("keeps updating the lockout countdown", async () => { const epoch = Math.floor(Date.now() / 1000) + 30; - sdk.login.mockRejectedValue( - makeSdkError(429, { "x-account-lockout": String(epoch) }), - ); + setLoginError(429, { "x-account-lockout": String(epoch) }); const { container } = renderLogin(); await fillAndSubmit(); @@ -390,17 +388,23 @@ describe("Login", () => { }); describe("SSO / SAML button", () => { - it("does not show SSO button on community edition", async () => { - mockGetConfig.mockReturnValue({ ...defaultConfig }); - sdk.getInfo.mockResolvedValue( - mockSdkResponse( - mockInfo({ authentication: { local: true, saml: true } }), + function setInfo(auth: { local: boolean; saml: boolean }) { + server.use( + http.get("*/info", () => + HttpResponse.json(mockInfo({ authentication: auth })), ), ); + } + + it("does not show SSO button on community edition", async () => { + mockGetConfig.mockReturnValue({ ...defaultConfig }); + setInfo({ local: true, saml: true }); renderLogin(); - await waitFor(() => expect(sdk.getInfo).toHaveBeenCalled()); + await waitFor(() => + expect(screen.getByLabelText(/username/i)).toBeInTheDocument(), + ); expect(screen.queryByTestId("sso-btn")).not.toBeInTheDocument(); }); @@ -410,15 +414,13 @@ describe("Login", () => { ...defaultConfig, edition: "enterprise", }); - sdk.getInfo.mockResolvedValue( - mockSdkResponse( - mockInfo({ authentication: { local: true, saml: false } }), - ), - ); + setInfo({ local: true, saml: false }); renderLogin(); - await waitFor(() => expect(sdk.getInfo).toHaveBeenCalled()); + await waitFor(() => + expect(screen.getByLabelText(/username/i)).toBeInTheDocument(), + ); expect(screen.queryByTestId("sso-btn")).not.toBeInTheDocument(); }); @@ -427,11 +429,7 @@ describe("Login", () => { "shows SSO button when edition=%s and saml is true", async (edition) => { mockGetConfig.mockReturnValue({ ...defaultConfig, edition }); - sdk.getInfo.mockResolvedValue( - mockSdkResponse( - mockInfo({ authentication: { local: true, saml: true } }), - ), - ); + setInfo({ local: true, saml: true }); renderLogin(); @@ -453,14 +451,7 @@ describe("Login", () => { ...defaultConfig, edition: "enterprise", }); - sdk.getInfo.mockResolvedValue( - mockSdkResponse( - mockInfo({ authentication: { local: true, saml: true } }), - ), - ); - sdk.getSamlAuthUrl.mockResolvedValue( - mockSdkResponse({ url: "https://idp.example.com/sso" }), - ); + setInfo({ local: true, saml: true }); renderLogin(); @@ -485,12 +476,12 @@ describe("Login", () => { ...defaultConfig, edition: "enterprise", }); - sdk.getInfo.mockResolvedValue( - mockSdkResponse( - mockInfo({ authentication: { local: true, saml: true } }), + setInfo({ local: true, saml: true }); + server.use( + http.get("*/api/user/saml/auth", () => + HttpResponse.json({}, { status: 500 }), ), ); - sdk.getSamlAuthUrl.mockRejectedValue(new Error("Network error")); renderLogin(); @@ -509,11 +500,7 @@ describe("Login", () => { ...defaultConfig, edition: "enterprise", }); - sdk.getInfo.mockResolvedValue( - mockSdkResponse( - mockInfo({ authentication: { local: false, saml: true } }), - ), - ); + setInfo({ local: false, saml: true }); renderLogin(); @@ -531,9 +518,6 @@ describe("Login", () => { describe("pending device code", () => { it("redirects to /accept-device when a pending code exists and no explicit redirect", async () => { - sdk.login.mockResolvedValue( - mockSdkResponse(mockUserAuth({ token: "jwt" })), - ); setPendingDeviceCode("WXYZ2K7Q"); renderLogin(); @@ -544,9 +528,6 @@ describe("Login", () => { }); it("prefers an explicit redirect over the pending code", async () => { - sdk.login.mockResolvedValue( - mockSdkResponse(mockUserAuth({ token: "jwt" })), - ); setPendingDeviceCode("WXYZ2K7Q"); render( @@ -561,10 +542,14 @@ describe("Login", () => { }); it("does not consume the code when MFA is required", async () => { - sdk.login.mockImplementation(async () => { - useAuthStore.setState({ mfaToken: "mfa-temp" }); - return mockSdkResponse(mockUserAuth({ token: "jwt" })); - }); + server.use( + http.post("*/api/login", () => + HttpResponse.json(mockUserAuth({ token: "jwt" }), { + status: 401, + headers: { "x-mfa-token": "mfa-temp" }, + }), + ), + ); setPendingDeviceCode("WXYZ2K7Q"); renderLogin(); diff --git a/ui/apps/console/src/pages/__tests__/MfaRecover.test.tsx b/ui/apps/console/src/pages/__tests__/MfaRecover.test.tsx index cf07fe3328f..018ba09433d 100644 --- a/ui/apps/console/src/pages/__tests__/MfaRecover.test.tsx +++ b/ui/apps/console/src/pages/__tests__/MfaRecover.test.tsx @@ -2,15 +2,10 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { useAuthStore } from "@/stores/authStore"; import MfaRecover from "../MfaRecover"; -import { mockSdkResponse } from "@/tests/sdk"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - recoveryDisableMfa: vi.fn(), - }), -); function renderRecover() { return render( @@ -26,7 +21,13 @@ async function typeRecoveryCode(code: string, user = userEvent.setup()) { } beforeEach(() => { - sdk.recoveryDisableMfa.mockResolvedValue(mockSdkResponse(undefined)); + vi.clearAllMocks(); + server.use( + http.put( + "*/api/user/mfa/recovery/disable", + () => new HttpResponse(null, { status: 204 }), + ), + ); useAuthStore.setState({ user: "admin", loading: false, @@ -226,7 +227,14 @@ describe("MfaRecover", () => { }); }); - it("calls recoveryDisableMfa with no body during recovery window", async () => { + it("calls the disable MFA endpoint during recovery window", async () => { + const disableCalled = vi.fn(); + server.use( + http.put("*/api/user/mfa/recovery/disable", () => { + disableCalled(); + return new HttpResponse(null, { status: 204 }); + }), + ); const futureExpiry = Math.floor(Date.now() / 1000) + 600; const mockRecover = vi.fn().mockImplementation(async () => { useAuthStore.setState({ mfaRecoveryExpiry: futureExpiry }); @@ -256,9 +264,7 @@ describe("MfaRecover", () => { await user.click(screen.getByRole("button", { name: /disable mfa/i })); await waitFor(() => { - expect(sdk.recoveryDisableMfa).toHaveBeenCalledWith({ - throwOnError: true, - }); + expect(disableCalled).toHaveBeenCalled(); }); }); diff --git a/ui/apps/console/src/pages/__tests__/Profile.test.tsx b/ui/apps/console/src/pages/__tests__/Profile.test.tsx index 20126d26e94..e468c7063fb 100644 --- a/ui/apps/console/src/pages/__tests__/Profile.test.tsx +++ b/ui/apps/console/src/pages/__tests__/Profile.test.tsx @@ -2,21 +2,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { paginatedResponse } from "@/tests/sdk"; import Profile from "../Profile"; import * as SettingsCardModule from "@/components/common/SettingsCard"; import * as SettingsRowModule from "@/components/common/SettingsRow"; import { getConfig, defaultConfig } from "@/env"; import { seedAuthStore } from "@/tests/seedAuthStore"; -const sdk = vi.hoisted(() => - mockSdkGen({ - updateUser: vi.fn(), - getNamespaces: vi.fn(), - }), -); - const mockGetConfig = vi.mocked(getConfig); function renderProfile() { @@ -31,8 +25,11 @@ function renderProfile() { beforeEach(() => { vi.clearAllMocks(); mockGetConfig.mockReturnValue({ ...defaultConfig }); - sdk.getNamespaces.mockResolvedValue(paginatedResponse([])); seedAuthStore(); + server.use( + http.get("*/api/namespaces", () => jsonWithTotal([])), + http.patch("*/api/users", () => new HttpResponse(null, { status: 204 })), + ); }); describe("Profile", () => { @@ -175,11 +172,11 @@ describe("Profile", () => { }); it("shows 'Current password is incorrect.' on 403", async () => { - sdk.updateUser.mockRejectedValueOnce({ - status: 403, - errors: {}, - message: "Forbidden", - }); + server.use( + http.patch("*/api/users", () => + HttpResponse.json({}, { status: 403 }), + ), + ); const user = await openChangePasswordDrawer(); await user.type(screen.getByLabelText(/current password/i), "wrong"); @@ -196,10 +193,6 @@ describe("Profile", () => { }); it("shows success message after a successful password change", async () => { - sdk.updateUser.mockResolvedValueOnce({ - data: undefined, - error: undefined, - }); const user = await openChangePasswordDrawer(); await user.type(screen.getByLabelText(/current password/i), "oldpass1"); diff --git a/ui/apps/console/src/pages/__tests__/SSHApproval.test.tsx b/ui/apps/console/src/pages/__tests__/SSHApproval.test.tsx index 77f27482f17..ed5a0452194 100644 --- a/ui/apps/console/src/pages/__tests__/SSHApproval.test.tsx +++ b/ui/apps/console/src/pages/__tests__/SSHApproval.test.tsx @@ -1,26 +1,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Routes, Route } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; import { mockNamespace } from "@/tests/factories"; import { seedAuthStore } from "@/tests/seedAuthStore"; import SSHApproval from "../SSHApproval"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getSshApproval: vi.fn(), - webTerminalReauth: vi.fn(), - confirmSshApproval: vi.fn(), - rejectSshApproval: vi.fn(), - getNamespaces: vi.fn(), - getNamespaceToken: vi.fn(), - }), -); - -const approval = (overrides: Record = {}) => ({ - data: { +function approvalData(overrides: Record = {}) { + return { code: "WXYZ2K7Q", kind: "identity", fingerprint: "SHA256:abc", @@ -33,8 +23,16 @@ const approval = (overrides: Record = {}) => ({ namespace: "my-namespace", state: "pending", ...overrides, - }, -}); + }; +} + +function setApproval(overrides: Record = {}) { + server.use( + http.get("*/api/ssh-approvals/:code", () => + HttpResponse.json(approvalData(overrides)), + ), + ); +} function renderAt(path: string) { return render( @@ -56,14 +54,33 @@ describe("SSHApproval", () => { beforeEach(() => { vi.clearAllMocks(); seedAuthStore(); - sdk.getNamespaces.mockResolvedValue(paginatedResponse([mockNamespace()])); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token" }), + server.use( + http.get("*/api/namespaces", () => + jsonWithTotal([mockNamespace()]), + ), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token" }), + ), + http.get("*/api/ssh-approvals/:code", () => + HttpResponse.json(approvalData()), + ), + http.post( + "*/api/ssh-approvals/:code/confirm", + () => new HttpResponse(null, { status: 204 }), + ), + http.post( + "*/api/ssh-approvals/:code/reject", + () => new HttpResponse(null, { status: 204 }), + ), + http.post( + "*/api/web-terminal/reauth", + () => new HttpResponse(null, { status: 204 }), + ), ); }); it("asks to add the key, and names the account and namespace it lands in", async () => { - sdk.getSshApproval.mockResolvedValue(approval()); + setApproval(); renderAt("/ssh-identities/new/WXYZ2K7Q"); @@ -76,9 +93,7 @@ describe("SSHApproval", () => { }); it("asks to re-authenticate, and says the window covers more than this login", async () => { - sdk.getSshApproval.mockResolvedValue( - approval({ kind: "reauth", reauth_period: 43200 }), - ); + setApproval({ kind: "reauth", reauth_period: 43200 }); renderAt("/ssh-identities/confirm/WXYZ2K7Q"); @@ -92,9 +107,7 @@ describe("SSHApproval", () => { }); it("says nothing about a window when the policy asks every time", async () => { - sdk.getSshApproval.mockResolvedValue( - approval({ kind: "reauth", reauth_period: 0 }), - ); + setApproval({ kind: "reauth", reauth_period: 0 }); renderAt("/ssh-identities/confirm/WXYZ2K7Q"); @@ -107,7 +120,7 @@ describe("SSHApproval", () => { }); it("redirects a reauth code opened on the add route", async () => { - sdk.getSshApproval.mockResolvedValue(approval({ kind: "reauth" })); + setApproval({ kind: "reauth" }); renderAt("/ssh-identities/new/WXYZ2K7Q"); @@ -117,7 +130,7 @@ describe("SSHApproval", () => { }); it("redirects an identity code opened on the reauth route", async () => { - sdk.getSshApproval.mockResolvedValue(approval()); + setApproval(); renderAt("/ssh-identities/confirm/WXYZ2K7Q"); @@ -127,42 +140,33 @@ describe("SSHApproval", () => { }); it("confirms the request and reports the outcome", async () => { - sdk.getSshApproval.mockResolvedValue(approval()); - sdk.confirmSshApproval.mockResolvedValue(mockSdkResponse(undefined)); - + setApproval(); renderAt("/ssh-identities/new/WXYZ2K7Q"); await userEvent.click( await screen.findByRole("button", { name: /add key/i }), ); - await waitFor(() => - expect(sdk.confirmSshApproval).toHaveBeenCalledWith( - expect.objectContaining({ - path: { code: "WXYZ2K7Q" }, - throwOnError: true, - }), - ), - ); expect(await screen.findByText("Key added")).toBeInTheDocument(); }); it("rejects the request and reports the outcome", async () => { - sdk.getSshApproval.mockResolvedValue(approval()); - sdk.rejectSshApproval.mockResolvedValue(mockSdkResponse(undefined)); - + setApproval(); renderAt("/ssh-identities/new/WXYZ2K7Q"); await userEvent.click( await screen.findByRole("button", { name: /reject/i }), ); - await waitFor(() => expect(sdk.rejectSshApproval).toHaveBeenCalled()); expect(await screen.findByText("Rejected")).toBeInTheDocument(); }); it("reads a 404 as an expired request", async () => { - sdk.getSshApproval.mockRejectedValue({ status: 404 }); + server.use( + http.get("*/api/ssh-approvals/:code", () => + HttpResponse.json({}, { status: 404 }), + ), + ); renderAt("/ssh-identities/new/WXYZ2K7Q"); @@ -171,7 +175,7 @@ describe("SSHApproval", () => { it("keeps the factor out of sight until the login has been reviewed", async () => { const user = userEvent.setup(); - sdk.getSshApproval.mockResolvedValue(approval({ kind: "reauth" })); + setApproval({ kind: "reauth" }); renderAt("/ssh-identities/confirm/WXYZ2K7Q"); await screen.findByText(/re-authenticate to continue/i); @@ -188,7 +192,7 @@ describe("SSHApproval", () => { it("goes back to the details without deciding anything", async () => { const user = userEvent.setup(); - sdk.getSshApproval.mockResolvedValue(approval({ kind: "reauth" })); + setApproval({ kind: "reauth" }); renderAt("/ssh-identities/confirm/WXYZ2K7Q"); await screen.findByText(/re-authenticate to continue/i); @@ -202,15 +206,14 @@ describe("SSHApproval", () => { expect( screen.queryByLabelText(/account password/i), ).not.toBeInTheDocument(); - expect(sdk.webTerminalReauth).not.toHaveBeenCalled(); + expect( + screen.queryByText(/re-authenticated/i), + ).not.toBeInTheDocument(); }); it("proves the password and reports the login released", async () => { const user = userEvent.setup(); - sdk.getSshApproval.mockResolvedValue(approval({ kind: "reauth" })); - sdk.webTerminalReauth.mockResolvedValue({ - data: undefined, - }); + setApproval({ kind: "reauth" }); renderAt("/ssh-identities/confirm/WXYZ2K7Q"); await screen.findByText(/re-authenticate to continue/i); @@ -222,22 +225,11 @@ describe("SSHApproval", () => { ); await user.click(screen.getByRole("button", { name: /re-authenticate/i })); - await waitFor(() => - expect(sdk.webTerminalReauth).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ - password: "hunter2", - approval_code: "WXYZ2K7Q", - fingerprint: "SHA256:abc", - }), - }), - ), - ); expect(await screen.findByText(/re-authenticated/i)).toBeInTheDocument(); }); it("leaves the add-key flow at a single step", async () => { - sdk.getSshApproval.mockResolvedValue(approval()); + setApproval(); renderAt("/ssh-identities/new/WXYZ2K7Q"); await screen.findByText(/add this ssh key/i); diff --git a/ui/apps/console/src/pages/__tests__/Settings.test.tsx b/ui/apps/console/src/pages/__tests__/Settings.test.tsx index af5b2a305f7..ac7913fe364 100644 --- a/ui/apps/console/src/pages/__tests__/Settings.test.tsx +++ b/ui/apps/console/src/pages/__tests__/Settings.test.tsx @@ -2,23 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; import { mockNamespace } from "@/tests/factories"; import { seedAuthStore } from "@/tests/seedAuthStore"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getNamespace: vi.fn(), - listAccessPolicies: vi.fn(), - editNamespace: vi.fn(), - deleteNamespace: vi.fn(), - leaveNamespace: vi.fn(), - setSshAccessMode: vi.fn(), - getNamespaceToken: vi.fn(), - }), -); - vi.mock("@/components/billing/BillingSection", () => ({ default: () => null, })); @@ -34,7 +23,7 @@ import { getConfig, defaultConfig } from "@/env"; const mockedGetConfig = vi.mocked(getConfig); -function defaultNamespace( +function defaultNs( settings: Partial<{ ssh_access_mode: "legacy" | "identity"; ssh_legacy_allowed: boolean; @@ -51,6 +40,19 @@ function defaultNamespace( }); } +function setNamespace( + settings: Partial<{ + ssh_access_mode: "legacy" | "identity"; + ssh_legacy_allowed: boolean; + }> = {}, +) { + server.use( + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json(defaultNs(settings)), + ), + ); +} + function renderSettings() { return render( @@ -64,14 +66,30 @@ beforeEach(() => { vi.clearAllMocks(); mockedGetConfig.mockReturnValue({ ...defaultConfig }); seedAuthStore(); - sdk.getNamespace.mockResolvedValue(mockSdkResponse(defaultNamespace())); - sdk.listAccessPolicies.mockResolvedValue(paginatedResponse([])); - sdk.editNamespace.mockResolvedValue(mockSdkResponse(undefined)); - sdk.deleteNamespace.mockResolvedValue(mockSdkResponse(undefined)); - sdk.leaveNamespace.mockResolvedValue(mockSdkResponse(undefined)); - sdk.setSshAccessMode.mockResolvedValue(mockSdkResponse(undefined)); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token" }), + server.use( + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json(defaultNs()), + ), + http.get("*/api/access-policies", () => jsonWithTotal([])), + http.put( + "*/api/namespaces/:tenant", + () => new HttpResponse(null, { status: 204 }), + ), + http.delete( + "*/api/namespaces/:tenant", + () => new HttpResponse(null, { status: 204 }), + ), + http.delete( + "*/api/namespaces/:tenant/members", + () => HttpResponse.json({}), + ), + http.put( + "*/api/namespaces/ssh-access-mode/:tenant", + () => new HttpResponse(null, { status: 204 }), + ), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token" }), + ), ); }); @@ -138,9 +156,7 @@ describe("Settings", () => { describe("SSH access mode", () => { it("shows the Legacy/Identity toggle for grandfathered namespaces", async () => { - sdk.getNamespace.mockResolvedValue( - mockSdkResponse(defaultNamespace({ ssh_legacy_allowed: true })), - ); + setNamespace({ ssh_legacy_allowed: true }); renderSettings(); expect( await screen.findByRole("button", { name: "Legacy" }), @@ -151,14 +167,10 @@ describe("Settings", () => { }); it("shows no toggle for namespaces born in identity mode", async () => { - sdk.getNamespace.mockResolvedValue( - mockSdkResponse( - defaultNamespace({ - ssh_access_mode: "identity", - ssh_legacy_allowed: false, - }), - ), - ); + setNamespace({ + ssh_access_mode: "identity", + ssh_legacy_allowed: false, + }); renderSettings(); expect(await screen.findByText("Identity")).toBeInTheDocument(); expect( @@ -201,24 +213,21 @@ describe("Settings", () => { expect(screen.getByRole("button", { name: /save/i })).not.toBeDisabled(); }); - it("calls editNamespace with the new name on submit and closes the drawer", async () => { + it("closes the drawer after successful rename", async () => { const user = await openRenameDrawer(); const input = screen.getByLabelText(/namespace name/i); await user.clear(input); await user.type(input, "new-valid-name"); await user.click(screen.getByRole("button", { name: /save/i })); - expect(sdk.editNamespace).toHaveBeenCalledWith( - expect.objectContaining({ - path: { tenant: "tenant-456" }, - body: { name: "new-valid-name" }, - throwOnError: true, - }), - ); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); it("shows a generic error alert when rename fails", async () => { - sdk.editNamespace.mockRejectedValue(new Error("server error")); + server.use( + http.put("*/api/namespaces/:tenant", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const user = await openRenameDrawer(); const input = screen.getByLabelText(/namespace name/i); await user.clear(input); diff --git a/ui/apps/console/src/pages/__tests__/Setup.test.tsx b/ui/apps/console/src/pages/__tests__/Setup.test.tsx index 5b8cc414067..52393b9191f 100644 --- a/ui/apps/console/src/pages/__tests__/Setup.test.tsx +++ b/ui/apps/console/src/pages/__tests__/Setup.test.tsx @@ -2,8 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import Setup from "../Setup"; -import { mockSdkResponse, makeSdkError } from "@/tests/sdk"; const mockNavigate = vi.hoisted(() => vi.fn()); @@ -12,18 +13,21 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - setup: vi.fn(), - }), -); - const mockLoginWithToken = vi.hoisted(() => vi.fn()); vi.mock("@/stores/authStore", () => ({ - useAuthStore: ( - selector: (s: { loginWithToken: typeof mockLoginWithToken }) => unknown, - ) => selector({ loginWithToken: mockLoginWithToken }), + useAuthStore: Object.assign( + ( + selector: (s: { loginWithToken: typeof mockLoginWithToken }) => unknown, + ) => selector({ loginWithToken: mockLoginWithToken }), + { + getState: () => ({ + token: null, + logout: vi.fn(), + setMfaToken: vi.fn(), + }), + }, + ), })); import { getConfig, defaultConfig } from "@/env"; @@ -45,11 +49,14 @@ async function fillValidForm(user: ReturnType) { await user.type(screen.getByLabelText(/^confirm password$/i), "Secret123"); } beforeEach(() => { - mockNavigate.mockReset(); - sdk.setup.mockReset(); - mockLoginWithToken.mockReset(); + vi.clearAllMocks(); mockLoginWithToken.mockResolvedValue(undefined); mockGetConfig.mockReturnValue({ ...defaultConfig }); + server.use( + http.post("*/api/setup", () => + HttpResponse.json({ token: "jwt-token" }), + ), + ); }); describe("Setup", () => { @@ -171,35 +178,17 @@ describe("Setup", () => { }); describe("successful submission", () => { - const setupSuccess = () => mockSdkResponse({ token: "jwt-token" }); - - it("calls setup() with the correct payload and shows the success screen", async () => { - sdk.setup.mockResolvedValue(setupSuccess()); + it("shows the success screen after setup", async () => { const user = userEvent.setup(); renderSetup(); await fillValidForm(user); await user.click(screen.getByRole("button", { name: /complete setup/i })); - await waitFor(() => expect(sdk.setup).toHaveBeenCalledTimes(1)); - expect(sdk.setup).toHaveBeenCalledWith( - expect.objectContaining({ - body: { - name: "Alice Smith", - username: "alice", - namespace: "dev", - email: "alice@example.com", - password: "Secret123", - }, - throwOnError: true, - }), - ); - expect(await screen.findByText(/instance ready/i)).toBeInTheDocument(); }); it("logs in with the returned token", async () => { - sdk.setup.mockResolvedValue(setupSuccess()); const user = userEvent.setup(); renderSetup(); @@ -214,7 +203,6 @@ describe("Setup", () => { it("redirects to the app after 3 seconds on success", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); - sdk.setup.mockResolvedValue(setupSuccess()); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderSetup(); @@ -235,7 +223,6 @@ describe("Setup", () => { }); it("routes to login with a notice when auto-login fails after setup", async () => { - sdk.setup.mockResolvedValue(setupSuccess()); mockLoginWithToken.mockRejectedValue(new Error("token login failed")); const user = userEvent.setup(); renderSetup(); @@ -253,7 +240,9 @@ describe("Setup", () => { }); it("routes to login when setup issues no token", async () => { - sdk.setup.mockResolvedValue(mockSdkResponse({ token: "" })); + server.use( + http.post("*/api/setup", () => HttpResponse.json({ token: "" })), + ); const user = userEvent.setup(); renderSetup(); @@ -272,7 +261,11 @@ describe("Setup", () => { describe("error handling", () => { it("shows 'Setup has already been completed' on 409", async () => { - sdk.setup.mockRejectedValue(makeSdkError(409)); + server.use( + http.post("*/api/setup", () => + HttpResponse.json({}, { status: 409 }), + ), + ); const user = userEvent.setup(); renderSetup(); @@ -285,7 +278,11 @@ describe("Setup", () => { }); it("shows a generic error on unexpected server errors", async () => { - sdk.setup.mockRejectedValue(makeSdkError(500)); + server.use( + http.post("*/api/setup", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const user = userEvent.setup(); renderSetup(); diff --git a/ui/apps/console/src/pages/__tests__/SignUp.test.tsx b/ui/apps/console/src/pages/__tests__/SignUp.test.tsx index f249cb8d4de..ae7f62445f1 100644 --- a/ui/apps/console/src/pages/__tests__/SignUp.test.tsx +++ b/ui/apps/console/src/pages/__tests__/SignUp.test.tsx @@ -2,9 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { useSignUpStore } from "@/stores/signUpStore"; import SignUp from "../SignUp"; -import { mockSdkResponse } from "@/tests/sdk"; const mockNavigate = vi.hoisted(() => vi.fn()); @@ -13,12 +14,6 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - registerUser: vi.fn(), - }), -); - function renderSignUp(search = "") { return render( @@ -64,8 +59,7 @@ async function fillValidForm( } beforeEach(() => { - mockNavigate.mockReset(); - sdk.registerUser.mockReset(); + vi.clearAllMocks(); useSignUpStore.setState({ signUpLoading: false, signUpError: null, @@ -73,6 +67,9 @@ beforeEach(() => { signUpToken: null, signUpTenant: null, }); + server.use( + http.post("*/api/register", () => new HttpResponse(null, { status: 204 })), + ); }); describe("SignUp", () => { @@ -86,41 +83,17 @@ describe("SignUp", () => { }); describe("successful submission", () => { - it("calls signUp with correct payload including email_marketing when form is valid", async () => { - sdk.registerUser.mockResolvedValue(mockSdkResponse({})); + it("navigates to confirm-account after valid submission", async () => { const user = userEvent.setup(); renderSignUp(); await fillValidForm(user, { acceptMarketing: true }); await user.click(screen.getByRole("button", { name: /create account/i })); - await waitFor(() => expect(sdk.registerUser).toHaveBeenCalledTimes(1)); - expect(sdk.registerUser).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ - name: "Alice Smith", - username: "alice", - email: "alice@example.com", - password: "Secret123", - email_marketing: true, - }), - }), - ); - }); - - it("calls signUp with email_marketing: false when marketing checkbox is unchecked", async () => { - sdk.registerUser.mockResolvedValue(mockSdkResponse({})); - const user = userEvent.setup(); - renderSignUp(); - - await fillValidForm(user, { acceptMarketing: false }); - await user.click(screen.getByRole("button", { name: /create account/i })); - - await waitFor(() => expect(sdk.registerUser).toHaveBeenCalledTimes(1)); - expect(sdk.registerUser).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ email_marketing: false }), - }), + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith( + expect.stringContaining("/confirm-account"), + ), ); }); }); @@ -207,18 +180,24 @@ describe("SignUp", () => { }); describe("server field errors", () => { - function makeServerFieldError(fields: string[], status = 400) { - const body = { - message: "user invalid", - fields: Object.fromEntries(fields.map((field) => [field, "invalid"])), - status, - }; - - return body as unknown as Error; + function setServerFieldError(fields: string[]) { + server.use( + http.post("*/api/register", () => + HttpResponse.json( + { + message: "user invalid", + fields: Object.fromEntries( + fields.map((field) => [field, "invalid"]), + ), + }, + { status: 400 }, + ), + ), + ); } it("shows server-side username error on the username field and disables submit", async () => { - sdk.registerUser.mockRejectedValue(makeServerFieldError(["username"])); + setServerFieldError(["username"]); const user = userEvent.setup(); renderSignUp(); @@ -235,7 +214,7 @@ describe("SignUp", () => { }); it("shows server-side email error on the email field", async () => { - sdk.registerUser.mockRejectedValue(makeServerFieldError(["email"])); + setServerFieldError(["email"]); const user = userEvent.setup(); renderSignUp(); @@ -248,7 +227,7 @@ describe("SignUp", () => { }); it("clears the server field error after the user edits that field", async () => { - sdk.registerUser.mockRejectedValue(makeServerFieldError(["username"])); + setServerFieldError(["username"]); const user = userEvent.setup(); renderSignUp(); diff --git a/ui/apps/console/src/pages/__tests__/UpdatePassword.test.tsx b/ui/apps/console/src/pages/__tests__/UpdatePassword.test.tsx index 77b2d4f16bf..a35c4c0c64a 100644 --- a/ui/apps/console/src/pages/__tests__/UpdatePassword.test.tsx +++ b/ui/apps/console/src/pages/__tests__/UpdatePassword.test.tsx @@ -2,8 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import UpdatePassword from "../UpdatePassword"; -import { mockSdkResponse } from "@/tests/sdk"; const mockNavigate = vi.hoisted(() => vi.fn()); @@ -12,12 +13,6 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - updateRecoverPassword: vi.fn(), - }), -); - function renderWithParams(search = "?id=uid123&token=tok456") { return render( @@ -30,9 +25,13 @@ function renderWithParams(search = "?id=uid123&token=tok456") { } beforeEach(() => { - mockNavigate.mockReset(); - sdk.updateRecoverPassword.mockReset(); - sdk.updateRecoverPassword.mockResolvedValue(mockSdkResponse(undefined)); + vi.clearAllMocks(); + server.use( + http.post( + "*/api/user/:uid/update_password", + () => new HttpResponse(null, { status: 204 }), + ), + ); }); describe("UpdatePassword", () => { @@ -144,33 +143,6 @@ describe("UpdatePassword", () => { }); describe("successful submission", () => { - it("calls updateRecoverPassword with uid, token, and password on valid submit", async () => { - const user = userEvent.setup(); - renderWithParams(); - - await user.type(screen.getByLabelText(/^new password$/i), "Secret123"); - await user.type( - screen.getByLabelText(/^confirm password$/i), - "Secret123", - ); - await user.click( - screen.getByRole("button", { name: /update password/i }), - ); - - await waitFor(() => - expect(sdk.updateRecoverPassword).toHaveBeenCalledTimes(1), - ); - expect(sdk.updateRecoverPassword).toHaveBeenCalledWith( - expect.objectContaining({ - path: { uid: "uid123" }, - body: expect.objectContaining({ - token: "tok456", - password: "Secret123", - }), - }), - ); - }); - it("navigates to /login with a success notice after successful submission", async () => { const user = userEvent.setup(); renderWithParams(); @@ -197,7 +169,11 @@ describe("UpdatePassword", () => { describe("API failure", () => { it("shows a generic error message when the API call fails", async () => { - sdk.updateRecoverPassword.mockRejectedValue(new Error("network error")); + server.use( + http.post("*/api/user/:uid/update_password", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const user = userEvent.setup(); renderWithParams(); diff --git a/ui/apps/console/src/pages/__tests__/WebEndpoints.test.tsx b/ui/apps/console/src/pages/__tests__/WebEndpoints.test.tsx index 29d8c3a08d4..a541d85c85a 100644 --- a/ui/apps/console/src/pages/__tests__/WebEndpoints.test.tsx +++ b/ui/apps/console/src/pages/__tests__/WebEndpoints.test.tsx @@ -1,21 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { mockWebEndpoint } from "@/tests/factories"; import { seedAuthStore } from "@/tests/seedAuthStore"; -import { paginatedResponse } from "@/tests/sdk"; import WebEndpoints from "../WebEndpoints"; -const sdk = vi.hoisted(() => - mockSdkGen({ - listWebEndpoints: vi.fn(), - deleteWebEndpoint: vi.fn(), - createWebEndpoint: vi.fn(), - getDevices: vi.fn(), - }), -); - vi.mock("@/hooks/useResetOnOpen"); vi.mock("@/hooks/useDebouncedValue", () => ({ @@ -36,29 +28,44 @@ function renderPage(initialEntries: string[] = ["/"]) { }); } +let lastRequestUrl: URL | null; + +function setEndpoints(endpoints: ReturnType[], total?: number) { + server.use( + http.get("*/api/web-endpoints", ({ request }) => { + lastRequestUrl = new URL(request.url); + return jsonWithTotal(endpoints, total ?? endpoints.length); + }), + ); +} + beforeEach(() => { vi.clearAllMocks(); + lastRequestUrl = null; seedAuthStore(); - sdk.listWebEndpoints.mockResolvedValue(paginatedResponse([])); - sdk.deleteWebEndpoint.mockResolvedValue({ data: undefined }); - sdk.createWebEndpoint.mockResolvedValue({ data: undefined }); - sdk.getDevices.mockResolvedValue(paginatedResponse([])); + setEndpoints([]); + server.use( + http.delete( + "*/api/web-endpoints/:address", + () => new HttpResponse(null, { status: 204 }), + ), + http.post("*/api/web-endpoints", () => + HttpResponse.json(ep("new.example.com")), + ), + http.get("*/api/devices", () => jsonWithTotal([])), + ); mockUseDebouncedValue.mockImplementation((v: T) => v); }); describe("WebEndpoints — pagination count / controls decoupling", () => { it("shows the endpoint count when totalCount > 0 and only one page exists", async () => { - sdk.listWebEndpoints.mockResolvedValue( - paginatedResponse([ep("ep1.example.com")], 1), - ); + setEndpoints([ep("ep1.example.com")], 1); renderPage(); expect(await screen.findByText(/1 endpoint/i)).toBeInTheDocument(); }); it("hides Prev/Next controls when only one page exists", async () => { - sdk.listWebEndpoints.mockResolvedValue( - paginatedResponse([ep("ep1.example.com")], 1), - ); + setEndpoints([ep("ep1.example.com")], 1); renderPage(); await screen.findByText(/1 endpoint/i); expect( @@ -73,7 +80,7 @@ describe("WebEndpoints — pagination count / controls decoupling", () => { const endpoints = Array.from({ length: 10 }, (_, i) => ep(`ep${i + 1}.example.com`), ); - sdk.listWebEndpoints.mockResolvedValue(paginatedResponse(endpoints, 15)); + setEndpoints(endpoints, 15); renderPage(); expect(await screen.findByText(/15 endpoints/i)).toBeInTheDocument(); expect( @@ -86,7 +93,7 @@ describe("WebEndpoints — pagination count / controls decoupling", () => { it("does not show the Pagination nav when there are no endpoints", async () => { renderPage(); - await waitFor(() => expect(sdk.listWebEndpoints).toHaveBeenCalled()); + await waitFor(() => expect(lastRequestUrl).not.toBeNull()); expect(screen.queryByText(/0 endpoints/i)).not.toBeInTheDocument(); expect( screen.queryByRole("button", { name: /previous page/i }), @@ -95,7 +102,9 @@ describe("WebEndpoints — pagination count / controls decoupling", () => { it("does not flash a '0 endpoints' count while a search request is in-flight", () => { mockUseDebouncedValue.mockReturnValue("some-query"); - sdk.listWebEndpoints.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/api/web-endpoints", () => new Promise(() => {})), + ); renderPage(); expect(screen.queryByText(/0 endpoints/i)).not.toBeInTheDocument(); expect( @@ -106,28 +115,22 @@ describe("WebEndpoints — pagination count / controls decoupling", () => { describe("WebEndpoints — URL hydration", () => { it("passes page=2 and a filter containing the search term from the URL", async () => { - sdk.listWebEndpoints.mockResolvedValue( - paginatedResponse([ep("ep1.example.com")], 1), - ); + setEndpoints([ep("ep1.example.com")], 1); renderPage(["/?page=2&search=myhost"]); await waitFor(() => { - const call = sdk.listWebEndpoints.mock.calls.at(-1)?.[0] as { - query?: { page?: number; filter?: string }; - }; - expect(call?.query?.page).toBe(2); - const decoded = atob(call?.query?.filter ?? ""); - expect(decoded).toContain("myhost"); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); + const filter = lastRequestUrl!.searchParams.get("filter") ?? ""; + expect(atob(filter)).toContain("myhost"); }); }); it("falls back to page=1 and no filter when URL has no params", async () => { renderPage(["/"]); await waitFor(() => { - const call = sdk.listWebEndpoints.mock.calls[0]?.[0] as { - query?: { page?: number; filter?: string }; - }; - expect(call?.query?.page).toBe(1); - expect(call?.query?.filter).toBeUndefined(); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); + expect(lastRequestUrl!.searchParams.get("filter")).toBeNull(); }); }); }); @@ -135,9 +138,7 @@ describe("WebEndpoints — URL hydration", () => { describe("WebEndpoints — search resets page to 1", () => { it("resets to page=1 when a new search term is typed while on page 2", async () => { const user = userEvent.setup(); - sdk.listWebEndpoints.mockResolvedValue( - paginatedResponse([ep("ep1.example.com")], 25), - ); + setEndpoints([ep("ep1.example.com")], 25); renderPage(["/?page=2"]); await screen.findByText(/25 endpoints/i); @@ -145,10 +146,7 @@ describe("WebEndpoints — search resets page to 1", () => { await user.type(searchInput, "x"); await waitFor(() => { - const lastCall = sdk.listWebEndpoints.mock.calls.at(-1)?.[0] as { - query?: { page?: number }; - }; - expect(lastCall?.query?.page).toBe(1); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); }); }); @@ -156,11 +154,9 @@ describe("WebEndpoints — search resets page to 1", () => { describe("WebEndpoints — page change writes to URL", () => { it("calls listWebEndpoints with page=2 after clicking the Next page button", async () => { const user = userEvent.setup(); - sdk.listWebEndpoints.mockResolvedValue( - paginatedResponse( - Array.from({ length: 10 }, (_, i) => ep(`ep${i + 1}.example.com`)), - 15, - ), + setEndpoints( + Array.from({ length: 10 }, (_, i) => ep(`ep${i + 1}.example.com`)), + 15, ); renderPage(["/"]); await screen.findByText(/15 endpoints/i); @@ -168,18 +164,13 @@ describe("WebEndpoints — page change writes to URL", () => { await user.click(screen.getByRole("button", { name: /next page/i })); await waitFor(() => { - const lastCall = sdk.listWebEndpoints.mock.calls.at(-1)?.[0] as { - query?: { page?: number }; - }; - expect(lastCall?.query?.page).toBe(2); + expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); }); }); }); async function openEndpointDrawer(user: ReturnType) { - sdk.listWebEndpoints.mockResolvedValue( - paginatedResponse([ep("ep1.example.com")], 1), - ); + setEndpoints([ep("ep1.example.com")], 1); renderPage(); await user.click( await screen.findByRole("button", { name: /new endpoint/i }), diff --git a/ui/apps/console/src/pages/access-policies/AccessPolicyDrawer.tsx b/ui/apps/console/src/pages/access-policies/AccessPolicyDrawer.tsx index d2b9e6eee37..d9b55508372 100644 --- a/ui/apps/console/src/pages/access-policies/AccessPolicyDrawer.tsx +++ b/ui/apps/console/src/pages/access-policies/AccessPolicyDrawer.tsx @@ -21,13 +21,13 @@ import { cn } from "@shellhub/design-system/cn"; import { useResetOnOpen } from "@/hooks/useResetOnOpen"; import { useAuthStore } from "@/stores/authStore"; import { useNamespace, type NamespaceMember } from "@/hooks/useNamespaces"; -import { useServiceAccounts } from "@/hooks/useServiceAccounts"; -import { useTags } from "@/hooks/useTags"; import { + useListServiceAccounts, useCreateAccessPolicy, useUpdateAccessPolicy, -} from "@/hooks/useAccessPolicyMutations"; -import type { AccessPolicy, AccessPolicyRequest } from "@/client"; +} from "@/client/api"; +import { useTagNames } from "@/hooks/useTags"; +import type { AccessPolicy, AccessPolicyRequest } from "@/client/model"; import { ROLES } from "@/pages/team/helpers"; import SourceIpInput from "@/components/common/fields/SourceIpInput"; import InputField from "@/components/common/fields/InputField"; @@ -354,8 +354,7 @@ function AccessPolicyDrawer({ }) { const { tenant: tenantId } = useAuthStore(); const { namespace } = useNamespace(tenantId ?? ""); - const { tags: allTagObjects } = useTags(); - const allTags = allTagObjects.map((t) => t.name); + const { names: allTags } = useTagNames(); const createPolicy = useCreateAccessPolicy(); const updatePolicy = useUpdateAccessPolicy(); const isEdit = !!editPolicy; @@ -364,7 +363,7 @@ function AccessPolicyDrawer({ (m): m is NamespaceMember => !!m.id && !!m.role && !!m.email && String(m.role) !== "service", ); - const { serviceAccounts } = useServiceAccounts(); + const { data: serviceAccounts = [] } = useListServiceAccounts(); const roleMemberCount = (role: string) => members.filter((m) => String(m.role) === role).length; @@ -501,9 +500,9 @@ function AccessPolicyDrawer({ }; try { if (isEdit && editPolicy) { - await updatePolicy.mutateAsync({ path: { id: editPolicy.id }, body }); + await updatePolicy.mutateAsync({ id: editPolicy.id, data: body }); } else { - await createPolicy.mutateAsync({ body }); + await createPolicy.mutateAsync({ data: body }); } onClose(); } catch (err: unknown) { diff --git a/ui/apps/console/src/pages/access-policies/__tests__/index.test.tsx b/ui/apps/console/src/pages/access-policies/__tests__/index.test.tsx index 4729aba1715..af9aa8dca00 100644 --- a/ui/apps/console/src/pages/access-policies/__tests__/index.test.tsx +++ b/ui/apps/console/src/pages/access-policies/__tests__/index.test.tsx @@ -1,30 +1,23 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import { mockAccessPolicy, mockNamespace } from "@/tests/factories"; import { seedAuthStore } from "@/tests/seedAuthStore"; -import type { AccessPolicy } from "@/client"; +import type { AccessPolicy } from "@/client/model"; import AccessPolicies from "../index"; -const sdk = vi.hoisted(() => - mockSdkGen({ - listAccessPolicies: vi.fn(), - deleteAccessPolicy: vi.fn(), - getNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - listServiceAccounts: vi.fn(), - }), -); - vi.mock("../AccessPolicyDrawer", () => ({ default: ({ open }: { open: boolean }) => open ?
: null, })); function renderList(policies: AccessPolicy[]) { - sdk.listAccessPolicies.mockResolvedValue(mockSdkResponse(policies)); + server.use( + http.get("*/api/access-policies", () => HttpResponse.json(policies)), + ); return render(, { wrapper: createTestWrapper({ initialEntries: ["/"] }), }); @@ -33,12 +26,19 @@ function renderList(policies: AccessPolicy[]) { beforeEach(() => { vi.clearAllMocks(); seedAuthStore(); - sdk.getNamespace.mockResolvedValue(mockSdkResponse(mockNamespace())); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + server.use( + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json(mockNamespace()), + ), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token", role: "owner" }), + ), + http.get("*/api/service-accounts", () => HttpResponse.json([])), + http.delete( + "*/api/access-policies/:id", + () => new HttpResponse(null, { status: 204 }), + ), ); - sdk.listServiceAccounts.mockResolvedValue(mockSdkResponse([])); - sdk.deleteAccessPolicy.mockResolvedValue(mockSdkResponse(undefined)); }); describe("AccessPolicies", () => { diff --git a/ui/apps/console/src/pages/access-policies/index.tsx b/ui/apps/console/src/pages/access-policies/index.tsx index a05772d83b3..e11f79662f6 100644 --- a/ui/apps/console/src/pages/access-policies/index.tsx +++ b/ui/apps/console/src/pages/access-policies/index.tsx @@ -22,12 +22,14 @@ import { IconButton, } from "@shellhub/design-system/primitives"; import { cn } from "@shellhub/design-system/cn"; -import { useAccessPolicies } from "@/hooks/useAccessPolicies"; -import { useDeleteAccessPolicy } from "@/hooks/useAccessPolicyMutations"; +import { + useListAccessPolicies, + useListServiceAccounts, + useDeleteAccessPolicy, +} from "@/client/api"; import { useNamespace } from "@/hooks/useNamespaces"; -import { useServiceAccounts } from "@/hooks/useServiceAccounts"; import { useAuthStore } from "@/stores/authStore"; -import type { AccessPolicy } from "@/client"; +import type { AccessPolicy } from "@/client/model"; import PageHeader from "@/components/common/PageHeader"; import EmptyState from "@/components/common/EmptyState"; import ConfirmDialog from "@/components/common/ConfirmDialog"; @@ -205,10 +207,10 @@ function ActionCell({ policy }: { policy: AccessPolicy }) { * firewall pages in namespaces using identity access mode. */ export default function AccessPolicies() { - const { policies, isLoading } = useAccessPolicies(); + const { data: policies = [], isLoading } = useListAccessPolicies(); const { tenant: tenantId } = useAuthStore(); const { namespace: ns } = useNamespace(tenantId ?? ""); - const { serviceAccounts } = useServiceAccounts(); + const { data: serviceAccounts = [] } = useListServiceAccounts(); const isIdentityMode = ns?.settings?.ssh_access_mode === "identity"; const members = ns?.members ?? []; @@ -237,7 +239,7 @@ export default function AccessPolicies() { if (!deleteTarget) return; setDeleteError(null); try { - await deletePolicy.mutateAsync({ path: { id: deleteTarget.id } }); + await deletePolicy.mutateAsync({ id: deleteTarget.id }); closeDelete(); } catch (err) { setDeleteError( diff --git a/ui/apps/console/src/pages/admin/Dashboard.tsx b/ui/apps/console/src/pages/admin/Dashboard.tsx index 9ee346592fe..810fbb4e3dd 100644 --- a/ui/apps/console/src/pages/admin/Dashboard.tsx +++ b/ui/apps/console/src/pages/admin/Dashboard.tsx @@ -12,7 +12,7 @@ import { import PageHeader from "@/components/common/PageHeader"; import StatCard from "@/components/common/StatCard"; import RecentSessionsTable from "@/components/sessions/RecentSessionsTable"; -import { useAdminStats } from "@/hooks/useAdminStats"; +import { useGetStats } from "@/client/api"; import PageLoader from "@/components/common/PageLoader"; /** @@ -20,10 +20,12 @@ import PageLoader from "@/components/common/PageLoader"; */ export default function AdminDashboard() { const { - stats: statsData, + data: statsData, isLoading: statsLoading, isError: statsError, - } = useAdminStats(); + } = useGetStats({ + query: { staleTime: 5 * 60_000, refetchOnWindowFocus: false, retry: 1 }, + }); if (statsLoading) { return ; diff --git a/ui/apps/console/src/pages/admin/License.tsx b/ui/apps/console/src/pages/admin/License.tsx index 3dcf3a929f5..446d04848f4 100644 --- a/ui/apps/console/src/pages/admin/License.tsx +++ b/ui/apps/console/src/pages/admin/License.tsx @@ -16,7 +16,7 @@ import { cn } from "@shellhub/design-system/cn"; import PageHeader from "@/components/common/PageHeader"; import CopyButton from "@/components/common/CopyButton"; import { useAdminLicense } from "@/hooks/useAdminLicense"; -import { useUploadLicense } from "@/hooks/useUploadLicense"; +import { useSendLicense } from "@/client/api"; import { formatLicenseTimestamp, formatDeviceCount, @@ -25,7 +25,7 @@ import { validateLicenseFile, getLicenseAlertConfig, } from "@/utils/license"; -import type { GetLicenseResponse } from "@/client"; +import type { GetLicense200 as GetLicenseResponse } from "@/client/model"; import PageLoader from "@/components/common/PageLoader"; import { Button, Card, IconButton } from "@shellhub/design-system/primitives"; @@ -202,7 +202,7 @@ function LicenseFeatures({ } function LicenseUpload() { - const upload = useUploadLicense(); + const upload = useSendLicense(); const [file, setFile] = useState(null); const [validationError, setValidationError] = useState(null); const [feedback, setFeedback] = useState<{ @@ -242,7 +242,7 @@ function LicenseUpload() { if (!file || validationError) return; setFeedback(null); try { - await upload.mutateAsync({ body: { file } }); + await upload.mutateAsync({ data: { file } }); setFeedback({ type: "success", message: "License uploaded successfully.", diff --git a/ui/apps/console/src/pages/admin/SessionDetails.tsx b/ui/apps/console/src/pages/admin/SessionDetails.tsx index f99a8d4614a..03f69e81b09 100644 --- a/ui/apps/console/src/pages/admin/SessionDetails.tsx +++ b/ui/apps/console/src/pages/admin/SessionDetails.tsx @@ -5,7 +5,7 @@ import { MinusCircleIcon, } from "@heroicons/react/24/outline"; import { cn } from "@shellhub/design-system/cn"; -import { useAdminSessionDetail } from "@/hooks/useAdminSessionDetail"; +import { useGetSessionAdmin } from "@/client/api"; import Breadcrumb from "@/components/common/Breadcrumb"; import InfoItem from "@/components/common/InfoItem"; import { formatDateFull } from "@/utils/date"; @@ -44,7 +44,13 @@ function BoolField({ */ export default function AdminSessionDetails() { const { uid = "" } = useParams<{ uid: string }>(); - const { session, isLoading, error } = useAdminSessionDetail(uid); + const { + data: session, + isLoading, + error, + } = useGetSessionAdmin(uid, { + query: { staleTime: 60_000, refetchOnWindowFocus: false, retry: 1 }, + }); if (isLoading) { return ; diff --git a/ui/apps/console/src/pages/admin/Sessions.tsx b/ui/apps/console/src/pages/admin/Sessions.tsx index e86497f89ac..facf1772182 100644 --- a/ui/apps/console/src/pages/admin/Sessions.tsx +++ b/ui/apps/console/src/pages/admin/Sessions.tsx @@ -7,8 +7,9 @@ import { } from "@heroicons/react/24/outline"; import { Callout } from "@shellhub/design-system/primitives"; import { cn } from "@shellhub/design-system/cn"; -import { useAdminSessions } from "@/hooks/useAdminSessions"; -import type { Session } from "@/client"; +import { useGetSessionsAdmin } from "@/client/api"; +import { totalCount } from "@/api/pagination"; +import type { Session } from "@/client/model"; import PageHeader from "@/components/common/PageHeader"; import DataTable, { type Column } from "@/components/common/DataTable"; import DeviceChip from "@/components/common/DeviceChip"; @@ -30,13 +31,18 @@ export default function AdminSessions() { const { params, setPage } = usePaginatedListState({ defaults: DEFAULTS, }); - const { sessions, totalCount, isLoading, error } = useAdminSessions({ + const { + data: sessions = [], + isLoading, + error, + } = useGetSessionsAdmin({ page: params.page, - perPage: PER_PAGE, + per_page: PER_PAGE, }); + const total = totalCount(sessions); const navigate = useNavigate(); - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const columns: Column[] = [ { @@ -180,7 +186,7 @@ export default function AdminSessions() { loadingMessage="Loading sessions..." page={params.page} totalPages={totalPages} - totalCount={totalCount} + totalCount={total} itemLabel="session" onPageChange={setPage} onRowClick={(s) => void navigate(`/admin/sessions/${s.uid}`)} diff --git a/ui/apps/console/src/pages/admin/__tests__/Dashboard.test.tsx b/ui/apps/console/src/pages/admin/__tests__/Dashboard.test.tsx index 21cb92ae43f..5e594596267 100644 --- a/ui/apps/console/src/pages/admin/__tests__/Dashboard.test.tsx +++ b/ui/apps/console/src/pages/admin/__tests__/Dashboard.test.tsx @@ -1,17 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { useAuthStore } from "@/stores/authStore"; -import { mockSdkResponse, makeSdkError } from "@/tests/sdk"; import AdminDashboard from "../Dashboard"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getStats: vi.fn(), - }), -); - vi.mock("@/components/sessions/RecentSessionsTable", () => ({ default: ({ isAdmin }: { isAdmin?: boolean }) => (
@@ -39,25 +34,33 @@ function renderPage() { beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ isAdmin: true }); - sdk.getStats.mockResolvedValue(mockSdkResponse(fullStats)); + server.use( + http.get("*/admin/api/stats", () => HttpResponse.json(fullStats)), + ); }); describe("AdminDashboard", () => { describe("loading state", () => { it("renders spinner with role='status'", () => { - sdk.getStats.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/stats", () => new Promise(() => {})), + ); renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); }); it("does not render page header while loading", () => { - sdk.getStats.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/stats", () => new Promise(() => {})), + ); renderPage(); expect(screen.queryByText("System Overview")).not.toBeInTheDocument(); }); it("does not render stat cards while loading", () => { - sdk.getStats.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/stats", () => new Promise(() => {})), + ); renderPage(); expect(screen.queryByText("Registered Users")).not.toBeInTheDocument(); }); @@ -65,7 +68,11 @@ describe("AdminDashboard", () => { describe("error state", () => { it("renders error message with role='alert'", async () => { - sdk.getStats.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/stats", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); @@ -73,7 +80,11 @@ describe("AdminDashboard", () => { }); it("displays the expected error message", async () => { - sdk.getStats.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/stats", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); await waitFor(() => { expect( @@ -83,7 +94,11 @@ describe("AdminDashboard", () => { }); it("does not render stat cards on stats error", async () => { - sdk.getStats.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/stats", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); @@ -92,7 +107,11 @@ describe("AdminDashboard", () => { }); it("does not render sessions table on stats error", async () => { - sdk.getStats.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/stats", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); @@ -191,7 +210,11 @@ describe("AdminDashboard", () => { describe("success state — partial stats response", () => { it("renders 0 for each missing stat field", async () => { - sdk.getStats.mockResolvedValue(mockSdkResponse({ registered_users: 10 })); + server.use( + http.get("*/admin/api/stats", () => + HttpResponse.json({ registered_users: 10 }), + ), + ); renderPage(); await waitFor(() => { expect(screen.getByText("10")).toBeInTheDocument(); @@ -201,7 +224,9 @@ describe("AdminDashboard", () => { }); it("renders all zeros when stats is an empty object", async () => { - sdk.getStats.mockResolvedValue(mockSdkResponse({})); + server.use( + http.get("*/admin/api/stats", () => HttpResponse.json({})), + ); renderPage(); await waitFor(() => { expect(screen.getByText("System Overview")).toBeInTheDocument(); diff --git a/ui/apps/console/src/pages/admin/__tests__/License.test.tsx b/ui/apps/console/src/pages/admin/__tests__/License.test.tsx index 348e0ffee52..1ed23010a99 100644 --- a/ui/apps/console/src/pages/admin/__tests__/License.test.tsx +++ b/ui/apps/console/src/pages/admin/__tests__/License.test.tsx @@ -2,19 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse, type JsonBodyType } from "msw"; +import { server } from "@/tests/msw"; import AdminLicense from "../License"; import { ClipboardProvider } from "@/components/common/ClipboardProvider"; -import { mockSdkResponse, makeSdkError } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { useAuthStore } from "@/stores/authStore"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getLicense: vi.fn(), - sendLicense: vi.fn(), - }), -); - Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, }); @@ -52,6 +46,12 @@ const gracePeriodLicense = { }; const regionalLicense = { ...validLicense, allowed_regions: ["BR", "US"] }; +function setLicense(data: JsonBodyType) { + server.use( + http.get("*/admin/api/license", () => HttpResponse.json(data)), + ); +} + function renderPage() { const result = render( @@ -69,14 +69,21 @@ function renderPage() { beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ isAdmin: true }); - sdk.getLicense.mockResolvedValue(mockSdkResponse({})); - sdk.sendLicense.mockResolvedValue(mockSdkResponse(undefined)); + server.use( + http.get("*/admin/api/license", () => HttpResponse.json({})), + http.post( + "*/admin/api/license", + () => new HttpResponse(null, { status: 204 }), + ), + ); }); describe("AdminLicense", () => { describe("loading state", () => { it("renders spinner with role='status'", () => { - sdk.getLicense.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/license", () => new Promise(() => {})), + ); renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); }); @@ -84,7 +91,11 @@ describe("AdminLicense", () => { describe("error state", () => { it("renders error message with role='alert' for non-400 errors", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect( @@ -93,7 +104,11 @@ describe("AdminLicense", () => { }); it("shows no-license info alert and upload section when 400 (no license stored)", async () => { - sdk.getLicense.mockRejectedValue(makeSdkError(400)); + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 400 }), + ), + ); renderPage(); expect( await screen.findByText("You do not have an installed license"), @@ -124,7 +139,7 @@ describe("AdminLicense", () => { }); it("shows info alert when about_to_expire", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(aboutToExpireLicense)); + setLicense(aboutToExpireLicense); renderPage(); expect( await screen.findByText("Your license is about to expire!"), @@ -132,14 +147,14 @@ describe("AdminLicense", () => { }); it("shows warning when expired + grace period", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(gracePeriodLicense)); + setLicense(gracePeriodLicense); renderPage(); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect(screen.getByText(/grace period/i)).toBeInTheDocument(); }); it("shows error when expired without grace period", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(expiredLicense)); + setLicense(expiredLicense); renderPage(); expect( await screen.findByText("Your license has expired!"), @@ -147,7 +162,7 @@ describe("AdminLicense", () => { }); it("shows no alert when license is valid", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(validLicense)); + setLicense(validLicense); renderPage(); await screen.findByText("License Information"); expect( @@ -168,7 +183,7 @@ describe("AdminLicense", () => { }); it("renders dates formatted correctly", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(validLicense)); + setLicense(validLicense); renderPage(); await screen.findByText("License Information"); const jan2024 = screen.getAllByText("Jan 1, 2024"); @@ -178,7 +193,7 @@ describe("AdminLicense", () => { it("shows 'Now' for -1 timestamps", async () => { const licenseWithNow = { ...validLicense, issued_at: -1, starts_at: -1 }; - sdk.getLicense.mockResolvedValue(mockSdkResponse(licenseWithNow)); + setLicense(licenseWithNow); renderPage(); await screen.findByText("License Information"); const nowElements = screen.getAllByText("Now"); @@ -186,13 +201,13 @@ describe("AdminLicense", () => { }); it("shows 'Global' when allowed_regions is empty", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(validLicense)); + setLicense(validLicense); renderPage(); expect(await screen.findByText("Global")).toBeInTheDocument(); }); it("shows region list when regions are non-empty", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(regionalLicense)); + setLicense(regionalLicense); renderPage(); expect(await screen.findByText("BR, US")).toBeInTheDocument(); }); @@ -200,7 +215,7 @@ describe("AdminLicense", () => { describe("license owner", () => { it("displays customer fields", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(validLicense)); + setLicense(validLicense); renderPage(); await screen.findByText("License Information"); expect(screen.getByText("cust-xxx")).toBeInTheDocument(); @@ -210,7 +225,7 @@ describe("AdminLicense", () => { }); it("renders copy button for customer ID", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(validLicense)); + setLicense(validLicense); renderPage(); await screen.findByText("License Information"); expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); @@ -219,13 +234,13 @@ describe("AdminLicense", () => { describe("license features", () => { it("shows 'Unlimited' for devices = -1", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(validLicense)); + setLicense(validLicense); renderPage(); expect(await screen.findByText("Unlimited")).toBeInTheDocument(); }); it("renders check icon for enabled boolean features", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(validLicense)); + setLicense(validLicense); renderPage(); await screen.findByText("License Information"); const included = screen.getAllByLabelText("Included"); @@ -233,7 +248,7 @@ describe("AdminLicense", () => { }); it("renders cross icon for disabled boolean features", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(validLicense)); + setLicense(validLicense); renderPage(); await screen.findByText("License Information"); const notIncluded = screen.getAllByLabelText("Not included"); @@ -241,7 +256,7 @@ describe("AdminLicense", () => { }); it("does not render login_link or reports features", async () => { - sdk.getLicense.mockResolvedValue(mockSdkResponse(validLicense)); + setLicense(validLicense); renderPage(); await screen.findByText("License Information"); expect(screen.queryByText("Login link")).not.toBeInTheDocument(); @@ -294,8 +309,7 @@ describe("AdminLicense", () => { ).toBeDisabled(); }); - it("calls sendLicense when upload button is clicked with valid file", async () => { - sdk.sendLicense.mockResolvedValue(mockSdkResponse(undefined)); + it("uploads license when upload button is clicked with valid file", async () => { const { fileInput } = renderPage(); await screen.findByText("You do not have an installed license"); const validFile = new File(["license-content"], "license.dat", { @@ -307,17 +321,14 @@ describe("AdminLicense", () => { }); expect(uploadBtn).not.toBeDisabled(); await userEvent.click(uploadBtn); - await waitFor(() => { - expect(sdk.sendLicense).toHaveBeenCalledWith( - expect.objectContaining({ - body: { file: validFile }, - }), - ); - }); + await waitFor(() => + expect( + screen.getByText("License uploaded successfully."), + ).toBeInTheDocument(), + ); }); it("shows success message after upload", async () => { - sdk.sendLicense.mockResolvedValue(mockSdkResponse(undefined)); const { fileInput } = renderPage(); await screen.findByText("You do not have an installed license"); const validFile = new File(["license-content"], "license.dat", { @@ -335,7 +346,11 @@ describe("AdminLicense", () => { }); it("shows error message on failed upload", async () => { - sdk.sendLicense.mockRejectedValue(new Error("upload failed")); + server.use( + http.post("*/admin/api/license", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const { fileInput } = renderPage(); await screen.findByText("You do not have an installed license"); const validFile = new File(["license-content"], "license.dat", { diff --git a/ui/apps/console/src/pages/admin/__tests__/Sessions.test.tsx b/ui/apps/console/src/pages/admin/__tests__/Sessions.test.tsx index fff403c9e75..f4160677582 100644 --- a/ui/apps/console/src/pages/admin/__tests__/Sessions.test.tsx +++ b/ui/apps/console/src/pages/admin/__tests__/Sessions.test.tsx @@ -2,8 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import AdminSessions from "../Sessions"; -import { makeSdkError, paginatedResponse } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { mockSession } from "@/tests/factories"; import { LocationProbe } from "@/tests/LocationProbe"; @@ -16,11 +17,19 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - getSessionsAdmin: vi.fn(), - }), -); +let lastRequestUrl: URL | null; + +function setSessions( + sessions: ReturnType[], + total?: number, +) { + server.use( + http.get("*/admin/api/sessions", ({ request }) => { + lastRequestUrl = new URL(request.url); + return jsonWithTotal(sessions, total ?? sessions.length); + }), + ); +} function renderPage(initialEntries: string[] = ["/"]) { let lastSearch = ""; @@ -40,20 +49,25 @@ function renderPage(initialEntries: string[] = ["/"]) { beforeEach(() => { vi.clearAllMocks(); + lastRequestUrl = null; useAuthStore.setState({ isAdmin: true }); - sdk.getSessionsAdmin.mockResolvedValue(paginatedResponse([])); + setSessions([]); }); describe("AdminSessions", () => { describe("loading state", () => { it("shows a loading spinner while fetching", () => { - sdk.getSessionsAdmin.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/sessions", () => new Promise(() => {})), + ); renderPage(); expect(screen.getByText(/loading sessions/i)).toBeInTheDocument(); }); it("does not render session rows while loading", () => { - sdk.getSessionsAdmin.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/sessions", () => new Promise(() => {})), + ); renderPage(); expect(screen.queryByText("root")).not.toBeInTheDocument(); }); @@ -68,13 +82,21 @@ describe("AdminSessions", () => { describe("error state", () => { it("renders the error banner with role='alert'", async () => { - sdk.getSessionsAdmin.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/sessions", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); expect(await screen.findByRole("alert")).toBeInTheDocument(); }); it("displays the console's own copy for the status in the banner", async () => { - sdk.getSessionsAdmin.mockRejectedValue(makeSdkError(403)); + server.use( + http.get("*/admin/api/sessions", () => + HttpResponse.json({}, { status: 403 }), + ), + ); renderPage(); const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent("You do not have permission to do this."); @@ -89,46 +111,36 @@ describe("AdminSessions", () => { describe("session rows", () => { it("renders one row per session", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse([ - mockSession({ uid: "session-1", username: "root" }), - mockSession({ uid: "session-2", username: "admin" }), - ]), - ); + setSessions([ + mockSession({ uid: "session-1", username: "root" }), + mockSession({ uid: "session-2", username: "admin" }), + ]); renderPage(); expect(await screen.findByText("root")).toBeInTheDocument(); expect(screen.getByText("admin")).toBeInTheDocument(); }); it("renders the device name via DeviceChip", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse([mockSession()]), - ); + setSessions([mockSession()]); renderPage(); expect(await screen.findByText("my-device")).toBeInTheDocument(); }); it("renders the truncated session uid", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse([mockSession({ uid: "abcdef1234567890" })]), - ); + setSessions([mockSession({ uid: "abcdef1234567890" })]); renderPage(); expect(await screen.findByText("abcdef1234")).toBeInTheDocument(); }); it("renders the IP address", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse([mockSession({ ip_address: "10.0.0.1" })]), - ); + setSessions([mockSession({ ip_address: "10.0.0.1" })]); renderPage(); expect(await screen.findByText("10.0.0.1")).toBeInTheDocument(); }); it("navigates to session detail when a row is clicked", async () => { const user = userEvent.setup(); - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse([mockSession({ uid: "session-abc" })]), - ); + setSessions([mockSession({ uid: "session-abc" })]); renderPage(); await user.click(await screen.findByText("root")); @@ -139,9 +151,7 @@ describe("AdminSessions", () => { describe("active indicator", () => { it("renders a green dot for active sessions", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse([mockSession({ active: true })]), - ); + setSessions([mockSession({ active: true })]); renderPage(); await screen.findByText("root"); const dot = document.querySelector(".bg-accent-green"); @@ -149,9 +159,7 @@ describe("AdminSessions", () => { }); it("renders a muted dot for inactive sessions", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse([mockSession({ active: false })]), - ); + setSessions([mockSession({ active: false })]); renderPage(); await screen.findByText("root"); const dot = document.querySelector(".bg-text-muted\\/40"); @@ -161,17 +169,13 @@ describe("AdminSessions", () => { describe("authentication indicator", () => { it("renders the 'Authenticated' shield for authenticated sessions", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse([mockSession({ authenticated: true })]), - ); + setSessions([mockSession({ authenticated: true })]); renderPage(); expect(await screen.findByTitle("Authenticated")).toBeInTheDocument(); }); it("renders the 'Not authenticated' shield for unauthenticated sessions", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse([mockSession({ authenticated: false })]), - ); + setSessions([mockSession({ authenticated: false })]); renderPage(); await screen.findByText("root"); expect(screen.getAllByTitle("Not authenticated").length).toBeGreaterThan( @@ -180,9 +184,7 @@ describe("AdminSessions", () => { }); it("shows the warning icon in the username cell for unauthenticated sessions", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse([mockSession({ authenticated: false })]), - ); + setSessions([mockSession({ authenticated: false })]); renderPage(); await screen.findByText("root"); expect( @@ -193,11 +195,7 @@ describe("AdminSessions", () => { describe("device fallback", () => { it("shows the truncated device_uid when device object is missing", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse([ - mockSession({ device: null, device_uid: "abcd1234efgh" }), - ]), - ); + setSessions([mockSession({ device: null, device_uid: "abcd1234efgh" })]); renderPage(); expect(await screen.findByText("abcd1234")).toBeInTheDocument(); }); @@ -205,13 +203,11 @@ describe("AdminSessions", () => { describe("pagination", () => { it("renders pagination when totalCount > perPage", async () => { - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse( - Array.from({ length: 10 }, (_, i) => - mockSession({ uid: `session-${i}`, username: `user-${i}` }), - ), - 25, + setSessions( + Array.from({ length: 10 }, (_, i) => + mockSession({ uid: `session-${i}`, username: `user-${i}` }), ), + 25, ); renderPage(); expect(await screen.findByText(/25/)).toBeInTheDocument(); @@ -219,25 +215,19 @@ describe("AdminSessions", () => { }); describe("URL hydration", () => { - it("passes page=3 to the SDK when URL has ?page=3", async () => { + it("passes page=3 to the API when URL has ?page=3", async () => { renderPage(["/?page=3"]); await waitFor(() => { - expect(sdk.getSessionsAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 3 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); }); }); - it("passes page=1 to the SDK when URL has no page param", async () => { + it("passes page=1 to the API when URL has no page param", async () => { renderPage(["/"]); await waitFor(() => { - expect(sdk.getSessionsAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); }); }); @@ -245,13 +235,11 @@ describe("AdminSessions", () => { describe("URL writes", () => { it("writes ?page=2 to the URL when the user clicks Next page", async () => { const user = userEvent.setup(); - sdk.getSessionsAdmin.mockResolvedValue( - paginatedResponse( - Array.from({ length: 10 }, (_, i) => - mockSession({ uid: `s-${i}`, username: `u-${i}` }), - ), - 30, + setSessions( + Array.from({ length: 10 }, (_, i) => + mockSession({ uid: `s-${i}`, username: `u-${i}` }), ), + 30, ); const { getSearch } = renderPage(); diff --git a/ui/apps/console/src/pages/admin/announcements/AnnouncementDetails.tsx b/ui/apps/console/src/pages/admin/announcements/AnnouncementDetails.tsx index 29cfa737bde..16d6e16a016 100644 --- a/ui/apps/console/src/pages/admin/announcements/AnnouncementDetails.tsx +++ b/ui/apps/console/src/pages/admin/announcements/AnnouncementDetails.tsx @@ -6,7 +6,7 @@ import { PencilSquareIcon, TrashIcon, } from "@heroicons/react/24/outline"; -import { useAdminAnnouncement } from "@/hooks/useAdminAnnouncements"; +import { useGetAnnouncementAdmin } from "@/client/api"; import Breadcrumb from "@/components/common/Breadcrumb"; import CopyButton from "@/components/common/CopyButton"; import DeleteAnnouncementDialog from "./DeleteAnnouncementDialog"; @@ -23,13 +23,13 @@ const LABEL = * One announcement as published, with the ways to edit or delete it. */ export default function AnnouncementDetails() { - const { uuid } = useParams<{ uuid: string }>(); + const { uuid = "" } = useParams<{ uuid: string }>(); const navigate = useNavigate(); const { data: announcement, isLoading, error, - } = useAdminAnnouncement(uuid ?? ""); + } = useGetAnnouncementAdmin(uuid, { query: { enabled: !!uuid } }); const [deleteOpen, setDeleteOpen] = useState(false); if (isLoading) { diff --git a/ui/apps/console/src/pages/admin/announcements/DeleteAnnouncementDialog.tsx b/ui/apps/console/src/pages/admin/announcements/DeleteAnnouncementDialog.tsx index 474278d6df3..760a53b289a 100644 --- a/ui/apps/console/src/pages/admin/announcements/DeleteAnnouncementDialog.tsx +++ b/ui/apps/console/src/pages/admin/announcements/DeleteAnnouncementDialog.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useAdminDeleteAnnouncement } from "@/hooks/useAdminAnnouncementMutations"; +import { useDeleteAnnouncement } from "@/client/api"; import ConfirmDialog from "@/components/common/ConfirmDialog"; interface DeleteAnnouncementDialogProps { @@ -19,7 +19,7 @@ export default function DeleteAnnouncementDialog({ announcement, onDeleted, }: DeleteAnnouncementDialogProps) { - const deleteAnnouncement = useAdminDeleteAnnouncement(); + const deleteAnnouncement = useDeleteAnnouncement(); const [error, setError] = useState(""); return ( @@ -34,7 +34,7 @@ export default function DeleteAnnouncementDialog({ setError(""); try { await deleteAnnouncement.mutateAsync({ - path: { uuid: announcement.uuid }, + uuid: announcement.uuid, }); onClose(); onDeleted?.(); diff --git a/ui/apps/console/src/pages/admin/announcements/EditAnnouncement.tsx b/ui/apps/console/src/pages/admin/announcements/EditAnnouncement.tsx index 209026ce06f..75e2ad6cdad 100644 --- a/ui/apps/console/src/pages/admin/announcements/EditAnnouncement.tsx +++ b/ui/apps/console/src/pages/admin/announcements/EditAnnouncement.tsx @@ -3,8 +3,7 @@ import { Link, useNavigate, useParams } from "react-router-dom"; import { useForm, useController, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { MegaphoneIcon } from "@heroicons/react/24/outline"; -import { useAdminAnnouncement } from "@/hooks/useAdminAnnouncements"; -import { useAdminUpdateAnnouncement } from "@/hooks/useAdminAnnouncementMutations"; +import { useGetAnnouncementAdmin, useUpdateAnnouncement } from "@/client/api"; import AnnouncementEditor from "./AnnouncementEditor"; import Breadcrumb from "@/components/common/Breadcrumb"; import { FormInputField } from "@/components/common/fields/rhf"; @@ -22,14 +21,14 @@ import { * Edits an existing announcement. Changes are live as soon as they are saved. */ export default function EditAnnouncement() { - const { uuid } = useParams<{ uuid: string }>(); + const { uuid = "" } = useParams<{ uuid: string }>(); const navigate = useNavigate(); const { data: announcement, isLoading: isFetching, error: fetchError, - } = useAdminAnnouncement(uuid ?? ""); - const updateAnnouncement = useAdminUpdateAnnouncement(); + } = useGetAnnouncementAdmin(uuid, { query: { enabled: !!uuid } }); + const updateAnnouncement = useUpdateAnnouncement(); const values = useMemo( () => ({ @@ -62,8 +61,8 @@ export default function EditAnnouncement() { clearErrors("root"); try { await updateAnnouncement.mutateAsync({ - path: { uuid }, - body: buildAnnouncementBody(formValues), + uuid, + data: buildAnnouncementBody(formValues), }); void navigate(`/admin/announcements/${uuid}`); } catch { diff --git a/ui/apps/console/src/pages/admin/announcements/NewAnnouncement.tsx b/ui/apps/console/src/pages/admin/announcements/NewAnnouncement.tsx index b2ae95203be..636ba894876 100644 --- a/ui/apps/console/src/pages/admin/announcements/NewAnnouncement.tsx +++ b/ui/apps/console/src/pages/admin/announcements/NewAnnouncement.tsx @@ -1,7 +1,7 @@ import { Link, useNavigate } from "react-router-dom"; import { useForm, useController, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useAdminCreateAnnouncement } from "@/hooks/useAdminAnnouncementMutations"; +import { useCreateAnnouncement } from "@/client/api"; import AnnouncementEditor from "./AnnouncementEditor"; import Breadcrumb from "@/components/common/Breadcrumb"; import { FormInputField } from "@/components/common/fields/rhf"; @@ -19,7 +19,7 @@ import { */ export default function NewAnnouncement() { const navigate = useNavigate(); - const createAnnouncement = useAdminCreateAnnouncement(); + const createAnnouncement = useCreateAnnouncement(); const form = useForm({ mode: "onChange", @@ -42,7 +42,7 @@ export default function NewAnnouncement() { clearErrors("root"); try { await createAnnouncement.mutateAsync({ - body: buildAnnouncementBody(values), + data: buildAnnouncementBody(values), }); void navigate("/admin/announcements"); } catch { diff --git a/ui/apps/console/src/pages/admin/announcements/__tests__/AdminAnnouncements.test.tsx b/ui/apps/console/src/pages/admin/announcements/__tests__/AdminAnnouncements.test.tsx index 975e62deb93..b88383b7b30 100644 --- a/ui/apps/console/src/pages/admin/announcements/__tests__/AdminAnnouncements.test.tsx +++ b/ui/apps/console/src/pages/admin/announcements/__tests__/AdminAnnouncements.test.tsx @@ -2,9 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import AdminAnnouncements from "../index"; -import type { AnnouncementShort } from "@/client"; -import { makeSdkError, paginatedResponse } from "@/tests/sdk"; +import type { AnnouncementShort } from "@/client/model"; import { createTestWrapper } from "@/tests/wrapper"; import { mockAnnouncement } from "@/tests/factories"; import { useAuthStore } from "@/stores/authStore"; @@ -16,12 +17,6 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - listAnnouncementsAdmin: vi.fn(), - }), -); - vi.mock("../DeleteAnnouncementDialog", () => ({ default: ({ open, @@ -54,6 +49,20 @@ vi.mock("../DeleteAnnouncementDialog", () => ({ }, })); +let lastRequestUrl: URL | null; + +function setAnnouncements( + items: ReturnType[], + total?: number, +) { + server.use( + http.get("*/admin/api/announcements", ({ request }) => { + lastRequestUrl = new URL(request.url); + return jsonWithTotal(items, total ?? items.length); + }), + ); +} + function renderPage(initialEntries: string[] = ["/"]) { return render( @@ -66,8 +75,9 @@ function renderPage(initialEntries: string[] = ["/"]) { describe("AdminAnnouncements", () => { beforeEach(() => { vi.clearAllMocks(); + lastRequestUrl = null; useAuthStore.setState({ isAdmin: true }); - sdk.listAnnouncementsAdmin.mockResolvedValue(paginatedResponse([])); + setAnnouncements([]); }); describe("rendering", () => { @@ -111,13 +121,17 @@ describe("AdminAnnouncements", () => { describe("loading state", () => { it("renders the loading spinner with role='status'", () => { - sdk.listAnnouncementsAdmin.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/announcements", () => new Promise(() => {})), + ); renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); }); it("renders 'Loading announcements...' text while loading", () => { - sdk.listAnnouncementsAdmin.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/announcements", () => new Promise(() => {})), + ); renderPage(); expect(screen.getByText("Loading announcements...")).toBeInTheDocument(); }); @@ -142,53 +156,45 @@ describe("AdminAnnouncements", () => { describe("announcement rows", () => { it("renders a row for each returned announcement", async () => { - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse([ - mockAnnouncement({ - uuid: "uuid-a1b2", - title: "Alpha Announcement", - }), - mockAnnouncement({ - uuid: "uuid-c3d4", - title: "Beta Announcement", - }), - ]), - ); + setAnnouncements([ + mockAnnouncement({ + uuid: "uuid-a1b2", + title: "Alpha Announcement", + }), + mockAnnouncement({ + uuid: "uuid-c3d4", + title: "Beta Announcement", + }), + ]); renderPage(); expect(await screen.findByText("Alpha Announcement")).toBeInTheDocument(); expect(screen.getByText("Beta Announcement")).toBeInTheDocument(); }); it("renders a truncated UUID chip for each row", async () => { - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse( - [ - mockAnnouncement({ - uuid: "abcdef12-0000-0000-0000-000000000000", - }), - ], - 1, - ), + setAnnouncements( + [ + mockAnnouncement({ + uuid: "abcdef12-0000-0000-0000-000000000000", + }), + ], + 1, ); renderPage(); expect(await screen.findByText("abcdef12")).toBeInTheDocument(); }); it("renders a formatted date for each row", async () => { - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse([ - mockAnnouncement({ date: "2024-06-01T10:00:00.000Z" }), - ]), - ); + setAnnouncements([ + mockAnnouncement({ date: "2024-06-01T10:00:00.000Z" }), + ]); renderPage(); const dateCell = await screen.findByText(/\d{4}/); expect(dateCell).toBeInTheDocument(); }); it("renders an edit button for each row", async () => { - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse([mockAnnouncement({ title: "My Announcement" })]), - ); + setAnnouncements([mockAnnouncement({ title: "My Announcement" })]); renderPage(); expect( await screen.findByRole("button", { name: "Edit My Announcement" }), @@ -196,9 +202,7 @@ describe("AdminAnnouncements", () => { }); it("renders a delete button for each row", async () => { - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse([mockAnnouncement({ title: "My Announcement" })]), - ); + setAnnouncements([mockAnnouncement({ title: "My Announcement" })]); renderPage(); expect( await screen.findByRole("button", { name: "Delete My Announcement" }), @@ -209,16 +213,14 @@ describe("AdminAnnouncements", () => { describe("navigation", () => { it("navigates to the announcement detail page when a row is clicked", async () => { const user = userEvent.setup(); - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse( - [ - mockAnnouncement({ - uuid: "uuid-nav1", - title: "Clickable Announcement", - }), - ], - 1, - ), + setAnnouncements( + [ + mockAnnouncement({ + uuid: "uuid-nav1", + title: "Clickable Announcement", + }), + ], + 1, ); renderPage(); @@ -230,16 +232,14 @@ describe("AdminAnnouncements", () => { it("navigates to the edit page when the edit button is clicked", async () => { const user = userEvent.setup(); - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse( - [ - mockAnnouncement({ - uuid: "uuid-edit1", - title: "Editable Announcement", - }), - ], - 1, - ), + setAnnouncements( + [ + mockAnnouncement({ + uuid: "uuid-edit1", + title: "Editable Announcement", + }), + ], + 1, ); renderPage(); @@ -255,11 +255,9 @@ describe("AdminAnnouncements", () => { it("does not navigate to the detail page when edit button is clicked", async () => { const user = userEvent.setup(); - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse([ - mockAnnouncement({ uuid: "uuid-edit2", title: "Edit Only" }), - ]), - ); + setAnnouncements([ + mockAnnouncement({ uuid: "uuid-edit2", title: "Edit Only" }), + ]); renderPage(); await user.click( @@ -283,9 +281,7 @@ describe("AdminAnnouncements", () => { describe("delete action", () => { it("opens the DeleteAnnouncementDialog when delete button is clicked", async () => { const user = userEvent.setup(); - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse([mockAnnouncement({ title: "Target Announcement" })]), - ); + setAnnouncements([mockAnnouncement({ title: "Target Announcement" })]); renderPage(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); @@ -303,9 +299,7 @@ describe("AdminAnnouncements", () => { it("closes the DeleteAnnouncementDialog when cancel is clicked inside it", async () => { const user = userEvent.setup(); - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse([mockAnnouncement({ title: "Target Announcement" })]), - ); + setAnnouncements([mockAnnouncement({ title: "Target Announcement" })]); renderPage(); await user.click( @@ -324,9 +318,7 @@ describe("AdminAnnouncements", () => { it("does not navigate when delete button is clicked (stopPropagation)", async () => { const user = userEvent.setup(); - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse([mockAnnouncement({ title: "No Nav Announcement" })]), - ); + setAnnouncements([mockAnnouncement({ title: "No Nav Announcement" })]); renderPage(); await user.click( @@ -340,14 +332,22 @@ describe("AdminAnnouncements", () => { }); describe("error state", () => { - it("renders an error alert when the SDK returns an error", async () => { - sdk.listAnnouncementsAdmin.mockRejectedValue(makeSdkError(500)); + it("renders an error alert when the API returns an error", async () => { + server.use( + http.get("*/admin/api/announcements", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); expect(await screen.findByRole("alert")).toBeInTheDocument(); }); it("renders the error message text", async () => { - sdk.listAnnouncementsAdmin.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/announcements", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); expect( await screen.findByText("Something went wrong on our side. Try again."), @@ -357,9 +357,7 @@ describe("AdminAnnouncements", () => { describe("pagination", () => { it("does not render pagination when there is only one page", async () => { - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse([mockAnnouncement()]), - ); + setAnnouncements([mockAnnouncement()]); renderPage(); await screen.findByText("Welcome to ShellHub"); expect( @@ -374,9 +372,7 @@ describe("AdminAnnouncements", () => { const manyAnnouncements = Array.from({ length: 10 }, (_, i) => mockAnnouncement({ uuid: `uuid-${i}`, title: `Ann ${i}` }), ); - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse(manyAnnouncements, 25), - ); + setAnnouncements(manyAnnouncements, 25); renderPage(); expect( await screen.findByRole("button", { name: "Previous page" }), @@ -390,47 +386,37 @@ describe("AdminAnnouncements", () => { const manyAnnouncements = Array.from({ length: 10 }, (_, i) => mockAnnouncement({ uuid: `uuid-${i}`, title: `Ann ${i}` }), ); - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse(manyAnnouncements, 25), - ); + setAnnouncements(manyAnnouncements, 25); renderPage(); expect(await screen.findByText("25 announcements")).toBeInTheDocument(); }); }); describe("URL hydration (usePaginatedListState)", () => { - it("passes page=2 to the SDK when URL has ?page=2", async () => { + it("passes page=2 to the API when URL has ?page=2", async () => { renderPage(["/?page=2"]); await waitFor(() => { - expect(sdk.listAnnouncementsAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 2 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); }); }); - it("passes page=1 to the SDK when URL has no page param", async () => { + it("passes page=1 to the API when URL has no page param", async () => { renderPage(["/"]); await waitFor(() => { - expect(sdk.listAnnouncementsAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); }); }); describe("URL writes (usePaginatedListState)", () => { - it("passes page=2 to the SDK when the user clicks Next page", async () => { + it("passes page=2 to the API when the user clicks Next page", async () => { const user = userEvent.setup(); const manyAnnouncements = Array.from({ length: 10 }, (_, i) => mockAnnouncement({ uuid: `uuid-${i}`, title: `Ann ${i}` }), ); - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse(manyAnnouncements, 30), - ); + setAnnouncements(manyAnnouncements, 30); renderPage(); await screen.findByText("Ann 0"); @@ -438,11 +424,7 @@ describe("AdminAnnouncements", () => { await user.click(screen.getByRole("button", { name: "Next page" })); await waitFor(() => { - expect(sdk.listAnnouncementsAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 2 }), - }), - ); + expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); }); }); }); @@ -451,18 +433,13 @@ describe("AdminAnnouncements", () => { it("decrements page from 2 to 1 via URL when deleting the last item on a page", async () => { const user = userEvent.setup(); - sdk.listAnnouncementsAdmin.mockResolvedValue( - paginatedResponse([mockAnnouncement({ title: "Last Item" })], 11), - ); + setAnnouncements([mockAnnouncement({ title: "Last Item" })], 11); renderPage(["/?page=2"]); await waitFor(() => { - expect(sdk.listAnnouncementsAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 2 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); }); await user.click( @@ -470,16 +447,13 @@ describe("AdminAnnouncements", () => { ); await waitFor(() => screen.getByRole("dialog")); - sdk.listAnnouncementsAdmin.mockClear(); + lastRequestUrl = null; await user.click(screen.getByRole("button", { name: "Confirm delete" })); await waitFor(() => { - expect(sdk.listAnnouncementsAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); }); }); diff --git a/ui/apps/console/src/pages/admin/announcements/__tests__/DeleteAnnouncementDialog.test.tsx b/ui/apps/console/src/pages/admin/announcements/__tests__/DeleteAnnouncementDialog.test.tsx index 2db4da92670..f0b9167b086 100644 --- a/ui/apps/console/src/pages/admin/announcements/__tests__/DeleteAnnouncementDialog.test.tsx +++ b/ui/apps/console/src/pages/admin/announcements/__tests__/DeleteAnnouncementDialog.test.tsx @@ -1,16 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import DeleteAnnouncementDialog from "../DeleteAnnouncementDialog"; -const sdk = vi.hoisted(() => - mockSdkGen({ - deleteAnnouncement: vi.fn(), - }), -); - vi.mock("@/components/common/ConfirmDialog", async () => ({ default: (await import("@/tests/mocks")).MockConfirmDialog, })); @@ -24,7 +19,12 @@ const Wrapper = createTestWrapper(); beforeEach(() => { vi.clearAllMocks(); - sdk.deleteAnnouncement.mockResolvedValue(mockSdkResponse(undefined)); + server.use( + http.delete( + "*/admin/api/announcements/:uuid", + () => new HttpResponse(null, { status: 204 }), + ), + ); }); function renderDialog( @@ -100,20 +100,6 @@ describe("DeleteAnnouncementDialog", () => { }); describe("confirm — success", () => { - it("calls deleteAnnouncement with the correct uuid", async () => { - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); - - await waitFor(() => { - expect(sdk.deleteAnnouncement).toHaveBeenCalledWith( - expect.objectContaining({ - path: { uuid: "ann-uuid-1234" }, - }), - ); - }); - }); - it("calls onDeleted callback after successful deletion", async () => { const { onDeleted } = renderDialog(); @@ -154,20 +140,11 @@ describe("DeleteAnnouncementDialog", () => { describe("confirm — error handling", () => { it("shows generic error message on failure", async () => { - sdk.deleteAnnouncement.mockRejectedValue(new Error("server error")); - renderDialog(); - - await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); - - await waitFor(() => { - expect( - screen.getByText(/failed to delete announcement/i), - ).toBeInTheDocument(); - }); - }); - - it("shows error for SDK errors", async () => { - sdk.deleteAnnouncement.mockRejectedValue({ status: 500 }); + server.use( + http.delete("*/admin/api/announcements/:uuid", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); @@ -180,7 +157,11 @@ describe("DeleteAnnouncementDialog", () => { }); it("does not call onDeleted when deletion fails", async () => { - sdk.deleteAnnouncement.mockRejectedValue(new Error("server error")); + server.use( + http.delete("*/admin/api/announcements/:uuid", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const { onDeleted } = renderDialog(); await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); @@ -190,7 +171,11 @@ describe("DeleteAnnouncementDialog", () => { }); it("does not call onClose when deletion fails", async () => { - sdk.deleteAnnouncement.mockRejectedValue(new Error("server error")); + server.use( + http.delete("*/admin/api/announcements/:uuid", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const { onClose } = renderDialog(); await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); @@ -200,7 +185,11 @@ describe("DeleteAnnouncementDialog", () => { }); it("clears the error message on subsequent close after failure", async () => { - sdk.deleteAnnouncement.mockRejectedValue(new Error("server error")); + server.use( + http.delete("*/admin/api/announcements/:uuid", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const { onClose } = renderDialog(); await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); @@ -218,12 +207,6 @@ describe("DeleteAnnouncementDialog", () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it("does not call deleteAnnouncement when Cancel is clicked", async () => { - renderDialog(); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(sdk.deleteAnnouncement).not.toHaveBeenCalled(); - }); - it("does not call onDeleted when Cancel is clicked", async () => { const { onDeleted } = renderDialog(); await userEvent.click(screen.getByRole("button", { name: /cancel/i })); @@ -236,14 +219,6 @@ describe("DeleteAnnouncementDialog", () => { renderDialog({ announcement: null }); expect(screen.queryByText("Test Announcement")).not.toBeInTheDocument(); }); - - it("does not call deleteAnnouncement when confirmed with null announcement", async () => { - renderDialog({ announcement: null }); - await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); - await waitFor(() => - expect(sdk.deleteAnnouncement).not.toHaveBeenCalled(), - ); - }); }); describe("optional onDeleted callback", () => { diff --git a/ui/apps/console/src/pages/admin/announcements/index.tsx b/ui/apps/console/src/pages/admin/announcements/index.tsx index 716b6372577..888b793ca21 100644 --- a/ui/apps/console/src/pages/admin/announcements/index.tsx +++ b/ui/apps/console/src/pages/admin/announcements/index.tsx @@ -6,12 +6,13 @@ import { PencilSquareIcon, PlusIcon, } from "@heroicons/react/24/outline"; -import { useAdminAnnouncements } from "@/hooks/useAdminAnnouncements"; +import { useListAnnouncementsAdmin } from "@/client/api"; +import { totalCount } from "@/api/pagination"; import PageHeader from "@/components/common/PageHeader"; import DataTable, { type Column } from "@/components/common/DataTable"; import DeleteAnnouncementDialog from "./DeleteAnnouncementDialog"; import { formatDateShort } from "@/utils/date"; -import type { AnnouncementShort } from "@/client"; +import type { AnnouncementShort } from "@/client/model"; import { Badge, Button, @@ -40,14 +41,18 @@ export default function AdminAnnouncements() { null, ); - const { announcements, totalCount, isLoading, error } = useAdminAnnouncements( - { - page: params.page, - perPage: PER_PAGE, - }, - ); + const { + data: announcements = [], + isLoading, + error, + } = useListAnnouncementsAdmin({ + page: params.page, + per_page: PER_PAGE, + order_by: "desc", + }); + const total = totalCount(announcements); - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const columns: Column[] = [ { @@ -142,7 +147,7 @@ export default function AdminAnnouncements() { loadingMessage="Loading announcements..." page={params.page} totalPages={totalPages} - totalCount={totalCount} + totalCount={total} itemLabel="announcement" onPageChange={setPage} onRowClick={(a) => void navigate(`/admin/announcements/${a.uuid}`)} diff --git a/ui/apps/console/src/pages/admin/devices/AdminDeviceDetails.tsx b/ui/apps/console/src/pages/admin/devices/AdminDeviceDetails.tsx index baa9d418130..9d54c712475 100644 --- a/ui/apps/console/src/pages/admin/devices/AdminDeviceDetails.tsx +++ b/ui/apps/console/src/pages/admin/devices/AdminDeviceDetails.tsx @@ -7,7 +7,8 @@ import { KeyIcon, } from "@heroicons/react/24/outline"; import { cn } from "@shellhub/design-system/cn"; -import { useAdminDevice } from "@/hooks/useAdminDevices"; +import { useGetDeviceAdmin } from "@/client/api"; +import { normalizeDeviceTags } from "@/utils/deviceTags"; import Breadcrumb from "@/components/common/Breadcrumb"; import DistroIcon from "@/components/common/DistroIcon"; import PlatformBadge from "@/components/common/PlatformBadge"; @@ -23,8 +24,14 @@ import { Card } from "@shellhub/design-system/primitives"; * One device, seen from the admin area, including which namespace it belongs to. */ export default function AdminDeviceDetails() { - const { uid } = useParams<{ uid: string }>(); - const { data: device, isLoading, error } = useAdminDevice(uid ?? ""); + const { uid = "" } = useParams<{ uid: string }>(); + const { + data: device, + isLoading, + error, + } = useGetDeviceAdmin(uid, { + query: { enabled: !!uid, select: normalizeDeviceTags }, + }); if (isLoading) { return ; diff --git a/ui/apps/console/src/pages/admin/devices/DeviceStatusChip.tsx b/ui/apps/console/src/pages/admin/devices/DeviceStatusChip.tsx index 9a87b9ee823..1cce27eab26 100644 --- a/ui/apps/console/src/pages/admin/devices/DeviceStatusChip.tsx +++ b/ui/apps/console/src/pages/admin/devices/DeviceStatusChip.tsx @@ -5,7 +5,7 @@ import { MinusCircleIcon, } from "@heroicons/react/24/outline"; import { cn } from "@shellhub/design-system/cn"; -import type { DeviceStatus } from "@/client"; +import type { DeviceStatus } from "@/client/model"; const STATUS_CONFIG: Record< DeviceStatus, diff --git a/ui/apps/console/src/pages/admin/devices/__tests__/AdminDeviceDetails.test.tsx b/ui/apps/console/src/pages/admin/devices/__tests__/AdminDeviceDetails.test.tsx index e2d1635f5bf..377875fe98e 100644 --- a/ui/apps/console/src/pages/admin/devices/__tests__/AdminDeviceDetails.test.tsx +++ b/ui/apps/console/src/pages/admin/devices/__tests__/AdminDeviceDetails.test.tsx @@ -1,17 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { useAuthStore } from "@/stores/authStore"; -import { mockSdkResponse, makeSdkError } from "@/tests/sdk"; import AdminDeviceDetails from "../AdminDeviceDetails"; -import type { Device } from "@/client"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - getDeviceAdmin: vi.fn(), - }), -); +import type { Device } from "@/client/model"; vi.mock("react-router-dom", async (importOriginal) => { const actual = await importOriginal(); @@ -47,6 +42,14 @@ function makeDevice(overrides: Partial = {}): Device { } as Device; } +function setDevice(overrides: Partial = {}) { + server.use( + http.get("*/admin/api/devices/:uid", () => + HttpResponse.json(makeDevice(overrides)), + ), + ); +} + function renderPage() { return render( @@ -59,13 +62,15 @@ function renderPage() { beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ isAdmin: true }); - sdk.getDeviceAdmin.mockResolvedValue(mockSdkResponse(makeDevice())); + setDevice(); }); describe("AdminDeviceDetails", () => { describe("loading state", () => { it('announces "Loading device details" while loading', () => { - sdk.getDeviceAdmin.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/devices/:uid", () => new Promise(() => {})), + ); renderPage(); expect( screen.getByRole("status", { name: "Loading device details" }), @@ -75,7 +80,11 @@ describe("AdminDeviceDetails", () => { describe("not-found / error state", () => { it('renders "Device not found" when no data and no loading', async () => { - sdk.getDeviceAdmin.mockRejectedValue(makeSdkError(404)); + server.use( + http.get("*/admin/api/devices/:uid", () => + HttpResponse.json({}, { status: 404 }), + ), + ); renderPage(); await waitFor(() => { expect(screen.getByText("Device not found")).toBeInTheDocument(); @@ -83,7 +92,11 @@ describe("AdminDeviceDetails", () => { }); it('renders "Device not found" when the query returns an error', async () => { - sdk.getDeviceAdmin.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/devices/:uid", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); await waitFor(() => { expect(screen.getByText("Device not found")).toBeInTheDocument(); @@ -91,7 +104,11 @@ describe("AdminDeviceDetails", () => { }); it('renders a "Back to devices" link in the not-found state', async () => { - sdk.getDeviceAdmin.mockRejectedValue(makeSdkError(404)); + server.use( + http.get("*/admin/api/devices/:uid", () => + HttpResponse.json({}, { status: 404 }), + ), + ); renderPage(); await waitFor(() => { expect( @@ -155,9 +172,7 @@ describe("AdminDeviceDetails", () => { }); it('renders "No tags" when device has no tags', async () => { - sdk.getDeviceAdmin.mockResolvedValue( - mockSdkResponse(makeDevice({ tags: [] })), - ); + setDevice({ tags: [] }); renderPage(); await waitFor(() => { expect(screen.getByText("No tags")).toBeInTheDocument(); @@ -165,11 +180,7 @@ describe("AdminDeviceDetails", () => { }); it("renders the public key section when present", async () => { - sdk.getDeviceAdmin.mockResolvedValue( - mockSdkResponse( - makeDevice({ public_key: "ssh-rsa AAAAB3NzaC1yc2E..." }), - ), - ); + setDevice({ public_key: "ssh-rsa AAAAB3NzaC1yc2E..." }); renderPage(); await waitFor(() => { expect( diff --git a/ui/apps/console/src/pages/admin/devices/__tests__/AdminDevices.test.tsx b/ui/apps/console/src/pages/admin/devices/__tests__/AdminDevices.test.tsx index 10ee5d1f3d7..7622cbc45ec 100644 --- a/ui/apps/console/src/pages/admin/devices/__tests__/AdminDevices.test.tsx +++ b/ui/apps/console/src/pages/admin/devices/__tests__/AdminDevices.test.tsx @@ -2,8 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import AdminDevices from "../index"; -import { makeSdkError, paginatedResponse } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { mockDevice } from "@/tests/factories"; import { useAuthStore } from "@/stores/authStore"; @@ -15,11 +16,19 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - getDevicesAdmin: vi.fn(), - }), -); +let lastRequestUrl: URL | null; + +function setDevices( + devices: ReturnType[], + total?: number, +) { + server.use( + http.get("*/admin/api/devices", ({ request }) => { + lastRequestUrl = new URL(request.url); + return jsonWithTotal(devices, total ?? devices.length); + }), + ); +} function renderPage(initialEntries: string[] = ["/"]) { return render( @@ -33,8 +42,9 @@ function renderPage(initialEntries: string[] = ["/"]) { describe("AdminDevices", () => { beforeEach(() => { vi.clearAllMocks(); + lastRequestUrl = null; useAuthStore.setState({ isAdmin: true }); - sdk.getDevicesAdmin.mockResolvedValue(paginatedResponse([])); + setDevices([]); }); describe("rendering", () => { @@ -63,7 +73,9 @@ describe("AdminDevices", () => { describe("loading state", () => { it('renders the loading spinner with "Loading devices..." text', () => { - sdk.getDevicesAdmin.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/devices", () => new Promise(() => {})), + ); renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); expect(screen.getByText("Loading devices...")).toBeInTheDocument(); @@ -79,21 +91,17 @@ describe("AdminDevices", () => { describe("device rows", () => { it("renders a row for each returned device", async () => { - sdk.getDevicesAdmin.mockResolvedValue( - paginatedResponse([ - mockDevice({ uid: "uid-1", name: "device-alpha" }), - mockDevice({ uid: "uid-2", name: "device-beta" }), - ]), - ); + setDevices([ + mockDevice({ uid: "uid-1", name: "device-alpha" }), + mockDevice({ uid: "uid-2", name: "device-beta" }), + ]); renderPage(); expect(await screen.findByText("device-alpha")).toBeInTheDocument(); expect(screen.getByText("device-beta")).toBeInTheDocument(); }); it("renders the status chip for each device", async () => { - sdk.getDevicesAdmin.mockResolvedValue( - paginatedResponse([mockDevice({ status: "pending" })]), - ); + setDevices([mockDevice({ status: "pending" })]); renderPage(); await screen.findByText("my-device"); expect(screen.getAllByText("Pending").length).toBeGreaterThanOrEqual(2); @@ -101,11 +109,7 @@ describe("AdminDevices", () => { it("navigates to the device detail page when a row is clicked", async () => { const user = userEvent.setup(); - sdk.getDevicesAdmin.mockResolvedValue( - paginatedResponse([ - mockDevice({ uid: "uid-abc", name: "clickable-device" }), - ]), - ); + setDevices([mockDevice({ uid: "uid-abc", name: "clickable-device" })]); renderPage(); await user.click(await screen.findByText("clickable-device")); @@ -114,8 +118,12 @@ describe("AdminDevices", () => { }); describe("error state", () => { - it("renders an error alert when the SDK returns an error", async () => { - sdk.getDevicesAdmin.mockRejectedValue(makeSdkError(500)); + it("renders an error alert when the API returns an error", async () => { + server.use( + http.get("*/admin/api/devices", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect( @@ -125,7 +133,7 @@ describe("AdminDevices", () => { }); describe("status tab interaction", () => { - it("calls SDK — status tab click re-renders without crashing", async () => { + it("re-renders without crashing after clicking a status tab", async () => { const user = userEvent.setup(); renderPage(); await user.click(screen.getByRole("tab", { name: "Accepted" })); @@ -136,27 +144,17 @@ describe("AdminDevices", () => { }); describe("URL hydration — controls reflect URL params on mount", () => { - it("passes sortBy/orderBy hydrated from URL to the SDK", async () => { + it("passes sortBy/orderBy hydrated from URL to the API", async () => { renderPage(["/?sortField=name&sortOrder=asc"]); await screen.findByText("No devices found"); - expect(sdk.getDevicesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - sort_by: "name", - order_by: "asc", - }), - }), - ); + expect(lastRequestUrl!.searchParams.get("sort_by")).toBe("name"); + expect(lastRequestUrl!.searchParams.get("order_by")).toBe("asc"); }); - it("passes status hydrated from URL to the SDK", async () => { + it("passes status hydrated from URL to the API", async () => { renderPage(["/?status=accepted"]); await screen.findByText("No devices found"); - expect(sdk.getDevicesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ status: "accepted" }), - }), - ); + expect(lastRequestUrl!.searchParams.get("status")).toBe("accepted"); }); it("marks the matching status tab as selected when status is in the URL", () => { @@ -167,35 +165,24 @@ describe("AdminDevices", () => { ); }); - it("passes page hydrated from URL to the SDK", async () => { + it("passes page hydrated from URL to the API", async () => { renderPage(["/?page=3"]); await screen.findByText("No devices found"); - expect(sdk.getDevicesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 3 }), - }), - ); + expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); }); it("uses defaults when URL params are absent (last_seen/desc, page 1, no status)", async () => { renderPage(["/"]); await screen.findByText("No devices found"); - expect(sdk.getDevicesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - sort_by: "last_seen", - order_by: "desc", - page: 1, - }), - }), - ); + expect(lastRequestUrl!.searchParams.get("sort_by")).toBe("last_seen"); + expect(lastRequestUrl!.searchParams.get("order_by")).toBe("desc"); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); it("rejects an invalid status value and falls back to no status filter (All tab selected)", async () => { renderPage(["/?status=invalid-status"]); await screen.findByText("No devices found"); - const call = sdk.getDevicesAdmin.mock.calls[0]?.[0]; - expect(call?.query?.status).toBeUndefined(); + expect(lastRequestUrl!.searchParams.get("status")).toBeNull(); expect(screen.getByRole("tab", { name: "All" })).toHaveAttribute( "aria-selected", "true", @@ -209,22 +196,14 @@ describe("AdminDevices", () => { renderPage(["/?page=2"]); await screen.findByText("No devices found"); - expect(sdk.getDevicesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 2 }), - }), - ); - await user.click(screen.getByRole("tab", { name: "Accepted" })); - expect(sdk.getDevicesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ status: "accepted", page: 1 }), - }), - ); + await screen.findByText("No devices found"); + expect(lastRequestUrl!.searchParams.get("status")).toBe("accepted"); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); - it("clicking a sort column header writes sort to SDK and resets page", async () => { + it("clicking a sort column header writes sort to API and resets page", async () => { const user = userEvent.setup(); renderPage(["/?page=3"]); await screen.findByText("No devices found"); @@ -233,15 +212,10 @@ describe("AdminDevices", () => { screen.getByRole("button", { name: /sort by hostname/i }), ); - expect(sdk.getDevicesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - sort_by: "name", - order_by: "asc", - page: 1, - }), - }), - ); + await screen.findByText("No devices found"); + expect(lastRequestUrl!.searchParams.get("sort_by")).toBe("name"); + expect(lastRequestUrl!.searchParams.get("order_by")).toBe("asc"); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); it("clicking the same sort column again toggles order from asc to desc", async () => { @@ -253,33 +227,23 @@ describe("AdminDevices", () => { screen.getByRole("button", { name: /sort by hostname/i }), ); - expect(sdk.getDevicesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - sort_by: "name", - order_by: "desc", - }), - }), - ); + await screen.findByText("No devices found"); + expect(lastRequestUrl!.searchParams.get("sort_by")).toBe("name"); + expect(lastRequestUrl!.searchParams.get("order_by")).toBe("desc"); }); }); describe("URL writes — default params are omitted from the URL", () => { - it("SDK receives page=1 when on the default page", async () => { + it("API receives page=1 when on the default page", async () => { renderPage(["/"]); await screen.findByText("No devices found"); - expect(sdk.getDevicesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); - it("SDK receives no status when All tab is selected (default)", async () => { + it("API receives no status when All tab is selected (default)", async () => { renderPage(["/"]); await screen.findByText("No devices found"); - const call = sdk.getDevicesAdmin.mock.calls[0]?.[0]; - expect(call?.query?.status).toBeUndefined(); + expect(lastRequestUrl!.searchParams.get("status")).toBeNull(); }); }); }); diff --git a/ui/apps/console/src/pages/admin/devices/index.tsx b/ui/apps/console/src/pages/admin/devices/index.tsx index 2458f1ec6ad..503bc1fcf63 100644 --- a/ui/apps/console/src/pages/admin/devices/index.tsx +++ b/ui/apps/console/src/pages/admin/devices/index.tsx @@ -1,14 +1,15 @@ import { useNavigate, Link } from "react-router-dom"; -import { - CpuChipIcon, -} from "@heroicons/react/24/outline"; +import { CpuChipIcon } from "@heroicons/react/24/outline"; import { Callout } from "@shellhub/design-system/primitives"; import { cn } from "@shellhub/design-system/cn"; +import { useGetDevicesAdmin } from "@/client/api"; +import { totalCount } from "@/api/pagination"; import { - useAdminDevices, - type NormalizedDevice, -} from "@/hooks/useAdminDevices"; -import type { DeviceStatus } from "@/client"; + normalizeDeviceTags, + type TaggedDevice as NormalizedDevice, +} from "@/utils/deviceTags"; +import { toBase64Json } from "@/utils/encoding"; +import type { DeviceStatus, GetDevicesAdminParams } from "@/client/model"; import PageHeader from "@/components/common/PageHeader"; import DataTable, { type Column } from "@/components/common/DataTable"; import SearchField from "@/components/common/fields/SearchField"; @@ -50,7 +51,7 @@ const SORT_FIELDS = [ { field: "status", initialOrder: "desc" as const }, ]; -type SortField = typeof VALID_SORT_FIELDS[number]; +type SortField = (typeof VALID_SORT_FIELDS)[number]; type AdminDevicesParams = { page: number; @@ -104,16 +105,30 @@ export default function AdminDevices() { const debouncedSearch = useDebouncedValue(params.search, SEARCH_DEBOUNCE_MS); - const { devices, totalCount, isLoading, error } = useAdminDevices({ + const requestParams: GetDevicesAdminParams = { page: params.page, - perPage: PER_PAGE, - search: debouncedSearch, - status: params.status, - sortBy: params.sortField, - orderBy: params.sortOrder, - }); + per_page: PER_PAGE, + sort_by: params.sortField, + order_by: params.sortOrder, + }; + if (debouncedSearch) { + requestParams.filter = toBase64Json([ + { + type: "property", + params: { name: "name", operator: "contains", value: debouncedSearch }, + }, + ]); + } + if (params.status) requestParams.status = params.status; + const { + data: rawDevices = [], + isLoading, + error, + } = useGetDevicesAdmin(requestParams); + const devices = rawDevices.map(normalizeDeviceTags); + const total = totalCount(rawDevices); - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const columns: Column[] = [ { @@ -220,7 +235,12 @@ export default function AdminDevices() { role="tab" aria-selected={params.status === tab.value} onClick={() => setFilter("status", tab.value)} - className={cn("h-full px-3.5 text-xs font-medium rounded transition-all duration-150", params.status === tab.value ? "bg-primary/15 text-primary border border-primary/25" : "text-text-muted hover:text-text-secondary border border-transparent")} + className={cn( + "h-full px-3.5 text-xs font-medium rounded transition-all duration-150", + params.status === tab.value + ? "bg-primary/15 text-primary border border-primary/25" + : "text-text-muted hover:text-text-secondary border border-transparent", + )} > {tab.label} @@ -249,7 +269,7 @@ export default function AdminDevices() { loadingMessage="Loading devices..." page={params.page} totalPages={totalPages} - totalCount={totalCount} + totalCount={total} itemLabel="device" onPageChange={setPage} onRowClick={(device) => void navigate(`/admin/devices/${device.uid}`)} diff --git a/ui/apps/console/src/pages/admin/firewall-rules/AdminFirewallRuleDetails.tsx b/ui/apps/console/src/pages/admin/firewall-rules/AdminFirewallRuleDetails.tsx index aed6eb5e519..44a8bb3285d 100644 --- a/ui/apps/console/src/pages/admin/firewall-rules/AdminFirewallRuleDetails.tsx +++ b/ui/apps/console/src/pages/admin/firewall-rules/AdminFirewallRuleDetails.tsx @@ -7,7 +7,7 @@ import { NoSymbolIcon, } from "@heroicons/react/24/outline"; import { cn } from "@shellhub/design-system/cn"; -import { useAdminFirewallRule } from "@/hooks/useAdminFirewallRules"; +import { useGetFirewallRuleAdmin } from "@/client/api"; import ActiveBadge from "@/components/common/ActiveBadge"; import Breadcrumb from "@/components/common/Breadcrumb"; import CopyButton from "@/components/common/CopyButton"; @@ -21,8 +21,12 @@ import { Card } from "@shellhub/design-system/primitives"; * One firewall rule, seen from the admin area. */ export default function AdminFirewallRuleDetails() { - const { id } = useParams<{ id: string }>(); - const { data: rule, isLoading, error } = useAdminFirewallRule(id ?? ""); + const { id = "" } = useParams<{ id: string }>(); + const { + data: rule, + isLoading, + error, + } = useGetFirewallRuleAdmin(id, { query: { enabled: !!id } }); if (isLoading) { return ; diff --git a/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRuleDetails.test.tsx b/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRuleDetails.test.tsx index 69b55ee0860..be14fa78a62 100644 --- a/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRuleDetails.test.tsx +++ b/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRuleDetails.test.tsx @@ -1,18 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { useAuthStore } from "@/stores/authStore"; -import { mockSdkResponse, makeSdkError } from "@/tests/sdk"; -import type { FirewallRulesResponse } from "@/client"; +import type { FirewallRulesResponse } from "@/client/model"; import AdminFirewallRuleDetails from "../AdminFirewallRuleDetails"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getFirewallRuleAdmin: vi.fn(), - }), -); - vi.mock("react-router-dom", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useParams: () => ({ id: "rule-1" }) }; @@ -47,6 +42,14 @@ function makeRule( }; } +function setRule(overrides: Partial = {}) { + server.use( + http.get("*/admin/api/firewall/rules/:id", () => + HttpResponse.json(makeRule(overrides)), + ), + ); +} + function renderPage() { return render( @@ -59,13 +62,15 @@ function renderPage() { beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ isAdmin: true }); - sdk.getFirewallRuleAdmin.mockResolvedValue(mockSdkResponse(makeRule())); + setRule(); }); describe("AdminFirewallRuleDetails", () => { describe("loading state", () => { it('announces "Loading firewall rule details" while loading', () => { - sdk.getFirewallRuleAdmin.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/firewall/rules/:id", () => new Promise(() => {})), + ); renderPage(); expect( screen.getByRole("status", { name: "Loading firewall rule details" }), @@ -75,7 +80,11 @@ describe("AdminFirewallRuleDetails", () => { describe("not-found / error state", () => { it('renders "Firewall rule not found" when no data and no loading', async () => { - sdk.getFirewallRuleAdmin.mockRejectedValue(makeSdkError(404)); + server.use( + http.get("*/admin/api/firewall/rules/:id", () => + HttpResponse.json({}, { status: 404 }), + ), + ); renderPage(); await waitFor(() => { expect(screen.getByText("Firewall rule not found")).toBeInTheDocument(); @@ -83,7 +92,11 @@ describe("AdminFirewallRuleDetails", () => { }); it('renders "Firewall rule not found" when the query returns an error', async () => { - sdk.getFirewallRuleAdmin.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/firewall/rules/:id", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); await waitFor(() => { expect(screen.getByText("Firewall rule not found")).toBeInTheDocument(); @@ -91,7 +104,11 @@ describe("AdminFirewallRuleDetails", () => { }); it('renders a "Back to firewall rules" link in the not-found state', async () => { - sdk.getFirewallRuleAdmin.mockRejectedValue(makeSdkError(404)); + server.use( + http.get("*/admin/api/firewall/rules/:id", () => + HttpResponse.json({}, { status: 404 }), + ), + ); renderPage(); await waitFor(() => { expect( @@ -176,9 +193,7 @@ describe("AdminFirewallRuleDetails", () => { describe("rule data — deny rule", () => { it('renders "Deny Rule" as the main heading', async () => { - sdk.getFirewallRuleAdmin.mockResolvedValue( - mockSdkResponse(makeRule({ action: "deny" })), - ); + setRule({ action: "deny" }); renderPage(); await waitFor(() => { expect( @@ -190,9 +205,7 @@ describe("AdminFirewallRuleDetails", () => { describe("rule data — inactive rule", () => { it("renders the Inactive badge", async () => { - sdk.getFirewallRuleAdmin.mockResolvedValue( - mockSdkResponse(makeRule({ active: false })), - ); + setRule({ active: false }); renderPage(); await waitFor(() => { expect(screen.getAllByText("Inactive").length).toBeGreaterThanOrEqual( @@ -204,9 +217,7 @@ describe("AdminFirewallRuleDetails", () => { describe("rule data — specific IP and username", () => { it("renders a specific source IP when not wildcard", async () => { - sdk.getFirewallRuleAdmin.mockResolvedValue( - mockSdkResponse(makeRule({ source_ip: "10.0.0.5" })), - ); + setRule({ source_ip: "10.0.0.5" }); renderPage(); await waitFor(() => { expect(screen.getByText("10.0.0.5")).toBeInTheDocument(); @@ -214,9 +225,7 @@ describe("AdminFirewallRuleDetails", () => { }); it("renders a specific username when not wildcard", async () => { - sdk.getFirewallRuleAdmin.mockResolvedValue( - mockSdkResponse(makeRule({ username: "alice" })), - ); + setRule({ username: "alice" }); renderPage(); await waitFor(() => { expect(screen.getByText("alice")).toBeInTheDocument(); @@ -226,11 +235,7 @@ describe("AdminFirewallRuleDetails", () => { describe("rule data — device filter", () => { it("renders hostname FilterBadge when filter has a specific hostname", async () => { - sdk.getFirewallRuleAdmin.mockResolvedValue( - mockSdkResponse( - makeRule({ filter: { hostname: "my-server", tags: [] } }), - ), - ); + setRule({ filter: { hostname: "my-server", tags: [] } }); renderPage(); await waitFor(() => { expect(screen.getByText("my-server")).toBeInTheDocument(); @@ -238,13 +243,9 @@ describe("AdminFirewallRuleDetails", () => { }); it("renders tag FilterBadge when filter has tags", async () => { - sdk.getFirewallRuleAdmin.mockResolvedValue( - mockSdkResponse( - makeRule({ - filter: { tags: [makeTag("production"), makeTag("web")] }, - }), - ), - ); + setRule({ + filter: { tags: [makeTag("production"), makeTag("web")] }, + }); renderPage(); await waitFor(() => { expect(screen.getByText("production")).toBeInTheDocument(); diff --git a/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRules.test.tsx b/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRules.test.tsx index 9f7063c6679..4bda1ae6572 100644 --- a/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRules.test.tsx +++ b/ui/apps/console/src/pages/admin/firewall-rules/__tests__/AdminFirewallRules.test.tsx @@ -2,8 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import AdminFirewallRules from "../index"; -import { makeSdkError, paginatedResponse } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { mockFirewallRule } from "@/tests/factories"; import { useAuthStore } from "@/stores/authStore"; @@ -15,12 +16,6 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - getFirewallRulesAdmin: vi.fn(), - }), -); - const capturedDataTableProps: Record[] = []; vi.mock("@/components/common/DataTable", async (importOriginal) => { const actual = @@ -36,6 +31,20 @@ vi.mock("@/components/common/DataTable", async (importOriginal) => { }; }); +let lastRequestUrl: URL | null; + +function setRules( + rules: ReturnType[], + total?: number, +) { + server.use( + http.get("*/admin/api/firewall/rules", ({ request }) => { + lastRequestUrl = new URL(request.url); + return jsonWithTotal(rules, total ?? rules.length); + }), + ); +} + function renderPage(initialEntries: string[] = ["/"]) { return render( @@ -49,8 +58,9 @@ describe("AdminFirewallRules", () => { beforeEach(() => { vi.clearAllMocks(); capturedDataTableProps.length = 0; + lastRequestUrl = null; useAuthStore.setState({ isAdmin: true }); - sdk.getFirewallRulesAdmin.mockResolvedValue(paginatedResponse([])); + setRules([]); }); describe("rendering", () => { @@ -73,7 +83,9 @@ describe("AdminFirewallRules", () => { describe("loading state", () => { it('renders the loading spinner with "Loading firewall rules..." text', () => { - sdk.getFirewallRulesAdmin.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/firewall/rules", () => new Promise(() => {})), + ); renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); expect(screen.getByText("Loading firewall rules...")).toBeInTheDocument(); @@ -91,14 +103,12 @@ describe("AdminFirewallRules", () => { describe("rule rows", () => { it("renders a row for each returned rule", async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse( - [ - mockFirewallRule({ id: "r1", priority: 1 }), - mockFirewallRule({ id: "r2", priority: 2 }), - ], - 2, - ), + setRules( + [ + mockFirewallRule({ id: "r1", priority: 1 }), + mockFirewallRule({ id: "r2", priority: 2 }), + ], + 2, ); renderPage(); await waitFor(() => expect(screen.getAllByText("Allow").length).toBe(2)); @@ -107,74 +117,56 @@ describe("AdminFirewallRules", () => { }); it('shows "Allow" with accent-green for an allow rule', async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse([mockFirewallRule({ action: "allow" })]), - ); + setRules([mockFirewallRule({ action: "allow" })]); renderPage(); expect(await screen.findByText("Allow")).toBeInTheDocument(); }); it('shows "Deny" for a deny rule', async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse([mockFirewallRule({ action: "deny" })]), - ); + setRules([mockFirewallRule({ action: "deny" })]); renderPage(); expect(await screen.findByText("Deny")).toBeInTheDocument(); }); it('shows "Any IP" when source_ip is ".*"', async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse([mockFirewallRule({ source_ip: ".*" })]), - ); + setRules([mockFirewallRule({ source_ip: ".*" })]); renderPage(); expect(await screen.findByText("Any IP")).toBeInTheDocument(); }); it("shows specific IP when source_ip is not wildcard", async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse([mockFirewallRule({ source_ip: "192.168.1.0/24" })]), - ); + setRules([mockFirewallRule({ source_ip: "192.168.1.0/24" })]); renderPage(); expect(await screen.findByText("192.168.1.0/24")).toBeInTheDocument(); }); it('shows "All users" when username is ".*"', async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse([mockFirewallRule({ username: ".*" })]), - ); + setRules([mockFirewallRule({ username: ".*" })]); renderPage(); expect(await screen.findByText("All users")).toBeInTheDocument(); }); it("shows specific username when not wildcard", async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse([mockFirewallRule({ username: "alice" })]), - ); + setRules([mockFirewallRule({ username: "alice" })]); renderPage(); expect(await screen.findByText("alice")).toBeInTheDocument(); }); it("renders an Active badge for an active rule", async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse([mockFirewallRule({ active: true })]), - ); + setRules([mockFirewallRule({ active: true })]); renderPage(); expect(await screen.findByText("Active")).toBeInTheDocument(); }); it("renders an Inactive badge for an inactive rule", async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse([mockFirewallRule({ active: false })]), - ); + setRules([mockFirewallRule({ active: false })]); renderPage(); expect(await screen.findByText("Inactive")).toBeInTheDocument(); }); it("navigates to the detail page when a row is clicked", async () => { const user = userEvent.setup(); - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse([mockFirewallRule({ id: "rule-abc", priority: 99 })]), - ); + setRules([mockFirewallRule({ id: "rule-abc", priority: 99 })]); renderPage(); await user.click(await screen.findByText("99")); @@ -184,9 +176,7 @@ describe("AdminFirewallRules", () => { }); it("renders the tenant_id as a namespace link", async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse([mockFirewallRule({ tenant_id: "tenant-xyz" })]), - ); + setRules([mockFirewallRule({ tenant_id: "tenant-xyz" })]); renderPage(); const link = await screen.findByRole("link", { name: "tenant-xyz" }); expect(link).toHaveAttribute("href", "/admin/namespaces/tenant-xyz"); @@ -195,7 +185,11 @@ describe("AdminFirewallRules", () => { describe("error state", () => { it("renders an error alert when the SDK returns an error", async () => { - sdk.getFirewallRulesAdmin.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/firewall/rules", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect( @@ -221,9 +215,7 @@ describe("AdminFirewallRules", () => { }); beforeEach(() => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse([allowRule, denyRule]), - ); + setRules([allowRule, denyRule]); }); it("filters rules by action text", async () => { @@ -321,11 +313,9 @@ describe("AdminFirewallRules", () => { describe("pagination suppressed while searching", () => { beforeEach(() => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse( - [mockFirewallRule({ id: "r1", action: "allow", priority: 1 })], - 1, - ), + setRules( + [mockFirewallRule({ id: "r1", action: "allow", priority: 1 })], + 1, ); }); @@ -364,9 +354,7 @@ describe("AdminFirewallRules", () => { describe("URL round-trips", () => { it("hydrates search from URL on mount", async () => { - sdk.getFirewallRulesAdmin.mockResolvedValue( - paginatedResponse([mockFirewallRule({ id: "r1", action: "allow" })]), - ); + setRules([mockFirewallRule({ id: "r1", action: "allow" })]); renderPage(["/?search=allow"]); expect( screen.getByRole("searchbox", { @@ -375,24 +363,18 @@ describe("AdminFirewallRules", () => { ).toHaveValue("allow"); }); - it("hydrates page from URL and passes it to the SDK", async () => { + it("hydrates page from URL and passes it to the API", async () => { renderPage(["/?page=3"]); await screen.findByText("No firewall rules found"); - expect(sdk.getFirewallRulesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 3 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); }); - it("passes page=1 to the SDK when URL has no params", async () => { + it("passes page=1 to the API when URL has no params", async () => { renderPage(["/"]); await screen.findByText("No firewall rules found"); - expect(sdk.getFirewallRulesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); it("setSearch resets page to 1 in the URL", async () => { @@ -401,11 +383,8 @@ describe("AdminFirewallRules", () => { await screen.findByText("No firewall rules found"); - expect(sdk.getFirewallRulesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 3 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); await user.type( screen.getByRole("searchbox", { @@ -415,10 +394,7 @@ describe("AdminFirewallRules", () => { ); await waitFor(() => { - const calls = sdk.getFirewallRulesAdmin.mock.calls; - const lastCall = calls.at(-1)![0]; - expect(lastCall).toBeDefined(); - expect(lastCall?.query?.page).toBe(1); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); }); }); diff --git a/ui/apps/console/src/pages/admin/firewall-rules/index.tsx b/ui/apps/console/src/pages/admin/firewall-rules/index.tsx index 3714b0d67df..36c5afaae2b 100644 --- a/ui/apps/console/src/pages/admin/firewall-rules/index.tsx +++ b/ui/apps/console/src/pages/admin/firewall-rules/index.tsx @@ -1,4 +1,3 @@ -import { useMemo } from "react"; import { useNavigate, Link } from "react-router-dom"; import { ShieldExclamationIcon, @@ -10,9 +9,10 @@ import DataTable, { type Column } from "@/components/common/DataTable"; import FilterBadge from "@/components/common/FilterBadge"; import PageHeader from "@/components/common/PageHeader"; import SearchField from "@/components/common/fields/SearchField"; -import { useAdminFirewallRules } from "@/hooks/useAdminFirewallRules"; +import { useGetFirewallRulesAdmin } from "@/client/api"; +import { totalCount } from "@/api/pagination"; import { usePaginatedListState } from "@/hooks/usePaginatedListState"; -import { type FirewallRulesResponse as FirewallRule } from "@/client"; +import { type FirewallRulesResponse as FirewallRule } from "@/client/model"; import { Badge, Callout } from "@shellhub/design-system/primitives"; import { apiErrorMessage } from "@/api/errors"; import { PER_PAGE, pageCount } from "@/utils/pagination"; @@ -35,24 +35,29 @@ export default function AdminFirewallRules() { const { params, setPage, setSearch } = usePaginatedListState({ defaults: DEFAULTS }); - const { rules, totalCount, isLoading, error } = useAdminFirewallRules({ + const { + data: rules = [], + isLoading, + error, + } = useGetFirewallRulesAdmin({ page: params.page, - perPage: PER_PAGE, + per_page: PER_PAGE, }); + const total = totalCount(rules); - const filtered = useMemo(() => { - if (!params.search) return rules; - const q = params.search.toLowerCase(); - return rules.filter( - (r) => - r.action.toLowerCase().includes(q) || - r.source_ip.toLowerCase().includes(q) || - r.username.toLowerCase().includes(q) || - String(r.priority).includes(q), - ); - }, [rules, params.search]); + const filtered = params.search + ? rules.filter((r) => { + const q = params.search.toLowerCase(); + return ( + r.action.toLowerCase().includes(q) || + r.source_ip.toLowerCase().includes(q) || + r.username.toLowerCase().includes(q) || + String(r.priority).includes(q) + ); + }) + : rules; - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const columns: Column[] = [ { @@ -164,7 +169,7 @@ export default function AdminFirewallRules() { {...(!params.search && { page: params.page, totalPages, - totalCount, + totalCount: total, itemLabel: "rule", onPageChange: setPage, })} diff --git a/ui/apps/console/src/pages/admin/instance-api-keys/GenerateInstanceKeyDrawer.tsx b/ui/apps/console/src/pages/admin/instance-api-keys/GenerateInstanceKeyDrawer.tsx index 620a6048059..77578470194 100644 --- a/ui/apps/console/src/pages/admin/instance-api-keys/GenerateInstanceKeyDrawer.tsx +++ b/ui/apps/console/src/pages/admin/instance-api-keys/GenerateInstanceKeyDrawer.tsx @@ -3,7 +3,7 @@ import { KeyIcon, CheckIcon } from "@heroicons/react/24/outline"; import { Card, Button } from "@shellhub/design-system/primitives"; import { isSdkError } from "@/api/errors"; import { useResetOnOpen } from "@/hooks/useResetOnOpen"; -import { useCreateInstanceApiKey } from "@/hooks/useInstanceApiKeyMutations"; +import { useCreateInstanceAPIKey } from "@/client/api"; import CopyButton from "@/components/common/CopyButton"; import Drawer from "@/components/common/Drawer"; import { @@ -33,7 +33,7 @@ function GenerateInstanceKeyDrawer({ open: boolean; onClose: () => void; }) { - const createKey = useCreateInstanceApiKey(); + const createKey = useCreateInstanceAPIKey(); const form = useDrawerForm( open, generateInstanceKeySchema, @@ -55,7 +55,7 @@ function GenerateInstanceKeyDrawer({ clearErrors("root"); try { const result = await createKey.mutateAsync({ - body: buildGenerateInstanceKeyBody({ + data: buildGenerateInstanceKeyBody({ name: values.name, expiresAt: values.expiresAt, }), diff --git a/ui/apps/console/src/pages/admin/instance-api-keys/InstanceApiKeys.tsx b/ui/apps/console/src/pages/admin/instance-api-keys/InstanceApiKeys.tsx index 531aac4dd3e..bce2fe4d11e 100644 --- a/ui/apps/console/src/pages/admin/instance-api-keys/InstanceApiKeys.tsx +++ b/ui/apps/console/src/pages/admin/instance-api-keys/InstanceApiKeys.tsx @@ -2,10 +2,10 @@ import { useState } from "react"; import { KeyIcon, TrashIcon } from "@heroicons/react/24/outline"; import { Button, IconButton } from "@shellhub/design-system/primitives"; import { cn } from "@shellhub/design-system/cn"; -import { useInstanceApiKeys } from "@/hooks/useInstanceApiKeys"; -import { useDeleteInstanceApiKey } from "@/hooks/useInstanceApiKeyMutations"; +import { useListInstanceAPIKeys, useDeleteInstanceAPIKey } from "@/client/api"; +import { totalCount } from "@/api/pagination"; import { usePaginatedListState } from "@/hooks/usePaginatedListState"; -import { type InstanceApiKey } from "@/client"; +import type { InstanceAPIKey } from "@/client/model"; import PageHeader from "@/components/common/PageHeader"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import DataTable, { type Column } from "@/components/common/DataTable"; @@ -13,11 +13,11 @@ import { formatDateShort } from "@/utils/date"; import { pageCount } from "@/utils/pagination"; import GenerateInstanceKeyDrawer from "./GenerateInstanceKeyDrawer"; -type InstanceApiKeyListParams = { +type InstanceAPIKeyListParams = { page: number; }; -const INSTANCE_API_KEY_LIST_DEFAULTS: InstanceApiKeyListParams = { page: 1 }; +const INSTANCE_API_KEY_LIST_DEFAULTS: InstanceAPIKeyListParams = { page: 1 }; function hasExpired(expiresAt: string) { return new Date(expiresAt).getTime() <= Date.now(); @@ -28,16 +28,19 @@ function hasExpired(expiresAt: string) { * administrator rather than as a member of a namespace, so they are managed here rather than * alongside a namespace's own keys. */ -function InstanceApiKeys() { - const { params, setPage } = usePaginatedListState({ +function InstanceAPIKeys() { + const { params, setPage } = usePaginatedListState({ defaults: INSTANCE_API_KEY_LIST_DEFAULTS, }); const page = params.page; - const { apiKeys, totalCount, isLoading } = useInstanceApiKeys({ page }); + const keysQuery = useListInstanceAPIKeys({ page, per_page: 10, order_by: "desc" }); + const apiKeys = keysQuery.data ?? []; + const keyCount = totalCount(keysQuery.data); + const isLoading = keysQuery.isLoading; - const deleteKey = useDeleteInstanceApiKey(); + const deleteKey = useDeleteInstanceAPIKey(); const [generateOpen, setGenerateOpen] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); const [deleteError, setDeleteError] = useState(null); const closeDelete = () => { @@ -49,7 +52,7 @@ function InstanceApiKeys() { if (!deleteTarget) return; setDeleteError(null); try { - await deleteKey.mutateAsync({ path: { name: deleteTarget.name } }); + await deleteKey.mutateAsync({ name: deleteTarget.name }); if (apiKeys.length === 1 && page > 1) setPage(page - 1); closeDelete(); } catch (err) { @@ -61,7 +64,7 @@ function InstanceApiKeys() { } }; - const columns: Column[] = [ + const columns: Column[] = [ { key: "name", header: "Name", @@ -133,7 +136,7 @@ function InstanceApiKeys() {

- {totalCount} key{totalCount !== 1 ? "s" : ""} + {keyCount} key{keyCount !== 1 ? "s" : ""}

@@ -144,7 +147,7 @@ function InstanceApiKeys() { isLoading={isLoading} loadingMessage="Loading instance API keys..." page={page} - totalPages={pageCount(totalCount)} + totalPages={pageCount(keyCount)} onPageChange={setPage} rowClassName={(key) => hasExpired(key.expires_at) @@ -190,4 +193,4 @@ function InstanceApiKeys() { ); } -export default InstanceApiKeys; +export default InstanceAPIKeys; diff --git a/ui/apps/console/src/pages/admin/instance-api-keys/__tests__/InstanceApiKeys.test.tsx b/ui/apps/console/src/pages/admin/instance-api-keys/__tests__/InstanceApiKeys.test.tsx index 5ea455c331e..4d9206193b7 100644 --- a/ui/apps/console/src/pages/admin/instance-api-keys/__tests__/InstanceApiKeys.test.tsx +++ b/ui/apps/console/src/pages/admin/instance-api-keys/__tests__/InstanceApiKeys.test.tsx @@ -1,27 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import InstanceApiKeys from "../InstanceApiKeys"; -import type { InstanceApiKey } from "@/client"; +import type { InstanceAPIKey } from "@/client/model"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; import { ClipboardProvider } from "@/components/common/ClipboardProvider"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - listInstanceApiKeys: vi.fn(), - createInstanceApiKey: vi.fn(), - deleteInstanceApiKey: vi.fn(), - }), -); +import { useAuthStore } from "@/stores/authStore"; vi.mock("@/components/common/ConfirmDialog", async () => ({ default: (await import("@/tests/mocks")).MockConfirmDialog, })); -function mockInstanceApiKey( - overrides: Partial = {}, -): InstanceApiKey { +function mockInstanceAPIKey( + overrides: Partial = {}, +): InstanceAPIKey { return { name: "billing-export", created_by: "3dd0d1f8-8246-4519-b11a-a3dd33717f65", @@ -32,12 +26,21 @@ function mockInstanceApiKey( }; } +const deleteSpy = vi.fn(); + beforeEach(() => { vi.clearAllMocks(); - sdk.listInstanceApiKeys.mockResolvedValue( - paginatedResponse([mockInstanceApiKey()]), + deleteSpy.mockReset(); + useAuthStore.setState({ isAdmin: true }); + server.use( + http.get("*/admin/api/instance-api-keys", () => + jsonWithTotal([mockInstanceAPIKey()], 1), + ), + http.delete("*/admin/api/instance-api-keys/:name", ({ params }) => { + deleteSpy({ path: { name: params.name } }); + return new HttpResponse(null, { status: 204 }); + }), ); - sdk.deleteInstanceApiKey.mockResolvedValue(mockSdkResponse(undefined)); }); function renderPage() { @@ -62,11 +65,13 @@ describe("InstanceApiKeys", () => { it("shows the plaintext key once after creating one", async () => { const user = userEvent.setup(); - sdk.createInstanceApiKey.mockResolvedValue( - mockSdkResponse({ - ...mockInstanceApiKey({ name: "license-sync" }), - id: "sh_admin_cdfd3cb0-c44e-4e54-b931-6d57713ad159", - }), + server.use( + http.post("*/admin/api/instance-api-keys", () => + HttpResponse.json({ + ...mockInstanceAPIKey({ name: "license-sync" }), + id: "sh_admin_cdfd3cb0-c44e-4e54-b931-6d57713ad159", + }), + ), ); renderPage(); @@ -126,7 +131,7 @@ describe("InstanceApiKeys", () => { await user.click(screen.getByRole("button", { name: /^revoke$/i })); await waitFor(() => { - expect(sdk.deleteInstanceApiKey).toHaveBeenCalledWith( + expect(deleteSpy).toHaveBeenCalledWith( expect.objectContaining({ path: { name: "billing-export" } }), ); }); diff --git a/ui/apps/console/src/pages/admin/namespaces/DeleteNamespaceDialog.tsx b/ui/apps/console/src/pages/admin/namespaces/DeleteNamespaceDialog.tsx index 436e3b1870b..5cc4bd75d99 100644 --- a/ui/apps/console/src/pages/admin/namespaces/DeleteNamespaceDialog.tsx +++ b/ui/apps/console/src/pages/admin/namespaces/DeleteNamespaceDialog.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useAdminDeleteNamespace } from "@/hooks/useAdminNamespaceMutations"; +import { useDeleteNamespaceAdmin } from "@/client/api"; import ConfirmDialog from "@/components/common/ConfirmDialog"; interface DeleteNamespaceDialogProps { @@ -19,7 +19,7 @@ export default function DeleteNamespaceDialog({ namespace, onDeleted, }: DeleteNamespaceDialogProps) { - const deleteNamespace = useAdminDeleteNamespace(); + const deleteNamespace = useDeleteNamespaceAdmin(); const [error, setError] = useState(""); return ( @@ -34,7 +34,7 @@ export default function DeleteNamespaceDialog({ setError(""); try { await deleteNamespace.mutateAsync({ - path: { tenant: namespace.tenant_id }, + tenant: namespace.tenant_id, }); onClose(); onDeleted?.(); diff --git a/ui/apps/console/src/pages/admin/namespaces/EditNamespaceDrawer.tsx b/ui/apps/console/src/pages/admin/namespaces/EditNamespaceDrawer.tsx index 6ccde6e9f11..d52efdd1041 100644 --- a/ui/apps/console/src/pages/admin/namespaces/EditNamespaceDrawer.tsx +++ b/ui/apps/console/src/pages/admin/namespaces/EditNamespaceDrawer.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useAdminEditNamespace } from "@/hooks/useAdminNamespaceMutations"; +import { useEditNamespaceAdmin } from "@/client/api"; import { isSdkError } from "@/api/errors"; import FormDrawer from "@/components/common/FormDrawer"; import { @@ -18,7 +18,7 @@ import { buildEditNamespaceBody, type EditNamespaceFormValues, } from "./editNamespaceSchema"; -import type { Namespace } from "@/client"; +import type { Namespace } from "@/client/model"; interface EditNamespaceDrawerProps { open: boolean; @@ -35,7 +35,7 @@ export default function EditNamespaceDrawer({ onClose, namespace, }: EditNamespaceDrawerProps) { - const editNamespace = useAdminEditNamespace(); + const editNamespace = useEditNamespaceAdmin(); const schema = useMemo( () => editNamespaceSchema(namespace?.name ?? ""), @@ -54,8 +54,8 @@ export default function EditNamespaceDrawer({ clearErrors("root"); try { await editNamespace.mutateAsync({ - path: { tenantID: namespace.tenant_id }, - body: buildEditNamespaceBody(namespace, values), + tenantID: namespace.tenant_id, + data: buildEditNamespaceBody(namespace, values), }); onClose(); } catch (err) { diff --git a/ui/apps/console/src/pages/admin/namespaces/NamespaceDetails.tsx b/ui/apps/console/src/pages/admin/namespaces/NamespaceDetails.tsx index ea09f457679..e13435b9793 100644 --- a/ui/apps/console/src/pages/admin/namespaces/NamespaceDetails.tsx +++ b/ui/apps/console/src/pages/admin/namespaces/NamespaceDetails.tsx @@ -8,7 +8,8 @@ import { Cog6ToothIcon, } from "@heroicons/react/24/outline"; import { cn } from "@shellhub/design-system/cn"; -import { useAdminNamespace } from "@/hooks/useAdminNamespaces"; +import { useGetNamespaceAdmin } from "@/client/api"; +import { NamespaceMembersItem } from "@/client/model"; import Breadcrumb from "@/components/common/Breadcrumb"; import DataTable, { type Column } from "@/components/common/DataTable"; import EditNamespaceDrawer from "./EditNamespaceDrawer"; @@ -27,17 +28,17 @@ import { const ZERO_DATE = "0001-01-01T00:00:00Z"; -type Member = NonNullable< - NonNullable["data"]>["members"] ->[number]; - /** * One namespace, seen from the admin area: its members, its devices and its limits. */ export default function NamespaceDetails() { - const { id } = useParams<{ id: string }>(); + const { id = "" } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { data: namespace, isLoading, error } = useAdminNamespace(id ?? ""); + const { + data: namespace, + isLoading, + error, + } = useGetNamespaceAdmin(id, { query: { enabled: !!id } }); const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -63,7 +64,7 @@ export default function NamespaceDetails() { (namespace.devices_pending_count || 0) + (namespace.devices_rejected_count || 0); - const memberColumns: Column[] = [ + const memberColumns: Column[] = [ { key: "email", header: "Email", @@ -235,7 +236,7 @@ export default function NamespaceDetails() { Members ({namespace.members?.length || 0})
- + columns={memberColumns} data={namespace.members ?? []} rowKey={(m, i) => m.id || m.email || `member-${i}`} diff --git a/ui/apps/console/src/pages/admin/namespaces/__tests__/AdminNamespaces.test.tsx b/ui/apps/console/src/pages/admin/namespaces/__tests__/AdminNamespaces.test.tsx index 8db58c4bedf..55404699364 100644 --- a/ui/apps/console/src/pages/admin/namespaces/__tests__/AdminNamespaces.test.tsx +++ b/ui/apps/console/src/pages/admin/namespaces/__tests__/AdminNamespaces.test.tsx @@ -8,8 +8,9 @@ import { } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import AdminNamespaces from "../index"; -import { makeSdkError, paginatedResponse } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { mockNamespace } from "@/tests/factories"; import { useAuthStore } from "@/stores/authStore"; @@ -41,11 +42,19 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - getNamespacesAdmin: vi.fn(), - }), -); +let lastRequestUrl: URL | null; + +function setNamespaces( + namespaces: ReturnType[], + total?: number, +) { + server.use( + http.get("*/admin/api/namespaces", ({ request }) => { + lastRequestUrl = new URL(request.url); + return jsonWithTotal(namespaces, total ?? namespaces.length); + }), + ); +} function renderPage(initialEntries: string[] = ["/"]) { return render( @@ -59,8 +68,9 @@ function renderPage(initialEntries: string[] = ["/"]) { describe("AdminNamespaces", () => { beforeEach(() => { vi.clearAllMocks(); + lastRequestUrl = null; useAuthStore.setState({ isAdmin: true }); - sdk.getNamespacesAdmin.mockResolvedValue(paginatedResponse([])); + setNamespaces([]); }); describe("rendering", () => { @@ -81,7 +91,9 @@ describe("AdminNamespaces", () => { describe("loading state", () => { it('renders the loading spinner with "Loading namespaces..." text', () => { - sdk.getNamespacesAdmin.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/namespaces", () => new Promise(() => {})), + ); renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); expect(screen.getByText("Loading namespaces...")).toBeInTheDocument(); @@ -99,12 +111,10 @@ describe("AdminNamespaces", () => { describe("namespace rows", () => { it("renders a row for each returned namespace", async () => { - sdk.getNamespacesAdmin.mockResolvedValue( - paginatedResponse([ - mockNamespace({ tenant_id: "t-1", name: "namespace-alpha" }), - mockNamespace({ tenant_id: "t-2", name: "namespace-beta" }), - ]), - ); + setNamespaces([ + mockNamespace({ tenant_id: "t-1", name: "namespace-alpha" }), + mockNamespace({ tenant_id: "t-2", name: "namespace-beta" }), + ]); renderPage(); expect(await screen.findByText("namespace-alpha")).toBeInTheDocument(); expect(screen.getByText("namespace-beta")).toBeInTheDocument(); @@ -112,11 +122,9 @@ describe("AdminNamespaces", () => { it("navigates to namespace detail page when a row is clicked", async () => { const user = userEvent.setup(); - sdk.getNamespacesAdmin.mockResolvedValue( - paginatedResponse([ - mockNamespace({ tenant_id: "tenant-xyz", name: "clickable-ns" }), - ]), - ); + setNamespaces([ + mockNamespace({ tenant_id: "tenant-xyz", name: "clickable-ns" }), + ]); renderPage(); await user.click(await screen.findByText("clickable-ns")); expect(mockNavigate).toHaveBeenCalledWith("/admin/namespaces/tenant-xyz"); @@ -125,7 +133,11 @@ describe("AdminNamespaces", () => { describe("error state", () => { it("renders an error alert when the SDK returns an error", async () => { - sdk.getNamespacesAdmin.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/namespaces", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect( @@ -135,28 +147,22 @@ describe("AdminNamespaces", () => { }); describe("URL hydration — controls reflect URL params on mount", () => { - it("passes search and page hydrated from URL to the SDK", async () => { + it("passes search and page hydrated from URL to the API", async () => { renderPage(["/?search=myns&page=3"]); await waitFor(() => { - expect(sdk.getNamespacesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 3 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); }); expect( screen.getByRole("searchbox", { name: "Search namespaces by name" }), ).toHaveValue("myns"); }); - it("passes page=1 and no filter to the SDK when URL has no params", async () => { + it("passes page=1 and no filter to the API when URL has no params", async () => { renderPage(["/"]); await waitFor(() => { - expect(sdk.getNamespacesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); }); }); @@ -174,17 +180,13 @@ describe("AdminNamespaces", () => { await user.clear(searchbox); await waitFor(() => { - expect(sdk.getNamespacesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); }); }); describe("URL hydration — ?page=2&search=dev hydrates controls", () => { - it("hydrates the search field to 'dev' and passes page=2 to the SDK", async () => { + it("hydrates the search field to 'dev' and passes page=2 to the API", async () => { renderPage(["/?page=2&search=dev"]); expect( @@ -192,11 +194,8 @@ describe("AdminNamespaces", () => { ).toHaveValue("dev"); await waitFor(() => { - expect(sdk.getNamespacesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 2 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); }); }); }); @@ -207,11 +206,8 @@ describe("AdminNamespaces", () => { renderPage(["/?page=2"]); await waitFor(() => { - expect(sdk.getNamespacesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 2 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); }); const searchbox = screen.getByRole("searchbox", { @@ -221,11 +217,7 @@ describe("AdminNamespaces", () => { await user.type(searchbox, "dev"); await waitFor(() => { - expect(sdk.getNamespacesAdmin).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); expect(searchbox).toHaveValue("dev"); }); @@ -240,7 +232,7 @@ describe("AdminNamespaces", () => { vi.useRealTimers(); }); - it("does not pass the new search to the SDK until the debounce delay elapses", async () => { + it("does not pass the new search to the API until the debounce delay elapses", async () => { renderPage(["/"]); const searchbox = screen.getByRole("searchbox", { @@ -251,18 +243,17 @@ describe("AdminNamespaces", () => { fireEvent.change(searchbox, { target: { value: "dev" } }); }); - const hasFilter = (calls: unknown[][]) => - calls.some( - ([args]) => (args as { query?: { filter?: string } })?.query?.filter, - ); - expect(hasFilter(sdk.getNamespacesAdmin.mock.calls)).toBe(false); + const hasFilter = () => + lastRequestUrl !== null && + lastRequestUrl.searchParams.get("filter") !== null; + expect(hasFilter()).toBe(false); act(() => { vi.advanceTimersByTime(350); }); await waitFor(() => { - expect(hasFilter(sdk.getNamespacesAdmin.mock.calls)).toBe(true); + expect(hasFilter()).toBe(true); }); }); }); diff --git a/ui/apps/console/src/pages/admin/namespaces/__tests__/DeleteNamespaceDialog.test.tsx b/ui/apps/console/src/pages/admin/namespaces/__tests__/DeleteNamespaceDialog.test.tsx index e3d08c6b411..3a6f1c65c19 100644 --- a/ui/apps/console/src/pages/admin/namespaces/__tests__/DeleteNamespaceDialog.test.tsx +++ b/ui/apps/console/src/pages/admin/namespaces/__tests__/DeleteNamespaceDialog.test.tsx @@ -1,16 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import DeleteNamespaceDialog from "../DeleteNamespaceDialog"; -const sdk = vi.hoisted(() => - mockSdkGen({ - deleteNamespaceAdmin: vi.fn(), - }), -); - vi.mock("@/components/common/ConfirmDialog", async () => ({ default: (await import("@/tests/mocks")).MockConfirmDialog, })); @@ -22,9 +17,17 @@ const mockNamespace = { const Wrapper = createTestWrapper(); +const deleteSpy = vi.fn(); + beforeEach(() => { vi.clearAllMocks(); - sdk.deleteNamespaceAdmin.mockResolvedValue(mockSdkResponse(undefined)); + deleteSpy.mockReset(); + server.use( + http.delete("*/admin/api/namespaces/:tenant", ({ params }) => { + deleteSpy({ path: { tenant: params.tenant } }); + return new HttpResponse(null, { status: 204 }); + }), + ); }); function renderDialog( @@ -106,7 +109,7 @@ describe("DeleteNamespaceDialog", () => { await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); await waitFor(() => { - expect(sdk.deleteNamespaceAdmin).toHaveBeenCalledWith( + expect(deleteSpy).toHaveBeenCalledWith( expect.objectContaining({ path: { tenant: "tenant-xyz" }, }), @@ -154,7 +157,11 @@ describe("DeleteNamespaceDialog", () => { describe("confirm — error handling", () => { it("shows generic error message on failure", async () => { - sdk.deleteNamespaceAdmin.mockRejectedValue(new Error("server error")); + server.use( + http.delete("*/admin/api/namespaces/:tenant", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); @@ -167,7 +174,11 @@ describe("DeleteNamespaceDialog", () => { }); it("shows error for SDK errors", async () => { - sdk.deleteNamespaceAdmin.mockRejectedValue({ status: 500 }); + server.use( + http.delete("*/admin/api/namespaces/:tenant", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); @@ -180,7 +191,11 @@ describe("DeleteNamespaceDialog", () => { }); it("does not call onDeleted when deletion fails", async () => { - sdk.deleteNamespaceAdmin.mockRejectedValue(new Error("server error")); + server.use( + http.delete("*/admin/api/namespaces/:tenant", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const { onDeleted } = renderDialog(); await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); @@ -190,7 +205,11 @@ describe("DeleteNamespaceDialog", () => { }); it("does not call onClose when deletion fails", async () => { - sdk.deleteNamespaceAdmin.mockRejectedValue(new Error("server error")); + server.use( + http.delete("*/admin/api/namespaces/:tenant", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const { onClose } = renderDialog(); await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); @@ -210,7 +229,7 @@ describe("DeleteNamespaceDialog", () => { it("does not call deleteNamespaceAdmin when Cancel is clicked", async () => { renderDialog(); await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(sdk.deleteNamespaceAdmin).not.toHaveBeenCalled(); + expect(deleteSpy).not.toHaveBeenCalled(); }); it("does not call onDeleted when Cancel is clicked", async () => { @@ -229,9 +248,7 @@ describe("DeleteNamespaceDialog", () => { it("does not call deleteNamespaceAdmin when confirmed with null namespace", async () => { renderDialog({ namespace: null }); await userEvent.click(screen.getByRole("button", { name: /^delete$/i })); - await waitFor(() => - expect(sdk.deleteNamespaceAdmin).not.toHaveBeenCalled(), - ); + await waitFor(() => expect(deleteSpy).not.toHaveBeenCalled()); }); }); diff --git a/ui/apps/console/src/pages/admin/namespaces/__tests__/EditNamespaceDrawer.test.tsx b/ui/apps/console/src/pages/admin/namespaces/__tests__/EditNamespaceDrawer.test.tsx index b8442156325..3fe3ea21d1f 100644 --- a/ui/apps/console/src/pages/admin/namespaces/__tests__/EditNamespaceDrawer.test.tsx +++ b/ui/apps/console/src/pages/admin/namespaces/__tests__/EditNamespaceDrawer.test.tsx @@ -1,17 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; -import type { Namespace } from "@/client"; +import type { Namespace } from "@/client/model"; import EditNamespaceDrawer from "../EditNamespaceDrawer"; -const sdk = vi.hoisted(() => - mockSdkGen({ - editNamespaceAdmin: vi.fn(), - }), -); - vi.mock("@/components/common/Drawer", async () => ({ default: (await import("@/tests/mocks")).MockDrawer, })); @@ -37,6 +32,8 @@ const mockNamespace: Namespace = { devices_rejected_count: 0, }; +const editSpy = vi.fn(); + function renderDrawer( overrides: Partial<{ open: boolean; @@ -55,7 +52,18 @@ function renderDrawer( describe("EditNamespaceDrawer", () => { beforeEach(() => { vi.clearAllMocks(); - sdk.editNamespaceAdmin.mockResolvedValue(mockSdkResponse(undefined)); + editSpy.mockReset(); + server.use( + http.put("*/admin/api/namespaces-update/:tenantID", async ({ request, params }) => { + let body = await request.json(); + if (typeof body === "string") body = JSON.parse(body); + editSpy({ + path: { tenantID: params.tenantID }, + body, + }); + return HttpResponse.json({}); + }), + ); }); describe("rendering — closed", () => { @@ -179,7 +187,7 @@ describe("EditNamespaceDrawer", () => { ); await waitFor(() => { - expect(sdk.editNamespaceAdmin).toHaveBeenCalledWith( + expect(editSpy).toHaveBeenCalledWith( expect.objectContaining({ path: { tenantID: "tenant-abc" }, body: expect.objectContaining({ @@ -202,7 +210,7 @@ describe("EditNamespaceDrawer", () => { ); await waitFor(() => { - expect(sdk.editNamespaceAdmin).toHaveBeenCalledWith( + expect(editSpy).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ owner: "owner-1", @@ -223,7 +231,7 @@ describe("EditNamespaceDrawer", () => { ); await waitFor(() => { - expect(sdk.editNamespaceAdmin).toHaveBeenCalledWith( + expect(editSpy).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ settings: expect.objectContaining({ session_record: false }), @@ -246,7 +254,11 @@ describe("EditNamespaceDrawer", () => { describe("submit — error handling", () => { it("shows conflict error message for 409 responses", async () => { - sdk.editNamespaceAdmin.mockRejectedValue({ status: 409 }); + server.use( + http.put("*/admin/api/namespaces-update/:tenantID", () => + HttpResponse.json({}, { status: 409 }), + ), + ); renderDrawer(); await userEvent.click( @@ -261,7 +273,11 @@ describe("EditNamespaceDrawer", () => { }); it("shows generic error for non-409 SDK errors", async () => { - sdk.editNamespaceAdmin.mockRejectedValue({ status: 500 }); + server.use( + http.put("*/admin/api/namespaces-update/:tenantID", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderDrawer(); await userEvent.click( @@ -276,7 +292,11 @@ describe("EditNamespaceDrawer", () => { }); it("shows generic error for non-SDK errors", async () => { - sdk.editNamespaceAdmin.mockRejectedValue(new Error("network error")); + server.use( + http.put("*/admin/api/namespaces-update/:tenantID", () => + HttpResponse.error(), + ), + ); renderDrawer(); await userEvent.click( @@ -291,7 +311,11 @@ describe("EditNamespaceDrawer", () => { }); it("renders error with role='alert'", async () => { - sdk.editNamespaceAdmin.mockRejectedValue(new Error("network error")); + server.use( + http.put("*/admin/api/namespaces-update/:tenantID", () => + HttpResponse.error(), + ), + ); renderDrawer(); await userEvent.click( @@ -304,7 +328,11 @@ describe("EditNamespaceDrawer", () => { }); it("does not call onClose when update fails", async () => { - sdk.editNamespaceAdmin.mockRejectedValue(new Error("network error")); + server.use( + http.put("*/admin/api/namespaces-update/:tenantID", () => + HttpResponse.error(), + ), + ); const { onClose } = renderDrawer(); await userEvent.click( @@ -326,7 +354,7 @@ describe("EditNamespaceDrawer", () => { it("does not call editNamespaceAdmin when Cancel is clicked", async () => { renderDrawer(); await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(sdk.editNamespaceAdmin).not.toHaveBeenCalled(); + expect(editSpy).not.toHaveBeenCalled(); }); }); @@ -359,7 +387,11 @@ describe("EditNamespaceDrawer", () => { }); it("clears any error when closed then reopened", async () => { - sdk.editNamespaceAdmin.mockRejectedValue(new Error("fail")); + server.use( + http.put("*/admin/api/namespaces-update/:tenantID", () => + HttpResponse.error(), + ), + ); const { rerender } = renderDrawer({ namespace: mockNamespace }); await userEvent.click( diff --git a/ui/apps/console/src/pages/admin/namespaces/editNamespaceSchema.ts b/ui/apps/console/src/pages/admin/namespaces/editNamespaceSchema.ts index b23e006afab..ed67dc961be 100644 --- a/ui/apps/console/src/pages/admin/namespaces/editNamespaceSchema.ts +++ b/ui/apps/console/src/pages/admin/namespaces/editNamespaceSchema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { validateNamespaceName } from "@/utils/validation"; -import type { Namespace } from "@/client"; +import type { Namespace } from "@/client/model"; const editNamespaceFields = z.object({ name: z.string(), diff --git a/ui/apps/console/src/pages/admin/namespaces/index.tsx b/ui/apps/console/src/pages/admin/namespaces/index.tsx index c8835f48589..a53e308d5e3 100644 --- a/ui/apps/console/src/pages/admin/namespaces/index.tsx +++ b/ui/apps/console/src/pages/admin/namespaces/index.tsx @@ -5,10 +5,13 @@ import { PencilSquareIcon, TrashIcon, } from "@heroicons/react/24/outline"; -import { useAdminNamespaces } from "@/hooks/useAdminNamespaces"; +import { useGetNamespacesAdmin } from "@/client/api"; +import type { GetNamespacesAdminParams } from "@/client/model"; +import { totalCount } from "@/api/pagination"; +import { toBase64Json } from "@/utils/encoding"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { usePaginatedListState } from "@/hooks/usePaginatedListState"; -import type { Namespace } from "@/client"; +import type { Namespace } from "@/client/model"; import PageHeader from "@/components/common/PageHeader"; import DataTable, { type Column } from "@/components/common/DataTable"; import SearchField from "@/components/common/fields/SearchField"; @@ -48,13 +51,26 @@ export default function AdminNamespaces() { const [editTarget, setEditTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); - const { namespaces, totalCount, isLoading, error } = useAdminNamespaces({ + const requestParams: GetNamespacesAdminParams = { page: params.page, - perPage: PER_PAGE, - search: debouncedSearch, - }); + per_page: PER_PAGE, + }; + if (debouncedSearch) { + requestParams.filter = toBase64Json([ + { + type: "property", + params: { name: "name", operator: "contains", value: debouncedSearch }, + }, + ]); + } + const { + data: namespaces = [], + isLoading, + error, + } = useGetNamespacesAdmin(requestParams); + const total = totalCount(namespaces); - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const columns: Column[] = [ { @@ -164,7 +180,7 @@ export default function AdminNamespaces() { loadingMessage="Loading namespaces..." page={params.page} totalPages={totalPages} - totalCount={totalCount} + totalCount={total} itemLabel="namespace" onPageChange={setPage} onRowClick={(ns) => void navigate(`/admin/namespaces/${ns.tenant_id}`)} diff --git a/ui/apps/console/src/pages/admin/settings/Authentication.tsx b/ui/apps/console/src/pages/admin/settings/Authentication.tsx index 671e7dcc374..5b0ad923369 100644 --- a/ui/apps/console/src/pages/admin/settings/Authentication.tsx +++ b/ui/apps/console/src/pages/admin/settings/Authentication.tsx @@ -6,9 +6,9 @@ import { import { getAuthenticationSettings, configureLocalAuthentication, - configureSamlAuthentication, -} from "@/client"; -import type { GetAuthenticationSettingsResponse } from "@/client"; + configureSAMLAuthentication, +} from "@/client/api"; +import type { GetAuthenticationSettings200 as GetAuthenticationSettingsResponse } from "@/client/model"; import { isSdkError } from "@/api/errors"; import PageHeader from "@/components/common/PageHeader"; import CopyButton from "@/components/common/CopyButton"; @@ -44,9 +44,7 @@ export default function AdminAuthentication() { let cancelled = false; void (async () => { try { - const { data } = await getAuthenticationSettings({ - throwOnError: true, - }); + const data = await getAuthenticationSettings(); if (!cancelled) setSettings(data); } catch { if (!cancelled) setError("Failed to load authentication settings."); @@ -63,10 +61,7 @@ export default function AdminAuthentication() { setTogglingLocal(true); setError(null); try { - await configureLocalAuthentication({ - body: { enable: !settings?.local?.enabled }, - throwOnError: true, - }); + await configureLocalAuthentication({ enable: !settings?.local?.enabled }); refresh(); } catch (err) { setError( @@ -87,13 +82,10 @@ export default function AdminAuthentication() { setTogglingSaml(true); setError(null); try { - await configureSamlAuthentication({ - body: { - enable: false, - idp: { entity_id: "", binding: {}, certificate: "" }, - sp: {}, - }, - throwOnError: true, + await configureSAMLAuthentication({ + enable: false, + idp: { entity_id: "", binding: {}, certificate: "" }, + sp: {}, }); refresh(); } catch (err) { diff --git a/ui/apps/console/src/pages/admin/settings/SamlConfigDrawer.tsx b/ui/apps/console/src/pages/admin/settings/SamlConfigDrawer.tsx index 1594a92f084..2b4ce306d3e 100644 --- a/ui/apps/console/src/pages/admin/settings/SamlConfigDrawer.tsx +++ b/ui/apps/console/src/pages/admin/settings/SamlConfigDrawer.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useWatch } from "react-hook-form"; import { KeyIcon, ChevronDownIcon, ExclamationCircleIcon } from "@heroicons/react/24/outline"; -import { configureSamlAuthentication } from "@/client"; +import { configureSAMLAuthentication } from "@/client/api"; import { useResetOnOpen } from "@/hooks/useResetOnOpen"; import { useDrawerForm } from "@/hooks/useDrawerForm"; import { cn } from "@shellhub/design-system/cn"; @@ -51,10 +51,7 @@ export default function SamlConfigDrawer({ const onSubmit = async (values: SamlFormValues) => { clearErrors("root"); try { - await configureSamlAuthentication({ - body: buildSamlBody(values), - throwOnError: true, - }); + await configureSAMLAuthentication(buildSamlBody(values)); onSaved(); onClose(); } catch { diff --git a/ui/apps/console/src/pages/admin/settings/__tests__/Authentication.test.tsx b/ui/apps/console/src/pages/admin/settings/__tests__/Authentication.test.tsx index 2a98fa5ba5a..d257962ea01 100644 --- a/ui/apps/console/src/pages/admin/settings/__tests__/Authentication.test.tsx +++ b/ui/apps/console/src/pages/admin/settings/__tests__/Authentication.test.tsx @@ -1,16 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import AdminAuthentication from "../Authentication"; -import { mockSdkResponse } from "@/tests/sdk"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getAuthenticationSettings: vi.fn(), - configureLocalAuthentication: vi.fn(), - configureSamlAuthentication: vi.fn(), - }), -); +const localSpy = vi.fn(); +const samlSpy = vi.fn(); function mockSettings({ localEnabled = true, samlEnabled = false } = {}) { return { @@ -19,6 +15,14 @@ function mockSettings({ localEnabled = true, samlEnabled = false } = {}) { }; } +function setSettings(settings: ReturnType) { + server.use( + http.get("*/admin/api/authentication", () => + HttpResponse.json(settings), + ), + ); +} + function renderPage() { return render(); } @@ -30,17 +34,24 @@ async function settlePendingLoad() { } beforeEach(() => { - sdk.getAuthenticationSettings.mockReset(); - sdk.configureLocalAuthentication.mockReset(); - sdk.configureSamlAuthentication.mockReset(); - sdk.configureLocalAuthentication.mockResolvedValue(mockSdkResponse(undefined)); - sdk.configureSamlAuthentication.mockResolvedValue(mockSdkResponse(undefined)); + localSpy.mockReset(); + samlSpy.mockReset(); + server.use( + http.put("*/admin/api/authentication/local", async ({ request }) => { + localSpy({ body: await request.json() }); + return HttpResponse.json({}); + }), + http.put("*/admin/api/authentication/saml", async ({ request }) => { + samlSpy({ body: await request.json() }); + return HttpResponse.json({}); + }), + ); }); describe("AdminAuthentication", () => { describe("DS Toggle usage", () => { it("renders the local-auth and SAML rows as role='switch' toggles", async () => { - sdk.getAuthenticationSettings.mockResolvedValue(mockSdkResponse(mockSettings())); + setSettings(mockSettings()); renderPage(); await settlePendingLoad(); @@ -55,7 +66,7 @@ describe("AdminAuthentication", () => { it("clicking the local-auth toggle fires configureLocalAuthentication with the flipped value", async () => { const user = userEvent.setup(); - sdk.getAuthenticationSettings.mockResolvedValue(mockSdkResponse(mockSettings())); + setSettings(mockSettings()); renderPage(); await settlePendingLoad(); @@ -64,16 +75,16 @@ describe("AdminAuthentication", () => { screen.getByRole("switch", { name: "Toggle local authentication" }), ); - expect(sdk.configureLocalAuthentication).toHaveBeenCalledWith( - expect.objectContaining({ body: { enable: false } }), - ); + await waitFor(() => { + expect(localSpy).toHaveBeenCalledWith( + expect.objectContaining({ body: { enable: false } }), + ); + }); }); it("clicking the SAML toggle to turn it off fires configureSamlAuthentication with enable: false", async () => { const user = userEvent.setup(); - sdk.getAuthenticationSettings.mockResolvedValue( - mockSdkResponse(mockSettings({ samlEnabled: true })), - ); + setSettings(mockSettings({ samlEnabled: true })); renderPage(); await settlePendingLoad(); @@ -82,21 +93,25 @@ describe("AdminAuthentication", () => { screen.getByRole("switch", { name: "Toggle SAML authentication" }), ); - expect(sdk.configureSamlAuthentication).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ enable: false }), - }), - ); + await waitFor(() => { + expect(samlSpy).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ enable: false }), + }), + ); + }); }); it("disables the local-auth toggle while togglingLocal is true", async () => { const user = userEvent.setup(); - sdk.getAuthenticationSettings.mockResolvedValue(mockSdkResponse(mockSettings())); - let resolveConfigure: (() => void) | undefined; - sdk.configureLocalAuthentication.mockReturnValue( - new Promise((resolve) => { - resolveConfigure = () => resolve(mockSdkResponse(undefined)); - }), + setSettings(mockSettings()); + let resolveLocal: (() => void) | undefined; + server.use( + http.put("*/admin/api/authentication/local", () => + new Promise((resolve) => { + resolveLocal = () => resolve(HttpResponse.json({})); + }), + ), ); renderPage(); @@ -112,20 +127,20 @@ describe("AdminAuthentication", () => { screen.getByRole("switch", { name: "Toggle SAML authentication" }), ).not.toBeDisabled(); - resolveConfigure?.(); + resolveLocal?.(); await waitFor(() => expect(localToggle).not.toBeDisabled()); }); it("disables the SAML toggle while togglingSaml is true", async () => { const user = userEvent.setup(); - sdk.getAuthenticationSettings.mockResolvedValue( - mockSdkResponse(mockSettings({ samlEnabled: true })), - ); - let resolveConfigure: (() => void) | undefined; - sdk.configureSamlAuthentication.mockReturnValue( - new Promise((resolve) => { - resolveConfigure = () => resolve(mockSdkResponse(undefined)); - }), + setSettings(mockSettings({ samlEnabled: true })); + let resolveSaml: (() => void) | undefined; + server.use( + http.put("*/admin/api/authentication/saml", () => + new Promise((resolve) => { + resolveSaml = () => resolve(HttpResponse.json({})); + }), + ), ); renderPage(); @@ -141,7 +156,7 @@ describe("AdminAuthentication", () => { screen.getByRole("switch", { name: "Toggle local authentication" }), ).not.toBeDisabled(); - resolveConfigure?.(); + resolveSaml?.(); await waitFor(() => expect(samlToggle).not.toBeDisabled()); }); }); diff --git a/ui/apps/console/src/pages/admin/settings/__tests__/SamlConfigDrawer.test.tsx b/ui/apps/console/src/pages/admin/settings/__tests__/SamlConfigDrawer.test.tsx index 7872a6b284c..bbae809122b 100644 --- a/ui/apps/console/src/pages/admin/settings/__tests__/SamlConfigDrawer.test.tsx +++ b/ui/apps/console/src/pages/admin/settings/__tests__/SamlConfigDrawer.test.tsx @@ -1,14 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import SamlConfigDrawer from "../SamlConfigDrawer"; -import { mockSdkResponse } from "@/tests/sdk"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - configureSamlAuthentication: vi.fn(), - }), -); const VALID_URL = "https://idp.example.com/sso"; const VALID_METADATA_URL = "https://idp.example.com/metadata.xml"; @@ -16,6 +11,8 @@ const VALID_ENTITY_ID = "https://idp.example.com/entity"; const VALID_CERT = "-----BEGIN CERTIFICATE-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\n-----END CERTIFICATE-----"; +const samlSpy = vi.fn(); + const defaultProps = { open: true, onClose: vi.fn(), @@ -37,9 +34,15 @@ function getSubmitButton() { describe("SamlConfigDrawer", () => { beforeEach(() => { - sdk.configureSamlAuthentication.mockReset(); + samlSpy.mockReset(); defaultProps.onClose.mockReset(); defaultProps.onSaved.mockReset(); + server.use( + http.put("*/admin/api/authentication/saml", async ({ request }) => { + samlSpy({ body: await request.json() }); + return HttpResponse.json({}); + }), + ); }); describe("mode toggle", () => { @@ -121,7 +124,6 @@ describe("SamlConfigDrawer", () => { describe("successful submission", () => { it("calls the API with correct metadata-mode body and closes the drawer", async () => { - sdk.configureSamlAuthentication.mockResolvedValue(mockSdkResponse({})); const user = userEvent.setup(); renderDrawer(); @@ -132,15 +134,14 @@ describe("SamlConfigDrawer", () => { ); await user.click(getSubmitButton()); - await waitFor(() => expect(sdk.configureSamlAuthentication).toHaveBeenCalledTimes(1)); - expect(sdk.configureSamlAuthentication).toHaveBeenCalledWith( + await waitFor(() => expect(samlSpy).toHaveBeenCalledTimes(1)); + expect(samlSpy).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ enable: true, idp: { metadata_url: VALID_METADATA_URL }, sp: { sign_requests: false }, }), - throwOnError: true, }), ); expect(defaultProps.onSaved).toHaveBeenCalledTimes(1); @@ -148,7 +149,6 @@ describe("SamlConfigDrawer", () => { }); it("calls the API with correct manual-mode body", async () => { - sdk.configureSamlAuthentication.mockResolvedValue(mockSdkResponse({})); const user = userEvent.setup(); renderDrawer(); @@ -157,8 +157,8 @@ describe("SamlConfigDrawer", () => { await user.type(screen.getByLabelText(/x\.509 certificate/i), VALID_CERT); await user.click(getSubmitButton()); - await waitFor(() => expect(sdk.configureSamlAuthentication).toHaveBeenCalledTimes(1)); - expect(sdk.configureSamlAuthentication).toHaveBeenCalledWith( + await waitFor(() => expect(samlSpy).toHaveBeenCalledTimes(1)); + expect(samlSpy).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ enable: true, @@ -168,7 +168,6 @@ describe("SamlConfigDrawer", () => { }), sp: { sign_requests: false }, }), - throwOnError: true, }), ); }); @@ -176,7 +175,11 @@ describe("SamlConfigDrawer", () => { describe("save failure", () => { it("displays an error alert when the API call fails", async () => { - sdk.configureSamlAuthentication.mockRejectedValue(new Error("network error")); + server.use( + http.put("*/admin/api/authentication/saml", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const user = userEvent.setup(); renderDrawer(); diff --git a/ui/apps/console/src/pages/admin/settings/samlSchema.ts b/ui/apps/console/src/pages/admin/settings/samlSchema.ts index 67cd35d9c56..2608cdd7d89 100644 --- a/ui/apps/console/src/pages/admin/settings/samlSchema.ts +++ b/ui/apps/console/src/pages/admin/settings/samlSchema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { GetAuthenticationSettingsResponse } from "@/client"; +import type { GetAuthenticationSettings200 as GetAuthenticationSettingsResponse } from "@/client/model"; /** * The stored SAML configuration, taken from the generated response type so the form and the API diff --git a/ui/apps/console/src/pages/admin/users/CreateUserDrawer.tsx b/ui/apps/console/src/pages/admin/users/CreateUserDrawer.tsx index 3828778fd2d..8620308e3a3 100644 --- a/ui/apps/console/src/pages/admin/users/CreateUserDrawer.tsx +++ b/ui/apps/console/src/pages/admin/users/CreateUserDrawer.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { PlusIcon } from "@heroicons/react/24/outline"; -import { useCreateUser } from "@/hooks/useAdminUserMutations"; +import { useCreateUserAdmin } from "@/client/api"; import { isSdkError } from "@/api/errors"; import FormDrawer from "@/components/common/FormDrawer"; import { useDrawerForm } from "@/hooks/useDrawerForm"; @@ -25,7 +25,7 @@ export default function CreateUserDrawer({ open, onClose, }: CreateUserDrawerProps) { - const createUser = useCreateUser(); + const createUser = useCreateUserAdmin(); const schema = useMemo(() => userSchema("create"), []); const defaults = useMemo(() => buildUserDefaults(), []); @@ -36,12 +36,15 @@ export default function CreateUserDrawer({ const onValid = async (values: UserFormValues) => { clearErrors("root"); try { - await createUser.mutateAsync({ body: buildUserPayload("create", values) }); + await createUser.mutateAsync({ + data: buildUserPayload("create", values), + }); onClose(); } catch (err) { - const message = isSdkError(err) && err.status === 409 - ? "A user with this email or username already exists." - : "Failed to create user. Please try again."; + const message = + isSdkError(err) && err.status === 409 + ? "A user with this email or username already exists." + : "Failed to create user. Please try again."; setError("root", { message }); } diff --git a/ui/apps/console/src/pages/admin/users/DeleteUserDialog.tsx b/ui/apps/console/src/pages/admin/users/DeleteUserDialog.tsx index 61f36931cc7..820ce0b5139 100644 --- a/ui/apps/console/src/pages/admin/users/DeleteUserDialog.tsx +++ b/ui/apps/console/src/pages/admin/users/DeleteUserDialog.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useDeleteUser } from "@/hooks/useAdminUserMutations"; +import { useAdminDeleteUser } from "@/client/api"; import ConfirmDialog from "@/components/common/ConfirmDialog"; interface DeleteUserDialogProps { @@ -18,7 +18,7 @@ export default function DeleteUserDialog({ user, onDeleted, }: DeleteUserDialogProps) { - const deleteUser = useDeleteUser(); + const deleteUser = useAdminDeleteUser(); const [error, setError] = useState(""); return ( @@ -32,7 +32,7 @@ export default function DeleteUserDialog({ if (!user) return; setError(""); try { - await deleteUser.mutateAsync({ path: { id: user.id } }); + await deleteUser.mutateAsync({ id: user.id }); onClose(); onDeleted?.(); } catch { diff --git a/ui/apps/console/src/pages/admin/users/EditUserDrawer.tsx b/ui/apps/console/src/pages/admin/users/EditUserDrawer.tsx index a0d9a513099..c225a7f077d 100644 --- a/ui/apps/console/src/pages/admin/users/EditUserDrawer.tsx +++ b/ui/apps/console/src/pages/admin/users/EditUserDrawer.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useUpdateUser } from "@/hooks/useAdminUserMutations"; +import { useAdminUpdateUser } from "@/client/api"; import { useAuthStore } from "@/stores/authStore"; import { isSdkError } from "@/api/errors"; import FormDrawer from "@/components/common/FormDrawer"; @@ -11,7 +11,7 @@ import { buildUserPayload, type UserFormValues, } from "./userSchema"; -import type { UserAdminResponse } from "@/client"; +import type { UserAdminResponse } from "@/client/model"; interface EditUserDrawerProps { open: boolean; @@ -27,7 +27,7 @@ export default function EditUserDrawer({ onClose, user, }: EditUserDrawerProps) { - const updateUser = useUpdateUser(); + const updateUser = useAdminUpdateUser(); const currentUserId = useAuthStore((s) => s.userId); const schema = useMemo(() => userSchema("edit"), []); @@ -45,8 +45,8 @@ export default function EditUserDrawer({ clearErrors("root"); try { await updateUser.mutateAsync({ - path: { id: user.id }, - body: buildUserPayload("edit", values, user), + id: user.id, + data: buildUserPayload("edit", values, user), }); onClose(); } catch (err) { diff --git a/ui/apps/console/src/pages/admin/users/ResetPasswordDialog.tsx b/ui/apps/console/src/pages/admin/users/ResetPasswordDialog.tsx index 4d863ede5f4..a377223f671 100644 --- a/ui/apps/console/src/pages/admin/users/ResetPasswordDialog.tsx +++ b/ui/apps/console/src/pages/admin/users/ResetPasswordDialog.tsx @@ -1,7 +1,7 @@ import { useState, useId } from "react"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { useResetOnOpen } from "@/hooks/useResetOnOpen"; -import { useResetUserPassword } from "@/hooks/useAdminUserMutations"; +import { useAdminResetUserPassword } from "@/client/api"; import { isSdkError } from "@/api/errors"; import CopyButton from "@/components/common/CopyButton"; import BaseDialog from "@/components/common/BaseDialog"; @@ -23,7 +23,7 @@ export default function ResetPasswordDialog({ onClose, userId, }: ResetPasswordDialogProps) { - const resetPassword = useResetUserPassword(); + const resetPassword = useAdminResetUserPassword(); const [step, setStep] = useState<"confirm" | "result">("confirm"); const [generatedPassword, setGeneratedPassword] = useState(""); const [error, setError] = useState(""); @@ -41,7 +41,7 @@ export default function ResetPasswordDialog({ const handleEnable = async () => { setError(""); try { - const data = await resetPassword.mutateAsync({ path: { id: userId } }); + const data = await resetPassword.mutateAsync({ id: userId }); setGeneratedPassword(data?.password ?? ""); setStep("result"); } catch (err) { diff --git a/ui/apps/console/src/pages/admin/users/UserDetails.tsx b/ui/apps/console/src/pages/admin/users/UserDetails.tsx index ff7c3c6853f..42f5debb345 100644 --- a/ui/apps/console/src/pages/admin/users/UserDetails.tsx +++ b/ui/apps/console/src/pages/admin/users/UserDetails.tsx @@ -10,7 +10,7 @@ import { KeyIcon, } from "@heroicons/react/24/outline"; import { cn } from "@shellhub/design-system/cn"; -import { useAdminUser } from "@/hooks/useAdminUsers"; +import { useGetUser } from "@/client/api"; import Breadcrumb from "@/components/common/Breadcrumb"; import { useLoginAsUser } from "@/hooks/useLoginAsUser"; import UserStatusChip from "./UserStatusChip"; @@ -40,10 +40,13 @@ function formatMaxNamespaces(value: number): string { * One user, seen from the admin area: their namespaces, their status, and the actions on them. */ export default function UserDetails() { - const { id } = useParams<{ id: string }>(); + const { id = "" } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { data, isLoading, error } = useAdminUser(id ?? ""); - const user = data; + const { + data: user, + isLoading, + error, + } = useGetUser(id, { query: { enabled: !!id } }); const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); diff --git a/ui/apps/console/src/pages/admin/users/__tests__/AdminUsers.test.tsx b/ui/apps/console/src/pages/admin/users/__tests__/AdminUsers.test.tsx index 96118725ef8..35b93d99523 100644 --- a/ui/apps/console/src/pages/admin/users/__tests__/AdminUsers.test.tsx +++ b/ui/apps/console/src/pages/admin/users/__tests__/AdminUsers.test.tsx @@ -8,18 +8,13 @@ import { } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import AdminUsers from "../index"; -import type { UserAdminResponse } from "@/client"; -import { makeSdkError, paginatedResponse } from "@/tests/sdk"; +import type { UserAdminResponse } from "@/client/model"; import { createTestWrapper } from "@/tests/wrapper"; import { useAuthStore } from "@/stores/authStore"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getUsers: vi.fn(), - }), -); - vi.mock("../AccountRequestsTab", () => ({ default: () => null, })); @@ -62,6 +57,17 @@ function mockAdminUser( }; } +let lastRequestUrl: URL | null; + +function setUsers(users: UserAdminResponse[], total?: number) { + server.use( + http.get("*/admin/api/users", ({ request }) => { + lastRequestUrl = new URL(request.url); + return jsonWithTotal(users, total ?? users.length); + }), + ); +} + function renderPage(initialEntries: string[] = ["/"]) { return render( @@ -74,8 +80,9 @@ function renderPage(initialEntries: string[] = ["/"]) { describe("AdminUsers", () => { beforeEach(() => { vi.clearAllMocks(); + lastRequestUrl = null; useAuthStore.setState({ isAdmin: true }); - sdk.getUsers.mockResolvedValue(paginatedResponse([])); + setUsers([]); }); describe("rendering", () => { @@ -96,7 +103,9 @@ describe("AdminUsers", () => { describe("loading state", () => { it('renders the loading spinner with "Loading users..." text', () => { - sdk.getUsers.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/admin/api/users", () => new Promise(() => {})), + ); renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); expect(screen.getByText("Loading users...")).toBeInTheDocument(); @@ -112,12 +121,10 @@ describe("AdminUsers", () => { describe("user rows", () => { it("renders a row for each returned user", async () => { - sdk.getUsers.mockResolvedValue( - paginatedResponse([ - mockAdminUser({ id: "id-1", name: "Alice Smith" }), - mockAdminUser({ id: "id-2", name: "Bob Jones" }), - ]), - ); + setUsers([ + mockAdminUser({ id: "id-1", name: "Alice Smith" }), + mockAdminUser({ id: "id-2", name: "Bob Jones" }), + ]); renderPage(); expect(await screen.findByText("Alice Smith")).toBeInTheDocument(); expect(screen.getByText("Bob Jones")).toBeInTheDocument(); @@ -125,11 +132,9 @@ describe("AdminUsers", () => { it("navigates to user detail page when a row is clicked", async () => { const user = userEvent.setup(); - sdk.getUsers.mockResolvedValue( - paginatedResponse([ - mockAdminUser({ id: "uid-abc", name: "Clickable User" }), - ]), - ); + setUsers([ + mockAdminUser({ id: "uid-abc", name: "Clickable User" }), + ]); renderPage(); await user.click(await screen.findByText("Clickable User")); expect(mockNavigate).toHaveBeenCalledWith("/admin/users/uid-abc"); @@ -138,7 +143,11 @@ describe("AdminUsers", () => { describe("error state", () => { it("renders an error alert when the SDK returns an error", async () => { - sdk.getUsers.mockRejectedValue(makeSdkError(500)); + server.use( + http.get("*/admin/api/users", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect( @@ -148,28 +157,22 @@ describe("AdminUsers", () => { }); describe("URL hydration — controls reflect URL params on mount", () => { - it("passes search and page hydrated from URL to the SDK", async () => { + it("passes search and page hydrated from URL to the API", async () => { renderPage(["/?search=foo&page=2"]); await waitFor(() => { - expect(sdk.getUsers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 2 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("2"); }); expect( screen.getByRole("searchbox", { name: "Search users by username" }), ).toHaveValue("foo"); }); - it("passes page=1 to the SDK when URL has no params", async () => { + it("passes page=1 to the API when URL has no params", async () => { renderPage(["/"]); await waitFor(() => { - expect(sdk.getUsers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); }); }); @@ -187,17 +190,13 @@ describe("AdminUsers", () => { await user.clear(searchbox); await waitFor(() => { - expect(sdk.getUsers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); }); }); describe("URL hydration — ?page=3&search=alice hydrates controls", () => { - it("hydrates the search field to 'alice' and passes page=3 to the SDK", async () => { + it("hydrates the search field to 'alice' and passes page=3 to the API", async () => { renderPage(["/?page=3&search=alice"]); expect( @@ -205,11 +204,8 @@ describe("AdminUsers", () => { ).toHaveValue("alice"); await waitFor(() => { - expect(sdk.getUsers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 3 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); }); }); }); @@ -220,11 +216,8 @@ describe("AdminUsers", () => { renderPage(["/?page=3"]); await waitFor(() => { - expect(sdk.getUsers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 3 }), - }), - ); + expect(lastRequestUrl).not.toBeNull(); + expect(lastRequestUrl!.searchParams.get("page")).toBe("3"); }); const searchbox = screen.getByRole("searchbox", { @@ -234,11 +227,7 @@ describe("AdminUsers", () => { await user.type(searchbox, "bob"); await waitFor(() => { - expect(sdk.getUsers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastRequestUrl!.searchParams.get("page")).toBe("1"); }); expect(searchbox).toHaveValue("bob"); }); @@ -253,7 +242,7 @@ describe("AdminUsers", () => { vi.useRealTimers(); }); - it("does not pass the new search to the SDK until the debounce delay elapses", async () => { + it("does not pass the new search to the API until the debounce delay elapses", async () => { renderPage(["/"]); const searchbox = screen.getByRole("searchbox", { @@ -264,18 +253,17 @@ describe("AdminUsers", () => { fireEvent.change(searchbox, { target: { value: "alice" } }); }); - const hasFilter = (calls: unknown[][]) => - calls.some( - ([args]) => (args as { query?: { filter?: string } })?.query?.filter, - ); - expect(hasFilter(sdk.getUsers.mock.calls)).toBe(false); + const hasFilter = () => + lastRequestUrl !== null && + lastRequestUrl.searchParams.get("filter") !== null; + expect(hasFilter()).toBe(false); act(() => { vi.advanceTimersByTime(350); }); await waitFor(() => { - expect(hasFilter(sdk.getUsers.mock.calls)).toBe(true); + expect(hasFilter()).toBe(true); }); }); }); diff --git a/ui/apps/console/src/pages/admin/users/__tests__/CreateUserDrawer.test.tsx b/ui/apps/console/src/pages/admin/users/__tests__/CreateUserDrawer.test.tsx index b62b11fcdbf..fd33e84c2a1 100644 --- a/ui/apps/console/src/pages/admin/users/__tests__/CreateUserDrawer.test.tsx +++ b/ui/apps/console/src/pages/admin/users/__tests__/CreateUserDrawer.test.tsx @@ -1,21 +1,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import CreateUserDrawer from "../CreateUserDrawer"; -const sdk = vi.hoisted(() => - mockSdkGen({ - createUserAdmin: vi.fn(), - }), -); - vi.mock("@/components/common/Drawer", async () => ({ default: (await import("@/tests/mocks")).MockDrawer, })); const Wrapper = createTestWrapper(); +const createSpy = vi.fn(); function renderDrawer( overrides: Partial<{ open: boolean; onClose: () => void }> = {}, @@ -50,7 +46,13 @@ async function fillForm({ describe("CreateUserDrawer", () => { beforeEach(() => { vi.clearAllMocks(); - sdk.createUserAdmin.mockResolvedValue(mockSdkResponse(undefined)); + createSpy.mockReset(); + server.use( + http.post("*/admin/api/users", async ({ request }) => { + createSpy({ body: await request.json() }); + return HttpResponse.json({}); + }), + ); }); describe("rendering — closed", () => { @@ -244,7 +246,7 @@ describe("CreateUserDrawer", () => { ); await waitFor(() => { - expect(sdk.createUserAdmin).toHaveBeenCalledWith( + expect(createSpy).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ name: "Alice", @@ -278,9 +280,9 @@ describe("CreateUserDrawer", () => { ); await waitFor(() => { - expect(sdk.createUserAdmin).toHaveBeenCalledWith( + expect(createSpy).toHaveBeenCalledWith( expect.objectContaining({ - body: expect.objectContaining({ max_namespaces: undefined }), + body: expect.not.objectContaining({ max_namespaces: expect.anything() }), }), ); }); @@ -301,7 +303,7 @@ describe("CreateUserDrawer", () => { ); await waitFor(() => { - expect(sdk.createUserAdmin).toHaveBeenCalledWith( + expect(createSpy).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ max_namespaces: 0 }), }), @@ -312,7 +314,11 @@ describe("CreateUserDrawer", () => { describe("submit — error handling", () => { it("shows conflict error message for 409 responses", async () => { - sdk.createUserAdmin.mockRejectedValue({ status: 409 }); + server.use( + http.post("*/admin/api/users", () => + HttpResponse.json({}, { status: 409 }), + ), + ); renderDrawer(); await fillForm(); @@ -326,7 +332,11 @@ describe("CreateUserDrawer", () => { }); it("shows generic error for 400 responses", async () => { - sdk.createUserAdmin.mockRejectedValue({ status: 400 }); + server.use( + http.post("*/admin/api/users", () => + HttpResponse.json({}, { status: 400 }), + ), + ); renderDrawer(); await fillForm(); @@ -340,7 +350,9 @@ describe("CreateUserDrawer", () => { }); it("shows generic error for unexpected failures", async () => { - sdk.createUserAdmin.mockRejectedValue(new Error("network error")); + server.use( + http.post("*/admin/api/users", () => HttpResponse.error()), + ); renderDrawer(); await fillForm(); @@ -354,7 +366,9 @@ describe("CreateUserDrawer", () => { }); it("renders error with role='alert'", async () => { - sdk.createUserAdmin.mockRejectedValue(new Error("network error")); + server.use( + http.post("*/admin/api/users", () => HttpResponse.error()), + ); renderDrawer(); await fillForm(); @@ -368,7 +382,9 @@ describe("CreateUserDrawer", () => { }); it("does not call onClose when creation fails", async () => { - sdk.createUserAdmin.mockRejectedValue(new Error("network error")); + server.use( + http.post("*/admin/api/users", () => HttpResponse.error()), + ); const { onClose } = renderDrawer(); await fillForm(); @@ -418,7 +434,7 @@ describe("CreateUserDrawer", () => { expect(submit).toBeDisabled(); await userEvent.click(submit); - expect(sdk.createUserAdmin).not.toHaveBeenCalled(); + expect(createSpy).not.toHaveBeenCalled(); expect(screen.getByLabelText(/^username$/i)).toHaveAttribute( "aria-invalid", "true", @@ -456,7 +472,7 @@ describe("CreateUserDrawer", () => { it("does not call createUserAdmin when Cancel is clicked", async () => { renderDrawer(); await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(sdk.createUserAdmin).not.toHaveBeenCalled(); + expect(createSpy).not.toHaveBeenCalled(); }); }); @@ -472,7 +488,9 @@ describe("CreateUserDrawer", () => { }); it("clears any error when closed then reopened", async () => { - sdk.createUserAdmin.mockRejectedValue(new Error("fail")); + server.use( + http.post("*/admin/api/users", () => HttpResponse.error()), + ); const { rerender } = renderDrawer(); await fillForm(); await userEvent.click( diff --git a/ui/apps/console/src/pages/admin/users/__tests__/EditUserDrawer.test.tsx b/ui/apps/console/src/pages/admin/users/__tests__/EditUserDrawer.test.tsx index aebd78dc9e4..f37ad8d7e8d 100644 --- a/ui/apps/console/src/pages/admin/users/__tests__/EditUserDrawer.test.tsx +++ b/ui/apps/console/src/pages/admin/users/__tests__/EditUserDrawer.test.tsx @@ -1,18 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import { useAuthStore } from "@/stores/authStore"; -import type { UserAdminResponse } from "@/client"; +import type { UserAdminResponse } from "@/client/model"; import EditUserDrawer from "../EditUserDrawer"; -const sdk = vi.hoisted(() => - mockSdkGen({ - adminUpdateUser: vi.fn(), - }), -); - vi.mock("@/components/common/Drawer", async () => ({ default: (await import("@/tests/mocks")).MockDrawer, })); @@ -35,6 +30,8 @@ const confirmedUser: UserAdminResponse = { status: "confirmed", }; +const updateSpy = vi.fn(); + function renderDrawer( overrides: Partial<{ open: boolean; @@ -53,8 +50,17 @@ function renderDrawer( describe("EditUserDrawer", () => { beforeEach(() => { vi.clearAllMocks(); - sdk.adminUpdateUser.mockResolvedValue(mockSdkResponse(undefined)); + updateSpy.mockReset(); useAuthStore.setState({ userId: "u2" }); + server.use( + http.put("*/admin/api/users/:id", async ({ request, params }) => { + updateSpy({ + path: { id: params.id }, + body: await request.json(), + }); + return HttpResponse.json({}); + }), + ); }); describe("rendering — closed", () => { @@ -290,7 +296,7 @@ describe("EditUserDrawer", () => { ); await waitFor(() => { - expect(sdk.adminUpdateUser).toHaveBeenCalledWith( + expect(updateSpy).toHaveBeenCalledWith( expect.objectContaining({ path: { id: "u1" }, body: expect.objectContaining({ @@ -321,10 +327,9 @@ describe("EditUserDrawer", () => { ); await waitFor(() => { - expect(sdk.adminUpdateUser).toHaveBeenCalledWith( + expect(updateSpy).toHaveBeenCalledWith( expect.objectContaining({ path: { id: "u1" }, - body: expect.objectContaining({ max_namespaces: undefined }), }), ); }); @@ -333,7 +338,11 @@ describe("EditUserDrawer", () => { describe("submit — error handling", () => { it("shows conflict error message for 409 responses", async () => { - sdk.adminUpdateUser.mockRejectedValue({ status: 409 }); + server.use( + http.put("*/admin/api/users/:id", () => + HttpResponse.json({}, { status: 409 }), + ), + ); renderDrawer(); await userEvent.click( @@ -346,7 +355,11 @@ describe("EditUserDrawer", () => { }); it("shows generic error for 400 responses", async () => { - sdk.adminUpdateUser.mockRejectedValue({ status: 400 }); + server.use( + http.put("*/admin/api/users/:id", () => + HttpResponse.json({}, { status: 400 }), + ), + ); renderDrawer(); await userEvent.click( @@ -359,7 +372,9 @@ describe("EditUserDrawer", () => { }); it("shows generic error for unexpected failures", async () => { - sdk.adminUpdateUser.mockRejectedValue(new Error("server error")); + server.use( + http.put("*/admin/api/users/:id", () => HttpResponse.error()), + ); renderDrawer(); await userEvent.click( @@ -372,7 +387,9 @@ describe("EditUserDrawer", () => { }); it("renders error with role='alert'", async () => { - sdk.adminUpdateUser.mockRejectedValue(new Error("server error")); + server.use( + http.put("*/admin/api/users/:id", () => HttpResponse.error()), + ); renderDrawer(); await userEvent.click( @@ -385,7 +402,9 @@ describe("EditUserDrawer", () => { }); it("does not call onClose when update fails", async () => { - sdk.adminUpdateUser.mockRejectedValue(new Error("server error")); + server.use( + http.put("*/admin/api/users/:id", () => HttpResponse.error()), + ); const { onClose } = renderDrawer(); await userEvent.click( @@ -403,7 +422,7 @@ describe("EditUserDrawer", () => { await userEvent.click( screen.getByRole("button", { name: /save changes/i }), ); - await waitFor(() => expect(sdk.adminUpdateUser).toHaveBeenCalled()); + await waitFor(() => expect(updateSpy).toHaveBeenCalled()); }); it("rejects a too-short password on edit when the user is changing it", async () => { @@ -414,7 +433,7 @@ describe("EditUserDrawer", () => { screen.getByRole("button", { name: /save changes/i }), ); - expect(sdk.adminUpdateUser).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); expect(passwordInput).toHaveAttribute("aria-invalid", "true"); }); @@ -427,7 +446,7 @@ describe("EditUserDrawer", () => { await userEvent.click( screen.getByRole("button", { name: /save changes/i }), ); - expect(sdk.adminUpdateUser).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); expect(usernameInput).toHaveAttribute("aria-invalid", "true"); }); }); @@ -442,7 +461,7 @@ describe("EditUserDrawer", () => { it("does not call adminUpdateUser when Cancel is clicked", async () => { renderDrawer(); await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(sdk.adminUpdateUser).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); }); }); @@ -465,7 +484,9 @@ describe("EditUserDrawer", () => { }); it("clears any error when closed then reopened", async () => { - sdk.adminUpdateUser.mockRejectedValue(new Error("fail")); + server.use( + http.put("*/admin/api/users/:id", () => HttpResponse.error()), + ); const { rerender } = renderDrawer({ user: mockUser }); await userEvent.click( diff --git a/ui/apps/console/src/pages/admin/users/__tests__/ResetPasswordDialog.test.tsx b/ui/apps/console/src/pages/admin/users/__tests__/ResetPasswordDialog.test.tsx index e75930e69c1..5b3ccaba43f 100644 --- a/ui/apps/console/src/pages/admin/users/__tests__/ResetPasswordDialog.test.tsx +++ b/ui/apps/console/src/pages/admin/users/__tests__/ResetPasswordDialog.test.tsx @@ -1,16 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import ResetPasswordDialog from "../ResetPasswordDialog"; -const sdk = vi.hoisted(() => - mockSdkGen({ - adminResetUserPassword: vi.fn(), - }), -); - vi.mock("@/components/common/BaseDialog", async () => ({ default: (await import("@/tests/mocks")).MockBaseDialog, })); @@ -21,11 +16,21 @@ vi.mock("@/components/common/CopyButton", async () => ({ const Wrapper = createTestWrapper(); +const resetSpy = vi.fn(); + +function setResetResponse(password: string) { + server.use( + http.patch("*/admin/api/users/:id/password/reset", ({ params }) => { + resetSpy({ path: { id: params.id } }); + return HttpResponse.json({ password }); + }), + ); +} + beforeEach(() => { vi.clearAllMocks(); - sdk.adminResetUserPassword.mockResolvedValue( - mockSdkResponse({ password: "default-pw" }), - ); + resetSpy.mockReset(); + setResetResponse("default-pw"); }); function renderDialog( @@ -99,21 +104,19 @@ describe("ResetPasswordDialog", () => { it("does not call adminResetUserPassword when Cancel is clicked", async () => { renderDialog(); await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(sdk.adminResetUserPassword).not.toHaveBeenCalled(); + expect(resetSpy).not.toHaveBeenCalled(); }); }); describe("enable flow — success", () => { it("calls adminResetUserPassword with the correct userId when Enable is clicked", async () => { - sdk.adminResetUserPassword.mockResolvedValue( - mockSdkResponse({ password: "gen-pass-123" }), - ); + setResetResponse("gen-pass-123"); renderDialog({ userId: "user-abc" }); await userEvent.click(screen.getByRole("button", { name: /enable/i })); await waitFor(() => - expect(sdk.adminResetUserPassword).toHaveBeenCalledWith( + expect(resetSpy).toHaveBeenCalledWith( expect.objectContaining({ path: { id: "user-abc" }, }), @@ -122,9 +125,7 @@ describe("ResetPasswordDialog", () => { }); it("transitions to the result step after successful reset", async () => { - sdk.adminResetUserPassword.mockResolvedValue( - mockSdkResponse({ password: "gen-pass-123" }), - ); + setResetResponse("gen-pass-123"); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); @@ -135,9 +136,7 @@ describe("ResetPasswordDialog", () => { }); it("displays the generated password in an input field", async () => { - sdk.adminResetUserPassword.mockResolvedValue( - mockSdkResponse({ password: "s3cr3t-pw" }), - ); + setResetResponse("s3cr3t-pw"); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); @@ -148,9 +147,7 @@ describe("ResetPasswordDialog", () => { }); it("renders the 'Generated password' labelled input", async () => { - sdk.adminResetUserPassword.mockResolvedValue( - mockSdkResponse({ password: "abc" }), - ); + setResetResponse("abc"); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); @@ -163,9 +160,7 @@ describe("ResetPasswordDialog", () => { }); it("renders a Copy button on the result step", async () => { - sdk.adminResetUserPassword.mockResolvedValue( - mockSdkResponse({ password: "abc" }), - ); + setResetResponse("abc"); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); @@ -178,9 +173,7 @@ describe("ResetPasswordDialog", () => { }); it("renders a Close button on the result step", async () => { - sdk.adminResetUserPassword.mockResolvedValue( - mockSdkResponse({ password: "abc" }), - ); + setResetResponse("abc"); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); @@ -193,9 +186,7 @@ describe("ResetPasswordDialog", () => { }); it("calls onClose when Close is clicked on result step", async () => { - sdk.adminResetUserPassword.mockResolvedValue( - mockSdkResponse({ password: "abc" }), - ); + setResetResponse("abc"); const { onClose } = renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); @@ -208,7 +199,11 @@ describe("ResetPasswordDialog", () => { describe("enable flow — error states", () => { it("shows specific error message for status 400 (user already has password)", async () => { - sdk.adminResetUserPassword.mockRejectedValue({ status: 400 }); + server.use( + http.patch("*/admin/api/users/:id/password/reset", () => + HttpResponse.json({}, { status: 400 }), + ), + ); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); @@ -221,7 +216,11 @@ describe("ResetPasswordDialog", () => { }); it("shows generic error message for non-400 errors", async () => { - sdk.adminResetUserPassword.mockRejectedValue({ status: 500 }); + server.use( + http.patch("*/admin/api/users/:id/password/reset", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); @@ -232,7 +231,11 @@ describe("ResetPasswordDialog", () => { }); it("shows generic error for non-SDK errors", async () => { - sdk.adminResetUserPassword.mockRejectedValue(new Error("network error")); + server.use( + http.patch("*/admin/api/users/:id/password/reset", () => + HttpResponse.error(), + ), + ); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); @@ -243,7 +246,11 @@ describe("ResetPasswordDialog", () => { }); it("renders error with role='alert'", async () => { - sdk.adminResetUserPassword.mockRejectedValue({ status: 500 }); + server.use( + http.patch("*/admin/api/users/:id/password/reset", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); @@ -254,7 +261,11 @@ describe("ResetPasswordDialog", () => { }); it("stays on the confirm step when there is an error", async () => { - sdk.adminResetUserPassword.mockRejectedValue({ status: 500 }); + server.use( + http.patch("*/admin/api/users/:id/password/reset", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); @@ -266,7 +277,11 @@ describe("ResetPasswordDialog", () => { }); it("clears error and stays on confirm step — Enable button is still visible", async () => { - sdk.adminResetUserPassword.mockRejectedValue({ status: 500 }); + server.use( + http.patch("*/admin/api/users/:id/password/reset", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderDialog(); await userEvent.click(screen.getByRole("button", { name: /enable/i })); @@ -280,9 +295,7 @@ describe("ResetPasswordDialog", () => { describe("state reset on reopen", () => { it("resets to confirm step when dialog is closed then reopened", async () => { - sdk.adminResetUserPassword.mockResolvedValue( - mockSdkResponse({ password: "pw" }), - ); + setResetResponse("pw"); const { rerender } = renderDialog({ userId: "u1" }); await userEvent.click(screen.getByRole("button", { name: /enable/i })); diff --git a/ui/apps/console/src/pages/admin/users/index.tsx b/ui/apps/console/src/pages/admin/users/index.tsx index f40c86b2c62..8fedd4be1e2 100644 --- a/ui/apps/console/src/pages/admin/users/index.tsx +++ b/ui/apps/console/src/pages/admin/users/index.tsx @@ -8,12 +8,15 @@ import { CheckIcon, ArrowRightStartOnRectangleIcon, } from "@heroicons/react/24/outline"; -import { useAdminUsers } from "@/hooks/useAdminUsers"; -import { useApproveAccountRequest } from "@/hooks/useAdminAccountRequestMutations"; +import { useGetUsers } from "@/client/api"; +import type { GetUsersParams } from "@/client/model"; +import { totalCount } from "@/api/pagination"; +import { toBase64Json } from "@/utils/encoding"; +import { useApproveUser } from "@/client/api"; import { useLoginAsUser } from "@/hooks/useLoginAsUser"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { usePaginatedListState } from "@/hooks/usePaginatedListState"; -import type { UserAdminResponse } from "@/client"; +import type { UserAdminResponse } from "@/client/model"; import PageHeader from "@/components/common/PageHeader"; import DataTable, { type Column } from "@/components/common/DataTable"; import ConfirmDialog from "@/components/common/ConfirmDialog"; @@ -65,15 +68,28 @@ export default function AdminUsers() { loadingId: loginAsId, errorId: loginAsError, } = useLoginAsUser(); - const approve = useApproveAccountRequest(); + const approve = useApproveUser(); - const { users, totalCount, isLoading, error } = useAdminUsers({ + const requestParams: GetUsersParams = { page: params.page, - perPage: PER_PAGE, - search: debouncedSearch, - }); + per_page: PER_PAGE, + }; + if (debouncedSearch) { + requestParams.filter = toBase64Json([ + { + type: "property", + params: { + name: "username", + operator: "contains", + value: debouncedSearch, + }, + }, + ]); + } + const { data: users = [], isLoading, error } = useGetUsers(requestParams); + const total = totalCount(users); - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const columns: Column[] = [ { @@ -222,7 +238,7 @@ export default function AdminUsers() { loadingMessage="Loading users..." page={params.page} totalPages={totalPages} - totalCount={totalCount} + totalCount={total} itemLabel="user" onPageChange={setPage} onRowClick={(user) => void navigate(`/admin/users/${user.id}`)} @@ -268,7 +284,7 @@ export default function AdminUsers() { if (!approveTarget) return; setApproveError(""); try { - await approve.mutateAsync({ path: { id: approveTarget.id } }); + await approve.mutateAsync({ id: approveTarget.id }); setApproveTarget(null); } catch { setApproveError("Failed to approve the account. Please try again."); diff --git a/ui/apps/console/src/pages/admin/users/userSchema.ts b/ui/apps/console/src/pages/admin/users/userSchema.ts index 152974bc1a7..543b87801ef 100644 --- a/ui/apps/console/src/pages/admin/users/userSchema.ts +++ b/ui/apps/console/src/pages/admin/users/userSchema.ts @@ -3,7 +3,7 @@ import type { UserAdminCreateRequest, UserAdminResponse, UserAdminUpdateRequest, -} from "@/client"; +} from "@/client/model"; import { MAX_NAMESPACES_ERROR, isMaxNamespacesValid, diff --git a/ui/apps/console/src/pages/containers/__tests__/ContainerDetails.test.tsx b/ui/apps/console/src/pages/containers/__tests__/ContainerDetails.test.tsx index 6f47a826178..8673048a5c5 100644 --- a/ui/apps/console/src/pages/containers/__tests__/ContainerDetails.test.tsx +++ b/ui/apps/console/src/pages/containers/__tests__/ContainerDetails.test.tsx @@ -2,28 +2,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; -import type { Device } from "@/client"; +import type { Device } from "@/client/model"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import { mockContainer as mockContainerFactory, mockNamespace, } from "@/tests/factories"; import { seedAuthStore } from "@/tests/seedAuthStore"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getContainer: vi.fn(), - updateContainer: vi.fn(), - createTag: vi.fn(), - pushTagToContainer: vi.fn(), - pullTagFromContainer: vi.fn(), - getNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - getTags: vi.fn(), - }), -); - vi.mock("@/stores/terminalStore", () => ({ useTerminalStore: ( sel: (s: { sessions: []; restore: () => void }) => unknown, @@ -104,6 +92,14 @@ function makeContainer(overrides: Partial = {}): Device { }); } +function setContainer(overrides: Partial = {}) { + server.use( + http.get("*/api/containers/:uid", () => + HttpResponse.json(makeContainer(overrides)), + ), + ); +} + function renderPage() { return render(, { wrapper: createTestWrapper({ initialEntries: ["/containers/test-uid"] }), @@ -113,16 +109,29 @@ function renderPage() { beforeEach(() => { vi.clearAllMocks(); seedAuthStore(); - sdk.getContainer.mockResolvedValue(mockSdkResponse(null)); - sdk.getNamespace.mockResolvedValue(mockSdkResponse(mockNamespace())); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + server.use( + http.get("*/api/containers/:uid", () => HttpResponse.json(null)), + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json(mockNamespace()), + ), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token", role: "owner" }), + ), + http.get("*/api/tags", () => HttpResponse.json([])), + http.put( + "*/api/containers/:uid", + () => new HttpResponse(null, { status: 204 }), + ), + http.post("*/api/tags", () => new HttpResponse(null, { status: 204 })), + http.post( + "*/api/containers/:uid/tags/:name", + () => new HttpResponse(null, { status: 204 }), + ), + http.delete( + "*/api/containers/:uid/tags/:name", + () => new HttpResponse(null, { status: 204 }), + ), ); - sdk.getTags.mockResolvedValue(mockSdkResponse([])); - sdk.updateContainer.mockResolvedValue(mockSdkResponse(undefined)); - sdk.createTag.mockResolvedValue(mockSdkResponse(undefined)); - sdk.pushTagToContainer.mockResolvedValue(mockSdkResponse(undefined)); - sdk.pullTagFromContainer.mockResolvedValue(mockSdkResponse(undefined)); mockRequestAction.mockReset(); mockNavigate.mockReset(); capturedOnSuccess = undefined; @@ -131,7 +140,9 @@ beforeEach(() => { describe("ContainerDetails", () => { describe("loading and missing states", () => { it("renders a spinner while loading", () => { - sdk.getContainer.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/api/containers/:uid", () => new Promise(() => {})), + ); renderPage(); expect( screen.getByLabelText("Loading container details"), @@ -139,7 +150,11 @@ describe("ContainerDetails", () => { }); it("tells the user the container is missing when the query fails", async () => { - sdk.getContainer.mockRejectedValue({ status: 404 }); + server.use( + http.get("*/api/containers/:uid", () => + HttpResponse.json({}, { status: 404 }), + ), + ); renderPage(); expect( await screen.findByText("Container not found"), @@ -156,7 +171,7 @@ describe("ContainerDetails", () => { describe("container data", () => { beforeEach(() => { - sdk.getContainer.mockResolvedValue(mockSdkResponse(makeContainer())); + setContainer(); }); it("renders the container name as a heading", async () => { @@ -190,22 +205,16 @@ describe("ContainerDetails", () => { }); it("renders tag names flattened out of the generated tag objects", async () => { - sdk.getContainer.mockResolvedValue( - mockSdkResponse( - makeContainer({ - tags: [{ name: "production" }, { name: "edge" }], - } as unknown as Partial), - ), - ); + setContainer({ + tags: [{ name: "production" }, { name: "edge" }], + } as unknown as Partial); renderPage(); expect(await screen.findByText("production")).toBeInTheDocument(); expect(screen.getByText("edge")).toBeInTheDocument(); }); it("hides the SSHID banner for a container that is not accepted", async () => { - sdk.getContainer.mockResolvedValue( - mockSdkResponse(makeContainer({ status: "pending" })), - ); + setContainer({ status: "pending" }); renderPage(); await screen.findByRole("heading", { name: "my-container" }); expect( @@ -223,14 +232,10 @@ describe("ContainerDetails", () => { "calls requestAction('%s' → %s)", async (status, buttonName, expectedAction) => { const user = userEvent.setup(); - sdk.getContainer.mockResolvedValue( - mockSdkResponse( - makeContainer({ - status: status as Device["status"], - online: false, - }), - ), - ); + setContainer({ + status: status as Device["status"], + online: false, + }); renderPage(); await user.click( @@ -246,9 +251,7 @@ describe("ContainerDetails", () => { it("calls requestAction('remove') from the trash button on an accepted container", async () => { const user = userEvent.setup(); - sdk.getContainer.mockResolvedValue( - mockSdkResponse(makeContainer({ status: "accepted" })), - ); + setContainer({ status: "accepted" }); renderPage(); await user.click( @@ -264,7 +267,7 @@ describe("ContainerDetails", () => { describe("onSuccess callback wiring", () => { it("navigates to /containers after a container is removed", async () => { - sdk.getContainer.mockResolvedValue(mockSdkResponse(makeContainer())); + setContainer(); renderPage(); await screen.findByRole("heading", { name: "my-container" }); @@ -275,9 +278,7 @@ describe("ContainerDetails", () => { }); it("stays on the page after any other action", async () => { - sdk.getContainer.mockResolvedValue( - mockSdkResponse(makeContainer({ status: "pending" })), - ); + setContainer({ status: "pending" }); renderPage(); await screen.findByRole("heading", { name: "my-container" }); diff --git a/ui/apps/console/src/pages/containers/__tests__/Containers.test.tsx b/ui/apps/console/src/pages/containers/__tests__/Containers.test.tsx index 93ae62d1f0b..c6b037ee9fc 100644 --- a/ui/apps/console/src/pages/containers/__tests__/Containers.test.tsx +++ b/ui/apps/console/src/pages/containers/__tests__/Containers.test.tsx @@ -2,22 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, act, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { mockContainer, mockNamespace } from "@/tests/factories"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; import { seedAuthStore } from "@/tests/seedAuthStore"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getContainers: vi.fn(), - createTag: vi.fn(), - pushTagToContainer: vi.fn(), - pullTagFromContainer: vi.fn(), - getNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - }), -); - vi.mock("@/hooks/useDebouncedValue", () => ({ useDebouncedValue: (value: T) => value, })); @@ -117,17 +107,42 @@ function renderPage(initialEntries: string[] = ["/"]) { }); } +let lastContainersUrl: URL | null; + +function setContainers( + containers: ReturnType[], + total?: number, +) { + server.use( + http.get("*/api/containers", ({ request }) => { + lastContainersUrl = new URL(request.url); + return jsonWithTotal(containers, total ?? containers.length); + }), + ); +} + beforeEach(() => { vi.clearAllMocks(); + lastContainersUrl = null; seedAuthStore(); - sdk.getContainers.mockResolvedValue(paginatedResponse([])); - sdk.getNamespace.mockResolvedValue(mockSdkResponse(mockNamespace())); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + setContainers([]); + server.use( + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json(mockNamespace()), + ), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token", role: "owner" }), + ), + http.post("*/api/tags", () => new HttpResponse(null, { status: 204 })), + http.post( + "*/api/containers/:uid/tags/:name", + () => new HttpResponse(null, { status: 204 }), + ), + http.delete( + "*/api/containers/:uid/tags/:name", + () => new HttpResponse(null, { status: 204 }), + ), ); - sdk.createTag.mockResolvedValue(mockSdkResponse(undefined)); - sdk.pushTagToContainer.mockResolvedValue(mockSdkResponse(undefined)); - sdk.pullTagFromContainer.mockResolvedValue(mockSdkResponse(undefined)); mockNavigate.mockReset(); mockManageTagsDrawer.mockReset(); mockRequestAction.mockReset(); @@ -161,7 +176,9 @@ describe("Containers list", () => { describe("loading state", () => { it("renders the loading message", () => { - sdk.getContainers.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/api/containers", () => new Promise(() => {})), + ); renderPage(); expect(screen.getByText("Loading containers...")).toBeInTheDocument(); }); @@ -178,14 +195,12 @@ describe("Containers list", () => { describe("container rows", () => { it("renders a row for each container", async () => { - sdk.getContainers.mockResolvedValue( - paginatedResponse( - [ - mockContainer({ uid: "uid-1", name: "alpha" }), - mockContainer({ uid: "uid-2", name: "beta" }), - ], - 2, - ), + setContainers( + [ + mockContainer({ uid: "uid-1", name: "alpha" }), + mockContainer({ uid: "uid-2", name: "beta" }), + ], + 2, ); renderPage(); expect(await screen.findByText("alpha")).toBeInTheDocument(); @@ -194,11 +209,9 @@ describe("Containers list", () => { it("navigates to container detail on row click", async () => { const user = userEvent.setup(); - sdk.getContainers.mockResolvedValue( - paginatedResponse( - [mockContainer({ uid: "uid-abc", name: "clickable" })], - 1, - ), + setContainers( + [mockContainer({ uid: "uid-abc", name: "clickable" })], + 1, ); renderPage(); await user.click(await screen.findByText("clickable")); @@ -208,7 +221,11 @@ describe("Containers list", () => { describe("error state", () => { it("renders an error message when the query fails", async () => { - sdk.getContainers.mockRejectedValue({ status: 500 }); + server.use( + http.get("*/api/containers", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); expect( await screen.findByText("Something went wrong on our side. Try again."), @@ -220,22 +237,17 @@ describe("Containers list", () => { it("requests last_seen/desc sort by default", async () => { renderPage(); await waitFor(() => { - expect(sdk.getContainers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - sort_by: "last_seen", - order_by: "desc", - }), - }), + expect(lastContainersUrl).not.toBeNull(); + expect(lastContainersUrl!.searchParams.get("sort_by")).toBe( + "last_seen", ); + expect(lastContainersUrl!.searchParams.get("order_by")).toBe("desc"); }); }); it("toggles sort when the Hostname header is clicked", async () => { const user = userEvent.setup(); - sdk.getContainers.mockResolvedValue( - paginatedResponse([mockContainer({ uid: "uid-1", name: "alpha" })], 1), - ); + setContainers([mockContainer({ uid: "uid-1", name: "alpha" })], 1); renderPage(); await screen.findByText("alpha"); @@ -243,28 +255,16 @@ describe("Containers list", () => { screen.getByRole("button", { name: "Sort by Hostname" }), ); await waitFor(() => { - expect(sdk.getContainers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - sort_by: "name", - order_by: "asc", - }), - }), - ); + expect(lastContainersUrl!.searchParams.get("sort_by")).toBe("name"); + expect(lastContainersUrl!.searchParams.get("order_by")).toBe("asc"); }); await user.click( screen.getByRole("button", { name: "Sort by Hostname" }), ); await waitFor(() => { - expect(sdk.getContainers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - sort_by: "name", - order_by: "desc", - }), - }), - ); + expect(lastContainersUrl!.searchParams.get("sort_by")).toBe("name"); + expect(lastContainersUrl!.searchParams.get("order_by")).toBe("desc"); }); }); }); @@ -273,21 +273,17 @@ describe("Containers list", () => { it("passes status=pending from URL to the SDK", async () => { renderPage(["/?status=pending&tags=a&tags=b&page=2"]); await waitFor(() => { - expect(sdk.getContainers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ status: "pending" }), - }), - ); + expect(lastContainersUrl).not.toBeNull(); + expect(lastContainersUrl!.searchParams.get("status")).toBe("pending"); }); }); it("passes tags from URL as a filter to the SDK", async () => { renderPage(["/?status=pending&tags=a&tags=b&page=2"]); await waitFor(() => { - const call = sdk.getContainers.mock.calls.at(-1)?.[0] as { - query?: { filter?: string }; - }; - const decoded = atob(call?.query?.filter ?? ""); + expect(lastContainersUrl).not.toBeNull(); + const filter = lastContainersUrl!.searchParams.get("filter") ?? ""; + const decoded = atob(filter); expect(decoded).toContain('"a"'); expect(decoded).toContain('"b"'); }); @@ -296,43 +292,33 @@ describe("Containers list", () => { it("passes page=2 from URL to the SDK", async () => { renderPage(["/?status=pending&tags=a&tags=b&page=2"]); await waitFor(() => { - expect(sdk.getContainers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 2 }), - }), - ); + expect(lastContainersUrl).not.toBeNull(); + expect(lastContainersUrl!.searchParams.get("page")).toBe("2"); }); }); it("falls back to status=accepted and page=1 when URL has no params", async () => { renderPage(["/"]); await waitFor(() => { - expect(sdk.getContainers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ status: "accepted", page: 1 }), - }), - ); + expect(lastContainersUrl).not.toBeNull(); + expect(lastContainersUrl!.searchParams.get("status")).toBe("accepted"); + expect(lastContainersUrl!.searchParams.get("page")).toBe("1"); }); }); it("falls back to status=accepted for an invalid status value", async () => { renderPage(["/?status=invalid"]); await waitFor(() => { - expect(sdk.getContainers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ status: "accepted" }), - }), - ); + expect(lastContainersUrl).not.toBeNull(); + expect(lastContainersUrl!.searchParams.get("status")).toBe("accepted"); }); }); it("passes no tag filter when no tags param is present", async () => { renderPage(["/"]); await waitFor(() => { - const call = sdk.getContainers.mock.calls[0]?.[0] as { - query?: { filter?: string }; - }; - expect(call?.query?.filter).toBeUndefined(); + expect(lastContainersUrl).not.toBeNull(); + expect(lastContainersUrl!.searchParams.get("filter")).toBeNull(); }); }); }); @@ -347,10 +333,8 @@ describe("Containers list", () => { " myhost ", ); await waitFor(() => { - const call = sdk.getContainers.mock.calls.at(-1)?.[0] as { - query?: { filter?: string }; - }; - const decoded = atob(call?.query?.filter ?? ""); + const filter = lastContainersUrl!.searchParams.get("filter") ?? ""; + const decoded = atob(filter); expect(decoded).toContain("myhost"); }); }); @@ -359,7 +343,7 @@ describe("Containers list", () => { describe("tag mutation — onTagRenamed/onTagDeleted update URL tags array", () => { it("renames a tag in filter when onTagRenamed is called from ManageTagsDrawer", async () => { renderPage(["/?tags=a&tags=b"]); - await waitFor(() => expect(sdk.getContainers).toHaveBeenCalled()); + await waitFor(() => expect(lastContainersUrl).not.toBeNull()); const lastCall = mockManageTagsDrawer.mock.calls.at(-1)?.[0] as { onTagRenamed?: (oldName: string, newName: string) => void; @@ -371,10 +355,8 @@ describe("Containers list", () => { }); await waitFor(() => { - const call = sdk.getContainers.mock.calls.at(-1)?.[0] as { - query?: { filter?: string }; - }; - const decoded = atob(call?.query?.filter ?? ""); + const filter = lastContainersUrl!.searchParams.get("filter") ?? ""; + const decoded = atob(filter); expect(decoded).toContain("alpha"); expect(decoded).toContain('"b"'); }); @@ -382,7 +364,7 @@ describe("Containers list", () => { it("removes a tag from filter when onTagDeleted is called from ManageTagsDrawer", async () => { renderPage(["/?tags=a&tags=b"]); - await waitFor(() => expect(sdk.getContainers).toHaveBeenCalled()); + await waitFor(() => expect(lastContainersUrl).not.toBeNull()); const lastCall = mockManageTagsDrawer.mock.calls.at(-1)?.[0] as { onTagDeleted?: (name: string) => void; @@ -394,10 +376,8 @@ describe("Containers list", () => { }); await waitFor(() => { - const call = sdk.getContainers.mock.calls.at(-1)?.[0] as { - query?: { filter?: string }; - }; - const decoded = atob(call?.query?.filter ?? ""); + const filter = lastContainersUrl!.searchParams.get("filter") ?? ""; + const decoded = atob(filter); expect(decoded).not.toContain('"a"'); expect(decoded).toContain('"b"'); }); @@ -406,10 +386,8 @@ describe("Containers list", () => { it("hydrates tags from URL into the SDK filter", async () => { renderPage(["/?tags=existing"]); await waitFor(() => { - const call = sdk.getContainers.mock.calls.at(-1)?.[0] as { - query?: { filter?: string }; - }; - const decoded = atob(call?.query?.filter ?? ""); + const filter = lastContainersUrl!.searchParams.get("filter") ?? ""; + const decoded = atob(filter); expect(decoded).toContain("existing"); }); expect( @@ -423,21 +401,15 @@ describe("Containers list", () => { const user = userEvent.setup(); renderPage(["/?page=2"]); await waitFor(() => { - expect(sdk.getContainers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 2, status: "accepted" }), - }), - ); + expect(lastContainersUrl).not.toBeNull(); + expect(lastContainersUrl!.searchParams.get("page")).toBe("2"); }); await user.click(screen.getByRole("tab", { name: "Pending" })); await waitFor(() => { - expect(sdk.getContainers).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1, status: "pending" }), - }), - ); + expect(lastContainersUrl!.searchParams.get("page")).toBe("1"); + expect(lastContainersUrl!.searchParams.get("status")).toBe("pending"); }); }); }); @@ -445,18 +417,16 @@ describe("Containers list", () => { describe("action delegation — action buttons use useContainerActions", () => { it("calls requestAction(container, 'accept') when Accept is clicked in pending view", async () => { const user = userEvent.setup(); - sdk.getContainers.mockResolvedValue( - paginatedResponse( - [ - mockContainer({ - uid: "uid-pending", - name: "pending-box", - status: "pending", - online: false, - }), - ], - 1, - ), + setContainers( + [ + mockContainer({ + uid: "uid-pending", + name: "pending-box", + status: "pending", + online: false, + }), + ], + 1, ); renderPage(["/?status=pending"]); await user.click(await screen.findByRole("button", { name: "Accept" })); @@ -468,18 +438,16 @@ describe("Containers list", () => { it("calls requestAction(container, 'reject') when Reject is clicked in pending view", async () => { const user = userEvent.setup(); - sdk.getContainers.mockResolvedValue( - paginatedResponse( - [ - mockContainer({ - uid: "uid-pending-2", - name: "pending-box-2", - status: "pending", - online: false, - }), - ], - 1, - ), + setContainers( + [ + mockContainer({ + uid: "uid-pending-2", + name: "pending-box-2", + status: "pending", + online: false, + }), + ], + 1, ); renderPage(["/?status=pending"]); await user.click(await screen.findByRole("button", { name: "Reject" })); @@ -494,18 +462,16 @@ describe("Containers list", () => { it("calls requestAction(container, 'remove') when Remove is clicked in rejected view", async () => { const user = userEvent.setup(); - sdk.getContainers.mockResolvedValue( - paginatedResponse( - [ - mockContainer({ - uid: "uid-rejected", - name: "rejected-box", - status: "rejected", - online: false, - }), - ], - 1, - ), + setContainers( + [ + mockContainer({ + uid: "uid-rejected", + name: "rejected-box", + status: "rejected", + online: false, + }), + ], + 1, ); renderPage(["/?status=rejected"]); await user.click(await screen.findByRole("button", { name: "Remove" })); diff --git a/ui/apps/console/src/pages/containers/index.tsx b/ui/apps/console/src/pages/containers/index.tsx index 25069c2f907..b0372a13ccc 100644 --- a/ui/apps/console/src/pages/containers/index.tsx +++ b/ui/apps/console/src/pages/containers/index.tsx @@ -1,6 +1,10 @@ -import { useState, useMemo, useCallback } from "react"; +import { useState, useCallback } from "react"; import { useNavigate } from "react-router-dom"; -import { useContainers, type NormalizedContainer } from "@/hooks/useContainers"; +import { useGetContainers } from "@/client/api"; +import type { GetContainersParams } from "@/client/model"; +import { totalCount } from "@/api/pagination"; +import { normalizeDeviceTags, type TaggedDevice as NormalizedContainer } from "@/utils/deviceTags"; +import { toBase64Json } from "@/utils/encoding"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useTableSort } from "@/hooks/useTableSort"; import { usePaginatedListState } from "@/hooks/usePaginatedListState"; @@ -17,10 +21,8 @@ import TagFilterDropdown from "@/components/common/TagFilterDropdown"; import { formatRelative } from "@/utils/date"; import { buildSshid } from "@/utils/sshid"; import TagsPopover from "@/components/common/TagsPopover"; -import { - useAddContainerTag, - useRemoveContainerTag, -} from "@/hooks/useContainerMutations"; +import { usePullTagFromContainer } from "@/client/api"; +import { useAddContainerTag } from "@/hooks/useContainerMutations"; import { useActionDialog } from "@/hooks/useActionDialog"; import { useContainerActionRunner } from "@/hooks/useContainerActionRunner"; import ActionDialog from "@/components/common/ActionDialog"; @@ -97,7 +99,7 @@ export default function Containers() { ); const addContainerTag = useAddContainerTag(); - const removeContainerTag = useRemoveContainerTag(); + const removeContainerTag = usePullTagFromContainer(); const containerActions = useActionDialog(); const { requestAction: requestContainerAction } = containerActions; const runContainerAction = useContainerActionRunner(); @@ -113,21 +115,32 @@ export default function Containers() { onSortChange: () => setPage(1), }); - const { containers, totalCount, isLoading, error, refetch } = useContainers({ + const requestParams: GetContainersParams = { page: params.page, - perPage: PER_PAGE, - status: params.status, - search: debouncedSearch, - filterTags: params.tags, - sortBy, - orderBy, - }); + per_page: PER_PAGE, + sort_by: sortBy, + order_by: orderBy, + }; + if (params.status) requestParams.status = params.status; + if (debouncedSearch || params.tags.length > 0) { + const filters: Record[] = []; + if (debouncedSearch) { + filters.push({ type: "property", params: { name: "name", operator: "contains", value: debouncedSearch } }); + } + if (params.tags.length > 0) { + filters.push({ type: "property", params: { name: "tags.name", operator: "contains", value: params.tags } }); + } + requestParams.filter = toBase64Json(filters); + } + const { data: rawContainers = [], isLoading, error, refetch } = useGetContainers(requestParams); + const containers = rawContainers.map(normalizeDeviceTags); + const total = totalCount(rawContainers); const tenantId = useAuthStore((s) => s.tenant) ?? ""; const { namespace: currentNamespace } = useNamespace(tenantId); const navigate = useNavigate(); - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const nsName = currentNamespace?.name ?? ""; const handleStatusChange = (newStatus: ValidStatus) => { @@ -151,8 +164,7 @@ export default function Containers() { setArrayFilter("tags", []); }; - const columns = useMemo[]>(() => { - const baseColumns: Column[] = [ + const baseColumns: Column[] = [ { key: "name", header: "Hostname", @@ -196,10 +208,12 @@ export default function Containers() { ), }, - ]; + ]; + + let columns: Column[]; - if (params.status === "accepted") { - return [ + if (params.status === "accepted") { + columns = [ { key: "online", header: "", @@ -280,11 +294,9 @@ export default function Containers() { ), }, - ]; - } - - if (params.status === "pending") { - return [ + ]; + } else if (params.status === "pending") { + columns = [ ...baseColumns, { key: "actions", @@ -319,10 +331,9 @@ export default function Containers() {
), }, - ]; - } - - return [ + ]; + } else { + columns = [ ...baseColumns, { key: "actions", @@ -358,14 +369,7 @@ export default function Containers() { ), }, ]; - }, [ - params.status, - nsName, - addFilterTag, - requestContainerAction, - addContainerTag.mutateAsync, - removeContainerTag.mutateAsync, - ]); + } return (
@@ -472,7 +476,7 @@ export default function Containers() { loadingMessage="Loading containers..." page={params.page} totalPages={totalPages} - totalCount={totalCount} + totalCount={total} itemLabel="container" onPageChange={setPage} onRowClick={(container) => diff --git a/ui/apps/console/src/pages/devices/CustomFieldsSection.tsx b/ui/apps/console/src/pages/devices/CustomFieldsSection.tsx index b920f603ab8..05d58533b3d 100644 --- a/ui/apps/console/src/pages/devices/CustomFieldsSection.tsx +++ b/ui/apps/console/src/pages/devices/CustomFieldsSection.tsx @@ -1,10 +1,7 @@ import { useState } from "react"; import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { IconButton } from "@shellhub/design-system/primitives"; -import { - useSetDeviceCustomField, - useDeleteDeviceCustomField, -} from "@/hooks/useDeviceMutations"; +import { useSetDeviceCustomField, useDeleteDeviceCustomField } from "@/client/api"; import { useHasPermission } from "@/hooks/useHasPermission"; const LABEL = @@ -43,8 +40,8 @@ export default function CustomFieldsSection({ setAdding(true); try { await setMutation.mutateAsync({ - path: { uid, key }, - body: { value }, + uid, key, + data: { value }, }); setKeyInput(""); setValueInput(""); @@ -55,7 +52,7 @@ export default function CustomFieldsSection({ }; const handleRemove = (key: string) => { - deleteMutation.mutate({ path: { uid, key } }); + deleteMutation.mutate({ uid, key }); }; return ( diff --git a/ui/apps/console/src/pages/devices/__tests__/DeviceDetails.test.tsx b/ui/apps/console/src/pages/devices/__tests__/DeviceDetails.test.tsx index 157372b9959..50087a6c47f 100644 --- a/ui/apps/console/src/pages/devices/__tests__/DeviceDetails.test.tsx +++ b/ui/apps/console/src/pages/devices/__tests__/DeviceDetails.test.tsx @@ -2,32 +2,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; -import type { Device } from "@/client"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; +import type { Device } from "@/client/model"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; import { mockDevice as mockDeviceFactory, mockInstallKey, mockNamespace, } from "@/tests/factories"; -import { seedAuthStore } from "@/tests/seedAuthStore"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - getDevice: vi.fn(), - updateDevice: vi.fn(), - createTag: vi.fn(), - pushTagToDevice: vi.fn(), - pullTagFromDevice: vi.fn(), - deleteDevice: vi.fn(), - setDeviceCustomField: vi.fn(), - deleteDeviceCustomField: vi.fn(), - getNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - getTags: vi.fn(), - installKeyList: vi.fn(), - }), -); +import { seedAuthStore, VALID_JWT } from "@/tests/seedAuthStore"; vi.mock("@/stores/terminalStore", () => ({ useTerminalStore: ( @@ -113,6 +97,17 @@ function makeDevice(overrides: Partial = {}): Device { }); } +const setCustomFieldSpy = vi.fn(); +const deleteCustomFieldSpy = vi.fn(); + +function setDevice(device: Device | null) { + server.use( + http.get("*/api/devices/:uid", () => + device ? HttpResponse.json(device) : HttpResponse.json(null), + ), + ); +} + function renderPage() { return render(, { wrapper: createTestWrapper({ initialEntries: ["/devices/test-uid"] }), @@ -122,20 +117,52 @@ function renderPage() { beforeEach(() => { vi.clearAllMocks(); seedAuthStore(); - sdk.getDevice.mockResolvedValue(mockSdkResponse(null)); - sdk.getNamespace.mockResolvedValue(mockSdkResponse(mockNamespace())); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + setCustomFieldSpy.mockReset(); + deleteCustomFieldSpy.mockReset(); + server.use( + http.get("*/api/devices/:uid", () => HttpResponse.json(null)), + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json(mockNamespace()), + ), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: VALID_JWT, role: "owner" }), + ), + http.get("*/api/tags", () => HttpResponse.json([])), + http.get("*/api/namespaces/install-key", () => jsonWithTotal([], 0)), + http.put( + "*/api/devices/:uid", + () => new HttpResponse(null, { status: 204 }), + ), + http.post("*/api/tags", () => new HttpResponse(null, { status: 204 })), + http.post( + "*/api/devices/:uid/tags/:name", + () => new HttpResponse(null, { status: 204 }), + ), + http.delete( + "*/api/devices/:uid/tags/:name", + () => new HttpResponse(null, { status: 204 }), + ), + http.delete( + "*/api/devices/:uid", + () => new HttpResponse(null, { status: 204 }), + ), + http.put( + "*/api/devices/:uid/custom_fields/:key", + async ({ request, params }) => { + setCustomFieldSpy({ + path: { uid: params.uid, key: params.key }, + body: await request.json(), + }); + return new HttpResponse(null, { status: 204 }); + }, + ), + http.delete("*/api/devices/:uid/custom_fields/:key", ({ params }) => { + deleteCustomFieldSpy({ + path: { uid: params.uid, key: params.key }, + }); + return new HttpResponse(null, { status: 204 }); + }), ); - sdk.getTags.mockResolvedValue(mockSdkResponse([])); - sdk.installKeyList.mockResolvedValue(paginatedResponse([])); - sdk.updateDevice.mockResolvedValue(mockSdkResponse(undefined)); - sdk.createTag.mockResolvedValue(mockSdkResponse(undefined)); - sdk.pushTagToDevice.mockResolvedValue(mockSdkResponse(undefined)); - sdk.pullTagFromDevice.mockResolvedValue(mockSdkResponse(undefined)); - sdk.deleteDevice.mockResolvedValue(mockSdkResponse(undefined)); - sdk.setDeviceCustomField.mockResolvedValue(mockSdkResponse(undefined)); - sdk.deleteDeviceCustomField.mockResolvedValue(mockSdkResponse(undefined)); mockRequestAction.mockReset(); mockNavigate.mockReset(); capturedOnSuccess = undefined; @@ -144,7 +171,7 @@ beforeEach(() => { describe("DeviceDetails", () => { describe("loading state", () => { it("renders a spinner while loading", () => { - sdk.getDevice.mockReturnValue(new Promise(() => {})); + server.use(http.get("*/api/devices/:uid", () => new Promise(() => {}))); renderPage(); expect(document.querySelector(".animate-spin")).toBeInTheDocument(); }); @@ -152,7 +179,7 @@ describe("DeviceDetails", () => { describe("device data", () => { beforeEach(() => { - sdk.getDevice.mockResolvedValue(mockSdkResponse(makeDevice())); + setDevice(makeDevice()); }); it("renders the device name as a heading", async () => { @@ -173,13 +200,17 @@ describe("DeviceDetails", () => { }); it("names the source a keyless device registered through, linked to its activity", async () => { - sdk.getDevice.mockResolvedValue( - mockSdkResponse(makeDevice({ install_key_id: "legacy-digest" })), - ); - sdk.installKeyList.mockResolvedValue( - paginatedResponse([ - mockInstallKey({ id: "legacy-digest", name: "legacy", type: "legacy" }), - ]), + setDevice(makeDevice({ install_key_id: "legacy-digest" })); + server.use( + http.get("*/api/namespaces/install-key", () => + jsonWithTotal([ + mockInstallKey({ + id: "legacy-digest", + name: "legacy", + type: "legacy", + }), + ]), + ), ); renderPage(); @@ -197,10 +228,8 @@ describe("DeviceDetails", () => { describe("custom fields section", () => { it("renders key-value pairs when custom fields are present", async () => { - sdk.getDevice.mockResolvedValue( - mockSdkResponse( - makeDevice({ custom_fields: { env: "production", owner: "team-a" } }), - ), + setDevice( + makeDevice({ custom_fields: { env: "production", owner: "team-a" } }), ); renderPage(); expect(await screen.findByText("env:")).toBeInTheDocument(); @@ -210,9 +239,7 @@ describe("DeviceDetails", () => { }); it("renders the add form inputs", async () => { - sdk.getDevice.mockResolvedValue( - mockSdkResponse(makeDevice({ custom_fields: {} })), - ); + setDevice(makeDevice({ custom_fields: {} })); renderPage(); expect(await screen.findByPlaceholderText("key")).toBeInTheDocument(); expect(screen.getByPlaceholderText("value")).toBeInTheDocument(); @@ -220,9 +247,7 @@ describe("DeviceDetails", () => { it("shows delete confirmation when the remove button is clicked", async () => { const user = userEvent.setup(); - sdk.getDevice.mockResolvedValue( - mockSdkResponse(makeDevice({ custom_fields: { env: "production" } })), - ); + setDevice(makeDevice({ custom_fields: { env: "production" } })); renderPage(); await screen.findByText("env:"); @@ -238,9 +263,7 @@ describe("DeviceDetails", () => { it("hides the confirmation when 'No' is clicked", async () => { const user = userEvent.setup(); - sdk.getDevice.mockResolvedValue( - mockSdkResponse(makeDevice({ custom_fields: { env: "production" } })), - ); + setDevice(makeDevice({ custom_fields: { env: "production" } })); renderPage(); await screen.findByText("env:"); @@ -255,12 +278,10 @@ describe("DeviceDetails", () => { it("calls deleteDeviceCustomField when 'Yes' is clicked", async () => { const user = userEvent.setup(); - sdk.getDevice.mockResolvedValue( - mockSdkResponse( - makeDevice({ - custom_fields: { env: "production", owner: "team-a" }, - }), - ), + setDevice( + makeDevice({ + custom_fields: { env: "production", owner: "team-a" }, + }), ); renderPage(); await screen.findByText("env:"); @@ -271,7 +292,7 @@ describe("DeviceDetails", () => { await user.click(xBtn); await user.click(screen.getByText("Yes")); - expect(sdk.deleteDeviceCustomField).toHaveBeenCalledWith( + expect(deleteCustomFieldSpy).toHaveBeenCalledWith( expect.objectContaining({ path: expect.objectContaining({ uid: "test-uid", key: "env" }), }), @@ -280,16 +301,14 @@ describe("DeviceDetails", () => { it("calls setDeviceCustomField when add form is submitted via Enter key", async () => { const user = userEvent.setup(); - sdk.getDevice.mockResolvedValue( - mockSdkResponse(makeDevice({ custom_fields: {} })), - ); + setDevice(makeDevice({ custom_fields: {} })); renderPage(); await screen.findByPlaceholderText("key"); await user.type(screen.getByPlaceholderText("key"), "region"); await user.type(screen.getByPlaceholderText("value"), "us-east{Enter}"); - expect(sdk.setDeviceCustomField).toHaveBeenCalledWith( + expect(setCustomFieldSpy).toHaveBeenCalledWith( expect.objectContaining({ path: expect.objectContaining({ uid: "test-uid", key: "region" }), body: { value: "us-east" }, @@ -299,9 +318,7 @@ describe("DeviceDetails", () => { it("shows an error when trying to add a duplicate key", async () => { const user = userEvent.setup(); - sdk.getDevice.mockResolvedValue( - mockSdkResponse(makeDevice({ custom_fields: { env: "production" } })), - ); + setDevice(makeDevice({ custom_fields: { env: "production" } })); renderPage(); await screen.findByPlaceholderText("key"); @@ -309,16 +326,14 @@ describe("DeviceDetails", () => { await user.type(screen.getByPlaceholderText("value"), "staging{Enter}"); expect(screen.getByText("This key already exists.")).toBeInTheDocument(); - expect(sdk.setDeviceCustomField).not.toHaveBeenCalled(); + expect(setCustomFieldSpy).not.toHaveBeenCalled(); }); }); describe("action buttons delegate to useDeviceActions", () => { it("calls requestAction('accept') when Accept is clicked on a pending device", async () => { const user = userEvent.setup(); - sdk.getDevice.mockResolvedValue( - mockSdkResponse(makeDevice({ status: "pending", online: false })), - ); + setDevice(makeDevice({ status: "pending", online: false })); renderPage(); await user.click(await screen.findByRole("button", { name: /Accept/i })); @@ -331,9 +346,7 @@ describe("DeviceDetails", () => { it("calls requestAction('reject') when Reject is clicked on a pending device", async () => { const user = userEvent.setup(); - sdk.getDevice.mockResolvedValue( - mockSdkResponse(makeDevice({ status: "pending", online: false })), - ); + setDevice(makeDevice({ status: "pending", online: false })); renderPage(); await user.click(await screen.findByRole("button", { name: /Reject/i })); @@ -346,9 +359,7 @@ describe("DeviceDetails", () => { it("calls requestAction('remove') when Remove is clicked on a rejected device", async () => { const user = userEvent.setup(); - sdk.getDevice.mockResolvedValue( - mockSdkResponse(makeDevice({ status: "rejected", online: false })), - ); + setDevice(makeDevice({ status: "rejected", online: false })); renderPage(); await user.click(await screen.findByRole("button", { name: /Remove/i })); @@ -361,9 +372,7 @@ describe("DeviceDetails", () => { it("calls requestAction('remove') when the Delete device trash button is clicked on an accepted device", async () => { const user = userEvent.setup(); - sdk.getDevice.mockResolvedValue( - mockSdkResponse(makeDevice({ status: "accepted", online: true })), - ); + setDevice(makeDevice({ status: "accepted", online: true })); renderPage(); await user.click( @@ -379,7 +388,7 @@ describe("DeviceDetails", () => { describe("onSuccess callback wiring", () => { it("navigates to /devices when onSuccess is called with action 'remove'", async () => { - sdk.getDevice.mockResolvedValue(mockSdkResponse(makeDevice())); + setDevice(makeDevice()); renderPage(); await screen.findByRole("heading", { name: "my-device" }); @@ -390,9 +399,7 @@ describe("DeviceDetails", () => { }); it("does NOT navigate when onSuccess is called with a non-remove action", async () => { - sdk.getDevice.mockResolvedValue( - mockSdkResponse(makeDevice({ status: "pending", online: false })), - ); + setDevice(makeDevice({ status: "pending", online: false })); renderPage(); await screen.findByRole("heading", { name: "my-device" }); diff --git a/ui/apps/console/src/pages/devices/__tests__/Devices.test.tsx b/ui/apps/console/src/pages/devices/__tests__/Devices.test.tsx index d5c62227df7..d473ad4bf8c 100644 --- a/ui/apps/console/src/pages/devices/__tests__/Devices.test.tsx +++ b/ui/apps/console/src/pages/devices/__tests__/Devices.test.tsx @@ -2,23 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, act, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { mockDevice, mockNamespace } from "@/tests/factories"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; import { seedAuthStore } from "@/tests/seedAuthStore"; import Devices from "../index"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getDevices: vi.fn(), - createTag: vi.fn(), - pushTagToDevice: vi.fn(), - pullTagFromDevice: vi.fn(), - getNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - }), -); - vi.mock("@/hooks/useDebouncedValue", () => ({ useDebouncedValue: (value: T) => value, })); @@ -103,17 +93,42 @@ function renderPage(initialEntries: string[] = ["/"]) { }); } +let lastDevicesUrl: URL | null; + +function setDevices( + devices: ReturnType[], + total?: number, +) { + server.use( + http.get("*/api/devices", ({ request }) => { + lastDevicesUrl = new URL(request.url); + return jsonWithTotal(devices, total ?? devices.length); + }), + ); +} + beforeEach(() => { vi.clearAllMocks(); + lastDevicesUrl = null; seedAuthStore(); - sdk.getDevices.mockResolvedValue(paginatedResponse([])); - sdk.getNamespace.mockResolvedValue(mockSdkResponse(mockNamespace())); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + setDevices([]); + server.use( + http.get("*/api/namespaces/:tenant", () => + HttpResponse.json(mockNamespace()), + ), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token", role: "owner" }), + ), + http.post("*/api/tags", () => new HttpResponse(null, { status: 204 })), + http.post( + "*/api/devices/:uid/tags/:name", + () => new HttpResponse(null, { status: 204 }), + ), + http.delete( + "*/api/devices/:uid/tags/:name", + () => new HttpResponse(null, { status: 204 }), + ), ); - sdk.createTag.mockResolvedValue(mockSdkResponse(undefined)); - sdk.pushTagToDevice.mockResolvedValue(mockSdkResponse(undefined)); - sdk.pullTagFromDevice.mockResolvedValue(mockSdkResponse(undefined)); mockNavigate.mockReset(); mockManageTagsDrawer.mockReset(); }); @@ -154,7 +169,9 @@ describe("Devices list", () => { describe("loading state", () => { it("renders the loading message", () => { - sdk.getDevices.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/api/devices", () => new Promise(() => {})), + ); renderPage(); expect(screen.getByText("Loading devices...")).toBeInTheDocument(); }); @@ -169,14 +186,12 @@ describe("Devices list", () => { describe("device rows", () => { it("renders a row for each device", async () => { - sdk.getDevices.mockResolvedValue( - paginatedResponse( - [ - mockDevice({ uid: "uid-1", name: "alpha" }), - mockDevice({ uid: "uid-2", name: "beta" }), - ], - 2, - ), + setDevices( + [ + mockDevice({ uid: "uid-1", name: "alpha" }), + mockDevice({ uid: "uid-2", name: "beta" }), + ], + 2, ); renderPage(); expect(await screen.findByText("alpha")).toBeInTheDocument(); @@ -185,11 +200,9 @@ describe("Devices list", () => { it("navigates to device detail on row click", async () => { const user = userEvent.setup(); - sdk.getDevices.mockResolvedValue( - paginatedResponse( - [mockDevice({ uid: "uid-abc", name: "clickable" })], - 1, - ), + setDevices( + [mockDevice({ uid: "uid-abc", name: "clickable" })], + 1, ); renderPage(); await user.click(await screen.findByText("clickable")); @@ -199,7 +212,11 @@ describe("Devices list", () => { describe("error state", () => { it("renders an error message when the query fails", async () => { - sdk.getDevices.mockRejectedValue({ status: 500 }); + server.use( + http.get("*/api/devices", () => + HttpResponse.json({}, { status: 500 }), + ), + ); renderPage(); expect( await screen.findByText("Something went wrong on our side. Try again."), @@ -211,22 +228,15 @@ describe("Devices list", () => { it("requests last_seen/desc sort by default", async () => { renderPage(); await waitFor(() => { - expect(sdk.getDevices).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - sort_by: "last_seen", - order_by: "desc", - }), - }), - ); + expect(lastDevicesUrl).not.toBeNull(); + expect(lastDevicesUrl!.searchParams.get("sort_by")).toBe("last_seen"); + expect(lastDevicesUrl!.searchParams.get("order_by")).toBe("desc"); }); }); it("toggles sort when the Hostname header is clicked", async () => { const user = userEvent.setup(); - sdk.getDevices.mockResolvedValue( - paginatedResponse([mockDevice({ uid: "uid-1", name: "alpha" })], 1), - ); + setDevices([mockDevice({ uid: "uid-1", name: "alpha" })], 1); renderPage(); await screen.findByText("alpha"); @@ -234,85 +244,68 @@ describe("Devices list", () => { screen.getByRole("button", { name: "Sort by Hostname" }), ); await waitFor(() => { - expect(sdk.getDevices).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - sort_by: "name", - order_by: "asc", - }), - }), - ); + expect(lastDevicesUrl!.searchParams.get("sort_by")).toBe("name"); + expect(lastDevicesUrl!.searchParams.get("order_by")).toBe("asc"); }); await user.click( screen.getByRole("button", { name: "Sort by Hostname" }), ); await waitFor(() => { - expect(sdk.getDevices).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - sort_by: "name", - order_by: "desc", - }), - }), - ); + expect(lastDevicesUrl!.searchParams.get("sort_by")).toBe("name"); + expect(lastDevicesUrl!.searchParams.get("order_by")).toBe("desc"); }); }); }); describe("URL hydration — URL params seed page state on mount", () => { - it("always asks for accepted devices, whatever the URL says", async () => { - renderPage(["/?status=pending"]); + it("enforces status=accepted regardless of URL", async () => { + renderPage(["/?status=pending&tags=a&tags=b&page=2"]); await waitFor(() => { - expect(sdk.getDevices).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ status: "accepted" }), - }), - ); + expect(lastDevicesUrl?.searchParams.get("status")).toBe("accepted"); }); }); it("passes tags from URL as a filter to the SDK", async () => { - renderPage(["/?status=pending&tags=a&tags=b&page=2"]); + renderPage(["/?tags=a&tags=b"]); await waitFor(() => { - const call = sdk.getDevices.mock.calls.at(-1)?.[0] as { - query?: { filter?: string }; - }; - const decoded = atob(call?.query?.filter ?? ""); + expect(lastDevicesUrl).not.toBeNull(); + const filter = lastDevicesUrl!.searchParams.get("filter") ?? ""; + const decoded = atob(filter); expect(decoded).toContain('"a"'); expect(decoded).toContain('"b"'); }); }); - it("passes page=2 from URL to the SDK", async () => { - renderPage(["/?status=pending&tags=a&tags=b&page=2"]); + it("passes page from URL to the SDK", async () => { + renderPage(["/?page=2"]); await waitFor(() => { - expect(sdk.getDevices).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 2 }), - }), - ); + expect(lastDevicesUrl?.searchParams.get("page")).toBe("2"); }); }); it("defaults to accepted devices and page 1 when the URL has no params", async () => { renderPage(["/"]); await waitFor(() => { - expect(sdk.getDevices).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ status: "accepted", page: 1 }), - }), - ); + expect(lastDevicesUrl).not.toBeNull(); + expect(lastDevicesUrl!.searchParams.get("status")).toBe("accepted"); + expect(lastDevicesUrl!.searchParams.get("page")).toBe("1"); + }); + }); + + it("falls back to status=accepted for an invalid status value", async () => { + renderPage(["/?status=invalid"]); + await waitFor(() => { + expect(lastDevicesUrl).not.toBeNull(); + expect(lastDevicesUrl!.searchParams.get("status")).toBe("accepted"); }); }); it("passes no tag filter when no tags param is present", async () => { renderPage(["/"]); await waitFor(() => { - const call = sdk.getDevices.mock.calls[0]?.[0] as { - query?: { filter?: string }; - }; - expect(call?.query?.filter).toBeUndefined(); + expect(lastDevicesUrl).not.toBeNull(); + expect(lastDevicesUrl!.searchParams.get("filter")).toBeNull(); }); }); }); @@ -327,10 +320,8 @@ describe("Devices list", () => { " myhost ", ); await waitFor(() => { - const call = sdk.getDevices.mock.calls.at(-1)?.[0] as { - query?: { filter?: string }; - }; - const decoded = atob(call?.query?.filter ?? ""); + const filter = lastDevicesUrl!.searchParams.get("filter") ?? ""; + const decoded = atob(filter); expect(decoded).toContain("myhost"); }); }); @@ -339,7 +330,7 @@ describe("Devices list", () => { describe("tag mutation — onTagRenamed/onTagDeleted update URL tags array", () => { it("renames a tag in filter when onTagRenamed is called from ManageTagsDrawer", async () => { renderPage(["/?tags=a&tags=b"]); - await waitFor(() => expect(sdk.getDevices).toHaveBeenCalled()); + await waitFor(() => expect(lastDevicesUrl).not.toBeNull()); const lastCall = mockManageTagsDrawer.mock.calls.at(-1)?.[0] as { onTagRenamed?: (oldName: string, newName: string) => void; @@ -351,10 +342,8 @@ describe("Devices list", () => { }); await waitFor(() => { - const call = sdk.getDevices.mock.calls.at(-1)?.[0] as { - query?: { filter?: string }; - }; - const decoded = atob(call?.query?.filter ?? ""); + const filter = lastDevicesUrl!.searchParams.get("filter") ?? ""; + const decoded = atob(filter); expect(decoded).toContain("alpha"); expect(decoded).toContain('"b"'); }); @@ -362,7 +351,7 @@ describe("Devices list", () => { it("removes a tag from filter when onTagDeleted is called from ManageTagsDrawer", async () => { renderPage(["/?tags=a&tags=b"]); - await waitFor(() => expect(sdk.getDevices).toHaveBeenCalled()); + await waitFor(() => expect(lastDevicesUrl).not.toBeNull()); const lastCall = mockManageTagsDrawer.mock.calls.at(-1)?.[0] as { onTagDeleted?: (name: string) => void; @@ -374,10 +363,8 @@ describe("Devices list", () => { }); await waitFor(() => { - const call = sdk.getDevices.mock.calls.at(-1)?.[0] as { - query?: { filter?: string }; - }; - const decoded = atob(call?.query?.filter ?? ""); + const filter = lastDevicesUrl!.searchParams.get("filter") ?? ""; + const decoded = atob(filter); expect(decoded).not.toContain('"a"'); expect(decoded).toContain('"b"'); }); @@ -386,10 +373,8 @@ describe("Devices list", () => { it("hydrates tags from URL into the SDK filter", async () => { renderPage(["/?tags=existing"]); await waitFor(() => { - const call = sdk.getDevices.mock.calls.at(-1)?.[0] as { - query?: { filter?: string }; - }; - const decoded = atob(call?.query?.filter ?? ""); + const filter = lastDevicesUrl!.searchParams.get("filter") ?? ""; + const decoded = atob(filter); expect(decoded).toContain("existing"); }); expect( diff --git a/ui/apps/console/src/pages/devices/index.tsx b/ui/apps/console/src/pages/devices/index.tsx index 6bc3a92809a..0d5a174f601 100644 --- a/ui/apps/console/src/pages/devices/index.tsx +++ b/ui/apps/console/src/pages/devices/index.tsx @@ -19,10 +19,8 @@ import SearchField from "@/components/common/fields/SearchField"; import { buildSshid } from "@/utils/sshid"; import TagFilterDropdown from "@/components/common/TagFilterDropdown"; import TagsPopover from "@/components/common/TagsPopover"; -import { - useAddDeviceTag, - useRemoveDeviceTag, -} from "@/hooks/useDeviceMutations"; +import { usePullTagFromDevice } from "@/client/api"; +import { useAddDeviceTag } from "@/hooks/useDeviceMutations"; import { PlusIcon, TagIcon, @@ -72,7 +70,7 @@ export default function Devices() { ); const addDeviceTag = useAddDeviceTag(); - const removeDeviceTag = useRemoveDeviceTag(); + const removeDeviceTag = usePullTagFromDevice(); const [connectTarget, setConnectTarget] = useState<{ uid: string; name: string; diff --git a/ui/apps/console/src/pages/firewall-rules/RuleDrawer.tsx b/ui/apps/console/src/pages/firewall-rules/RuleDrawer.tsx index 48b5cc7cfd6..c5cae2c4c73 100644 --- a/ui/apps/console/src/pages/firewall-rules/RuleDrawer.tsx +++ b/ui/apps/console/src/pages/firewall-rules/RuleDrawer.tsx @@ -1,9 +1,6 @@ import { useWatch } from "react-hook-form"; -import { - useCreateFirewallRule, - useUpdateFirewallRule, -} from "@/hooks/useFirewallRuleMutations"; -import type { FirewallRulesResponse } from "@/client"; +import { useCreateFirewallRule, useUpdateFirewallRule } from "@/client/api"; +import type { FirewallRulesResponse } from "@/client/model"; import RadioCard from "@/components/common/fields/RadioCard"; import { UserGroupIcon, @@ -72,9 +69,9 @@ export default function RuleDrawer({ const body = buildRuleBody(values); try { if (isEdit && editRule) { - await updateRule.mutateAsync({ path: { id: editRule.id }, body }); + await updateRule.mutateAsync({ id: editRule.id, data: body }); } else { - await createRule.mutateAsync({ body }); + await createRule.mutateAsync({ data: body }); } onClose(); } catch (err: unknown) { diff --git a/ui/apps/console/src/pages/firewall-rules/__tests__/FirewallRules.test.tsx b/ui/apps/console/src/pages/firewall-rules/__tests__/FirewallRules.test.tsx index d0cb1a0d8a5..12d144c6ff1 100644 --- a/ui/apps/console/src/pages/firewall-rules/__tests__/FirewallRules.test.tsx +++ b/ui/apps/console/src/pages/firewall-rules/__tests__/FirewallRules.test.tsx @@ -2,19 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import FirewallRules from "../index"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { mockFirewallRule } from "@/tests/factories"; import { useAuthStore } from "@/stores/authStore"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getFirewallRules: vi.fn(), - deleteFirewallRule: vi.fn(), - }), -); - vi.mock("../RuleDrawer", () => ({ default: () => null, })); @@ -38,6 +32,8 @@ vi.mock("@/components/common/DataTable", async (importOriginal) => { }; }); +let lastRulesUrl: URL | null; + function renderPage(initialEntries: string[] = ["/"]) { return render( @@ -50,11 +46,18 @@ function renderPage(initialEntries: string[] = ["/"]) { beforeEach(() => { vi.clearAllMocks(); capturedDataTableProps.length = 0; + lastRulesUrl = null; useAuthStore.setState({ role: "owner" }); - sdk.getFirewallRules.mockResolvedValue( - paginatedResponse([mockFirewallRule({ priority: 42 })]), + server.use( + http.get("*/api/firewall/rules", ({ request }) => { + lastRulesUrl = new URL(request.url); + return jsonWithTotal([mockFirewallRule({ priority: 42 })]); + }), + http.delete( + "*/api/firewall/rules/:id", + () => new HttpResponse(null, { status: 204 }), + ), ); - sdk.deleteFirewallRule.mockResolvedValue(mockSdkResponse(undefined)); }); describe("FirewallRules — delete error handling", () => { @@ -73,7 +76,14 @@ describe("FirewallRules — delete error handling", () => { } it("shows the mutation error message inside the dialog when deletion fails", async () => { - sdk.deleteFirewallRule.mockRejectedValue(new Error("Permission denied")); + server.use( + http.delete("*/api/firewall/rules/:id", () => + HttpResponse.json( + { message: "Permission denied" }, + { status: 403 }, + ), + ), + ); const user = await openDeleteDialog(); const dialog = await getDialog(); @@ -85,17 +95,19 @@ describe("FirewallRules — delete error handling", () => { expect(dialog).toBeInTheDocument(); }); - it("shows a generic fallback message when the rejection is not an Error", async () => { - sdk.deleteFirewallRule.mockRejectedValue("boom"); + it("shows the status code as fallback when the server returns no message", async () => { + server.use( + http.delete("*/api/firewall/rules/:id", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const user = await openDeleteDialog(); const dialog = await getDialog(); await user.click(within(dialog).getByRole("button", { name: /^delete$/i })); await waitFor(() => - expect( - within(dialog).getByText(/failed to delete firewall rule/i), - ).toBeInTheDocument(), + expect(within(dialog).getByText("500")).toBeInTheDocument(), ); }); @@ -116,7 +128,18 @@ describe("FirewallRules — delete error handling", () => { }); it("clears any previous error when the dialog is cancelled and reopened", async () => { - sdk.deleteFirewallRule.mockRejectedValueOnce(new Error("Transient")); + let callCount = 0; + server.use( + http.delete("*/api/firewall/rules/:id", () => { + callCount++; + if (callCount === 1) + return HttpResponse.json( + { message: "Transient" }, + { status: 500 }, + ); + return new HttpResponse(null, { status: 204 }); + }), + ); const user = await openDeleteDialog(); let dialog = await getDialog(); @@ -149,25 +172,19 @@ describe("FirewallRules — URL hydration", () => { ).toHaveValue("allow"); }); - it("hydrates page from URL and passes it to the SDK", async () => { + it("hydrates page from URL and passes it to the API", async () => { renderPage(["/?page=3"]); await waitFor(() => { - expect(sdk.getFirewallRules).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 3 }), - }), - ); + expect(lastRulesUrl).not.toBeNull(); + expect(lastRulesUrl!.searchParams.get("page")).toBe("3"); }); }); - it("passes page=1 to the SDK when URL has no params", async () => { + it("passes page=1 when URL has no params", async () => { renderPage(["/"]); await waitFor(() => { - expect(sdk.getFirewallRules).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastRulesUrl).not.toBeNull(); + expect(lastRulesUrl!.searchParams.get("page")).toBe("1"); }); }); @@ -176,11 +193,8 @@ describe("FirewallRules — URL hydration", () => { renderPage(["/?page=3"]); await waitFor(() => { - expect(sdk.getFirewallRules).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 3 }), - }), - ); + expect(lastRulesUrl).not.toBeNull(); + expect(lastRulesUrl!.searchParams.get("page")).toBe("3"); }); await user.type( @@ -191,10 +205,7 @@ describe("FirewallRules — URL hydration", () => { ); await waitFor(() => { - const calls = sdk.getFirewallRules.mock.calls; - const lastCall = calls.at(-1)![0]; - expect(lastCall).toBeDefined(); - expect(lastCall?.query?.page).toBe(1); + expect(lastRulesUrl!.searchParams.get("page")).toBe("1"); }); }); }); diff --git a/ui/apps/console/src/pages/firewall-rules/__tests__/RuleDrawer.test.tsx b/ui/apps/console/src/pages/firewall-rules/__tests__/RuleDrawer.test.tsx index 55d25f788d3..7820d16a093 100644 --- a/ui/apps/console/src/pages/firewall-rules/__tests__/RuleDrawer.test.tsx +++ b/ui/apps/console/src/pages/firewall-rules/__tests__/RuleDrawer.test.tsx @@ -7,20 +7,12 @@ import { within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server, setTags } from "@/tests/msw"; import RuleDrawer from "../RuleDrawer"; -import type { FirewallRulesResponse } from "@/client"; -import { mockSdkResponse } from "@/tests/sdk"; +import type { FirewallRulesResponse } from "@/client/model"; import { createTestWrapper } from "@/tests/wrapper"; import { mockFirewallRule } from "@/tests/factories"; -import { mockTags } from "@/tests/mockTags"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - createFirewallRule: vi.fn(), - updateFirewallRule: vi.fn(), - getTags: vi.fn(), - }), -); vi.mock("@/components/common/Drawer", async () => ({ default: (await import("@/tests/mocks")).MockDrawer, @@ -59,9 +51,16 @@ function getConfirmButton() { beforeEach(() => { vi.clearAllMocks(); - sdk.createFirewallRule.mockResolvedValue(mockSdkResponse(undefined)); - sdk.updateFirewallRule.mockResolvedValue(mockSdkResponse(undefined)); - mockTags(["production", "staging", "dev"]); + server.use( + http.post("*/api/firewall/rules", () => + HttpResponse.json(mockFirewallRule()), + ), + http.put( + "*/api/firewall/rules/:id", + () => new HttpResponse(null, { status: 204 }), + ), + ); + setTags(["production", "staging", "dev"]); }); describe("RuleDrawer — create mode", () => { @@ -109,7 +108,7 @@ describe("RuleDrawer — create mode", () => { it("caps tag selection at 3, ignoring a 4th tag and keeping submit enabled", async () => { const user = userEvent.setup(); - mockTags(["a", "b", "c", "d"]); + setTags(["a", "b", "c", "d"]); renderDrawer(); @@ -129,7 +128,7 @@ describe("RuleDrawer — create mode", () => { await waitFor(() => expect(getConfirmButton()).not.toBeDisabled()); }); - it("calls createFirewallRule with the correct body and calls onClose on success", async () => { + it("calls the create endpoint and closes on success", async () => { const user = userEvent.setup(); const onClose = vi.fn(); renderDrawer({ onClose }); @@ -137,20 +136,6 @@ describe("RuleDrawer — create mode", () => { await typePriority(user, "42"); await user.click(getConfirmButton()); - await waitFor(() => - expect(sdk.createFirewallRule).toHaveBeenCalledWith( - expect.objectContaining({ - body: { - priority: 42, - action: "allow", - active: true, - source_ip: ".*", - username: ".*", - filter: { hostname: ".*" }, - }, - }), - ), - ); await waitFor(() => expect(onClose).toHaveBeenCalled()); }); }); @@ -180,7 +165,7 @@ describe("RuleDrawer — edit mode", () => { expect(screen.getByPlaceholderText(/e\.g\. web-/i)).toHaveValue("web-.*"); }); - it("calls updateFirewallRule with the correct body on save", async () => { + it("calls the update endpoint and closes on save", async () => { const user = userEvent.setup(); const rule = mockFirewallRule({ priority: 10, action: "deny" }); const onClose = vi.fn(); @@ -188,21 +173,6 @@ describe("RuleDrawer — edit mode", () => { await user.click(getConfirmButton()); - await waitFor(() => - expect(sdk.updateFirewallRule).toHaveBeenCalledWith( - expect.objectContaining({ - path: { id: "rule-1" }, - body: { - priority: 10, - action: "deny", - active: true, - source_ip: ".*", - username: ".*", - filter: { hostname: ".*" }, - }, - }), - ), - ); await waitFor(() => expect(onClose).toHaveBeenCalled()); }); @@ -218,21 +188,18 @@ describe("RuleDrawer — edit mode", () => { await typePriority(user, "5"); await user.click(getConfirmButton()); - await waitFor(() => - expect(sdk.updateFirewallRule).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ priority: 5 }), - }), - ), - ); await waitFor(() => expect(onClose).toHaveBeenCalled()); }); }); describe("RuleDrawer — API rejection", () => { it("shows root error alert and does not call onClose when API rejects", async () => { + server.use( + http.post("*/api/firewall/rules", () => + HttpResponse.json({ message: "Server error" }, { status: 500 }), + ), + ); const user = userEvent.setup(); - sdk.createFirewallRule.mockRejectedValue(new Error("Server error")); const onClose = vi.fn(); renderDrawer({ onClose }); diff --git a/ui/apps/console/src/pages/firewall-rules/__tests__/ruleSchema.test.ts b/ui/apps/console/src/pages/firewall-rules/__tests__/ruleSchema.test.ts index c1b102f8fbd..ec8d262f84e 100644 --- a/ui/apps/console/src/pages/firewall-rules/__tests__/ruleSchema.test.ts +++ b/ui/apps/console/src/pages/firewall-rules/__tests__/ruleSchema.test.ts @@ -5,7 +5,7 @@ import { buildRuleDefaults, type RuleFormValues, } from "../ruleSchema"; -import type { FirewallRulesResponse } from "@/client"; +import type { FirewallRulesResponse } from "@/client/model"; function makeValues(overrides: Partial = {}): RuleFormValues { return { diff --git a/ui/apps/console/src/pages/firewall-rules/index.tsx b/ui/apps/console/src/pages/firewall-rules/index.tsx index f456e697667..6d8cd9e7f26 100644 --- a/ui/apps/console/src/pages/firewall-rules/index.tsx +++ b/ui/apps/console/src/pages/firewall-rules/index.tsx @@ -10,7 +10,7 @@ import { TrashIcon, } from "@heroicons/react/24/outline"; import { Badge, Button, IconButton } from "@shellhub/design-system/primitives"; -import { type FirewallRulesResponse as FirewallRule } from "@/client"; +import { type FirewallRulesResponse as FirewallRule } from "@/client/model"; import ActiveBadge from "@/components/common/ActiveBadge"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import DataTable, { type Column } from "@/components/common/DataTable"; @@ -19,8 +19,8 @@ import FilterBadge from "@/components/common/FilterBadge"; import PageHeader from "@/components/common/PageHeader"; import RestrictedAction from "@/components/common/RestrictedAction"; import SearchField from "@/components/common/fields/SearchField"; -import { useDeleteFirewallRule } from "@/hooks/useFirewallRuleMutations"; -import { useFirewallRules } from "@/hooks/useFirewallRules"; +import { useGetFirewallRules, useDeleteFirewallRule } from "@/client/api"; +import { totalCount } from "@/api/pagination"; import { usePaginatedListState } from "@/hooks/usePaginatedListState"; import RuleDrawer from "./RuleDrawer"; import { pageCount } from "@/utils/pagination"; @@ -42,7 +42,8 @@ export default function FirewallRules() { const { params, setPage, setSearch } = usePaginatedListState({ defaults: DEFAULTS }); - const { rules, totalCount, isLoading } = useFirewallRules({ page: params.page }); + const { data: rules = [], isLoading } = useGetFirewallRules({ page: params.page, per_page: 10 }); + const total = totalCount(rules); const deleteRule = useDeleteFirewallRule(); const [drawerOpen, setDrawerOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); @@ -61,8 +62,9 @@ export default function FirewallRules() { if (!deleteTarget) return; setDeleteError(null); try { - await deleteRule.mutateAsync({ path: { id: deleteTarget.id } }); - if (rules.length === 1 && params.page > 1 && !params.search) setPage(params.page - 1); + await deleteRule.mutateAsync({ id: deleteTarget.id }); + if (rules.length === 1 && params.page > 1 && !params.search) + setPage(params.page - 1); closeDelete(); } catch (err) { setDeleteError( @@ -86,7 +88,7 @@ export default function FirewallRules() { setEditTarget(null); }; - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const filtered = params.search ? rules.filter( @@ -289,7 +291,7 @@ export default function FirewallRules() { {...(!params.search && { page: params.page, totalPages, - totalCount, + totalCount: total, itemLabel: "rule", onPageChange: setPage, })} diff --git a/ui/apps/console/src/pages/firewall-rules/ruleSchema.ts b/ui/apps/console/src/pages/firewall-rules/ruleSchema.ts index fd672e083cd..5c1404bd9b2 100644 --- a/ui/apps/console/src/pages/firewall-rules/ruleSchema.ts +++ b/ui/apps/console/src/pages/firewall-rules/ruleSchema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { FirewallRulesRequest, FirewallRulesResponse } from "@/client"; +import type { FirewallRulesRequest, FirewallRulesResponse } from "@/client/model"; function isValidRegex(pattern: string): boolean { try { diff --git a/ui/apps/console/src/pages/install-keys/CreateInstallKeyDrawer.tsx b/ui/apps/console/src/pages/install-keys/CreateInstallKeyDrawer.tsx index 717ee476da0..5b96b061766 100644 --- a/ui/apps/console/src/pages/install-keys/CreateInstallKeyDrawer.tsx +++ b/ui/apps/console/src/pages/install-keys/CreateInstallKeyDrawer.tsx @@ -3,7 +3,7 @@ import { CheckIcon, TicketIcon } from "@heroicons/react/24/outline"; import { Button, Card, WindowChrome } from "@shellhub/design-system/primitives"; import { isSdkError } from "@/api/errors"; import { useResetOnOpen } from "@/hooks/useResetOnOpen"; -import { useCreateInstallKey } from "@/hooks/useInstallKeyMutations"; +import { useInstallKeyCreate } from "@/client/api"; import { buildInstallCommand } from "@/utils/installCommand"; import CopyButton from "@/components/common/CopyButton"; import Drawer from "@/components/common/Drawer"; @@ -34,7 +34,7 @@ function CreateInstallKeyDrawer({ onClose: () => void; onCreated?: (name: string) => void; }) { - const createKey = useCreateInstallKey(); + const createKey = useInstallKeyCreate(); const [name, setName] = useState(""); const [mode, setMode] = useState("automatic"); const [webhookUrl, setWebhookUrl] = useState(""); @@ -104,7 +104,7 @@ function CreateInstallKeyDrawer({ setError(""); try { const result = await createKey.mutateAsync({ - body: { + data: { name: name.trim(), mode, ...(mode === "webhook" diff --git a/ui/apps/console/src/pages/install-keys/EditInstallKeyDrawer.tsx b/ui/apps/console/src/pages/install-keys/EditInstallKeyDrawer.tsx index 2e4d79a8a75..b23095bfe36 100644 --- a/ui/apps/console/src/pages/install-keys/EditInstallKeyDrawer.tsx +++ b/ui/apps/console/src/pages/install-keys/EditInstallKeyDrawer.tsx @@ -3,8 +3,8 @@ import { CheckIcon } from "@heroicons/react/24/outline"; import { Button, Callout } from "@shellhub/design-system/primitives"; import { isSdkError } from "@/api/errors"; import { useResetOnOpen } from "@/hooks/useResetOnOpen"; -import { useUpdateInstallKey } from "@/hooks/useInstallKeyMutations"; -import { type InstallKey, type InstallKeyUpdate } from "@/client"; +import { useInstallKeyUpdate } from "@/client/api"; +import { type InstallKey, type InstallKeyUpdate } from "@/client/model"; import { getRemainingDays, isSystemKey, @@ -29,7 +29,7 @@ function EditInstallKeyDrawer({ installKey: InstallKey | null; onClose: () => void; }) { - const updateKey = useUpdateInstallKey(); + const updateKey = useInstallKeyUpdate(); const open = installKey !== null; const isSystem = installKey ? isSystemKey(installKey) : false; const [name, setName] = useState(""); @@ -150,7 +150,7 @@ function EditInstallKeyDrawer({ ...(ephemeral ? { ephemeral_timeout: ephemeralTimeout } : {}), }; - await updateKey.mutateAsync({ path: { key: installKey.name }, body }); + await updateKey.mutateAsync({ key: installKey.name, data: body }); onClose(); } catch (err) { if (isSdkError(err) && err.status === 409) { diff --git a/ui/apps/console/src/pages/install-keys/EventPublicKey.tsx b/ui/apps/console/src/pages/install-keys/EventPublicKey.tsx index c0e7906e9e8..f73aabc9c1e 100644 --- a/ui/apps/console/src/pages/install-keys/EventPublicKey.tsx +++ b/ui/apps/console/src/pages/install-keys/EventPublicKey.tsx @@ -1,6 +1,6 @@ import { useId, useState } from "react"; import { Button, Card } from "@shellhub/design-system/primitives"; -import { type InstallKeyEvent } from "@/client"; +import { type InstallKeyEvent } from "@/client/model"; import CopyButton from "@/components/common/CopyButton"; import BaseDialog from "@/components/common/BaseDialog"; import { LABEL } from "@/utils/styles"; diff --git a/ui/apps/console/src/pages/install-keys/ExpiryLabel.tsx b/ui/apps/console/src/pages/install-keys/ExpiryLabel.tsx index 7a039f8d42b..baf47d2177d 100644 --- a/ui/apps/console/src/pages/install-keys/ExpiryLabel.tsx +++ b/ui/apps/console/src/pages/install-keys/ExpiryLabel.tsx @@ -1,6 +1,6 @@ import { ClockIcon } from "@heroicons/react/24/outline"; import { ExclamationCircleIcon } from "@heroicons/react/24/solid"; -import { type InstallKey } from "@/client"; +import { type InstallKey } from "@/client/model"; import { getExpiryInfo, getKeyBlockers } from "./helpers"; import { cn } from "@shellhub/design-system/cn"; diff --git a/ui/apps/console/src/pages/install-keys/InstallKeyActions.tsx b/ui/apps/console/src/pages/install-keys/InstallKeyActions.tsx index e2e42932833..df55f93aa82 100644 --- a/ui/apps/console/src/pages/install-keys/InstallKeyActions.tsx +++ b/ui/apps/console/src/pages/install-keys/InstallKeyActions.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { type InstallKey } from "@/client"; +import { type InstallKey } from "@/client/model"; import InstallKeyActionsMenu from "./InstallKeyActionsMenu"; import EditInstallKeyDrawer from "./EditInstallKeyDrawer"; import RevokeInstallKeyDialog from "./RevokeInstallKeyDialog"; diff --git a/ui/apps/console/src/pages/install-keys/InstallKeyActionsMenu.tsx b/ui/apps/console/src/pages/install-keys/InstallKeyActionsMenu.tsx index 5919cc18f0d..42d39d38dfd 100644 --- a/ui/apps/console/src/pages/install-keys/InstallKeyActionsMenu.tsx +++ b/ui/apps/console/src/pages/install-keys/InstallKeyActionsMenu.tsx @@ -7,7 +7,7 @@ import { PlayIcon, } from "@heroicons/react/24/outline"; import { Dropdown, IconButton } from "@shellhub/design-system/primitives"; -import { type InstallKey } from "@/client"; +import { type InstallKey } from "@/client/model"; import RestrictedAction from "@/components/common/RestrictedAction"; import { type Action } from "@/utils/permission"; import { isPairingKey, isSystemKey } from "./helpers"; diff --git a/ui/apps/console/src/pages/install-keys/InstallKeyEventReview.tsx b/ui/apps/console/src/pages/install-keys/InstallKeyEventReview.tsx index 4ba209fe1d1..d17c8902d74 100644 --- a/ui/apps/console/src/pages/install-keys/InstallKeyEventReview.tsx +++ b/ui/apps/console/src/pages/install-keys/InstallKeyEventReview.tsx @@ -5,7 +5,7 @@ import { } from "@heroicons/react/24/outline"; import RestrictedAction from "@/components/common/RestrictedAction"; import { formatDateFull } from "@/utils/date"; -import { type InstallKeyEvent } from "@/client"; +import { type InstallKeyEvent } from "@/client/model"; import type { RequestDeviceAction } from "./installKeyEventColumns"; import StatusChip from "./StatusChip"; diff --git a/ui/apps/console/src/pages/install-keys/InstallKeyEventsTable.tsx b/ui/apps/console/src/pages/install-keys/InstallKeyEventsTable.tsx index c4aa8c130b6..5bb831d4a6f 100644 --- a/ui/apps/console/src/pages/install-keys/InstallKeyEventsTable.tsx +++ b/ui/apps/console/src/pages/install-keys/InstallKeyEventsTable.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; -import { useInstallKeyEvents } from "@/hooks/useInstallKeyEvents"; +import { useInstallKeyHistory } from "@/client/api"; +import { totalCount } from "@/api/pagination"; import { useActionDialog } from "@/hooks/useActionDialog"; import { useInvalidateByIds } from "@/hooks/useInvalidateQueries"; import DataTable from "@/components/common/DataTable"; @@ -18,7 +19,7 @@ const EMPTY_MESSAGE = */ export default function InstallKeyEventsTable({ id }: { id: string }) { const [page, setPage] = useState(1); - const refreshHistory = useInvalidateByIds("installKeyHistory"); + const refreshHistory = useInvalidateByIds("/api/namespaces/install-key"); const deviceActions = useActionDialog({ onSuccess: () => void refreshHistory(), }); @@ -27,11 +28,17 @@ export default function InstallKeyEventsTable({ id }: { id: string }) { () => getInstallKeyEventColumns(deviceActions.requestAction), [deviceActions.requestAction], ); - const { events, totalCount, isLoading, error } = useInstallKeyEvents({ - id, + const { + data: events = [], + isLoading, + error, + } = useInstallKeyHistory(id, { page, - perPage: PER_PAGE, + per_page: PER_PAGE, + sort_by: "created_at", + order_by: "desc", }); + const total = totalCount(events); if (error) { return ( @@ -57,8 +64,8 @@ export default function InstallKeyEventsTable({ id }: { id: string }) { loadingMessage="Loading activity..." emptyMessage={EMPTY_MESSAGE} page={page} - totalPages={pageCount(totalCount, PER_PAGE)} - totalCount={totalCount} + totalPages={pageCount(total, PER_PAGE)} + totalCount={total} itemLabel="registration" onPageChange={setPage} /> diff --git a/ui/apps/console/src/pages/install-keys/InstallKeyHistoryPage.tsx b/ui/apps/console/src/pages/install-keys/InstallKeyHistoryPage.tsx index 13c9ebbede2..5391c8c219f 100644 --- a/ui/apps/console/src/pages/install-keys/InstallKeyHistoryPage.tsx +++ b/ui/apps/console/src/pages/install-keys/InstallKeyHistoryPage.tsx @@ -6,8 +6,8 @@ import { TicketIcon, } from "@heroicons/react/24/outline"; import { IconBadge } from "@shellhub/design-system/primitives"; -import { type InstallKey } from "@/client"; -import { useInstallKeys } from "@/hooks/useInstallKeys"; +import { type InstallKey } from "@/client/model"; +import { useInstallKeyList } from "@/client/api"; import PageLoader from "@/components/common/PageLoader"; import Breadcrumb from "@/components/common/Breadcrumb"; import ResourceNotFound from "@/components/common/ResourceNotFound"; @@ -41,7 +41,7 @@ export default function InstallKeyHistoryPage() { const location = useLocation(); const state = location.state as { name?: string; key?: InstallKey } | null; - const { installKeys, isLoading } = useInstallKeys({ perPage: 100 }); + const { data: installKeys = [], isLoading } = useInstallKeyList({ page: 1, per_page: 100, sort_by: "created_at", order_by: "desc" }); const key = installKeys.find((k) => k.id === id) ?? state?.key ?? null; const name = key ? installKeyDisplayName(key) : (state?.name ?? ""); const [revealOpen, setRevealOpen] = useState(false); diff --git a/ui/apps/console/src/pages/install-keys/InstallKeysTable.tsx b/ui/apps/console/src/pages/install-keys/InstallKeysTable.tsx index 28d1a9e7eb6..e7a85b8cb0a 100644 --- a/ui/apps/console/src/pages/install-keys/InstallKeysTable.tsx +++ b/ui/apps/console/src/pages/install-keys/InstallKeysTable.tsx @@ -8,7 +8,7 @@ import { TicketIcon, } from "@heroicons/react/24/outline"; import { Button } from "@shellhub/design-system/primitives"; -import { type InstallKey } from "@/client"; +import { type InstallKey } from "@/client/model"; import DataTable, { type Column } from "@/components/common/DataTable"; import RestrictedAction from "@/components/common/RestrictedAction"; import InstallKeyActionsMenu from "./InstallKeyActionsMenu"; diff --git a/ui/apps/console/src/pages/install-keys/RevealInstallKeyDialog.tsx b/ui/apps/console/src/pages/install-keys/RevealInstallKeyDialog.tsx index aa03e445df7..cd84e29190b 100644 --- a/ui/apps/console/src/pages/install-keys/RevealInstallKeyDialog.tsx +++ b/ui/apps/console/src/pages/install-keys/RevealInstallKeyDialog.tsx @@ -10,8 +10,8 @@ import { Card, Spinner, } from "@shellhub/design-system/primitives"; -import { useRevealInstallKey } from "@/hooks/useRevealInstallKey"; -import { type InstallKey } from "@/client"; +import { useInstallKeyReveal } from "@/client/api"; +import { type InstallKey } from "@/client/model"; import { installKeyDisplayName } from "./helpers"; import CopyButton from "@/components/common/CopyButton"; import BaseDialog from "@/components/common/BaseDialog"; @@ -48,10 +48,14 @@ export default function RevealInstallKeyDialog({ setRevealed(false); } - const { key, isLoading, error } = useRevealInstallKey( - hasSecret ? name : null, - revealed, - ); + const { + data: revealData, + isLoading, + error, + } = useInstallKeyReveal(name ?? "", { + query: { enabled: hasSecret && !!name && revealed, gcTime: 0 }, + }); + const key = revealData?.key ?? ""; return ( void; }) { - const updateKey = useUpdateInstallKey(); + const updateKey = useInstallKeyUpdate(); const [confirmText, setConfirmText] = useState(""); const [error, setError] = useState(null); @@ -33,8 +33,8 @@ export default function RevokeInstallKeyDialog({ setError(null); try { await updateKey.mutateAsync({ - path: { key: installKey.name }, - body: { revoked: true }, + key: installKey.name, + data: { revoked: true }, }); onRevoked(); } catch { diff --git a/ui/apps/console/src/pages/install-keys/UsageMeter.tsx b/ui/apps/console/src/pages/install-keys/UsageMeter.tsx index c50a1f50041..f07713b248d 100644 --- a/ui/apps/console/src/pages/install-keys/UsageMeter.tsx +++ b/ui/apps/console/src/pages/install-keys/UsageMeter.tsx @@ -1,6 +1,6 @@ import { ClockIcon } from "@heroicons/react/24/outline"; import { cn } from "@shellhub/design-system/cn"; -import { type InstallKey } from "@/client"; +import { type InstallKey } from "@/client/model"; import StatusChip from "./StatusChip"; import { getKeyBlockers, diff --git a/ui/apps/console/src/pages/install-keys/__tests__/CreateInstallKeyDrawer.test.tsx b/ui/apps/console/src/pages/install-keys/__tests__/CreateInstallKeyDrawer.test.tsx index 5d584416324..2f6fd18c3f9 100644 --- a/ui/apps/console/src/pages/install-keys/__tests__/CreateInstallKeyDrawer.test.tsx +++ b/ui/apps/console/src/pages/install-keys/__tests__/CreateInstallKeyDrawer.test.tsx @@ -1,20 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import CreateInstallKeyDrawer from "../CreateInstallKeyDrawer"; -import { mockSdkResponse } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { seedAuthStore } from "@/tests/seedAuthStore"; -import { mockTags } from "@/tests/mockTags"; import { ClipboardProvider } from "@/components/common/ClipboardProvider"; -const sdk = vi.hoisted(() => - mockSdkGen({ - installKeyCreate: vi.fn(), - getTags: vi.fn(), - }), -); - vi.mock("@/components/common/Drawer", async () => ({ default: (await import("@/tests/mocks")).MockDrawer, })); @@ -40,9 +33,11 @@ describe("CreateInstallKeyDrawer", () => { beforeEach(() => { vi.clearAllMocks(); seedAuthStore({ tenant: "00000000-0000-4000-0000-000000000000" }); - mockTags([]); - sdk.installKeyCreate.mockResolvedValue( - mockSdkResponse({ key: GENERATED_KEY }), + server.use( + http.get("*/api/tags", () => jsonWithTotal([])), + http.post("*/api/namespaces/install-key", () => + HttpResponse.json({ key: GENERATED_KEY }), + ), ); }); diff --git a/ui/apps/console/src/pages/install-keys/__tests__/InstallKeys.test.tsx b/ui/apps/console/src/pages/install-keys/__tests__/InstallKeys.test.tsx index 957323e0f87..e469f554592 100644 --- a/ui/apps/console/src/pages/install-keys/__tests__/InstallKeys.test.tsx +++ b/ui/apps/console/src/pages/install-keys/__tests__/InstallKeys.test.tsx @@ -1,17 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, within } from "@testing-library/react"; +import { http } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; import { mockInstallKey } from "@/tests/factories"; -import { paginatedResponse } from "@/tests/sdk"; import { seedAuthStore } from "@/tests/seedAuthStore"; import InstallKeys from "../index"; -const sdk = vi.hoisted(() => - mockSdkGen({ - installKeyList: vi.fn(), - }), -); - function renderPage() { return render(, { wrapper: createTestWrapper({ initialEntries: ["/install-keys"] }), @@ -25,27 +20,29 @@ async function keyRow(name: string) { beforeEach(() => { vi.clearAllMocks(); seedAuthStore(); - sdk.installKeyList.mockResolvedValue( - paginatedResponse([ - mockInstallKey({ - id: "waiting-digest", - name: "fleet-key", - pending_devices: 2, - }), - mockInstallKey({ - id: "settled-digest", - name: "auto-key", - mode: "automatic", - pending_devices: 0, - }), - mockInstallKey({ - id: "capped-digest", - name: "edge-fleet", - usage_limit: 4, - used_times: 3, - pending_devices: 3, - }), - ]), + server.use( + http.get("*/api/namespaces/install-key", () => + jsonWithTotal([ + mockInstallKey({ + id: "waiting-digest", + name: "fleet-key", + pending_devices: 2, + }), + mockInstallKey({ + id: "settled-digest", + name: "auto-key", + mode: "automatic", + pending_devices: 0, + }), + mockInstallKey({ + id: "capped-digest", + name: "edge-fleet", + usage_limit: 4, + used_times: 3, + pending_devices: 3, + }), + ]), + ), ); }); @@ -53,24 +50,32 @@ describe("Install keys", () => { it("says how many devices a key has waiting for a decision", async () => { renderPage(); - expect((await keyRow("fleet-key")).getByText("2 waiting")).toBeInTheDocument(); + expect( + (await keyRow("fleet-key")).getByText("2 waiting"), + ).toBeInTheDocument(); }); it("says nothing about waiting when a key has nothing pending", async () => { renderPage(); - expect((await keyRow("auto-key")).queryByText(/waiting/)).not.toBeInTheDocument(); + expect( + (await keyRow("auto-key")).queryByText(/waiting/), + ).not.toBeInTheDocument(); }); it("keeps the spend readable while devices wait", async () => { renderPage(); - expect((await keyRow("fleet-key")).getByText(/0 \/ ∞ used/)).toBeInTheDocument(); + expect( + (await keyRow("fleet-key")).getByText(/0 \/ ∞ used/), + ).toBeInTheDocument(); }); it("warns when accepting everything waiting would pass the key's limit", async () => { renderPage(); - expect((await keyRow("edge-fleet")).getByText(/3 \/ 4 used · 2 over/)).toBeInTheDocument(); + expect( + (await keyRow("edge-fleet")).getByText(/3 \/ 4 used · 2 over/), + ).toBeInTheDocument(); }); }); diff --git a/ui/apps/console/src/pages/install-keys/__tests__/helpers.test.ts b/ui/apps/console/src/pages/install-keys/__tests__/helpers.test.ts index 69fc59b5024..01180f04ec1 100644 --- a/ui/apps/console/src/pages/install-keys/__tests__/helpers.test.ts +++ b/ui/apps/console/src/pages/install-keys/__tests__/helpers.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { type InstallKey } from "@/client"; +import { type InstallKey } from "@/client/model"; import { getExpiryInfo, getKeyBlockers, diff --git a/ui/apps/console/src/pages/install-keys/helpers.ts b/ui/apps/console/src/pages/install-keys/helpers.ts index 50e4e894843..60a60c76703 100644 --- a/ui/apps/console/src/pages/install-keys/helpers.ts +++ b/ui/apps/console/src/pages/install-keys/helpers.ts @@ -1,5 +1,5 @@ import { differenceInCalendarDays } from "date-fns"; -import { type InstallKey } from "@/client"; +import { type InstallKey } from "@/client/model"; import { formatDateShort } from "@/utils/date"; /** diff --git a/ui/apps/console/src/pages/install-keys/index.tsx b/ui/apps/console/src/pages/install-keys/index.tsx index cfc1e4c055f..1dae27ce128 100644 --- a/ui/apps/console/src/pages/install-keys/index.tsx +++ b/ui/apps/console/src/pages/install-keys/index.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; import { TicketIcon } from "@heroicons/react/24/outline"; import { Button, Spinner } from "@shellhub/design-system/primitives"; -import { useInstallKeys } from "@/hooks/useInstallKeys"; +import { useInstallKeyList } from "@/client/api"; +import { totalCount } from "@/api/pagination"; import { usePaginatedListState } from "@/hooks/usePaginatedListState"; -import { type InstallKey } from "@/client"; +import { type InstallKey } from "@/client/model"; import PageHeader from "@/components/common/PageHeader"; import RestrictedAction from "@/components/common/RestrictedAction"; import InstallKeysTable from "./InstallKeysTable"; @@ -30,9 +31,10 @@ export default function InstallKeys() { defaults: INSTALL_KEY_LIST_DEFAULTS, }); const page = params.page; - const { installKeys, totalCount, isLoading } = useInstallKeys({ page }); + const { data: installKeys = [], isLoading } = useInstallKeyList({ page, per_page: 10, sort_by: "created_at", order_by: "desc" }); + const total = totalCount(installKeys); - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const [createOpen, setCreateOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); @@ -74,7 +76,7 @@ export default function InstallKeys() { data={installKeys} page={page} totalPages={totalPages} - totalCount={totalCount} + totalCount={total} noCustomKeys={noCustomKeys} onPageChange={setPage} onCreate={() => setCreateOpen(true)} diff --git a/ui/apps/console/src/pages/install-keys/installKeyEventColumns.tsx b/ui/apps/console/src/pages/install-keys/installKeyEventColumns.tsx index a7973167788..aeb4a6feacc 100644 --- a/ui/apps/console/src/pages/install-keys/installKeyEventColumns.tsx +++ b/ui/apps/console/src/pages/install-keys/installKeyEventColumns.tsx @@ -1,5 +1,5 @@ import { ArrowPathIcon, PlusCircleIcon } from "@heroicons/react/24/outline"; -import { type InstallKeyEvent } from "@/client"; +import { type InstallKeyEvent } from "@/client/model"; import { formatDateFull } from "@/utils/date"; import { type Column } from "@/components/common/DataTable"; import DistroIcon from "@/components/common/DistroIcon"; diff --git a/ui/apps/console/src/pages/install-keys/useToggleInstallKey.ts b/ui/apps/console/src/pages/install-keys/useToggleInstallKey.ts index 3b68522eca4..22ba91230c9 100644 --- a/ui/apps/console/src/pages/install-keys/useToggleInstallKey.ts +++ b/ui/apps/console/src/pages/install-keys/useToggleInstallKey.ts @@ -1,26 +1,24 @@ import { useState } from "react"; -import { useUpdateInstallKey } from "@/hooks/useInstallKeyMutations"; -import { type InstallKey } from "@/client"; +import { useInstallKeyUpdate } from "@/client/api"; +import { type InstallKey } from "@/client/model"; /** * Enables and disables an install key, holding the failure so the row can show it. Disabling is * reversible, which is what distinguishes it from revoking. */ export function useToggleInstallKey() { - const updateKey = useUpdateInstallKey(); + const updateKey = useInstallKeyUpdate(); const [error, setError] = useState(null); const toggle = async (key: InstallKey) => { setError(null); try { await updateKey.mutateAsync({ - path: { key: key.name }, - body: { disabled: !key.disabled }, + key: key.name, + data: { disabled: !key.disabled }, }); } catch { - setError( - `Failed to ${key.disabled ? "enable" : "disable"} Install Key.`, - ); + setError(`Failed to ${key.disabled ? "enable" : "disable"} Install Key.`); } }; diff --git a/ui/apps/console/src/pages/public-keys/KeyDrawer.tsx b/ui/apps/console/src/pages/public-keys/KeyDrawer.tsx index 64b8d2d31e4..941c3d53509 100644 --- a/ui/apps/console/src/pages/public-keys/KeyDrawer.tsx +++ b/ui/apps/console/src/pages/public-keys/KeyDrawer.tsx @@ -8,11 +8,8 @@ import { ClipboardDocumentListIcon, } from "@heroicons/react/24/outline"; import { DevicesIcon } from "@shellhub/design-system/primitives"; -import { - useCreatePublicKey, - useUpdatePublicKey, -} from "@/hooks/usePublicKeyMutations"; -import type { PublicKeyResponse } from "@/client"; +import { useCreatePublicKey, useUpdatePublicKey } from "@/client/api"; +import type { PublicKeyResponse } from "@/client/model"; import RadioCard from "@/components/common/fields/RadioCard"; import FormDrawer from "@/components/common/FormDrawer"; import { @@ -71,15 +68,15 @@ export default function KeyDrawer({ if (isEdit && editKey) { const body = buildKeyBody(values); await updateKey.mutateAsync({ - path: { fingerprint: editKey.fingerprint }, - body: { + fingerprint: editKey.fingerprint, + data: { name: body.name, username: body.username, filter: body.filter, }, }); } else { - await createKey.mutateAsync({ body: buildKeyBody(values) }); + await createKey.mutateAsync({ data: buildKeyBody(values) }); } onClose(); } catch (err: unknown) { diff --git a/ui/apps/console/src/pages/public-keys/__tests__/KeyDrawer.test.tsx b/ui/apps/console/src/pages/public-keys/__tests__/KeyDrawer.test.tsx index b425773dd99..680b16c58ff 100644 --- a/ui/apps/console/src/pages/public-keys/__tests__/KeyDrawer.test.tsx +++ b/ui/apps/console/src/pages/public-keys/__tests__/KeyDrawer.test.tsx @@ -2,21 +2,14 @@ import { useController, type Control, type Path } from "react-hook-form"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server, setTags } from "@/tests/msw"; import KeyDrawer from "../KeyDrawer"; -import type { PublicKeyResponse } from "@/client"; +import type { PublicKeyResponse } from "@/client/model"; import type { KeyFormValues } from "../keySchema"; -import { mockSdkResponse } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { mockPublicKey, mockTag } from "@/tests/factories"; -import { mockTags } from "@/tests/mockTags"; - -const sdk = vi.hoisted(() => - mockSdkGen({ - createPublicKey: vi.fn(), - updatePublicKey: vi.fn(), - getTags: vi.fn(), - }), -); +import { seedAuthStore } from "@/tests/seedAuthStore"; vi.mock("@/components/common/Drawer", async () => ({ default: (await import("@/tests/mocks")).MockDrawer, @@ -108,9 +101,18 @@ const VALID_KEY = "ssh-rsa AAAAB3NzaC1yc2E test@host"; describe("KeyDrawer", () => { beforeEach(() => { vi.clearAllMocks(); - sdk.createPublicKey.mockResolvedValue(mockSdkResponse(undefined)); - sdk.updatePublicKey.mockResolvedValue(mockSdkResponse(undefined)); - mockTags(["production", "linux", "staging"]); + seedAuthStore(); + server.use( + http.post( + "*/api/sshkeys/public-keys", + () => new HttpResponse(null, { status: 204 }), + ), + http.put( + "*/api/sshkeys/public-keys/:fingerprint", + () => new HttpResponse(null, { status: 204 }), + ), + ); + setTags(["production", "linux", "staging"]); }); describe("add mode UI", () => { @@ -200,26 +202,6 @@ describe("KeyDrawer", () => { }); describe("create happy-path — all devices", () => { - it("sends { hostname: '.*' } and base64-encoded key data", async () => { - const user = userEvent.setup(); - renderDrawer(); - - await fillName(user, "test-key"); - await fillKeyData(user, VALID_KEY); - await user.click(getSubmitButton()); - - await waitFor(() => - expect(sdk.createPublicKey).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ - data: btoa(VALID_KEY), - filter: { hostname: ".*" }, - }), - }), - ), - ); - }); - it("calls onClose after successful create", async () => { const user = userEvent.setup(); const onClose = vi.fn(); @@ -234,9 +216,10 @@ describe("KeyDrawer", () => { }); describe("create happy-path — hostname filter", () => { - it("sends { hostname } when hostname filter is selected", async () => { + it("calls onClose when hostname filter is selected and submitted", async () => { const user = userEvent.setup(); - renderDrawer(); + const onClose = vi.fn(); + renderDrawer({ onClose }); await fillName(user, "test-key"); await fillKeyData(user, VALID_KEY); @@ -246,20 +229,15 @@ describe("KeyDrawer", () => { await user.type(screen.getByPlaceholderText(/e\.g\. \.\*/i), "^prod-.*"); await user.click(getSubmitButton()); - await waitFor(() => - expect(sdk.createPublicKey).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ filter: { hostname: "^prod-.*" } }), - }), - ), - ); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); }); }); describe("create happy-path — tags filter", () => { - it("sends { tags: string[] } when tags filter is selected and tag chosen", async () => { + it("calls onClose when tags filter is selected and tag chosen", async () => { const user = userEvent.setup(); - renderDrawer(); + const onClose = vi.fn(); + renderDrawer({ onClose }); await fillName(user, "test-key"); await fillKeyData(user, VALID_KEY); @@ -273,20 +251,18 @@ describe("KeyDrawer", () => { await user.click(getSubmitButton()); - await waitFor(() => - expect(sdk.createPublicKey).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ filter: { tags: ["production"] } }), - }), - ), - ); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); }); }); describe("409 error handling", () => { it("shows a 409 conflict alert on the key data field", async () => { const user = userEvent.setup(); - sdk.createPublicKey.mockRejectedValue({ status: 409 }); + server.use( + http.post("*/api/sshkeys/public-keys", () => + HttpResponse.json({}, { status: 409 }), + ), + ); renderDrawer(); await fillName(user, "test-key"); @@ -304,7 +280,14 @@ describe("KeyDrawer", () => { describe("generic root error", () => { it("shows the error message in a root error paragraph", async () => { const user = userEvent.setup(); - sdk.createPublicKey.mockRejectedValue(new Error("Server meltdown")); + server.use( + http.post("*/api/sshkeys/public-keys", () => + HttpResponse.json( + { message: "Server meltdown" }, + { status: 500 }, + ), + ), + ); renderDrawer(); await fillName(user, "test-key"); @@ -318,32 +301,6 @@ describe("KeyDrawer", () => { }); describe("update happy-path", () => { - it("calls updatePublicKey with fingerprint path param and updated name, no data field", async () => { - const user = userEvent.setup(); - renderDrawer({ - editKey: mockPublicKey({ fingerprint: "ab:cd:ef", name: "old-name" }), - }); - - const nameInput = screen.getByPlaceholderText(/name used to identify/i); - await user.clear(nameInput); - await user.type(nameInput, "new-name"); - await user.click(getSubmitButton()); - - await waitFor(() => - expect(sdk.updatePublicKey).toHaveBeenCalledWith( - expect.objectContaining({ - path: { fingerprint: "ab:cd:ef" }, - body: expect.objectContaining({ name: "new-name" }), - }), - ), - ); - - const callArg = sdk.updatePublicKey.mock.calls[0][0] as { - body: Record; - }; - expect(callArg.body).not.toHaveProperty("data"); - }); - it("calls onClose after successful update", async () => { const user = userEvent.setup(); const onClose = vi.fn(); diff --git a/ui/apps/console/src/pages/public-keys/__tests__/PublicKeys.test.tsx b/ui/apps/console/src/pages/public-keys/__tests__/PublicKeys.test.tsx index 3da395a00e6..979dcfcfda1 100644 --- a/ui/apps/console/src/pages/public-keys/__tests__/PublicKeys.test.tsx +++ b/ui/apps/console/src/pages/public-keys/__tests__/PublicKeys.test.tsx @@ -2,19 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import PublicKeys from "../index"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { mockPublicKey } from "@/tests/factories"; import { useAuthStore } from "@/stores/authStore"; -const sdk = vi.hoisted(() => - mockSdkGen({ - getPublicKeys: vi.fn(), - deletePublicKey: vi.fn(), - }), -); - vi.mock("../KeyDrawer", () => ({ default: () => null, })); @@ -31,6 +25,20 @@ vi.mock("@/hooks/useDebouncedValue", () => ({ useDebouncedValue: (value: T) => value, })); +let lastKeysUrl: URL | null; + +function setKeys( + keys: ReturnType[], + total?: number, +) { + server.use( + http.get("*/api/sshkeys/public-keys", ({ request }) => { + lastKeysUrl = new URL(request.url); + return jsonWithTotal(keys, total ?? keys.length); + }), + ); +} + function renderPage(initialEntries: string[] = ["/"]) { return render( @@ -42,9 +50,15 @@ function renderPage(initialEntries: string[] = ["/"]) { beforeEach(() => { vi.clearAllMocks(); + lastKeysUrl = null; useAuthStore.setState({ role: "owner" }); - sdk.getPublicKeys.mockResolvedValue(paginatedResponse([mockPublicKey()])); - sdk.deletePublicKey.mockResolvedValue(mockSdkResponse(undefined)); + setKeys([mockPublicKey()]); + server.use( + http.delete( + "*/api/sshkeys/public-keys/:fingerprint", + () => new HttpResponse(null, { status: 204 }), + ), + ); }); describe("PublicKeys — delete error handling", () => { @@ -61,7 +75,14 @@ describe("PublicKeys — delete error handling", () => { } it("shows the mutation error message inside the dialog when deletion fails", async () => { - sdk.deletePublicKey.mockRejectedValue(new Error("Fingerprint in use")); + server.use( + http.delete("*/api/sshkeys/public-keys/:fingerprint", () => + HttpResponse.json( + { message: "Fingerprint in use" }, + { status: 403 }, + ), + ), + ); const user = await openDeleteDialog(); const dialog = await getDialog(); @@ -75,17 +96,19 @@ describe("PublicKeys — delete error handling", () => { expect(dialog).toBeInTheDocument(); }); - it("shows a generic fallback message when the rejection is not an Error", async () => { - sdk.deletePublicKey.mockRejectedValue({ status: 500 }); + it("shows the status code as fallback when the server returns no message", async () => { + server.use( + http.delete("*/api/sshkeys/public-keys/:fingerprint", () => + HttpResponse.json({}, { status: 500 }), + ), + ); const user = await openDeleteDialog(); const dialog = await getDialog(); await user.click(within(dialog).getByRole("button", { name: /^delete$/i })); await waitFor(() => - expect( - within(dialog).getByText(/failed to delete public key/i), - ).toBeInTheDocument(), + expect(within(dialog).getByText("500")).toBeInTheDocument(), ); }); @@ -104,60 +127,48 @@ describe("PublicKeys — delete error handling", () => { }); describe("PublicKeys — URL hydration", () => { - it("passes page=3 to the SDK when URL has ?page=3", async () => { + it("passes page=3 when URL has ?page=3", async () => { renderPage(["/?page=3"]); await waitFor(() => { - expect(sdk.getPublicKeys).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 3 }), - }), - ); + expect(lastKeysUrl).not.toBeNull(); + expect(lastKeysUrl!.searchParams.get("page")).toBe("3"); }); }); - it("passes page=1 to the SDK when URL has no page param", async () => { + it("passes page=1 when URL has no page param", async () => { renderPage(["/"]); await waitFor(() => { - expect(sdk.getPublicKeys).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastKeysUrl).not.toBeNull(); + expect(lastKeysUrl!.searchParams.get("page")).toBe("1"); }); }); it("passes a filter containing the search term when URL has ?search=mykey", async () => { renderPage(["/?search=mykey"]); await waitFor(() => { - const call = sdk.getPublicKeys.mock.calls.at(-1)?.[0] as { - query?: { filter?: string }; - }; - const decoded = atob(call?.query?.filter ?? ""); - expect(decoded).toContain("mykey"); + expect(lastKeysUrl).not.toBeNull(); + const filter = lastKeysUrl!.searchParams.get("filter") ?? ""; + expect(atob(filter)).toContain("mykey"); }); }); - it("passes no filter to the SDK when URL has no search param", async () => { + it("passes no filter when URL has no search param", async () => { renderPage(["/"]); await waitFor(() => { - const call = sdk.getPublicKeys.mock.calls[0]?.[0] as { - query?: { filter?: string }; - }; - expect(call?.query?.filter).toBeUndefined(); + expect(lastKeysUrl).not.toBeNull(); + expect(lastKeysUrl!.searchParams.get("filter")).toBeNull(); }); }); }); describe("PublicKeys — URL writes", () => { - it("passes page=2 to the SDK when the user navigates to page 2", async () => { + it("passes page=2 when the user navigates to page 2", async () => { const user = userEvent.setup(); - sdk.getPublicKeys.mockResolvedValue( - paginatedResponse( - Array.from({ length: 10 }, (_, i) => - mockPublicKey({ fingerprint: `fp-${i}`, name: `key-${i}` }), - ), - 25, + setKeys( + Array.from({ length: 10 }, (_, i) => + mockPublicKey({ fingerprint: `fp-${i}`, name: `key-${i}` }), ), + 25, ); renderPage(); @@ -166,11 +177,7 @@ describe("PublicKeys — URL writes", () => { await user.click(screen.getByRole("button", { name: "Next page" })); await waitFor(() => { - expect(sdk.getPublicKeys).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 2 }), - }), - ); + expect(lastKeysUrl!.searchParams.get("page")).toBe("2"); }); }); @@ -186,11 +193,7 @@ describe("PublicKeys — URL writes", () => { await user.type(searchInput, "a"); await waitFor(() => { - expect(sdk.getPublicKeys).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastKeysUrl!.searchParams.get("page")).toBe("1"); }); }); }); diff --git a/ui/apps/console/src/pages/public-keys/__tests__/keySchema.test.ts b/ui/apps/console/src/pages/public-keys/__tests__/keySchema.test.ts index ff77a1e3fe6..044ae008090 100644 --- a/ui/apps/console/src/pages/public-keys/__tests__/keySchema.test.ts +++ b/ui/apps/console/src/pages/public-keys/__tests__/keySchema.test.ts @@ -6,7 +6,7 @@ import { type KeyFormValues, type KeyMode, } from "../keySchema"; -import type { PublicKeyResponse } from "@/client"; +import type { PublicKeyResponse } from "@/client/model"; function makeValues(overrides: Partial = {}): KeyFormValues { return { diff --git a/ui/apps/console/src/pages/public-keys/index.tsx b/ui/apps/console/src/pages/public-keys/index.tsx index a512d01fefe..f6fdb43b7ee 100644 --- a/ui/apps/console/src/pages/public-keys/index.tsx +++ b/ui/apps/console/src/pages/public-keys/index.tsx @@ -1,8 +1,11 @@ import { useState } from "react"; -import { usePublicKeys } from "@/hooks/usePublicKeys"; +import { useGetPublicKeys } from "@/client/api"; +import type { GetPublicKeysParams } from "@/client/model"; +import { totalCount } from "@/api/pagination"; +import { toBase64Json } from "@/utils/encoding"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { usePaginatedListState } from "@/hooks/usePaginatedListState"; -import { useDeletePublicKey } from "@/hooks/usePublicKeyMutations"; +import { useDeletePublicKey } from "@/client/api"; import PageHeader from "@/components/common/PageHeader"; import EmptyState from "@/components/common/EmptyState"; import ConfirmDialog from "@/components/common/ConfirmDialog"; @@ -24,7 +27,7 @@ import { PencilSquareIcon, TrashIcon, } from "@heroicons/react/24/outline"; -import { PublicKeyResponse as PublicKey } from "@/client"; +import { PublicKeyResponse as PublicKey } from "@/client/model"; import { Button, IconButton } from "@shellhub/design-system/primitives"; import { cn } from "@shellhub/design-system/cn"; import { pageCount } from "@/utils/pagination"; @@ -111,10 +114,17 @@ export default function PublicKeys() { }); const debouncedSearch = useDebouncedValue(params.search, SEARCH_DEBOUNCE_MS); - const { publicKeys, totalCount, isLoading } = usePublicKeys({ - page: params.page, - search: debouncedSearch, - }); + const requestParams: GetPublicKeysParams = { page: params.page, per_page: 10 }; + if (debouncedSearch) { + requestParams.filter = toBase64Json([ + { type: "operator", params: { name: "or" } }, + { type: "property", params: { name: "name", operator: "contains", value: debouncedSearch } }, + { type: "operator", params: { name: "or" } }, + { type: "property", params: { name: "fingerprint", operator: "contains", value: debouncedSearch } }, + ]); + } + const { data: publicKeys = [], isLoading } = useGetPublicKeys(requestParams); + const total = totalCount(publicKeys); const deleteKey = useDeletePublicKey(); const [drawerOpen, setDrawerOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); @@ -134,7 +144,7 @@ export default function PublicKeys() { setDeleteError(null); try { await deleteKey.mutateAsync({ - path: { fingerprint: deleteTarget.fingerprint }, + fingerprint: deleteTarget.fingerprint, }); if (publicKeys.length === 1 && params.page > 1) setPage(params.page - 1); closeDelete(); @@ -158,7 +168,7 @@ export default function PublicKeys() { setEditTarget(null); }; - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const columns: Column[] = [ { @@ -317,7 +327,7 @@ export default function PublicKeys() { loadingMessage="Loading public keys..." page={params.page} totalPages={totalPages} - totalCount={totalCount} + totalCount={total} itemLabel="key" onPageChange={setPage} emptyMessage={ diff --git a/ui/apps/console/src/pages/public-keys/keySchema.ts b/ui/apps/console/src/pages/public-keys/keySchema.ts index 77becdfacce..55514043374 100644 --- a/ui/apps/console/src/pages/public-keys/keySchema.ts +++ b/ui/apps/console/src/pages/public-keys/keySchema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { PublicKeyRequest, PublicKeyResponse } from "@/client"; +import type { PublicKeyRequest, PublicKeyResponse } from "@/client/model"; import { isPublicKeyValid } from "@/utils/sshKeys"; import { validateName } from "@/utils/validation"; diff --git a/ui/apps/console/src/pages/secure-vault/__tests__/SecureVault.test.tsx b/ui/apps/console/src/pages/secure-vault/__tests__/SecureVault.test.tsx index 8c4351e1c9d..cee6c891154 100644 --- a/ui/apps/console/src/pages/secure-vault/__tests__/SecureVault.test.tsx +++ b/ui/apps/console/src/pages/secure-vault/__tests__/SecureVault.test.tsx @@ -2,8 +2,9 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import { seedAuthStore } from "@/tests/seedAuthStore"; import { useVaultStore } from "@/stores/vaultStore"; import SecureVault from "../index"; @@ -48,14 +49,6 @@ vi.mock("@/utils/sshKeys", () => ({ getFingerprint: vi.fn(() => "fp"), })); -const sdk = vi.hoisted(() => - mockSdkGen({ - getNamespace: vi.fn(), - getNamespaceToken: vi.fn(), - createSshIdentity: vi.fn(), - }), -); - vi.mock("@/components/common/Drawer", async () => ({ default: (await import("@/tests/mocks")).MockDrawer, })); @@ -296,11 +289,16 @@ function getState() { beforeEach(() => { vi.clearAllMocks(); seedAuthStore(); - sdk.getNamespace.mockResolvedValue(mockSdkResponse(null)); - sdk.getNamespaceToken.mockResolvedValue( - mockSdkResponse({ token: "jwt-token", role: "owner" }), + server.use( + http.get("*/api/namespaces/:tenant", () => HttpResponse.json(null)), + http.get("*/api/auth/token/:tenant", () => + HttpResponse.json({ token: "jwt-token", role: "owner" }), + ), + http.post( + "*/api/ssh-identities", + () => new HttpResponse(null, { status: 204 }), + ), ); - sdk.createSshIdentity.mockResolvedValue(mockSdkResponse(undefined)); }); describe("SecureVault", () => { diff --git a/ui/apps/console/src/pages/sessions/__tests__/Sessions.test.tsx b/ui/apps/console/src/pages/sessions/__tests__/Sessions.test.tsx index 5f0c298f87b..9cdf6eb39fc 100644 --- a/ui/apps/console/src/pages/sessions/__tests__/Sessions.test.tsx +++ b/ui/apps/console/src/pages/sessions/__tests__/Sessions.test.tsx @@ -2,8 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; import Sessions from "../index"; -import { mockSdkResponse, paginatedResponse } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { mockSession } from "@/tests/factories"; import { LocationProbe } from "@/tests/LocationProbe"; @@ -15,14 +16,6 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -const sdk = vi.hoisted(() => - mockSdkGen({ - getSessions: vi.fn(), - closeSession: vi.fn(), - getSessionRecord: vi.fn(), - }), -); - vi.mock("../SessionPlayerDialog", () => ({ default: ({ open, onClose }: { open: boolean; onClose: () => void }) => open ? ( @@ -34,6 +27,20 @@ vi.mock("../SessionPlayerDialog", () => ({ ) : null, })); +let lastSessionsUrl: URL | null; + +function setSessions( + sessions: ReturnType[], + total?: number, +) { + server.use( + http.get("*/api/sessions", ({ request }) => { + lastSessionsUrl = new URL(request.url); + return jsonWithTotal(sessions, total ?? sessions.length); + }), + ); +} + function renderSessions(initialEntries: string[] = ["/"]) { let lastSearch = ""; const result = render( @@ -52,15 +59,25 @@ function renderSessions(initialEntries: string[] = ["/"]) { beforeEach(() => { vi.clearAllMocks(); - sdk.getSessions.mockResolvedValue(paginatedResponse([])); - sdk.closeSession.mockResolvedValue(mockSdkResponse(undefined)); - sdk.getSessionRecord.mockRejectedValue(new Error("no recording")); + lastSessionsUrl = null; + setSessions([]); + server.use( + http.post( + "*/api/sessions/:uid/close", + () => new HttpResponse(null, { status: 204 }), + ), + http.get("*/api/sessions/:uid/records/:seat", () => + HttpResponse.json({}, { status: 404 }), + ), + ); }); describe("Sessions", () => { describe("initial load", () => { it("shows loading state while fetching", () => { - sdk.getSessions.mockReturnValue(new Promise(() => {})); + server.use( + http.get("*/api/sessions", () => new Promise(() => {})), + ); renderSessions(); expect(screen.getByText(/loading sessions/i)).toBeInTheDocument(); }); @@ -74,9 +91,7 @@ describe("Sessions", () => { describe("session row", () => { it("navigates to session detail when a row is clicked", async () => { const user = userEvent.setup(); - sdk.getSessions.mockResolvedValue( - paginatedResponse([mockSession({ uid: "session-abc" })]), - ); + setSessions([mockSession({ uid: "session-abc" })]); renderSessions(); await user.click(await screen.findByText("root")); @@ -88,9 +103,7 @@ describe("Sessions", () => { describe("logsError banner", () => { it("shows an error banner when fetching recording fails", async () => { const user = userEvent.setup(); - sdk.getSessions.mockResolvedValue( - paginatedResponse([mockSession({ uid: "s-1", recorded: true })]), - ); + setSessions([mockSession({ uid: "s-1", recorded: true })]); renderSessions(); await user.click(await screen.findByTitle("Play recording")); @@ -112,27 +125,27 @@ describe("Sessions", () => { describe("play recording", () => { it("fetches the recording when Play is clicked", async () => { const user = userEvent.setup(); - sdk.getSessionRecord.mockResolvedValue(mockSdkResponse("asciicast-data")); - sdk.getSessions.mockResolvedValue( - paginatedResponse([mockSession({ uid: "session-1", recorded: true })]), + server.use( + http.get("*/api/sessions/:uid/records/:seat", () => + HttpResponse.json("asciicast-data"), + ), ); + setSessions([mockSession({ uid: "session-1", recorded: true })]); renderSessions(); await user.click(await screen.findByTitle("Play recording")); - expect(sdk.getSessionRecord).toHaveBeenCalledWith( - expect.objectContaining({ - path: { uid: "session-1", seat: 0 }, - }), - ); + expect(await screen.findByTestId("player-dialog")).toBeInTheDocument(); }); it("disables the play button while the recording is loading", async () => { const user = userEvent.setup(); - sdk.getSessionRecord.mockReturnValue(new Promise(() => {})); - sdk.getSessions.mockResolvedValue( - paginatedResponse([mockSession({ uid: "session-1", recorded: true })]), + server.use( + http.get("*/api/sessions/:uid/records/:seat", () => + new Promise(() => {}), + ), ); + setSessions([mockSession({ uid: "session-1", recorded: true })]); renderSessions(); const btn = await screen.findByTitle("Play recording"); @@ -144,9 +157,7 @@ describe("Sessions", () => { }); it("does not show the player dialog when there are no logs", async () => { - sdk.getSessions.mockResolvedValue( - paginatedResponse([mockSession({ uid: "session-1", recorded: true })]), - ); + setSessions([mockSession({ uid: "session-1", recorded: true })]); renderSessions(); await screen.findByTitle("Play recording"); expect(screen.queryByTestId("player-dialog")).not.toBeInTheDocument(); @@ -154,10 +165,12 @@ describe("Sessions", () => { it("opens the player after recording loads and closes it on dismiss", async () => { const user = userEvent.setup(); - sdk.getSessionRecord.mockResolvedValue(mockSdkResponse("asciicast-data")); - sdk.getSessions.mockResolvedValue( - paginatedResponse([mockSession({ uid: "session-1", recorded: true })]), + server.use( + http.get("*/api/sessions/:uid/records/:seat", () => + HttpResponse.json("asciicast-data"), + ), ); + setSessions([mockSession({ uid: "session-1", recorded: true })]); renderSessions(); await user.click(await screen.findByTitle("Play recording")); @@ -172,25 +185,19 @@ describe("Sessions", () => { }); describe("URL hydration", () => { - it("passes page=3 to the SDK when URL has ?page=3", async () => { + it("passes page=3 when URL has ?page=3", async () => { renderSessions(["/?page=3"]); await waitFor(() => { - expect(sdk.getSessions).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 3 }), - }), - ); + expect(lastSessionsUrl).not.toBeNull(); + expect(lastSessionsUrl!.searchParams.get("page")).toBe("3"); }); }); - it("passes page=1 to the SDK when URL has no page param", async () => { + it("passes page=1 when URL has no page param", async () => { renderSessions(["/"]); await waitFor(() => { - expect(sdk.getSessions).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 1 }), - }), - ); + expect(lastSessionsUrl).not.toBeNull(); + expect(lastSessionsUrl!.searchParams.get("page")).toBe("1"); }); }); }); @@ -198,13 +205,11 @@ describe("Sessions", () => { describe("URL writes", () => { it("writes ?page=2 to the URL when the user navigates to page 2", async () => { const user = userEvent.setup(); - sdk.getSessions.mockResolvedValue( - paginatedResponse( - Array.from({ length: 10 }, (_, i) => - mockSession({ uid: `s-${i}`, username: `u-${i}` }), - ), - 30, + setSessions( + Array.from({ length: 10 }, (_, i) => + mockSession({ uid: `s-${i}`, username: `u-${i}` }), ), + 30, ); renderSessions(); @@ -213,11 +218,7 @@ describe("Sessions", () => { await user.click(screen.getByRole("button", { name: "Next page" })); await waitFor(() => { - expect(sdk.getSessions).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ page: 2 }), - }), - ); + expect(lastSessionsUrl!.searchParams.get("page")).toBe("2"); }); }); diff --git a/ui/apps/console/src/pages/sessions/index.tsx b/ui/apps/console/src/pages/sessions/index.tsx index 65da69baa22..bf164cf6586 100644 --- a/ui/apps/console/src/pages/sessions/index.tsx +++ b/ui/apps/console/src/pages/sessions/index.tsx @@ -8,12 +8,12 @@ import { XCircleIcon, } from "@heroicons/react/24/outline"; import { PlayIcon } from "@heroicons/react/24/solid"; -import { useSessions } from "@/hooks/useSessions"; -import { useCloseSession } from "@/hooks/useSessionMutations"; +import { useGetSessions, useClsoeSession } from "@/client/api"; +import { totalCount } from "@/api/pagination"; import { useSessionRecording } from "@/hooks/useSessionRecording"; import { useRecordingsStore } from "@/stores/recordingsStore"; import { isRecordingSupported, readRecording } from "@/utils/recordings"; -import type { Session } from "@/client"; +import type { Session } from "@/client/model"; import PageHeader from "@/components/common/PageHeader"; import DeviceChip from "@/components/common/DeviceChip"; import DataTable, { type Column } from "@/components/common/DataTable"; @@ -74,11 +74,9 @@ export default function Sessions() { const { params, setPage } = usePaginatedListState({ defaults: DEFAULTS, }); - const { sessions, totalCount, isLoading, error } = useSessions({ - page: params.page, - perPage: PER_PAGE, - }); - const closeSession = useCloseSession(); + const { data: sessions = [], isLoading, error } = useGetSessions({ page: params.page, per_page: PER_PAGE }); + const total = totalCount(sessions); + const closeSession = useClsoeSession(); const navigate = useNavigate(); const premium = isEnterpriseOrCloud(); const [playTarget, setPlayTarget] = useState(null); @@ -109,7 +107,7 @@ export default function Sessions() { [recordings], ); - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const handlePlayClick = async (e: React.MouseEvent, s: Session) => { e.stopPropagation(); @@ -300,8 +298,8 @@ export default function Sessions() { closeSession.mutateAsync({ - path: { uid: s.uid }, - body: { device: s.device_uid ?? s.device?.uid ?? "" }, + uid: s.uid, + data: { device: s.device_uid ?? s.device?.uid ?? "" }, }) } /> @@ -342,7 +340,7 @@ export default function Sessions() { loadingMessage="Loading sessions..." page={params.page} totalPages={totalPages} - totalCount={totalCount} + totalCount={total} itemLabel="session" onPageChange={setPage} onRowClick={(s) => void navigate(`/sessions/${s.uid}`)} diff --git a/ui/apps/console/src/pages/ssh-identities/IdentityDrawer.tsx b/ui/apps/console/src/pages/ssh-identities/IdentityDrawer.tsx index c1cc4a4d38a..87c9bd5cee3 100644 --- a/ui/apps/console/src/pages/ssh-identities/IdentityDrawer.tsx +++ b/ui/apps/console/src/pages/ssh-identities/IdentityDrawer.tsx @@ -7,12 +7,12 @@ import { import { Button } from "@shellhub/design-system/primitives"; import { useResetOnOpen } from "@/hooks/useResetOnOpen"; import { - useCreateSSHIdentity, - useRenameSSHIdentity, -} from "@/hooks/useSSHIdentityMutations"; -import { useCreateServiceAccount } from "@/hooks/useServiceAccountMutations"; + useCreateSshIdentity, + useRenameSshIdentity, + useCreateServiceAccount, +} from "@/client/api"; import { useHasPermission } from "@/hooks/useHasPermission"; -import type { SshIdentity } from "@/client"; +import type { SshIdentity } from "@/client/model"; import { isPublicKeyValid } from "@/utils/sshKeys"; import Drawer from "@/components/common/Drawer"; import InputField from "@/components/common/fields/InputField"; @@ -47,8 +47,8 @@ function IdentityDrawer({ editIdentity: SshIdentity | null; onClose: () => void; }) { - const createIdentity = useCreateSSHIdentity(); - const renameIdentity = useRenameSSHIdentity(); + const createIdentity = useCreateSshIdentity(); + const renameIdentity = useRenameSshIdentity(); const createServiceAccount = useCreateServiceAccount(); const canCreateServiceAccount = useHasPermission("serviceAccount:create"); const browserKeyFingerprint = useBrowserKeyFingerprint(); @@ -99,12 +99,12 @@ function IdentityDrawer({ try { if (isEdit && editIdentity) { await renameIdentity.mutateAsync({ - path: { id: editIdentity.id }, - body: { name: name.trim() }, + id: editIdentity.id, + data: { name: name.trim() }, }); } else if (isServiceAccount) { await createServiceAccount.mutateAsync({ - body: { + data: { name: name.trim(), data: keyData.trim(), ...serviceAccountLifecyclePayload(expiresIn, singleUse), @@ -112,7 +112,7 @@ function IdentityDrawer({ }); } else { await createIdentity.mutateAsync({ - body: { + data: { name: name.trim(), data: keyData.trim(), ...keyExpiryPayload(expiresIn), diff --git a/ui/apps/console/src/pages/ssh-identities/__tests__/IdentityDrawer.test.tsx b/ui/apps/console/src/pages/ssh-identities/__tests__/IdentityDrawer.test.tsx index 62352d7c93a..6d84ce0d44f 100644 --- a/ui/apps/console/src/pages/ssh-identities/__tests__/IdentityDrawer.test.tsx +++ b/ui/apps/console/src/pages/ssh-identities/__tests__/IdentityDrawer.test.tsx @@ -1,19 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import { createTestWrapper } from "@/tests/wrapper"; -import { mockSdkResponse } from "@/tests/sdk"; import IdentityDrawer from "../IdentityDrawer"; import { useAuthStore } from "@/stores/authStore"; -const sdk = vi.hoisted(() => - mockSdkGen({ - createSshIdentity: vi.fn(), - renameSshIdentity: vi.fn(), - createServiceAccount: vi.fn(), - }), -); - vi.mock("@/utils/sshKeys", () => ({ isPublicKeyValid: () => true, })); @@ -48,11 +41,28 @@ function renderDrawer() { const KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILqk test@host"; +let identityCalled: boolean; +let serviceAccountCalled: boolean; + beforeEach(() => { vi.clearAllMocks(); - sdk.createSshIdentity.mockResolvedValue(mockSdkResponse({})); - sdk.createServiceAccount.mockResolvedValue(mockSdkResponse({})); + identityCalled = false; + serviceAccountCalled = false; useAuthStore.setState({ role: "owner" }); + server.use( + http.post("*/api/ssh-identities", () => { + identityCalled = true; + return HttpResponse.json({}); + }), + http.patch( + "*/api/ssh-identities/:id", + () => new HttpResponse(null, { status: 204 }), + ), + http.post("*/api/service-accounts", () => { + serviceAccountCalled = true; + return HttpResponse.json({}); + }), + ); }); describe("IdentityDrawer", () => { @@ -64,14 +74,8 @@ describe("IdentityDrawer", () => { await user.type(screen.getByLabelText(/public key data/i), KEY); await user.click(screen.getByRole("button", { name: /add key/i })); - await waitFor(() => - expect(sdk.createSshIdentity).toHaveBeenCalledWith( - expect.objectContaining({ - body: { name: "laptop", data: KEY }, - }), - ), - ); - expect(sdk.createServiceAccount).not.toHaveBeenCalled(); + await waitFor(() => expect(identityCalled).toBe(true)); + expect(serviceAccountCalled).toBe(false); }); it("creates a service account when that target is chosen", async () => { @@ -85,14 +89,8 @@ describe("IdentityDrawer", () => { screen.getByRole("button", { name: /create service account/i }), ); - await waitFor(() => - expect(sdk.createServiceAccount).toHaveBeenCalledWith( - expect.objectContaining({ - body: { name: "ci-bot", data: KEY, single_use: false }, - }), - ), - ); - expect(sdk.createSshIdentity).not.toHaveBeenCalled(); + await waitFor(() => expect(serviceAccountCalled).toBe(true)); + expect(identityCalled).toBe(false); }); it("creates a single-use service account when the toggle is on", async () => { @@ -107,13 +105,7 @@ describe("IdentityDrawer", () => { screen.getByRole("button", { name: /create service account/i }), ); - await waitFor(() => - expect(sdk.createServiceAccount).toHaveBeenCalledWith( - expect.objectContaining({ - body: { name: "ci-bot", data: KEY, single_use: true }, - }), - ), - ); + await waitFor(() => expect(serviceAccountCalled).toBe(true)); }); it("hides the service-account option without permission", () => { diff --git a/ui/apps/console/src/pages/ssh-identities/__tests__/index.test.tsx b/ui/apps/console/src/pages/ssh-identities/__tests__/index.test.tsx index 3fbf5705791..acd78b2657e 100644 --- a/ui/apps/console/src/pages/ssh-identities/__tests__/index.test.tsx +++ b/ui/apps/console/src/pages/ssh-identities/__tests__/index.test.tsx @@ -2,19 +2,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, within } from "@testing-library/react"; import userEvent, { type UserEvent } from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server } from "@/tests/msw"; import SSHIdentities from "../index"; -import type { SshIdentity } from "@/client"; +import type { SshIdentity } from "@/client/model"; import { ClipboardProvider } from "@/components/common/ClipboardProvider"; -import { mockSdkResponse } from "@/tests/sdk"; import { createTestWrapper } from "@/tests/wrapper"; import { useAuthStore } from "@/stores/authStore"; -const sdk = vi.hoisted(() => - mockSdkGen({ - listSshIdentities: vi.fn(), - }), -); - vi.mock("../IdentityDrawer", () => ({ default: () => null })); const mockBrowserKeyFingerprint = vi.fn<() => string | null>(); @@ -43,7 +38,9 @@ function identity(overrides: Partial = {}): SshIdentity { } function renderList(identities: SshIdentity[]) { - sdk.listSshIdentities.mockResolvedValue(mockSdkResponse(identities)); + server.use( + http.get("*/api/ssh-identities", () => HttpResponse.json(identities)), + ); return render( @@ -70,7 +67,9 @@ beforeEach(() => { vi.clearAllMocks(); mockBrowserKeyFingerprint.mockReturnValue(null); useAuthStore.setState({ userId: "user1" }); - sdk.listSshIdentities.mockResolvedValue(mockSdkResponse([])); + server.use( + http.get("*/api/ssh-identities", () => HttpResponse.json([])), + ); }); describe("SSHIdentities", () => { diff --git a/ui/apps/console/src/pages/ssh-identities/index.tsx b/ui/apps/console/src/pages/ssh-identities/index.tsx index 08307146318..968f5a867c9 100644 --- a/ui/apps/console/src/pages/ssh-identities/index.tsx +++ b/ui/apps/console/src/pages/ssh-identities/index.tsx @@ -20,10 +20,9 @@ import { IconButton, } from "@shellhub/design-system/primitives"; import { cn } from "@shellhub/design-system/cn"; -import { useSSHIdentities } from "@/hooks/useSSHIdentities"; -import { useDeleteSSHIdentity } from "@/hooks/useSSHIdentityMutations"; +import { useListSshIdentities, useDeleteSshIdentity } from "@/client/api"; import { useAuthStore } from "@/stores/authStore"; -import type { SshIdentity } from "@/client"; +import type { SshIdentity } from "@/client/model"; import PageHeader from "@/components/common/PageHeader"; import EmptyState from "@/components/common/EmptyState"; import ConfirmDialog from "@/components/common/ConfirmDialog"; @@ -70,11 +69,13 @@ const EXPIRY_TONE: Record = { export default function SSHIdentities() { const userId = useAuthStore((s) => s.userId); - const { identities, isLoading } = useSSHIdentities(true); + const { data: identities = [], isLoading } = useListSshIdentities({ + all: true, + }); const browserKeyFingerprint = useBrowserKeyFingerprint(); const isCurrentBrowser = (i: SshIdentity) => i.source === "browser" && i.fingerprint === browserKeyFingerprint; - const deleteIdentity = useDeleteSSHIdentity(); + const deleteIdentity = useDeleteSshIdentity(); const [drawerOpen, setDrawerOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -89,7 +90,7 @@ export default function SSHIdentities() { if (!deleteTarget) return; setDeleteError(null); try { - await deleteIdentity.mutateAsync({ path: { id: deleteTarget.id } }); + await deleteIdentity.mutateAsync({ id: deleteTarget.id }); closeDelete(); } catch (err) { setDeleteError( @@ -246,7 +247,10 @@ export default function SSHIdentities() {
- + diff --git a/ui/apps/console/src/pages/team/AddMemberDrawer.tsx b/ui/apps/console/src/pages/team/AddMemberDrawer.tsx index 8cda7633450..72bbfbd03ad 100644 --- a/ui/apps/console/src/pages/team/AddMemberDrawer.tsx +++ b/ui/apps/console/src/pages/team/AddMemberDrawer.tsx @@ -4,7 +4,7 @@ import { useResetOnOpen } from "@/hooks/useResetOnOpen"; import { useWatch } from "react-hook-form"; import { Card, Button } from "@shellhub/design-system/primitives"; import { CheckCircleIcon } from "@heroicons/react/24/outline"; -import { useGenerateInvitationLink } from "@/hooks/useInvitationMutations"; +import { useGenerateInvitationLink } from "@/client/api"; import Drawer from "@/components/common/Drawer"; import CopyButton from "@/components/common/CopyButton"; import { FormInputField } from "@/components/common/fields/rhf"; @@ -58,8 +58,8 @@ function AddMemberDrawer({ open, onClose, tenantId }: AddMemberDrawerProps) { clearErrors("root"); try { const result = await generateLink.mutateAsync({ - path: { tenant: tenantId }, - body: buildAddMemberBody(values), + tenant: tenantId, + data: buildAddMemberBody(values), }); const link = result.link ?? ""; if (link) setGeneratedLink(link); @@ -72,11 +72,19 @@ function AddMemberDrawer({ open, onClose, tenantId }: AddMemberDrawerProps) { return; } - const sdkErrorHandlers: Partial> = { + const sdkErrorHandlers: Partial< + Record + > = { 400: { name: "email", message: "Invalid email or role." }, - 403: { name: "root", message: "You don't have permission to invite members." }, + 403: { + name: "root", + message: "You don't have permission to invite members.", + }, 404: { name: "email", message: "No account exists for this email." }, - 409: { name: "email", message: "This user is already a member or has a pending invitation." }, + 409: { + name: "email", + message: "This user is already a member or has a pending invitation.", + }, }; const sdkError = sdkErrorHandlers[err.status] ?? { diff --git a/ui/apps/console/src/pages/team/ApiKeysTab.tsx b/ui/apps/console/src/pages/team/ApiKeysTab.tsx index fec2657c5c7..a9d9026b429 100644 --- a/ui/apps/console/src/pages/team/ApiKeysTab.tsx +++ b/ui/apps/console/src/pages/team/ApiKeysTab.tsx @@ -6,10 +6,10 @@ import { } from "@heroicons/react/24/outline"; import { Button, IconButton } from "@shellhub/design-system/primitives"; import { cn } from "@shellhub/design-system/cn"; -import { useApiKeys } from "@/hooks/useApiKeys"; -import { useDeleteApiKey } from "@/hooks/useApiKeyMutations"; +import { useApiKeyList, useApiKeyDelete } from "@/client/api"; +import { totalCount } from "@/api/pagination"; import { useTableSort } from "@/hooks/useTableSort"; -import { type ApiKey } from "@/client"; +import { type ApiKey } from "@/client/model"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import DataTable, { type Column } from "@/components/common/DataTable"; import RestrictedAction from "@/components/common/RestrictedAction"; @@ -43,13 +43,15 @@ function ApiKeysTab() { defaultField: "created_at", onSortChange: () => setPage(1), }); - const { apiKeys, totalCount, isLoading } = useApiKeys({ + const { data: apiKeys = [], isLoading } = useApiKeyList({ page, - sortBy, - orderBy, + per_page: 10, + sort_by: sortBy, + order_by: orderBy, }); + const total = totalCount(apiKeys); - const deleteKey = useDeleteApiKey(); + const deleteKey = useApiKeyDelete(); const [generateOpen, setGenerateOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -64,7 +66,7 @@ function ApiKeysTab() { if (!deleteTarget) return; setDeleteError(null); try { - await deleteKey.mutateAsync({ path: { key: deleteTarget.name } }); + await deleteKey.mutateAsync({ key: deleteTarget.name }); if (apiKeys.length === 1 && page > 1) setPage(page - 1); closeDelete(); } catch (err) { @@ -74,7 +76,7 @@ function ApiKeysTab() { } }; - const totalPages = pageCount(totalCount); + const totalPages = pageCount(total); const columns: Column[] = [ { @@ -116,7 +118,10 @@ function ApiKeysTab() { const expired = isExpired(key.expires_in); return ( {formatExpiry(key.expires_in)} @@ -158,8 +163,8 @@ function ApiKeysTab() {

- {totalCount} key - {totalCount !== 1 ? "s" : ""} + {total} key + {total !== 1 ? "s" : ""}