diff --git a/ui-react/apps/console/src/App.tsx b/ui-react/apps/console/src/App.tsx index 43b3da2847f..13a2dc23cc1 100644 --- a/ui-react/apps/console/src/App.tsx +++ b/ui-react/apps/console/src/App.tsx @@ -41,6 +41,8 @@ const SecureVault = lazy(() => import("./pages/secure-vault")); const AdminDashboard = lazy(() => import("./pages/admin/Dashboard")); const AdminLicense = lazy(() => import("./pages/admin/License")); const AdminUnauthorized = lazy(() => import("./pages/admin/Unauthorized")); +const AdminUsers = lazy(() => import("./pages/admin/users")); +const AdminUserDetails = lazy(() => import("./pages/admin/users/UserDetails")); export default function App() { return ( @@ -59,7 +61,10 @@ export default function App() { }> } /> } /> - } /> + } + /> {getConfig().cloud && ( <> @@ -71,7 +76,10 @@ export default function App() { }> {/* Admin panel — layout wraps all /admin routes including unauthorized */} }> - } /> + } + /> }> } /> }> @@ -79,7 +87,15 @@ export default function App() { path="/admin" element={} /> - } /> + } + /> + } /> + } + /> diff --git a/ui-react/apps/console/src/api/__tests__/errors.test.ts b/ui-react/apps/console/src/api/__tests__/errors.test.ts new file mode 100644 index 00000000000..7cdc38f646c --- /dev/null +++ b/ui-react/apps/console/src/api/__tests__/errors.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { isSdkError } from "../errors"; + +describe("isSdkError", () => { + describe("returns true for valid SDK errors", () => { + it("returns true when object has numeric status property", () => { + expect(isSdkError({ status: 400 })).toBe(true); + }); + + it("returns true for status 200", () => { + expect(isSdkError({ status: 200 })).toBe(true); + }); + + it("returns true for status 500", () => { + expect(isSdkError({ status: 500, headers: new Headers() })).toBe(true); + }); + + it("returns true when extra properties are present", () => { + expect(isSdkError({ status: 401, extra: true })).toBe(true); + }); + + it("returns true for enriched arrays (real SDK shape)", () => { + expect( + isSdkError(Object.assign(["username"], { status: 400, headers: new Headers() })), + ).toBe(true); + }); + }); + + describe("returns false for non-SDK errors", () => { + it("returns false for null", () => { + expect(isSdkError(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isSdkError(undefined)).toBe(false); + }); + + it("returns false for a plain string", () => { + expect(isSdkError("error")).toBe(false); + }); + + it("returns false for a number", () => { + expect(isSdkError(42)).toBe(false); + }); + + it("returns false for an object missing status", () => { + expect(isSdkError({ code: 404 })).toBe(false); + }); + + it("returns false when status is a string instead of a number", () => { + expect(isSdkError({ status: "400" })).toBe(false); + }); + + it("returns false for an empty object", () => { + expect(isSdkError({})).toBe(false); + }); + + it("returns false for a plain Error instance without status", () => { + expect(isSdkError(new Error("oops"))).toBe(false); + }); + + it("returns false for a plain array without status", () => { + expect(isSdkError(["username"])).toBe(false); + }); + }); +}); diff --git a/ui-react/apps/console/src/hooks/__tests__/useAdminUserMutations.test.ts b/ui-react/apps/console/src/hooks/__tests__/useAdminUserMutations.test.ts new file mode 100644 index 00000000000..117aafbee35 --- /dev/null +++ b/ui-react/apps/console/src/hooks/__tests__/useAdminUserMutations.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + useCreateUser, + useUpdateUser, + useDeleteUser, + useResetUserPassword, +} from "../useAdminUserMutations"; + +const mockCreateFn = vi.fn(); +const mockUpdateFn = vi.fn(); +const mockDeleteFn = vi.fn(); +const mockResetPasswordFn = vi.fn(); +const mockInvalidate = vi.fn(); + +vi.mock("../../client/@tanstack/react-query.gen", () => ({ + createUserAdminMutation: vi.fn(() => ({ mutationFn: mockCreateFn })), + adminUpdateUserMutation: vi.fn(() => ({ mutationFn: mockUpdateFn })), + adminDeleteUserMutation: vi.fn(() => ({ mutationFn: mockDeleteFn })), + adminResetUserPasswordMutation: vi.fn(() => ({ + mutationFn: mockResetPasswordFn, + })), +})); + +vi.mock("../useInvalidateQueries", () => ({ + useInvalidateByIds: vi.fn(() => mockInvalidate), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("useCreateUser", () => { + describe("mutation call", () => { + it("calls createUserAdmin with the provided body", async () => { + mockCreateFn.mockResolvedValue(undefined); + const { result } = renderHook(() => useCreateUser(), { + wrapper: createWrapper(), + }); + + const body = { + body: { + name: "Alice", + username: "alice", + email: "alice@example.com", + password: "pass1", + }, + }; + await act(() => result.current.mutateAsync(body as never)); + + expect(mockCreateFn).toHaveBeenCalledWith(body, expect.anything()); + }); + }); + + describe("on success", () => { + it("calls invalidate after successful mutation", async () => { + mockCreateFn.mockResolvedValue(undefined); + const { result } = renderHook(() => useCreateUser(), { + wrapper: createWrapper(), + }); + + await act(() => result.current.mutateAsync({} as never)); + + await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); + }); + }); + + describe("on failure", () => { + it("exposes error when mutation fails", async () => { + const error = new Error("create failed"); + mockCreateFn.mockRejectedValue(error); + const { result } = renderHook(() => useCreateUser(), { + wrapper: createWrapper(), + }); + + act(() => result.current.mutate({} as never)); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBe(error); + }); + + it("does not call invalidate when mutation fails", async () => { + mockCreateFn.mockRejectedValue(new Error("create failed")); + const { result } = renderHook(() => useCreateUser(), { + wrapper: createWrapper(), + }); + + act(() => result.current.mutate({} as never)); + + 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 () => { + mockUpdateFn.mockResolvedValue(undefined); + const { result } = renderHook(() => useUpdateUser(), { + wrapper: createWrapper(), + }); + + const vars = { path: { id: "u1" }, body: { name: "Bob" } }; + await act(() => result.current.mutateAsync(vars as never)); + + expect(mockUpdateFn).toHaveBeenCalledWith(vars, expect.anything()); + }); + }); + + describe("on success", () => { + it("calls invalidate after successful update", async () => { + mockUpdateFn.mockResolvedValue(undefined); + const { result } = renderHook(() => useUpdateUser(), { + wrapper: createWrapper(), + }); + + await act(() => result.current.mutateAsync({} as never)); + + await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); + }); + }); + + describe("on failure", () => { + it("exposes error when update fails", async () => { + const error = new Error("update failed"); + mockUpdateFn.mockRejectedValue(error); + const { result } = renderHook(() => useUpdateUser(), { + wrapper: createWrapper(), + }); + + act(() => result.current.mutate({} as never)); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBe(error); + }); + + it("does not call invalidate when update fails", async () => { + mockUpdateFn.mockRejectedValue(new Error("update failed")); + const { result } = renderHook(() => useUpdateUser(), { + wrapper: createWrapper(), + }); + + act(() => result.current.mutate({} as never)); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(mockInvalidate).not.toHaveBeenCalled(); + }); + }); +}); + +describe("useDeleteUser", () => { + describe("mutation call", () => { + it("calls adminDeleteUser with the path", async () => { + mockDeleteFn.mockResolvedValue(undefined); + const { result } = renderHook(() => useDeleteUser(), { + wrapper: createWrapper(), + }); + + const vars = { path: { id: "u1" } }; + await act(() => result.current.mutateAsync(vars as never)); + + expect(mockDeleteFn).toHaveBeenCalledWith(vars, expect.anything()); + }); + }); + + describe("on success", () => { + it("calls invalidate after successful delete", async () => { + mockDeleteFn.mockResolvedValue(undefined); + const { result } = renderHook(() => useDeleteUser(), { + wrapper: createWrapper(), + }); + + await act(() => result.current.mutateAsync({} as never)); + + await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); + }); + }); + + describe("on failure", () => { + it("exposes error when delete fails", async () => { + const error = new Error("delete failed"); + mockDeleteFn.mockRejectedValue(error); + const { result } = renderHook(() => useDeleteUser(), { + wrapper: createWrapper(), + }); + + act(() => result.current.mutate({} as never)); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBe(error); + }); + + it("does not call invalidate when delete fails", async () => { + mockDeleteFn.mockRejectedValue(new Error("delete failed")); + const { result } = renderHook(() => useDeleteUser(), { + wrapper: createWrapper(), + }); + + act(() => result.current.mutate({} as never)); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(mockInvalidate).not.toHaveBeenCalled(); + }); + }); +}); + +describe("useResetUserPassword", () => { + describe("mutation call", () => { + it("calls adminResetUserPassword with the path", async () => { + mockResetPasswordFn.mockResolvedValue({ password: "generated-pw" }); + const { result } = renderHook(() => useResetUserPassword(), { + wrapper: createWrapper(), + }); + + const vars = { path: { id: "u1" } }; + await act(() => result.current.mutateAsync(vars as never)); + + expect(mockResetPasswordFn).toHaveBeenCalledWith(vars, expect.anything()); + }); + + it("returns the generated password from the mutation", async () => { + mockResetPasswordFn.mockResolvedValue({ password: "s3cr3t-pass" }); + const { result } = renderHook(() => useResetUserPassword(), { + wrapper: createWrapper(), + }); + + const data = await act(() => result.current.mutateAsync({} as never)); + + expect(data).toEqual({ password: "s3cr3t-pass" }); + }); + }); + + describe("on success", () => { + it("calls invalidate after successful password reset", async () => { + mockResetPasswordFn.mockResolvedValue({ password: "pw" }); + const { result } = renderHook(() => useResetUserPassword(), { + wrapper: createWrapper(), + }); + + await act(() => result.current.mutateAsync({} as never)); + + await waitFor(() => expect(mockInvalidate).toHaveBeenCalledTimes(1)); + }); + }); + + describe("on failure", () => { + it("exposes error when reset fails", async () => { + const error = new Error("reset failed"); + mockResetPasswordFn.mockRejectedValue(error); + const { result } = renderHook(() => useResetUserPassword(), { + wrapper: createWrapper(), + }); + + act(() => result.current.mutate({} as never)); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBe(error); + }); + + it("does not call invalidate when reset fails", async () => { + mockResetPasswordFn.mockRejectedValue(new Error("reset failed")); + const { result } = renderHook(() => useResetUserPassword(), { + wrapper: createWrapper(), + }); + + act(() => result.current.mutate({} as never)); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(mockInvalidate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui-react/apps/console/src/hooks/__tests__/useAdminUsers.test.ts b/ui-react/apps/console/src/hooks/__tests__/useAdminUsers.test.ts new file mode 100644 index 00000000000..aaf8ec0572f --- /dev/null +++ b/ui-react/apps/console/src/hooks/__tests__/useAdminUsers.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useAdminUsers, useAdminUser } from "../useAdminUsers"; +import { useAuthStore } from "../../stores/authStore"; + +// Mock the SDK functions used by the generated options/queryFn helpers. +vi.mock("../../client", () => ({ + getUsers: vi.fn(), + getUser: vi.fn(), +})); + +vi.mock("../../client/@tanstack/react-query.gen", () => ({ + getUsersQueryKey: vi.fn((opts: unknown) => [{ _id: "getUsers" }, opts]), + getUserOptions: vi.fn((opts: unknown) => ({ + queryKey: [{ _id: "getUser" }, opts], + queryFn: mockGetUserFn, + })), +})); + +vi.mock("../../api/pagination", () => ({ + paginatedQueryFn: vi.fn( + (_sdkFn: unknown, opts: { query: Record }) => { + return () => mockGetUsersFn(opts) as unknown; + }, + ), +})); + +const mockGetUsersFn = vi.fn(); +const mockGetUserFn = vi.fn(); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, retryDelay: 0 }, + }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +beforeEach(() => { + vi.clearAllMocks(); + // Default: authenticated admin + useAuthStore.setState({ isAdmin: true } as never); +}); + +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" }, + ]; + mockGetUsersFn.mockResolvedValue({ data: users, totalCount: 2 }); + + const { result } = renderHook(() => useAdminUsers(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.users).toEqual(users); + }); + + it("returns totalCount from the paginated query result", async () => { + mockGetUsersFn.mockResolvedValue({ data: [], totalCount: 99 }); + + const { result } = renderHook(() => useAdminUsers(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.totalCount).toBe(99); + }); + + it("defaults users to empty array while loading", () => { + // Never resolves — stays in loading state + mockGetUsersFn.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useAdminUsers(), { + wrapper: createWrapper(), + }); + + expect(result.current.users).toEqual([]); + }); + + it("defaults totalCount to 0 while loading", () => { + mockGetUsersFn.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useAdminUsers(), { + wrapper: createWrapper(), + }); + + expect(result.current.totalCount).toBe(0); + }); + + it("returns isLoading true initially", () => { + mockGetUsersFn.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useAdminUsers(), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(true); + }); + + it("exposes error when query fails", async () => { + const networkError = new Error("network failure"); + mockGetUsersFn.mockRejectedValue(networkError); + + const { result } = renderHook(() => useAdminUsers(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(result.current.error).toBe(networkError); + }); + + it("exposes refetch function", () => { + mockGetUsersFn.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useAdminUsers(), { + wrapper: createWrapper(), + }); + + expect(typeof result.current.refetch).toBe("function"); + }); + }); + + describe("when user is not admin", () => { + it("does not execute the query", async () => { + useAuthStore.setState({ isAdmin: false } as never); + + const { result } = renderHook(() => useAdminUsers(), { + wrapper: createWrapper(), + }); + + // Query is disabled — stays in non-loading state with empty data + expect(result.current.isLoading).toBe(false); + expect(result.current.users).toEqual([]); + expect(mockGetUsersFn).not.toHaveBeenCalled(); + }); + }); + + describe("search filter", () => { + it("passes search parameter to the query options", async () => { + mockGetUsersFn.mockResolvedValue({ data: [], totalCount: 0 }); + + const { result } = renderHook(() => useAdminUsers({ search: "alice" }), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + // Query ran — the mock was called + expect(mockGetUsersFn).toHaveBeenCalled(); + }); + + it("does not pass filter when search is empty", async () => { + mockGetUsersFn.mockResolvedValue({ data: [], totalCount: 0 }); + + renderHook(() => useAdminUsers({ search: "" }), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(mockGetUsersFn).toHaveBeenCalled()); + // paginatedQueryFn receives options without a filter key when search is empty + const [opts] = mockGetUsersFn.mock.calls[0] as [ + { query: Record }, + ]; + expect(opts.query.filter).toBeUndefined(); + }); + + it("includes a base64-encoded filter when search is non-empty", async () => { + mockGetUsersFn.mockResolvedValue({ data: [], totalCount: 0 }); + + renderHook(() => useAdminUsers({ search: "alice" }), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(mockGetUsersFn).toHaveBeenCalled()); + const [opts] = mockGetUsersFn.mock.calls[0] as [ + { query: Record }, + ]; + expect(typeof opts.query.filter).toBe("string"); + // Verify it decodes to valid JSON containing the search term + const decoded = JSON.parse( + atob(opts.query.filter as string), + ) as unknown[]; + expect(JSON.stringify(decoded)).toContain("alice"); + }); + }); + + describe("pagination defaults", () => { + it("uses page 1 and perPage 10 as defaults", async () => { + mockGetUsersFn.mockResolvedValue({ data: [], totalCount: 0 }); + + renderHook(() => useAdminUsers(), { wrapper: createWrapper() }); + + await waitFor(() => expect(mockGetUsersFn).toHaveBeenCalled()); + const [opts] = mockGetUsersFn.mock.calls[0] as [ + { query: Record }, + ]; + expect(opts.query.page).toBe(1); + expect(opts.query.per_page).toBe(10); + }); + + it("forwards custom page and perPage", async () => { + mockGetUsersFn.mockResolvedValue({ data: [], totalCount: 0 }); + + renderHook(() => useAdminUsers({ page: 3, perPage: 25 }), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(mockGetUsersFn).toHaveBeenCalled()); + const [opts] = mockGetUsersFn.mock.calls[0] as [ + { query: Record }, + ]; + 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" }; + mockGetUserFn.mockResolvedValue(user); + + const { result } = renderHook(() => useAdminUser("u1"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual(user); + }); + + it("is loading initially when id is provided", () => { + mockGetUserFn.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useAdminUser("u1"), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(true); + }); + + it("exposes error when query fails", async () => { + const err = new Error("not found"); + mockGetUserFn.mockRejectedValue(err); + + const { result } = renderHook(() => useAdminUser("u1"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + }); + + describe("when id is empty", () => { + it("does not execute the query", () => { + const { result } = renderHook(() => useAdminUser(""), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(false); + expect(mockGetUserFn).not.toHaveBeenCalled(); + }); + }); + + describe("when user is not admin", () => { + it("does not execute the query even when id is provided", () => { + useAuthStore.setState({ isAdmin: false } as never); + + const { result } = renderHook(() => useAdminUser("u1"), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(false); + expect(mockGetUserFn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui-react/apps/console/src/hooks/useAdminUserMutations.ts b/ui-react/apps/console/src/hooks/useAdminUserMutations.ts new file mode 100644 index 00000000000..96fa20cb670 --- /dev/null +++ b/ui-react/apps/console/src/hooks/useAdminUserMutations.ts @@ -0,0 +1,40 @@ +import { useMutation } from "@tanstack/react-query"; +import { + createUserAdminMutation, + adminUpdateUserMutation, + adminDeleteUserMutation, + adminResetUserPasswordMutation, +} from "../client/@tanstack/react-query.gen"; +import { useInvalidateByIds } from "./useInvalidateQueries"; + +export function useCreateUser() { + const invalidate = useInvalidateByIds("getUsers"); + return useMutation({ + ...createUserAdminMutation(), + onSuccess: invalidate, + }); +} + +export function useUpdateUser() { + const invalidate = useInvalidateByIds("getUsers", "getUser"); + return useMutation({ + ...adminUpdateUserMutation(), + onSuccess: invalidate, + }); +} + +export function useDeleteUser() { + const invalidate = useInvalidateByIds("getUsers", "getUser"); + return useMutation({ + ...adminDeleteUserMutation(), + onSuccess: invalidate, + }); +} + +export function useResetUserPassword() { + const invalidate = useInvalidateByIds("getUsers", "getUser"); + return useMutation({ + ...adminResetUserPasswordMutation(), + onSuccess: invalidate, + }); +} diff --git a/ui-react/apps/console/src/hooks/useAdminUsers.ts b/ui-react/apps/console/src/hooks/useAdminUsers.ts new file mode 100644 index 00000000000..bbe281dd814 --- /dev/null +++ b/ui-react/apps/console/src/hooks/useAdminUsers.ts @@ -0,0 +1,72 @@ +import { useQuery } from "@tanstack/react-query"; +import { + getUsers as getUsersSdk, + type GetUsersData, + type UserAdminResponse, +} from "../client"; +import { + getUsersQueryKey, + getUserOptions, +} from "../client/@tanstack/react-query.gen"; +import { paginatedQueryFn, type PaginatedResult } from "../api/pagination"; +import { useAuthStore } from "../stores/authStore"; +import { isSdkError } from "../api/errors"; + +function buildUsernameFilter(search: string): string { + const filter = [ + { + type: "property", + params: { name: "username", operator: "contains", value: search }, + }, + ]; + return btoa(JSON.stringify(filter)); +} + +interface UseAdminUsersParams { + page?: number; + perPage?: number; + search?: string; +} + +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, + }; +} + +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-react/apps/console/src/hooks/useLoginAsUser.ts b/ui-react/apps/console/src/hooks/useLoginAsUser.ts new file mode 100644 index 00000000000..29083013066 --- /dev/null +++ b/ui-react/apps/console/src/hooks/useLoginAsUser.ts @@ -0,0 +1,35 @@ +import { useState, useCallback, useRef } from "react"; +import { getUserTokenAdmin } from "../client"; + +export function useLoginAsUser() { + const [loadingId, setLoadingId] = useState(null); + const [errorId, setErrorId] = useState(null); + const loadingRef = useRef(false); + + const loginAs = useCallback(async (userId: string) => { + if (loadingRef.current) return; + loadingRef.current = true; + setLoadingId(userId); + setErrorId(null); + try { + const { data } = await getUserTokenAdmin({ + path: { id: userId }, + throwOnError: true, + }); + if (data?.token) { + window.open( + `/login?token=${encodeURIComponent(data.token)}`, + "_blank", + "noopener,noreferrer", + ); + } + } catch { + setErrorId(userId); + } finally { + loadingRef.current = false; + setLoadingId(null); + } + }, []); + + return { loginAs, loadingId, errorId }; +} diff --git a/ui-react/apps/console/src/pages/admin/users/CreateUserDrawer.tsx b/ui-react/apps/console/src/pages/admin/users/CreateUserDrawer.tsx new file mode 100644 index 00000000000..bb200a01788 --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/CreateUserDrawer.tsx @@ -0,0 +1,200 @@ +import { useState, type FormEvent } from "react"; +import { PlusIcon } from "@heroicons/react/24/outline"; +import { useResetOnOpen } from "../../../hooks/useResetOnOpen"; +import { useCreateUser } from "../../../hooks/useAdminUserMutations"; +import { isSdkError } from "../../../api/errors"; +import Drawer from "../../../components/common/Drawer"; +import { LABEL, INPUT } from "../../../utils/styles"; +import PasswordInput from "./PasswordInput"; +import NamespaceLimitFields from "./NamespaceLimitFields"; + +interface CreateUserDrawerProps { + open: boolean; + onClose: () => void; +} + +export default function CreateUserDrawer({ + open, + onClose, +}: CreateUserDrawerProps) { + const createUser = useCreateUser(); + + const [name, setName] = useState(""); + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [admin, setAdmin] = useState(false); + const [limitEnabled, setLimitEnabled] = useState(false); + const [limitDisabled, setLimitDisabled] = useState(false); + const [maxNamespaces, setMaxNamespaces] = useState(1); + const [error, setError] = useState(""); + + useResetOnOpen(open, () => { + setName(""); + setUsername(""); + setEmail(""); + setPassword(""); + setAdmin(false); + setLimitEnabled(false); + setLimitDisabled(false); + setMaxNamespaces(1); + setError(""); + }); + + const computeMaxNamespaces = (): number | undefined => { + if (!limitEnabled) return undefined; + if (limitDisabled) return 0; + return maxNamespaces; + }; + + const canSubmit + = name.trim() && username.trim() && email.trim() && password.trim(); + + const handleSubmit = async (e?: FormEvent) => { + e?.preventDefault(); + if (!canSubmit) return; + setError(""); + try { + await createUser.mutateAsync({ + body: { + name: name.trim(), + username: username.trim(), + email: email.trim(), + password, + admin, + max_namespaces: computeMaxNamespaces(), + }, + }); + onClose(); + } catch (err) { + if (isSdkError(err) && err.status === 409) { + setError("A user with this email or username already exists."); + } else { + setError("Failed to create user. Please try again."); + } + } + }; + + return ( + + + + + )} + > +
void handleSubmit(e)} className="space-y-5"> + {/* Name */} +
+ + setName(e.target.value)} + placeholder="John Doe" + autoFocus={open} + className={INPUT} + /> +
+ + {/* Username */} +
+ + setUsername(e.target.value)} + placeholder="johndoe" + className={INPUT} + /> +

+ 3-30 characters, letters, numbers, hyphens, dots, underscores, @ +

+
+ + {/* Email */} +
+ + setEmail(e.target.value)} + placeholder="john@example.com" + className={INPUT} + /> +
+ + {/* Password */} + + + {/* Namespace Limit */} + + + {/* Admin */} + + + {/* Error */} + {error && ( +

+ {error} +

+ )} + +
+ ); +} diff --git a/ui-react/apps/console/src/pages/admin/users/DeleteUserDialog.tsx b/ui-react/apps/console/src/pages/admin/users/DeleteUserDialog.tsx new file mode 100644 index 00000000000..3683211e90f --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/DeleteUserDialog.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import { useDeleteUser } from "../../../hooks/useAdminUserMutations"; +import ConfirmDialog from "../../../components/common/ConfirmDialog"; + +interface DeleteUserDialogProps { + open: boolean; + onClose: () => void; + user: { id: string; name: string } | null; + onDeleted?: () => void; +} + +export default function DeleteUserDialog({ + open, + onClose, + user, + onDeleted, +}: DeleteUserDialogProps) { + const deleteUser = useDeleteUser(); + const [error, setError] = useState(""); + + return ( + { + setError(""); + onClose(); + }} + onConfirm={async () => { + if (!user) return; + setError(""); + try { + await deleteUser.mutateAsync({ path: { id: user.id } }); + onClose(); + onDeleted?.(); + } catch { + setError("Failed to delete user. Please try again."); + } + }} + title="Delete User" + description={( + <> + Are you sure you want to remove{" "} + {user?.name}{" "} + and all associated namespace data? This action cannot be undone. + {error && ( + {error} + )} + + )} + confirmLabel="Delete" + /> + ); +} diff --git a/ui-react/apps/console/src/pages/admin/users/EditUserDrawer.tsx b/ui-react/apps/console/src/pages/admin/users/EditUserDrawer.tsx new file mode 100644 index 00000000000..2d271e028c3 --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/EditUserDrawer.tsx @@ -0,0 +1,258 @@ +import { useState, type FormEvent } from "react"; +import { useResetOnOpen } from "../../../hooks/useResetOnOpen"; +import { useUpdateUser } from "../../../hooks/useAdminUserMutations"; +import { useAuthStore } from "../../../stores/authStore"; +import { isSdkError } from "../../../api/errors"; +import Drawer from "../../../components/common/Drawer"; +import { LABEL, INPUT } from "../../../utils/styles"; +import PasswordInput from "./PasswordInput"; +import NamespaceLimitFields from "./NamespaceLimitFields"; +import type { UserStatus } from "./UserStatusChip"; + +export interface EditableUser { + id: string; + name: string; + username: string; + email: string; + admin?: boolean; + max_namespaces?: number; + status?: UserStatus; +} + +interface EditUserDrawerProps { + open: boolean; + onClose: () => void; + user: EditableUser | null; +} + +export default function EditUserDrawer({ + open, + onClose, + user, +}: EditUserDrawerProps) { + const updateUser = useUpdateUser(); + const currentUsername = useAuthStore((s) => s.username); + + const [name, setName] = useState(""); + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmed, setConfirmed] = useState(false); + const [admin, setAdmin] = useState(false); + const [limitEnabled, setLimitEnabled] = useState(false); + const [limitDisabled, setLimitDisabled] = useState(false); + const [maxNamespaces, setMaxNamespaces] = useState(1); + const [error, setError] = useState(""); + + useResetOnOpen(open, () => { + setName(user?.name ?? ""); + setUsername(user?.username ?? ""); + setEmail(user?.email ?? ""); + setPassword(""); + setConfirmed(user?.status === "confirmed"); + setAdmin(user?.admin ?? false); + + const maxNs = user?.max_namespaces; + if (maxNs !== undefined && maxNs >= 0) { + setLimitEnabled(true); + setLimitDisabled(maxNs === 0); + setMaxNamespaces(maxNs || 1); + } else { + setLimitEnabled(false); + setLimitDisabled(false); + setMaxNamespaces(1); + } + + setError(""); + }); + + const isConfirmed = user?.status === "confirmed"; + const canChangeStatus = !isConfirmed; + const isSelf = user?.username === currentUsername; + + const computeMaxNamespaces = (): number | undefined => { + if (!limitEnabled) { + const orig = user?.max_namespaces; + return orig !== undefined && orig < 0 ? orig : undefined; + } + if (limitDisabled) return 0; + return maxNamespaces; + }; + + const canSubmit = name.trim() && username.trim() && email.trim(); + + const handleSubmit = async (e?: FormEvent) => { + e?.preventDefault(); + if (!canSubmit || !user) return; + setError(""); + try { + await updateUser.mutateAsync({ + path: { id: user.id }, + body: { + name: name.trim(), + username: username.trim(), + email: email.trim(), + password, + confirmed, + admin, + max_namespaces: computeMaxNamespaces(), + }, + }); + onClose(); + } catch (err) { + if (isSdkError(err) && err.status === 409) { + setError("A user with this email or username already exists."); + } else { + setError("Failed to update user. Please try again."); + } + } + }; + + return ( + {user.username} : undefined + } + footer={( + <> + + + + )} + > +
void handleSubmit(e)} className="space-y-5"> + {/* Name */} +
+ + setName(e.target.value)} + autoFocus={open} + className={INPUT} + /> +
+ + {/* Username */} +
+ + setUsername(e.target.value)} + className={INPUT} + /> +

+ 3-30 characters, letters, numbers, hyphens, dots, underscores, @ +

+
+ + {/* Email */} +
+ + setEmail(e.target.value)} + className={INPUT} + /> +
+ + {/* Password */} + + + {/* Namespace Limit */} + + + {/* Confirmed */} + + + {/* Admin */} + + + {/* Error */} + {error && ( +

+ {error} +

+ )} + +
+ ); +} diff --git a/ui-react/apps/console/src/pages/admin/users/NamespaceLimitFields.tsx b/ui-react/apps/console/src/pages/admin/users/NamespaceLimitFields.tsx new file mode 100644 index 00000000000..2636a12e194 --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/NamespaceLimitFields.tsx @@ -0,0 +1,68 @@ +import { LABEL, INPUT } from "../../../utils/styles"; + +interface NamespaceLimitFieldsProps { + idPrefix: string; + limitEnabled: boolean; + onLimitEnabledChange: (v: boolean) => void; + limitDisabled: boolean; + onLimitDisabledChange: (v: boolean) => void; + maxNamespaces: number; + onMaxNamespacesChange: (v: number) => void; +} + +export default function NamespaceLimitFields({ + idPrefix, + limitEnabled, + onLimitEnabledChange, + limitDisabled, + onLimitDisabledChange, + maxNamespaces, + onMaxNamespacesChange, +}: NamespaceLimitFieldsProps) { + return ( +
+ + {limitEnabled && ( +
+ + {!limitDisabled && ( +
+ + + onMaxNamespacesChange(parseInt(e.target.value, 10) || 1)} + className={`${INPUT} w-32`} + /> +
+ )} +
+ )} +
+ ); +} diff --git a/ui-react/apps/console/src/pages/admin/users/PasswordInput.tsx b/ui-react/apps/console/src/pages/admin/users/PasswordInput.tsx new file mode 100644 index 00000000000..5ff19c1eee3 --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/PasswordInput.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline"; +import { LABEL, INPUT } from "../../../utils/styles"; + +interface PasswordInputProps { + id: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + hint?: string; +} + +export default function PasswordInput({ + id, + value, + onChange, + placeholder, + hint, +}: PasswordInputProps) { + const [show, setShow] = useState(false); + + return ( +
+ +
+ onChange(e.target.value)} + placeholder={placeholder} + className={`${INPUT} pr-10`} + /> + +
+ {hint &&

{hint}

} +
+ ); +} diff --git a/ui-react/apps/console/src/pages/admin/users/ResetPasswordDialog.tsx b/ui-react/apps/console/src/pages/admin/users/ResetPasswordDialog.tsx new file mode 100644 index 00000000000..2886e3eee61 --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/ResetPasswordDialog.tsx @@ -0,0 +1,157 @@ +import { useState, useId } from "react"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; +import { useResetOnOpen } from "../../../hooks/useResetOnOpen"; +import { useResetUserPassword } from "../../../hooks/useAdminUserMutations"; +import { isSdkError } from "../../../api/errors"; +import CopyButton from "../../../components/common/CopyButton"; +import BaseDialog from "../../../components/common/BaseDialog"; + +interface ResetPasswordDialogProps { + open: boolean; + onClose: () => void; + userId: string; +} + +export default function ResetPasswordDialog({ + open, + onClose, + userId, +}: ResetPasswordDialogProps) { + const resetPassword = useResetUserPassword(); + const [step, setStep] = useState<"confirm" | "result">("confirm"); + const [generatedPassword, setGeneratedPassword] = useState(""); + const [error, setError] = useState(""); + + const autoId = useId(); + const titleId = `reset-pw-title-${autoId}`; + const descId = `reset-pw-desc-${autoId}`; + + useResetOnOpen(open, () => { + setStep("confirm"); + setGeneratedPassword(""); + setError(""); + }); + + const handleEnable = async () => { + setError(""); + try { + const data = await resetPassword.mutateAsync({ path: { id: userId } }); + setGeneratedPassword(data?.password ?? ""); + setStep("result"); + } catch (err) { + if (isSdkError(err) && err.status === 400) { + setError("This user already has a local password."); + } else { + setError("Failed to set password. Please try again."); + } + } + }; + + return ( + + {step === "confirm" ? ( + <> + {/* Header */} +
+

+ Enable Local Authentication +

+
+ + {/* Body */} +
+

+ This will generate a temporary password for this SAML-only user, + enabling them to log in with local credentials. They should change + this password after their first login. +

+ {error && ( +

+ {error} +

+ )} +
+ + {/* Footer */} +
+ + +
+ + ) : ( + <> + {/* Header */} +
+

+ Password Generated +

+
+ + {/* Body */} +
+
+ +

+ Make sure to copy this password now. It will not be shown again. +

+
+
+ + +
+
+ + {/* Footer */} +
+ +
+ + )} +
+ ); +} diff --git a/ui-react/apps/console/src/pages/admin/users/UserDetails.tsx b/ui-react/apps/console/src/pages/admin/users/UserDetails.tsx new file mode 100644 index 00000000000..55f936a8425 --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/UserDetails.tsx @@ -0,0 +1,303 @@ +import { useState } from "react"; +import { useParams, useNavigate, Link } from "react-router-dom"; +import { + ChevronRightIcon, + UsersIcon, + PencilSquareIcon, + TrashIcon, + ArrowRightStartOnRectangleIcon, + InformationCircleIcon, + ClockIcon, + KeyIcon, +} from "@heroicons/react/24/outline"; +import { useAdminUser } from "../../../hooks/useAdminUsers"; +import { useLoginAsUser } from "../../../hooks/useLoginAsUser"; +import CopyButton from "../../../components/common/CopyButton"; +import UserStatusChip from "./UserStatusChip"; +import EditUserDrawer from "./EditUserDrawer"; +import ResetPasswordDialog from "./ResetPasswordDialog"; +import DeleteUserDialog from "./DeleteUserDialog"; +import { formatDateFull } from "../../../utils/date"; + +const LABEL + = "text-2xs font-mono font-semibold uppercase tracking-label text-text-muted"; +const VALUE = "text-sm text-text-primary font-medium mt-0.5"; +const ZERO_DATE = "0001-01-01T00:00:00Z"; + +function InfoItem({ + label, + value, + mono, + copyable, +}: { + label: string; + value: string; + mono?: boolean; + copyable?: boolean; +}) { + return ( +
+
{label}
+
+ + {value || "\u2014"} + + {copyable && value && } +
+
+ ); +} + +function formatMaxNamespaces(value: number): string { + if (value < 0) return "Unlimited"; + if (value === 0) return "Disabled"; + return String(value); +} + +export default function UserDetails() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { data, isLoading, error } = useAdminUser(id ?? ""); + const user = data; + + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [resetPasswordOpen, setResetPasswordOpen] = useState(false); + const { + loginAs, + loadingId: loginAsId, + errorId: loginAsErrorId, + } = useLoginAsUser(); + + if (isLoading) { + return ( +
+ + Loading user details +
+ ); + } + + if (error || !user) { + return ( +
+ +

User not found

+ + Back to users + +
+ ); + } + + const isSamlOnly + = user.preferences.auth_methods.length === 1 + && user.preferences.auth_methods[0] === "saml"; + const userStatus = user.status; + const lastLogin = user.last_login === ZERO_DATE ? null : user.last_login; + + return ( +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+
+ +
+
+

+ {user.name} +

+
+ + {user.admin && ( + + Admin + + )} +
+
+
+ + {/* Actions */} +
+ + {isSamlOnly && ( + + )} + + +
+
+ + {/* Info Grid */} +
+ {/* Identity Card */} +
+

+ + Identity +

+
+ + + + + +
+
Status
+
+ +
+
+
+
+ + {/* Account Card */} +
+

+ + Account +

+
+
+
Created
+
{formatDateFull(user.created_at)}
+
+
+
Last Login
+
+ {lastLogin ? formatDateFull(lastLogin) : "Never logged in"} +
+
+
+
Max Namespaces
+
+ {formatMaxNamespaces(user.max_namespaces)} +
+
+ +
+
MFA
+
+ + {user.mfa.enabled ? "Enabled" : "Disabled"} + +
+
+
+
Auth Methods
+
+ {user.preferences.auth_methods.map((method) => ( + + {method.toUpperCase()} + + ))} +
+
+
+
+
+ + {/* Edit Drawer */} + setEditOpen(false)} + user={user} + /> + + {/* Reset Password Dialog */} + setResetPasswordOpen(false)} + userId={id ?? ""} + /> + + {/* Delete Confirmation */} + setDeleteOpen(false)} + user={user ? { id: user.id, name: user.name } : null} + onDeleted={() => void navigate("/admin/users")} + /> +
+ ); +} diff --git a/ui-react/apps/console/src/pages/admin/users/UserStatusChip.tsx b/ui-react/apps/console/src/pages/admin/users/UserStatusChip.tsx new file mode 100644 index 00000000000..41944469d8e --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/UserStatusChip.tsx @@ -0,0 +1,45 @@ +import { + CheckCircleIcon, + ExclamationCircleIcon, +} from "@heroicons/react/24/outline"; + +export type UserStatus = "confirmed" | "not-confirmed"; + +const STATUS_CONFIG: Record< + UserStatus, + { + Icon: typeof CheckCircleIcon; + label: string; + className: string; + } +> = { + confirmed: { + Icon: CheckCircleIcon, + label: "Confirmed", + className: + "bg-accent-green/10 text-accent-green border border-accent-green/20", + }, + "not-confirmed": { + Icon: ExclamationCircleIcon, + label: "Not Confirmed", + className: "bg-accent-red/10 text-accent-red border border-accent-red/20", + }, +}; + +interface UserStatusChipProps { + status: UserStatus; +} + +export default function UserStatusChip({ status }: UserStatusChipProps) { + const config = STATUS_CONFIG[status] ?? STATUS_CONFIG["not-confirmed"]; + const { Icon, label, className } = config; + + return ( + + + {label} + + ); +} diff --git a/ui-react/apps/console/src/pages/admin/users/__tests__/CreateUserDrawer.test.tsx b/ui-react/apps/console/src/pages/admin/users/__tests__/CreateUserDrawer.test.tsx new file mode 100644 index 00000000000..1cee29cb166 --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/__tests__/CreateUserDrawer.test.tsx @@ -0,0 +1,422 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CreateUserDrawer from "../CreateUserDrawer"; +import { useCreateUser } from "../../../../hooks/useAdminUserMutations"; +vi.mock("../../../../hooks/useAdminUserMutations", () => ({ + useCreateUser: vi.fn(), +})); + +vi.mock("../../../../utils/styles", () => ({ + LABEL: "label", + INPUT: "input", +})); + +vi.mock("../../../../components/common/Drawer", async () => ({ + default: (await import("./mocks")).MockDrawer, +})); + +const mockMutateAsync = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useCreateUser).mockReturnValue({ + mutateAsync: mockMutateAsync, + } as never); +}); + +function renderDrawer( + overrides: Partial<{ open: boolean; onClose: () => void }> = {}, +) { + const defaults = { open: true, onClose: vi.fn() }; + const props = { ...defaults, ...overrides }; + return { onClose: props.onClose, ...render() }; +} + +async function fillForm({ + name = "Alice", + username = "alice", + email = "alice@example.com", + password = "pass123", +}: Partial<{ + name: string; + username: string; + email: string; + password: string; +}> = {}) { + if (name) await userEvent.type(screen.getByLabelText(/^name$/i), name); + if (username) + await userEvent.type(screen.getByLabelText(/^username$/i), username); + if (email) await userEvent.type(screen.getByLabelText(/^email$/i), email); + if (password) + await userEvent.type(screen.getByLabelText(/^password$/i), password); +} + +describe("CreateUserDrawer", () => { + describe("rendering — closed", () => { + it("renders nothing when open is false", () => { + renderDrawer({ open: false }); + expect(screen.queryByText("Create User")).not.toBeInTheDocument(); + }); + }); + + describe("rendering — open", () => { + it("renders the 'Create User' title", () => { + renderDrawer(); + expect( + screen.getByRole("heading", { name: "Create User" }), + ).toBeInTheDocument(); + }); + + it("renders the Name input field", () => { + renderDrawer(); + expect(screen.getByLabelText(/^name$/i)).toBeInTheDocument(); + }); + + it("renders the Username input field", () => { + renderDrawer(); + expect(screen.getByLabelText(/^username$/i)).toBeInTheDocument(); + }); + + it("renders the Email input field", () => { + renderDrawer(); + expect(screen.getByLabelText(/^email$/i)).toBeInTheDocument(); + }); + + it("renders the Password input field", () => { + renderDrawer(); + expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); + }); + + it("renders the 'Create User' submit button", () => { + renderDrawer(); + expect( + screen.getByRole("button", { name: /create user/i }), + ).toBeInTheDocument(); + }); + + it("renders the Cancel button", () => { + renderDrawer(); + expect( + screen.getByRole("button", { name: /cancel/i }), + ).toBeInTheDocument(); + }); + + it("submit button is disabled when form is empty", () => { + renderDrawer(); + expect( + screen.getByRole("button", { name: /create user/i }), + ).toBeDisabled(); + }); + + it("password field is of type password by default", () => { + renderDrawer(); + expect(screen.getByLabelText(/^password$/i)).toHaveAttribute( + "type", + "password", + ); + }); + }); + + describe("form enabling", () => { + it("enables submit button when all required fields are filled", async () => { + renderDrawer(); + await fillForm(); + expect( + screen.getByRole("button", { name: /create user/i }), + ).not.toBeDisabled(); + }); + + it("keeps submit disabled when name is missing", async () => { + renderDrawer(); + await fillForm({ name: "" }); + expect( + screen.getByRole("button", { name: /create user/i }), + ).toBeDisabled(); + }); + + it("keeps submit disabled when username is missing", async () => { + renderDrawer(); + await fillForm({ username: "" }); + expect( + screen.getByRole("button", { name: /create user/i }), + ).toBeDisabled(); + }); + + it("keeps submit disabled when email is missing", async () => { + renderDrawer(); + await fillForm({ email: "" }); + expect( + screen.getByRole("button", { name: /create user/i }), + ).toBeDisabled(); + }); + + it("keeps submit disabled when password is missing", async () => { + renderDrawer(); + await fillForm({ password: "" }); + expect( + screen.getByRole("button", { name: /create user/i }), + ).toBeDisabled(); + }); + }); + + describe("password visibility toggle", () => { + it("shows password in plaintext when Show password button is clicked", async () => { + renderDrawer(); + await userEvent.click( + screen.getByRole("button", { name: /show password/i }), + ); + expect(screen.getByLabelText(/^password$/i)).toHaveAttribute( + "type", + "text", + ); + }); + + it("hides password again when Hide password button is clicked", async () => { + renderDrawer(); + await userEvent.click( + screen.getByRole("button", { name: /show password/i }), + ); + await userEvent.click( + screen.getByRole("button", { name: /hide password/i }), + ); + expect(screen.getByLabelText(/^password$/i)).toHaveAttribute( + "type", + "password", + ); + }); + }); + + describe("namespace limit controls", () => { + it("does not show namespace limit sub-options by default", () => { + renderDrawer(); + expect( + screen.queryByLabelText(/disable namespace creation/i), + ).not.toBeInTheDocument(); + }); + + it("shows sub-options when 'Set namespace creation limit' is checked", async () => { + renderDrawer(); + await userEvent.click( + screen.getByLabelText(/set namespace creation limit/i), + ); + expect( + screen.getByLabelText(/disable namespace creation/i), + ).toBeInTheDocument(); + }); + + it("shows max namespaces input when limit is enabled but disable is unchecked", async () => { + renderDrawer(); + await userEvent.click( + screen.getByLabelText(/set namespace creation limit/i), + ); + expect(screen.getByLabelText(/max namespaces/i)).toBeInTheDocument(); + }); + + it("hides max namespaces input when 'Disable namespace creation' is checked", async () => { + renderDrawer(); + await userEvent.click( + screen.getByLabelText(/set namespace creation limit/i), + ); + await userEvent.click( + screen.getByLabelText(/disable namespace creation/i), + ); + expect( + screen.queryByLabelText(/max namespaces/i), + ).not.toBeInTheDocument(); + }); + }); + + describe("admin checkbox", () => { + it("renders 'Admin user' checkbox unchecked by default", () => { + renderDrawer(); + expect(screen.getByLabelText(/admin user/i)).not.toBeChecked(); + }); + }); + + describe("submit — success", () => { + it("calls mutateAsync with the correct payload", async () => { + mockMutateAsync.mockResolvedValue(undefined); + renderDrawer(); + await fillForm(); + + await userEvent.click( + screen.getByRole("button", { name: /create user/i }), + ); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + body: expect.objectContaining({ + name: "Alice", + username: "alice", + email: "alice@example.com", + password: "pass123", + admin: false, + }), + }); + }); + }); + + it("calls onClose after successful creation", async () => { + mockMutateAsync.mockResolvedValue(undefined); + const { onClose } = renderDrawer(); + await fillForm(); + + await userEvent.click( + screen.getByRole("button", { name: /create user/i }), + ); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + + it("sends max_namespaces as undefined when limit is not enabled", async () => { + mockMutateAsync.mockResolvedValue(undefined); + renderDrawer(); + await fillForm(); + + await userEvent.click( + screen.getByRole("button", { name: /create user/i }), + ); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + body: expect.objectContaining({ max_namespaces: undefined }), + }); + }); + }); + + it("sends max_namespaces as 0 when namespace creation is disabled", async () => { + mockMutateAsync.mockResolvedValue(undefined); + renderDrawer(); + await fillForm(); + + await userEvent.click( + screen.getByLabelText(/set namespace creation limit/i), + ); + await userEvent.click( + screen.getByLabelText(/disable namespace creation/i), + ); + await userEvent.click( + screen.getByRole("button", { name: /create user/i }), + ); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + body: expect.objectContaining({ max_namespaces: 0 }), + }); + }); + }); + }); + + describe("submit — error handling", () => { + it("shows conflict error message for 409 responses", async () => { + mockMutateAsync.mockRejectedValue({ status: 409 }); + renderDrawer(); + await fillForm(); + + await userEvent.click( + screen.getByRole("button", { name: /create user/i }), + ); + + await waitFor(() => { + expect(screen.getByText(/already exists/i)).toBeInTheDocument(); + }); + }); + + it("shows generic error for 400 responses", async () => { + mockMutateAsync.mockRejectedValue({ status: 400 }); + renderDrawer(); + await fillForm(); + + await userEvent.click( + screen.getByRole("button", { name: /create user/i }), + ); + + await waitFor(() => { + expect(screen.getByText(/failed to create user/i)).toBeInTheDocument(); + }); + }); + + it("shows generic error for unexpected failures", async () => { + mockMutateAsync.mockRejectedValue(new Error("network error")); + renderDrawer(); + await fillForm(); + + await userEvent.click( + screen.getByRole("button", { name: /create user/i }), + ); + + await waitFor(() => { + expect(screen.getByText(/failed to create user/i)).toBeInTheDocument(); + }); + }); + + it("renders error with role='alert'", async () => { + mockMutateAsync.mockRejectedValue(new Error("network error")); + renderDrawer(); + await fillForm(); + + await userEvent.click( + screen.getByRole("button", { name: /create user/i }), + ); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + }); + + it("does not call onClose when creation fails", async () => { + mockMutateAsync.mockRejectedValue(new Error("network error")); + const { onClose } = renderDrawer(); + await fillForm(); + + await userEvent.click( + screen.getByRole("button", { name: /create user/i }), + ); + + await waitFor(() => screen.getByRole("alert")); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe("cancel", () => { + it("calls onClose when Cancel is clicked", async () => { + const { onClose } = renderDrawer(); + await userEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not call mutateAsync when Cancel is clicked", async () => { + renderDrawer(); + await userEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(mockMutateAsync).not.toHaveBeenCalled(); + }); + }); + + describe("state reset on reopen", () => { + it("clears the name field when closed then reopened", async () => { + const { rerender } = renderDrawer(); + await userEvent.type(screen.getByLabelText(/^name$/i), "Alice"); + + rerender(); + rerender(); + + expect(screen.getByLabelText(/^name$/i)).toHaveValue(""); + }); + + it("clears any error when closed then reopened", async () => { + mockMutateAsync.mockRejectedValue(new Error("fail")); + const { rerender } = renderDrawer(); + await fillForm(); + await userEvent.click( + screen.getByRole("button", { name: /create user/i }), + ); + await waitFor(() => screen.getByRole("alert")); + + rerender(); + rerender(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/ui-react/apps/console/src/pages/admin/users/__tests__/EditUserDrawer.test.tsx b/ui-react/apps/console/src/pages/admin/users/__tests__/EditUserDrawer.test.tsx new file mode 100644 index 00000000000..68be1165b71 --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/__tests__/EditUserDrawer.test.tsx @@ -0,0 +1,458 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import EditUserDrawer, { type EditableUser } from "../EditUserDrawer"; +import { useUpdateUser } from "../../../../hooks/useAdminUserMutations"; +import { useAuthStore } from "../../../../stores/authStore"; +vi.mock("../../../../hooks/useAdminUserMutations", () => ({ + useUpdateUser: vi.fn(), +})); + +vi.mock("../../../../utils/styles", () => ({ + LABEL: "label", + INPUT: "input", +})); + +vi.mock("../../../../components/common/Drawer", async () => ({ + default: (await import("./mocks")).MockDrawer, +})); + +const mockMutateAsync = vi.fn(); + +const mockUser: EditableUser = { + id: "u1", + name: "Alice Smith", + username: "alice", + email: "alice@example.com", + admin: false, + status: "not-confirmed", +}; + +const confirmedUser: EditableUser = { + ...mockUser, + status: "confirmed", +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useUpdateUser).mockReturnValue({ + mutateAsync: mockMutateAsync, + } as never); + useAuthStore.setState({ username: "admin" } as never); +}); + +function renderDrawer( + overrides: Partial<{ + open: boolean; + onClose: () => void; + user: EditableUser | null; + }> = {}, +) { + const defaults = { open: true, onClose: vi.fn(), user: mockUser }; + const props = { ...defaults, ...overrides }; + return { onClose: props.onClose, ...render() }; +} + +describe("EditUserDrawer", () => { + describe("rendering — closed", () => { + it("renders nothing when open is false", () => { + renderDrawer({ open: false }); + expect(screen.queryByText("Edit User")).not.toBeInTheDocument(); + }); + }); + + describe("rendering — open", () => { + it("renders the 'Edit User' title", () => { + renderDrawer(); + expect(screen.getByText("Edit User")).toBeInTheDocument(); + }); + + it("renders the Name input", () => { + renderDrawer(); + expect(screen.getByLabelText(/^name$/i)).toBeInTheDocument(); + }); + + it("renders the Username input", () => { + renderDrawer(); + expect(screen.getByLabelText(/^username$/i)).toBeInTheDocument(); + }); + + it("renders the Email input", () => { + renderDrawer(); + expect(screen.getByLabelText(/^email$/i)).toBeInTheDocument(); + }); + + it("renders the Password input", () => { + renderDrawer(); + expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); + }); + + it("renders the 'Save Changes' submit button", () => { + renderDrawer(); + expect( + screen.getByRole("button", { name: /save changes/i }), + ).toBeInTheDocument(); + }); + + it("renders the Cancel button", () => { + renderDrawer(); + expect( + screen.getByRole("button", { name: /cancel/i }), + ).toBeInTheDocument(); + }); + }); + + describe("form pre-filling", () => { + it("pre-fills the Name field with the user's name", () => { + renderDrawer(); + expect(screen.getByLabelText(/^name$/i)).toHaveValue("Alice Smith"); + }); + + it("pre-fills the Username field with the user's username", () => { + renderDrawer(); + expect(screen.getByLabelText(/^username$/i)).toHaveValue("alice"); + }); + + it("pre-fills the Email field with the user's email", () => { + renderDrawer(); + expect(screen.getByLabelText(/^email$/i)).toHaveValue( + "alice@example.com", + ); + }); + + it("leaves the Password field blank", () => { + renderDrawer(); + expect(screen.getByLabelText(/^password$/i)).toHaveValue(""); + }); + + it("pre-fills confirmed checkbox as unchecked when user is not confirmed", () => { + renderDrawer({ user: mockUser }); + expect(screen.getByLabelText(/^confirmed$/i)).not.toBeChecked(); + }); + + it("pre-fills admin checkbox as unchecked when user is not admin", () => { + renderDrawer({ user: mockUser }); + expect(screen.getByLabelText(/^admin user$/i)).not.toBeChecked(); + }); + }); + + describe("form enabling", () => { + it("submit button is enabled when all required fields are filled", () => { + renderDrawer(); + expect( + screen.getByRole("button", { name: /save changes/i }), + ).not.toBeDisabled(); + }); + + it("disables submit button when name is cleared", async () => { + renderDrawer(); + const nameInput = screen.getByLabelText(/^name$/i); + await userEvent.clear(nameInput); + expect( + screen.getByRole("button", { name: /save changes/i }), + ).toBeDisabled(); + }); + + it("disables submit button when username is cleared", async () => { + renderDrawer(); + await userEvent.clear(screen.getByLabelText(/^username$/i)); + expect( + screen.getByRole("button", { name: /save changes/i }), + ).toBeDisabled(); + }); + + it("disables submit button when email is cleared", async () => { + renderDrawer(); + await userEvent.clear(screen.getByLabelText(/^email$/i)); + expect( + screen.getByRole("button", { name: /save changes/i }), + ).toBeDisabled(); + }); + }); + + describe("confirmed checkbox constraint", () => { + it("confirmed checkbox is disabled for an already-confirmed user", () => { + renderDrawer({ user: confirmedUser }); + expect(screen.getByLabelText(/^confirmed$/i)).toBeDisabled(); + }); + + it("confirmed checkbox is enabled for a pending (unconfirmed) user", () => { + renderDrawer({ user: mockUser }); + expect(screen.getByLabelText(/^confirmed$/i)).not.toBeDisabled(); + }); + + it("recognises status='confirmed' as a confirmed user", () => { + const userWithStatus: EditableUser = { + ...mockUser, + status: "confirmed", + }; + renderDrawer({ user: userWithStatus }); + expect(screen.getByLabelText(/^confirmed$/i)).toBeDisabled(); + }); + }); + + describe("admin checkbox — self-demotion constraint", () => { + it("admin checkbox is disabled when editing your own admin account", () => { + useAuthStore.setState({ username: "alice" } as never); + const selfAdmin: EditableUser = { ...mockUser, admin: true }; + renderDrawer({ user: selfAdmin }); + expect(screen.getByLabelText(/^admin user$/i)).toBeDisabled(); + }); + + it("admin checkbox is enabled when editing another admin", () => { + useAuthStore.setState({ username: "admin" } as never); + const otherAdmin: EditableUser = { + ...mockUser, + username: "bob", + admin: true, + }; + renderDrawer({ user: otherAdmin }); + expect(screen.getByLabelText(/^admin user$/i)).not.toBeDisabled(); + }); + + it("admin checkbox is enabled for a non-admin self user", () => { + useAuthStore.setState({ username: "alice" } as never); + const selfNonAdmin: EditableUser = { ...mockUser, admin: false }; + renderDrawer({ user: selfNonAdmin }); + expect(screen.getByLabelText(/^admin user$/i)).not.toBeDisabled(); + }); + }); + + describe("password visibility toggle", () => { + it("shows password in plaintext when Show password button is clicked", async () => { + renderDrawer(); + await userEvent.click( + screen.getByRole("button", { name: /show password/i }), + ); + expect(screen.getByLabelText(/^password$/i)).toHaveAttribute( + "type", + "text", + ); + }); + + it("hides password again when Hide password is clicked", async () => { + renderDrawer(); + await userEvent.click( + screen.getByRole("button", { name: /show password/i }), + ); + await userEvent.click( + screen.getByRole("button", { name: /hide password/i }), + ); + expect(screen.getByLabelText(/^password$/i)).toHaveAttribute( + "type", + "password", + ); + }); + }); + + describe("namespace limit controls", () => { + it("does not show namespace sub-options by default when max_namespaces is undefined", () => { + renderDrawer({ user: { ...mockUser, max_namespaces: undefined } }); + expect( + screen.queryByLabelText(/disable namespace creation/i), + ).not.toBeInTheDocument(); + }); + + it("pre-enables namespace limit when max_namespaces is set", () => { + renderDrawer({ user: { ...mockUser, max_namespaces: 5 } }); + expect(screen.getByLabelText(/max namespaces/i)).toBeInTheDocument(); + }); + + it("pre-checks disable namespace creation when max_namespaces is 0", () => { + renderDrawer({ user: { ...mockUser, max_namespaces: 0 } }); + expect( + screen.getByLabelText(/disable namespace creation/i), + ).toBeChecked(); + }); + + it("hides max namespaces input when disable is checked", async () => { + renderDrawer({ user: { ...mockUser, max_namespaces: 5 } }); + await userEvent.click( + screen.getByLabelText(/disable namespace creation/i), + ); + expect( + screen.queryByLabelText(/max namespaces/i), + ).not.toBeInTheDocument(); + }); + }); + + describe("submit — success", () => { + it("calls mutateAsync with the correct payload", async () => { + mockMutateAsync.mockResolvedValue(undefined); + renderDrawer(); + + const nameInput = screen.getByLabelText(/^name$/i); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, "Alice Updated"); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + path: { id: "u1" }, + body: expect.objectContaining({ + name: "Alice Updated", + username: "alice", + email: "alice@example.com", + }), + }); + }); + }); + + it("calls onClose after successful update", async () => { + mockMutateAsync.mockResolvedValue(undefined); + const { onClose } = renderDrawer(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + + it("sends max_namespaces as undefined when limit is not enabled", async () => { + mockMutateAsync.mockResolvedValue(undefined); + renderDrawer({ user: { ...mockUser, max_namespaces: undefined } }); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + path: { id: "u1" }, + body: expect.objectContaining({ max_namespaces: undefined }), + }); + }); + }); + }); + + describe("submit — error handling", () => { + it("shows conflict error message for 409 responses", async () => { + mockMutateAsync.mockRejectedValue({ status: 409 }); + renderDrawer(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => { + expect(screen.getByText(/already exists/i)).toBeInTheDocument(); + }); + }); + + it("shows generic error for 400 responses", async () => { + mockMutateAsync.mockRejectedValue({ status: 400 }); + renderDrawer(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => { + expect(screen.getByText(/failed to update user/i)).toBeInTheDocument(); + }); + }); + + it("shows generic error for unexpected failures", async () => { + mockMutateAsync.mockRejectedValue(new Error("server error")); + renderDrawer(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => { + expect(screen.getByText(/failed to update user/i)).toBeInTheDocument(); + }); + }); + + it("renders error with role='alert'", async () => { + mockMutateAsync.mockRejectedValue(new Error("server error")); + renderDrawer(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + }); + + it("does not call onClose when update fails", async () => { + mockMutateAsync.mockRejectedValue(new Error("server error")); + const { onClose } = renderDrawer(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => screen.getByRole("alert")); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe("cancel", () => { + it("calls onClose when Cancel is clicked", async () => { + const { onClose } = renderDrawer(); + await userEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not call mutateAsync when Cancel is clicked", async () => { + renderDrawer(); + await userEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(mockMutateAsync).not.toHaveBeenCalled(); + }); + }); + + describe("state reset on reopen", () => { + it("reloads user data when drawer is closed then reopened", async () => { + const { rerender } = renderDrawer({ user: mockUser }); + + const nameInput = screen.getByLabelText(/^name$/i); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, "Changed Name"); + + rerender( + , + ); + rerender( + , + ); + + expect(screen.getByLabelText(/^name$/i)).toHaveValue("Alice Smith"); + }); + + it("clears any error when closed then reopened", async () => { + mockMutateAsync.mockRejectedValue(new Error("fail")); + const { rerender } = renderDrawer({ user: mockUser }); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + await waitFor(() => screen.getByRole("alert")); + + rerender( + , + ); + rerender( + , + ); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + }); + + describe("null user", () => { + it("renders the drawer with empty fields when user is null", () => { + renderDrawer({ user: null }); + expect(screen.getByLabelText(/^name$/i)).toHaveValue(""); + expect(screen.getByLabelText(/^username$/i)).toHaveValue(""); + expect(screen.getByLabelText(/^email$/i)).toHaveValue(""); + }); + }); +}); diff --git a/ui-react/apps/console/src/pages/admin/users/__tests__/ResetPasswordDialog.test.tsx b/ui-react/apps/console/src/pages/admin/users/__tests__/ResetPasswordDialog.test.tsx new file mode 100644 index 00000000000..fda6765dd94 --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/__tests__/ResetPasswordDialog.test.tsx @@ -0,0 +1,309 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ResetPasswordDialog from "../ResetPasswordDialog"; +import { useResetUserPassword } from "../../../../hooks/useAdminUserMutations"; + +vi.mock("../../../../hooks/useAdminUserMutations", () => ({ + useResetUserPassword: vi.fn(), +})); + +// BaseDialog renders open/close state; we flatten it to a simple div for test isolation. +vi.mock("../../../../components/common/BaseDialog", () => ({ + default: ({ + open, + onClose, + children, + }: { + open: boolean; + onClose: () => void; + children: React.ReactNode; + }) => { + if (!open) return null; + return ( +
+ + {children} +
+ ); + }, +})); + +vi.mock("../../../../components/common/CopyButton", () => ({ + default: ({ text }: { text: string }) => ( + + ), +})); + +const mockMutateAsync = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useResetUserPassword).mockReturnValue({ + mutateAsync: mockMutateAsync, + } as never); +}); + +function renderDialog( + overrides: Partial<{ + open: boolean; + onClose: () => void; + userId: string; + }> = {}, +) { + const defaults = { open: true, onClose: vi.fn(), userId: "user-123" }; + const props = { ...defaults, ...overrides }; + return { + onClose: props.onClose, + ...render(), + }; +} + +describe("ResetPasswordDialog", () => { + describe("rendering — closed", () => { + it("renders nothing when open is false", () => { + renderDialog({ open: false }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + describe("rendering — confirm step (initial)", () => { + it("renders the dialog when open is true", () => { + renderDialog(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("renders the 'Enable Local Authentication' heading", () => { + renderDialog(); + expect( + screen.getByText("Enable Local Authentication"), + ).toBeInTheDocument(); + }); + + it("renders the explanatory description text", () => { + renderDialog(); + expect(screen.getByText(/temporary password/i)).toBeInTheDocument(); + }); + + it("renders the Enable button", () => { + renderDialog(); + expect( + screen.getByRole("button", { name: /enable/i }), + ).toBeInTheDocument(); + }); + + it("renders the Cancel button", () => { + renderDialog(); + expect( + screen.getByRole("button", { name: /cancel/i }), + ).toBeInTheDocument(); + }); + + it("does not render the password result step content initially", () => { + renderDialog(); + expect(screen.queryByText("Password Generated")).not.toBeInTheDocument(); + }); + }); + + describe("cancel", () => { + it("calls onClose when Cancel is clicked", async () => { + const { onClose } = renderDialog(); + await userEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not call mutateAsync when Cancel is clicked", async () => { + renderDialog(); + await userEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(mockMutateAsync).not.toHaveBeenCalled(); + }); + }); + + describe("enable flow — success", () => { + it("calls mutateAsync with the correct userId when Enable is clicked", async () => { + mockMutateAsync.mockResolvedValue({ password: "gen-pass-123" }); + renderDialog({ userId: "user-abc" }); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => + expect(mockMutateAsync).toHaveBeenCalledWith({ + path: { id: "user-abc" }, + }), + ); + }); + + it("transitions to the result step after successful reset", async () => { + mockMutateAsync.mockResolvedValue({ password: "gen-pass-123" }); + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => { + expect(screen.getByText("Password Generated")).toBeInTheDocument(); + }); + }); + + it("displays the generated password in an input field", async () => { + mockMutateAsync.mockResolvedValue({ password: "s3cr3t-pw" }); + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => { + expect(screen.getByDisplayValue("s3cr3t-pw")).toBeInTheDocument(); + }); + }); + + it("renders the 'Generated password' labelled input", async () => { + mockMutateAsync.mockResolvedValue({ password: "abc" }); + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => { + expect( + screen.getByLabelText(/generated password/i), + ).toBeInTheDocument(); + }); + }); + + it("renders a Copy button on the result step", async () => { + mockMutateAsync.mockResolvedValue({ password: "abc" }); + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /copy/i }), + ).toBeInTheDocument(); + }); + }); + + it("renders a Close button on the result step", async () => { + mockMutateAsync.mockResolvedValue({ password: "abc" }); + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Close" }), + ).toBeInTheDocument(); + }); + }); + + it("calls onClose when Close is clicked on result step", async () => { + mockMutateAsync.mockResolvedValue({ password: "abc" }); + const { onClose } = renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + await waitFor(() => screen.getByText("Password Generated")); + await userEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe("enable flow — error states", () => { + it("shows specific error message for status 400 (user already has password)", async () => { + mockMutateAsync.mockRejectedValue({ status: 400 }); + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => { + expect( + screen.getByText(/already has a local password/i), + ).toBeInTheDocument(); + }); + }); + + it("shows generic error message for non-400 errors", async () => { + mockMutateAsync.mockRejectedValue({ status: 500 }); + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => { + expect(screen.getByText(/failed to set password/i)).toBeInTheDocument(); + }); + }); + + it("shows generic error for non-SDK errors", async () => { + mockMutateAsync.mockRejectedValue(new Error("network error")); + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => { + expect(screen.getByText(/failed to set password/i)).toBeInTheDocument(); + }); + }); + + it("renders error with role='alert'", async () => { + mockMutateAsync.mockRejectedValue({ status: 500 }); + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + }); + + it("stays on the confirm step when there is an error", async () => { + mockMutateAsync.mockRejectedValue({ status: 500 }); + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => { + expect(screen.getByText(/failed to set password/i)).toBeInTheDocument(); + }); + expect(screen.queryByText("Password Generated")).not.toBeInTheDocument(); + }); + + it("clears error and stays on confirm step — Enable button is still visible", async () => { + mockMutateAsync.mockRejectedValue({ status: 500 }); + renderDialog(); + + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + + await waitFor(() => screen.getByRole("alert")); + // The Enable button should still be present so the user can retry + expect( + screen.getByRole("button", { name: /enable/i }), + ).toBeInTheDocument(); + }); + }); + + describe("state reset on reopen", () => { + it("resets to confirm step when dialog is closed then reopened", async () => { + mockMutateAsync.mockResolvedValue({ password: "pw" }); + const { rerender } = renderDialog({ userId: "u1" }); + + // Move to result step + await userEvent.click(screen.getByRole("button", { name: /enable/i })); + await waitFor(() => screen.getByText("Password Generated")); + + // Close and reopen + rerender( + , + ); + rerender( + , + ); + + // Should be back on confirm step + expect( + screen.getByText("Enable Local Authentication"), + ).toBeInTheDocument(); + expect(screen.queryByText("Password Generated")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/ui-react/apps/console/src/pages/admin/users/__tests__/UserStatusChip.test.tsx b/ui-react/apps/console/src/pages/admin/users/__tests__/UserStatusChip.test.tsx new file mode 100644 index 00000000000..b17e173f2ce --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/__tests__/UserStatusChip.test.tsx @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import UserStatusChip from "../UserStatusChip"; + +describe("UserStatusChip", () => { + it("renders 'Confirmed' for confirmed status", () => { + render(); + expect(screen.getByText("Confirmed")).toBeInTheDocument(); + }); + + it("renders 'Not Confirmed' for not-confirmed status", () => { + render(); + expect(screen.getByText("Not Confirmed")).toBeInTheDocument(); + }); +}); diff --git a/ui-react/apps/console/src/pages/admin/users/__tests__/mocks.tsx b/ui-react/apps/console/src/pages/admin/users/__tests__/mocks.tsx new file mode 100644 index 00000000000..2ad93963212 --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/__tests__/mocks.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +export const MockDrawer = ({ + open, + onClose, + title, + children, + footer, +}: { + open: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + footer?: React.ReactNode; +}) => { + if (!open) return null; + return ( +
+

{title}

+ +
{children}
+ {footer &&
{footer as React.ReactNode}
} +
+ ); +}; diff --git a/ui-react/apps/console/src/pages/admin/users/index.tsx b/ui-react/apps/console/src/pages/admin/users/index.tsx new file mode 100644 index 00000000000..5d1fca25a74 --- /dev/null +++ b/ui-react/apps/console/src/pages/admin/users/index.tsx @@ -0,0 +1,269 @@ +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { + UsersIcon, + PlusIcon, + MagnifyingGlassIcon, + PencilSquareIcon, + TrashIcon, + ArrowRightStartOnRectangleIcon, + ExclamationCircleIcon, +} from "@heroicons/react/24/outline"; +import { useAdminUsers } from "../../../hooks/useAdminUsers"; +import { useLoginAsUser } from "../../../hooks/useLoginAsUser"; +import type { UserAdminResponse } from "../../../client"; +import PageHeader from "../../../components/common/PageHeader"; +import Pagination from "../../../components/common/Pagination"; +import UserStatusChip from "./UserStatusChip"; +import CreateUserDrawer from "./CreateUserDrawer"; +import EditUserDrawer from "./EditUserDrawer"; +import DeleteUserDialog from "./DeleteUserDialog"; +import { TH as TH_BASE } from "../../../utils/styles"; + +const TH = `${TH_BASE} whitespace-nowrap`; +const PER_PAGE = 10; +const SEARCH_DEBOUNCE_MS = 300; + +export default function AdminUsers() { + const navigate = useNavigate(); + const [page, setPage] = useState(1); + const [searchInput, setSearchInput] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + const { + loginAs, + loadingId: loginAsId, + errorId: loginAsError, + } = useLoginAsUser(); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchInput); + setPage(1); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [searchInput]); + + const { users, totalCount, isLoading, error } = useAdminUsers({ + page, + perPage: PER_PAGE, + search: debouncedSearch, + }); + + const totalPages = Math.ceil(totalCount / PER_PAGE); + + return ( +
+ } + overline="Account Management" + title="Users" + description="Manage all user accounts in the instance" + > + + + + {/* Search */} +
+ + setSearchInput(e.target.value)} + placeholder="Search by username..." + aria-label="Search users by username" + className="h-full pl-9 pr-3 bg-card border border-border rounded-md text-xs text-text-primary font-mono placeholder:text-text-secondary focus:outline-none focus:border-primary/40 focus:ring-1 focus:ring-primary/15 transition-all duration-200 w-56" + /> +
+ + {error && ( +
+ + {error.message} +
+ )} + + {/* Table */} +
+
+ + + + + + + + + + + + {isLoading && users.length === 0 ? ( + + + + ) : users.length === 0 ? ( + + + + ) : ( + users.map((user) => ( + void navigate(`/admin/users/${user.id}`)} + className="group hover:bg-hover-subtle transition-colors cursor-pointer" + > + + + + + + + )) + )} + +
NameEmailUsernameStatusActions
+
+ + + Loading users... + +
+
+ +

+ {debouncedSearch + ? `No users matching "${debouncedSearch}"` + : "No users found"} +

+
+
+ + {user.name} + + {user.admin && ( + + Admin + + )} +
+
+ + {user.email} + + + + {user.username} + + + + +
+ + + +
+
+
+
+ + + + {/* Create User Drawer */} + setCreateOpen(false)} + /> + + {/* Edit User Drawer */} + setEditTarget(null)} + user={editTarget} + /> + + {/* Delete Confirmation */} + setDeleteTarget(null)} + user={deleteTarget} + /> +
+ ); +}