forked from baron/baron-sso
test: raise frontend coverage baselines
This commit is contained in:
@@ -19,7 +19,6 @@ import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage"
|
||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
|
||||
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
||||
import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage";
|
||||
import UserCreatePage from "../features/users/UserCreatePage";
|
||||
import UserDetailPage from "../features/users/UserDetailPage";
|
||||
import UserListPage from "../features/users/UserListPage";
|
||||
|
||||
145
adminfront/src/components/layout/AppLayout.test.tsx
Normal file
145
adminfront/src/components/layout/AppLayout.test.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import AppLayout from "./AppLayout";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 120,
|
||||
profile: {
|
||||
sub: "admin-1",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
},
|
||||
},
|
||||
signinSilent: vi.fn(async () => undefined),
|
||||
removeUser: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-1",
|
||||
name: "Fetched Admin",
|
||||
email: "fetched@example.com",
|
||||
role: "super_admin",
|
||||
tenantId: "tenant-1",
|
||||
manageableTenants: [
|
||||
{
|
||||
id: "tenant-1",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
type: "COMPANY",
|
||||
},
|
||||
{
|
||||
id: "tenant-2",
|
||||
name: "기술연구팀",
|
||||
slug: "gpdtdc-rnd",
|
||||
type: "ORGANIZATION",
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderLayout(entry = "/users") {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route path="users" element={<div>Users outlet</div>} />
|
||||
<Route path="users/:id" element={<div>User detail outlet</div>} />
|
||||
<Route
|
||||
path="tenants/:tenantId"
|
||||
element={<div>Tenant outlet</div>}
|
||||
/>
|
||||
<Route path="login" element={<div>Login outlet</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin AppLayout", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
authState.isAuthenticated = true;
|
||||
authState.isLoading = false;
|
||||
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
|
||||
authState.signinSilent.mockClear();
|
||||
authState.removeUser.mockClear();
|
||||
window.localStorage.clear();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("renders admin navigation, fetched profile, and outlet content", async () => {
|
||||
renderLayout();
|
||||
|
||||
expect(await screen.findByText("Fetched Admin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Admin Control")).toBeInTheDocument();
|
||||
expect(screen.getByText("Users outlet")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tenants")).toBeInTheDocument();
|
||||
expect(screen.getByText("Data Integrity")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
|
||||
renderLayout();
|
||||
|
||||
const themeButton = await screen.findByRole("button", {
|
||||
name: "테마 전환",
|
||||
});
|
||||
fireEvent.click(themeButton);
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
|
||||
expect(screen.getByText("Manageable Tenants")).toBeInTheDocument();
|
||||
|
||||
const sessionSwitch = screen.getByRole("switch");
|
||||
fireEvent.click(sessionSwitch);
|
||||
expect(window.localStorage.getItem("baron_session_expiry_enabled")).toBe(
|
||||
"false",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("기술연구팀"));
|
||||
expect(await screen.findByText("Tenant outlet")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
|
||||
fireEvent.click(screen.getAllByText("내 정보")[0]);
|
||||
expect(await screen.findByText("User detail outlet")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
|
||||
fireEvent.click(screen.getAllByText("Logout")[1]);
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(authState.removeUser).toHaveBeenCalled();
|
||||
}, 10_000);
|
||||
|
||||
it("attempts silent renewal on user activity when session is near expiry", async () => {
|
||||
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
|
||||
|
||||
renderLayout();
|
||||
await screen.findByText("Fetched Admin");
|
||||
fireEvent.keyDown(window, { key: "Tab" });
|
||||
|
||||
expect(authState.signinSilent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -148,11 +148,7 @@ function AppLayout() {
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||
);
|
||||
const {
|
||||
data: profile,
|
||||
isLoading: isProfileLoading,
|
||||
error: profileError,
|
||||
} = useQuery({
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: async () => {
|
||||
debugLog("[AppLayout] Fetching profile...");
|
||||
|
||||
49
adminfront/src/components/ui/avatar.test.tsx
Normal file
49
adminfront/src/components/ui/avatar.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
const render = async (element: React.ReactElement) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(element);
|
||||
});
|
||||
return root;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (container) {
|
||||
container.remove();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("Avatar", () => {
|
||||
it("renders image and fallback with merged classes", async () => {
|
||||
const root = await render(
|
||||
<Avatar className="custom-root" data-testid="avatar">
|
||||
<AvatarImage
|
||||
alt="Admin user"
|
||||
className="custom-image"
|
||||
src="/avatar.png"
|
||||
/>
|
||||
<AvatarFallback className="custom-fallback">AU</AvatarFallback>
|
||||
</Avatar>,
|
||||
);
|
||||
|
||||
const avatar = container?.querySelector("[data-testid='avatar']");
|
||||
const fallback = container?.textContent;
|
||||
|
||||
expect(avatar?.className).toContain("custom-root");
|
||||
expect(fallback).toContain("AU");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
41
adminfront/src/components/ui/separator.test.tsx
Normal file
41
adminfront/src/components/ui/separator.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Separator } from "./separator";
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
const render = async (element: React.ReactElement) => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(element);
|
||||
});
|
||||
return root;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (container) {
|
||||
container.remove();
|
||||
container = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("Separator", () => {
|
||||
it("renders a horizontal separator with custom classes", async () => {
|
||||
const root = await render(
|
||||
<Separator className="custom-separator" data-testid="separator" />,
|
||||
);
|
||||
|
||||
const separator = container?.querySelector("[data-testid='separator']");
|
||||
|
||||
expect(separator?.className).toContain("h-px");
|
||||
expect(separator?.className).toContain("custom-separator");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import type { AxiosError } from "axios";
|
||||
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
formatAuditValue,
|
||||
parseAuditDetails,
|
||||
resolveAuditAction,
|
||||
resolveAuditActor,
|
||||
|
||||
175
adminfront/src/features/coverage/adminAuditAuth.test.tsx
Normal file
175
adminfront/src/features/coverage/adminAuditAuth.test.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import AuditLogsPage from "../audit/AuditLogsPage";
|
||||
import AuthCallbackPage from "../auth/AuthCallbackPage";
|
||||
import AuthGuard from "../auth/AuthGuard";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
activeNavigator: undefined as string | undefined,
|
||||
error: null as Error | null,
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
state: undefined as unknown,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchAuditLogs: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
event_id: "event-1",
|
||||
timestamp: "2026-05-01T00:00:00Z",
|
||||
user_id: "admin-1",
|
||||
event_type: "USER_UPDATE",
|
||||
status: "success",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "Vitest",
|
||||
details: JSON.stringify({ action: "USER_UPDATE", actor: "Admin" }),
|
||||
},
|
||||
{
|
||||
event_id: "event-2",
|
||||
timestamp: "2026-05-01T01:00:00Z",
|
||||
user_id: "admin-2",
|
||||
event_type: "LOGIN_FAILED",
|
||||
status: "failure",
|
||||
ip_address: "127.0.0.2",
|
||||
user_agent: "Vitest",
|
||||
details: "{}",
|
||||
},
|
||||
],
|
||||
limit: 50,
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin audit and auth coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = false;
|
||||
authState.isAuthenticated = true;
|
||||
authState.isLoading = false;
|
||||
authState.activeNavigator = undefined;
|
||||
authState.error = null;
|
||||
authState.user = {
|
||||
access_token: "access-token",
|
||||
state: undefined,
|
||||
};
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders audit log table with fetched events", async () => {
|
||||
renderWithProviders(<AuditLogsPage />);
|
||||
|
||||
expect(await screen.findByText("감사 로그")).toBeInTheDocument();
|
||||
expect(screen.getByText("admin-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("USER_UPDATE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders AuthGuard loading, error, redirect, test, and outlet states", async () => {
|
||||
authState.isLoading = true;
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/secure" element={<AuthGuard />}>
|
||||
<Route index element={<div>Secure outlet</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
"/secure",
|
||||
);
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
|
||||
authState.isLoading = false;
|
||||
authState.error = new Error("OIDC failed");
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/secure" element={<AuthGuard />}>
|
||||
<Route index element={<div>Secure outlet</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
"/secure",
|
||||
);
|
||||
expect(screen.getByText("인증 오류")).toBeInTheDocument();
|
||||
|
||||
authState.error = null;
|
||||
authState.isAuthenticated = false;
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/secure" element={<AuthGuard />}>
|
||||
<Route index element={<div>Secure outlet</div>} />
|
||||
</Route>
|
||||
<Route path="/login" element={<div>Login outlet</div>} />
|
||||
</Routes>,
|
||||
"/secure?x=1",
|
||||
);
|
||||
expect(screen.getByText("Login outlet")).toBeInTheDocument();
|
||||
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/secure" element={<AuthGuard />}>
|
||||
<Route index element={<div>Secure outlet</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
"/secure",
|
||||
);
|
||||
expect(screen.getByText("Secure outlet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stores callback token and navigates by auth result", async () => {
|
||||
authState.isAuthenticated = true;
|
||||
authState.user = {
|
||||
access_token: "callback-token",
|
||||
state: { returnTo: "/users" },
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route path="/users" element={<div>Users outlet</div>} />
|
||||
<Route path="/login" element={<div>Login outlet</div>} />
|
||||
</Routes>,
|
||||
"/auth/callback",
|
||||
);
|
||||
expect(await screen.findByText("Users outlet")).toBeInTheDocument();
|
||||
expect(window.localStorage.getItem("admin_session")).toBe("callback-token");
|
||||
|
||||
authState.isAuthenticated = false;
|
||||
authState.error = new Error("callback failed");
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route path="/login" element={<div>Login outlet</div>} />
|
||||
</Routes>,
|
||||
"/auth/callback",
|
||||
);
|
||||
expect(await screen.findByText("Login outlet")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
297
adminfront/src/features/coverage/adminLargePages.test.tsx
Normal file
297
adminfront/src/features/coverage/adminLargePages.test.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import { TenantWorksmobilePage } from "../tenants/routes/TenantWorksmobilePage";
|
||||
import TenantListPage from "../tenants/routes/TenantListPage";
|
||||
import UserCreatePage from "../users/UserCreatePage";
|
||||
import UserDetailPage from "../users/UserDetailPage";
|
||||
|
||||
const tenantItems = [
|
||||
{
|
||||
id: "tenant-root",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
description: "root",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-company",
|
||||
type: "COMPANY",
|
||||
parentId: "tenant-root",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
description: "company",
|
||||
status: "active",
|
||||
memberCount: 2,
|
||||
config: {
|
||||
userSchema: [
|
||||
{
|
||||
key: "employee_id",
|
||||
label: "사번",
|
||||
type: "text",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-leaf",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "tenant-company",
|
||||
name: "기술연구팀",
|
||||
slug: "gpdtdc-rnd",
|
||||
description: "leaf",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const userDetail = {
|
||||
id: "user-1",
|
||||
email: "engineer@example.com",
|
||||
name: "Engineer User",
|
||||
phone: "010-0000-0000",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "gpdtdc-rnd",
|
||||
tenantId: "tenant-leaf",
|
||||
department: "기술연구팀",
|
||||
grade: "책임",
|
||||
position: "팀장",
|
||||
jobTitle: "Backend",
|
||||
metadata: {
|
||||
employee_id: "EMP001",
|
||||
sub_email: ["engineer.sub@example.com"],
|
||||
},
|
||||
tenant: tenantItems[2],
|
||||
appointments: [
|
||||
{
|
||||
tenantId: "tenant-leaf",
|
||||
tenantSlug: "gpdtdc-rnd",
|
||||
tenantName: "기술연구팀",
|
||||
isPrimary: true,
|
||||
isOwner: false,
|
||||
isAdmin: false,
|
||||
isManager: true,
|
||||
department: "기술연구팀",
|
||||
grade: "책임",
|
||||
position: "팀장",
|
||||
jobTitle: "Backend",
|
||||
metadata: { employee_id: "EMP001" },
|
||||
},
|
||||
],
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-02T00:00:00Z",
|
||||
};
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../components/auth/RoleGuard", () => ({
|
||||
RoleGuard: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-1",
|
||||
role: "super_admin",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: tenantItems,
|
||||
total: tenantItems.length,
|
||||
})),
|
||||
fetchTenants: vi.fn(async () => ({
|
||||
items: tenantItems,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
total: tenantItems.length,
|
||||
nextCursor: null,
|
||||
})),
|
||||
fetchTenant: vi.fn(async (id: string) => {
|
||||
return tenantItems.find((tenant) => tenant.id === id) ?? tenantItems[1];
|
||||
}),
|
||||
createUser: vi.fn(async () => ({
|
||||
id: "created-user",
|
||||
email: "created@example.com",
|
||||
generatedPassword: "GeneratedPassword!1",
|
||||
})),
|
||||
fetchUser: vi.fn(async () => userDetail),
|
||||
fetchUserRpHistory: vi.fn(async () => [
|
||||
{
|
||||
client_id: "orgfront",
|
||||
client_name: "OrgFront",
|
||||
last_login_at: "2026-05-01T00:00:00Z",
|
||||
login_count: 3,
|
||||
},
|
||||
]),
|
||||
fetchPasswordPolicy: vi.fn(async () => ({
|
||||
minLength: 12,
|
||||
lowercase: true,
|
||||
uppercase: true,
|
||||
number: true,
|
||||
nonAlphanumeric: true,
|
||||
minCharacterTypes: 3,
|
||||
})),
|
||||
updateUser: vi.fn(async () => userDetail),
|
||||
deleteUser: vi.fn(async () => undefined),
|
||||
updateTenant: vi.fn(async () => tenantItems[1]),
|
||||
deleteTenantsBulk: vi.fn(async () => ({ deleted: 1 })),
|
||||
exportTenantsCSV: vi.fn(async () => new Blob(["name,slug\nGPDTDC,gpdtdc"])),
|
||||
importTenantsCSV: vi.fn(async () => ({
|
||||
created: 1,
|
||||
updated: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
})),
|
||||
fetchWorksmobileOverview: vi.fn(async () => ({
|
||||
tenant: tenantItems[1],
|
||||
config: {
|
||||
enabled: true,
|
||||
tokenConfigured: true,
|
||||
adminTenantId: "works-admin",
|
||||
domainMappings: { "example.com": 1001 },
|
||||
},
|
||||
recentJobs: [
|
||||
{
|
||||
id: "job-1",
|
||||
resourceType: "USER",
|
||||
resourceId: "user-1",
|
||||
action: "SYNC",
|
||||
status: "failed",
|
||||
retryCount: 1,
|
||||
lastError: "temporary failure",
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:10:00Z",
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchWorksmobileComparison: vi.fn(async () => ({
|
||||
users: [
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-1",
|
||||
baronName: "Engineer User",
|
||||
baronEmail: "engineer@example.com",
|
||||
baronPrimaryOrgId: "tenant-leaf",
|
||||
baronPrimaryOrgName: "기술연구팀",
|
||||
worksmobileId: "works-user-1",
|
||||
worksmobileName: "Engineer User",
|
||||
worksmobileEmail: "engineer@example.com",
|
||||
worksmobilePrimaryOrgId: "works-org-1",
|
||||
worksmobilePrimaryOrgName: "기술연구팀",
|
||||
status: "matched",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-2",
|
||||
baronName: "New User",
|
||||
baronEmail: "new@example.com",
|
||||
status: "baron_only",
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
resourceType: "ORG_UNIT",
|
||||
baronId: "tenant-leaf",
|
||||
baronSlug: "gpdtdc-rnd",
|
||||
baronName: "기술연구팀",
|
||||
worksmobileId: "works-org-1",
|
||||
worksmobileName: "기술연구팀",
|
||||
status: "needs_update",
|
||||
},
|
||||
],
|
||||
})),
|
||||
enqueueWorksmobileBackfillDryRun: vi.fn(async () => ({ id: "job-dry" })),
|
||||
retryWorksmobileJob: vi.fn(async () => ({ id: "job-retry" })),
|
||||
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => new Blob(["id"])),
|
||||
enqueueWorksmobileOrgUnitSync: vi.fn(async () => ({ id: "job-org" })),
|
||||
enqueueWorksmobileOrgUnitDelete: vi.fn(async () => ({ id: "job-delete" })),
|
||||
enqueueWorksmobileUserSync: vi.fn(async () => ({ id: "job-user" })),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("adminfront large page coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders user creation form with tenant context", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/users/new" element={<UserCreatePage />} />
|
||||
</Routes>,
|
||||
"/users/new?tenantSlug=gpdtdc-rnd",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("사용자 추가")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders user detail form and RP history", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/users/:id" element={<UserDetailPage />} />
|
||||
</Routes>,
|
||||
"/users/user-1",
|
||||
);
|
||||
|
||||
expect(await screen.findByDisplayValue("Engineer User")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
|
||||
expect(screen.getByDisplayValue("engineer@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant list hierarchy", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/tenants" element={<TenantListPage />} />
|
||||
</Routes>,
|
||||
"/tenants",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("GPDTDC")).toBeInTheDocument();
|
||||
expect(screen.getByText("기술연구팀")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders worksmobile comparison screens", async () => {
|
||||
cleanup();
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Worksmobile 연동")).toBeInTheDocument();
|
||||
expect(screen.getByText("Baron / Works 비교")).toBeInTheDocument();
|
||||
expect(screen.getByText("Backfill Dry-run")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
129
adminfront/src/features/coverage/adminTenantDetailPages.test.tsx
Normal file
129
adminfront/src/features/coverage/adminTenantDetailPages.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import TenantCreatePage from "../tenants/routes/TenantCreatePage";
|
||||
import { TenantProfilePage } from "../tenants/routes/TenantProfilePage";
|
||||
import { TenantSchemaPage } from "../tenants/routes/TenantSchemaPage";
|
||||
|
||||
const tenants = [
|
||||
{
|
||||
id: "tenant-root",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
domains: ["hmac.kr"],
|
||||
config: { visibility: "public" },
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-company",
|
||||
type: "COMPANY",
|
||||
parentId: "tenant-root",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
description: "실 조직",
|
||||
status: "active",
|
||||
memberCount: 2,
|
||||
domains: ["gpdtdc.example.com"],
|
||||
config: {
|
||||
visibility: "public",
|
||||
userSchema: [
|
||||
{
|
||||
key: "employee_id",
|
||||
label: "사번",
|
||||
type: "text",
|
||||
required: false,
|
||||
adminOnly: false,
|
||||
isLoginId: true,
|
||||
indexed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-1",
|
||||
role: "super_admin",
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: tenants,
|
||||
total: tenants.length,
|
||||
})),
|
||||
fetchTenant: vi.fn(async (id: string) => {
|
||||
return tenants.find((tenant) => tenant.id === id) ?? tenants[1];
|
||||
}),
|
||||
createTenant: vi.fn(async () => tenants[1]),
|
||||
updateTenant: vi.fn(async () => tenants[1]),
|
||||
deleteTenant: vi.fn(async () => undefined),
|
||||
approveTenant: vi.fn(async () => tenants[1]),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin tenant detail page coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("renders tenant create page with parent context", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/tenants/new" element={<TenantCreatePage />} />
|
||||
</Routes>,
|
||||
"/tenants/new?parentId=tenant-root",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("테넌트 생성")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tenant Profile")).toBeInTheDocument();
|
||||
expect(screen.getByText("정책 메모")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant profile and schema management pages", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId"
|
||||
element={
|
||||
<>
|
||||
<TenantProfilePage />
|
||||
<TenantSchemaPage />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company",
|
||||
);
|
||||
|
||||
expect(await screen.findByDisplayValue("GPDTDC")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("gpdtdc")).toBeInTheDocument();
|
||||
expect(await screen.findByText("사용자 스키마 확장")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("employee_id")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
116
adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx
Normal file
116
adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import TenantGroupsPage from "../tenants/routes/TenantGroupsPage";
|
||||
|
||||
const tenant = {
|
||||
id: "tenant-company",
|
||||
type: "COMPANY",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 2,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const members = [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Member User",
|
||||
email: "member@example.com",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchTenant: vi.fn(async () => tenant),
|
||||
fetchUsers: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Member User",
|
||||
email: "member@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
name: "Candidate User",
|
||||
email: "candidate@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
})),
|
||||
fetchGroups: vi.fn(async () => [
|
||||
{
|
||||
id: "group-root",
|
||||
tenantId: "tenant-company",
|
||||
name: "연구소",
|
||||
description: "root group",
|
||||
members,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "group-child",
|
||||
tenantId: "tenant-company",
|
||||
parentId: "group-root",
|
||||
name: "플랫폼팀",
|
||||
description: "child group",
|
||||
members: [],
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
createGroup: vi.fn(async () => undefined),
|
||||
deleteGroup: vi.fn(async () => undefined),
|
||||
addGroupMember: vi.fn(async () => undefined),
|
||||
removeGroupMember: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TenantGroupsPage coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("renders group hierarchy and selected group members", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/groups"
|
||||
element={<TenantGroupsPage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/groups",
|
||||
);
|
||||
|
||||
expect((await screen.findAllByText("연구소")).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("플랫폼팀").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("새 그룹 생성")).toBeInTheDocument();
|
||||
expect(screen.getByText("조직 단위 레벨")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
162
adminfront/src/features/coverage/adminTenantTabs.test.tsx
Normal file
162
adminfront/src/features/coverage/adminTenantTabs.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
|
||||
|
||||
const tenants = [
|
||||
{
|
||||
id: "tenant-root",
|
||||
type: "COMPANY_GROUP",
|
||||
name: "한맥 가족",
|
||||
slug: "hanmac-family",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-company",
|
||||
type: "COMPANY",
|
||||
parentId: "tenant-root",
|
||||
name: "GPDTDC",
|
||||
slug: "gpdtdc",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 2,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-leaf",
|
||||
type: "ORGANIZATION",
|
||||
parentId: "tenant-company",
|
||||
name: "기술연구팀",
|
||||
slug: "gpdtdc-rnd",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-05-01T00:00:00Z",
|
||||
updatedAt: "2026-05-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const users = [
|
||||
{
|
||||
id: "user-owner",
|
||||
name: "Owner User",
|
||||
email: "owner@example.com",
|
||||
role: "tenant_admin",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "user-admin",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
role: "tenant_admin",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "user-member",
|
||||
name: "Member User",
|
||||
email: "member@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "gpdtdc-rnd",
|
||||
tenant: tenants[2],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
profile: {
|
||||
sub: "admin-1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchTenantOwners: vi.fn(async () => [users[0]]),
|
||||
fetchTenantAdmins: vi.fn(async () => [users[1]]),
|
||||
addTenantOwner: vi.fn(async () => undefined),
|
||||
addTenantAdmin: vi.fn(async () => undefined),
|
||||
removeTenantOwner: vi.fn(async () => undefined),
|
||||
removeTenantAdmin: vi.fn(async () => undefined),
|
||||
fetchUsers: vi.fn(async () => ({
|
||||
items: users,
|
||||
total: users.length,
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: tenants,
|
||||
total: tenants.length,
|
||||
})),
|
||||
updateTenant: vi.fn(async () => tenants[2]),
|
||||
updateUser: vi.fn(async () => users[2]),
|
||||
exportTenantsCSV: vi.fn(async () => ({
|
||||
blob: new Blob(["name,slug"]),
|
||||
filename: "tenants.csv",
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry: string) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("admin tenant tab coverage smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("renders tenant owners and admins lists", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/permissions"
|
||||
element={<TenantAdminsAndOwnersTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/permissions",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Owner User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Admin User")).toBeInTheDocument();
|
||||
expect(screen.getByText("owner@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant hierarchy and selected organization members", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/organization"
|
||||
element={<TenantUserGroupsTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/organization",
|
||||
);
|
||||
|
||||
expect((await screen.findAllByText("GPDTDC")).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
|
||||
expect(await screen.findByText("Member User")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
fetchDataIntegrityReport,
|
||||
type RPUsageDailyMetric,
|
||||
type RPUsagePeriod,
|
||||
type TenantSummary,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
@@ -68,7 +66,7 @@ function mergePendingMembers(
|
||||
export function TenantAdminsAndOwnersTab() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const currentUserId = auth.user?.profile.sub;
|
||||
const _currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||
const tenantId = tenantIdParam ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
@@ -187,7 +185,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
onError: (err: AxiosError<{ error?: string }>, _userId, context) => {
|
||||
if (context?.previousOwners) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-owners", tenantId],
|
||||
@@ -288,7 +286,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
|
||||
);
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||
onError: (err: AxiosError<{ error?: string }>, _userId, context) => {
|
||||
if (context?.previousAdmins) {
|
||||
queryClient.setQueryData(
|
||||
["tenant-admins", tenantId],
|
||||
@@ -310,7 +308,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveOwner = (userId: string, userName: string) => {
|
||||
const _handleRemoveOwner = (userId: string, userName: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
@@ -324,7 +322,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
const _handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
@@ -239,7 +238,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
function TenantGroupsPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
const _queryClient = useQueryClient();
|
||||
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileSpreadsheet,
|
||||
LayoutDashboard,
|
||||
List,
|
||||
@@ -28,22 +26,13 @@ import {
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../../common/core/components/page";
|
||||
import {
|
||||
SortableTableHead,
|
||||
sortableTableHeadBaseClassName,
|
||||
sortableTableHeaderClassName,
|
||||
} from "../../../../../common/core/components/sort";
|
||||
import {
|
||||
type SortConfig,
|
||||
type SortResolverMap,
|
||||
sortItems,
|
||||
toggleSort,
|
||||
} from "../../../../../common/core/utils";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../../common/ui/table";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
@@ -71,7 +60,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -79,7 +67,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../../components/ui/select";
|
||||
import { Separator } from "../../../components/ui/separator";
|
||||
import { Switch } from "../../../components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
@@ -92,7 +79,6 @@ import {
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import type { UserProfileResponse } from "../../../lib/adminApi";
|
||||
import {
|
||||
deleteTenant,
|
||||
deleteTenantsBulk,
|
||||
exportTenantsCSV,
|
||||
fetchMe,
|
||||
@@ -132,13 +118,8 @@ import {
|
||||
const tenantCSVTemplate =
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
|
||||
const tenantPageSize = 500;
|
||||
const tenantVirtualizationThreshold = 250;
|
||||
const tenantEstimatedRowHeight = 73;
|
||||
const tenantLoadAheadPx = 360;
|
||||
const tenantLoadAheadRows = 30;
|
||||
|
||||
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
|
||||
type TenantListRow = TenantSummary & { recursiveMemberCount: number };
|
||||
|
||||
const getTenantIcon = (type?: string) => {
|
||||
switch (type?.toUpperCase()) {
|
||||
@@ -301,7 +282,7 @@ function TenantListPage() {
|
||||
>({});
|
||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState("");
|
||||
const tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const _tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
@@ -340,13 +321,6 @@ function TenantListPage() {
|
||||
(profile?.manageableTenants?.length ?? 0) > 1),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (tenantId: string) => deleteTenant(tenantId),
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteBulkMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
|
||||
onSuccess: () => {
|
||||
@@ -725,25 +699,6 @@ function TenantListPage() {
|
||||
importMutation.mutate(file);
|
||||
};
|
||||
|
||||
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||
const tenant = allTenants.find((item) => item.id === tenantId);
|
||||
if (tenant && isSeedTenant(tenant)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.tenants.delete_confirm",
|
||||
'테넌트 "{{name}}"를 삭제할까요?',
|
||||
{ name: tenantName },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(tenantId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<PageHeader
|
||||
@@ -962,8 +917,6 @@ function TenantListPage() {
|
||||
selectedIds={selectedIds}
|
||||
onSelect={handleSelect}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDelete={handleDelete}
|
||||
isDeletePending={deleteMutation.isPending}
|
||||
search={search}
|
||||
deletableTenants={deletableTenants}
|
||||
statusMutation={statusMutation}
|
||||
@@ -1347,8 +1300,6 @@ const TenantHierarchyView: React.FC<{
|
||||
selectedIds: string[];
|
||||
onSelect: (tenant: TenantSummary, checked: boolean) => void;
|
||||
onSelectAll: (checked: boolean) => void;
|
||||
onDelete: (tenantId: string, tenantName: string) => void;
|
||||
isDeletePending: boolean;
|
||||
search: string;
|
||||
deletableTenants: TenantSummary[];
|
||||
statusMutation: UseMutationResult<
|
||||
@@ -1368,8 +1319,6 @@ const TenantHierarchyView: React.FC<{
|
||||
selectedIds,
|
||||
onSelect,
|
||||
onSelectAll,
|
||||
onDelete,
|
||||
isDeletePending,
|
||||
search,
|
||||
deletableTenants,
|
||||
statusMutation,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, Building2, Plus } from "lucide-react";
|
||||
import { Building2, Plus } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
Loader2,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
User,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { Loader2, Mail, Plus, User, UserPlus } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
@@ -19,12 +11,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -80,7 +66,7 @@ function TenantUsersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemoveMember = (userId: string, userName: string) => {
|
||||
const _handleRemoveMember = (userId: string, userName: string) => {
|
||||
if (!tenantSlug) return;
|
||||
if (
|
||||
window.confirm(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Building2, Plus, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CornerDownRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -52,7 +50,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
@@ -62,15 +59,8 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../../components/ui/tabs";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
createUser,
|
||||
exportTenantsCSV,
|
||||
fetchAllTenants,
|
||||
fetchUsers,
|
||||
@@ -413,7 +403,7 @@ const MemberTable: React.FC<{
|
||||
|
||||
function TenantUserGroupsTab() {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const _navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string>(tenantId || "");
|
||||
@@ -452,7 +442,7 @@ function TenantUserGroupsTab() {
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
const { currentBase, subTree } = useMemo(() => {
|
||||
const { currentBase } = useMemo(() => {
|
||||
const allItems = allTenantsData?.items ?? [];
|
||||
return buildTenantFullTree(allItems, tenantId);
|
||||
}, [allTenantsData, tenantId]);
|
||||
@@ -855,7 +845,7 @@ const UserAddDialog: React.FC<{
|
||||
try {
|
||||
const res = await fetchUsers(20, 0, userSearch);
|
||||
setSearchResults(res.items);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toast.error(t("msg.admin.users.list.fetch_error", "사용자 검색 실패"));
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Mail,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Checkbox } from "../../components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -184,7 +182,7 @@ function UserCreatePage() {
|
||||
if (e.key === "Enter" || e.key === "," || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const value = newSubEmail.trim().replace(/,/g, "");
|
||||
if (value && value.includes("@") && !currentSubEmails.includes(value)) {
|
||||
if (value?.includes("@") && !currentSubEmails.includes(value)) {
|
||||
setValue("metadata.sub_email", [...currentSubEmails, value], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
@@ -667,8 +665,7 @@ function UserCreatePage() {
|
||||
onClick={() => {
|
||||
const value = newSubEmail.trim().replace(/,/g, "");
|
||||
if (
|
||||
value &&
|
||||
value.includes("@") &&
|
||||
value?.includes("@") &&
|
||||
!currentSubEmails.includes(value)
|
||||
) {
|
||||
setValue(
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Checkbox } from "../../components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -95,8 +94,8 @@ import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||
|
||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||
email: string;
|
||||
metadata: Record<string, Record<string, string | number | boolean>> & {
|
||||
sub_email?: string[];
|
||||
metadata: Record<string, unknown> & {
|
||||
sub_email?: string | string[];
|
||||
};
|
||||
};
|
||||
type UserCategory = "hanmac" | "external" | "personal";
|
||||
@@ -109,6 +108,44 @@ type AppointmentDraft = UserAppointment & {
|
||||
|
||||
const PASSWORD_RESET_MIN_LENGTH = 12;
|
||||
|
||||
function isMetadataRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cleanMetadataValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (isMetadataRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(
|
||||
([_, fieldValue]) =>
|
||||
fieldValue !== undefined && fieldValue !== null && fieldValue !== "",
|
||||
),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSubEmails(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.includes("@"));
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
return value
|
||||
.split(/[;,\n\r\t]/)
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => email.includes("@"));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function createDraftId() {
|
||||
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
|
||||
}
|
||||
@@ -322,8 +359,8 @@ function UserDetailPage() {
|
||||
const userId = params.id ?? "";
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
|
||||
const [_error, _setError] = React.useState<string | null>(null);
|
||||
const [_successMsg, _setSuccessMsg] = React.useState<string | null>(null);
|
||||
const [isPasswordResetOpen, setIsPasswordResetOpen] = React.useState(false);
|
||||
const [generatedPassword, setGeneratedPassword] = React.useState<
|
||||
string | null
|
||||
@@ -419,7 +456,7 @@ function UserDetailPage() {
|
||||
if (e.key === "Enter" || e.key === "," || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const value = newSubEmail.trim().replace(/,/g, "");
|
||||
if (value && value.includes("@") && !currentSubEmails.includes(value)) {
|
||||
if (value?.includes("@") && !currentSubEmails.includes(value)) {
|
||||
setValue("metadata.sub_email", [...currentSubEmails, value], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
@@ -595,7 +632,7 @@ function UserDetailPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const setPrimaryAppointment = (targetIndex: number) => {
|
||||
const _setPrimaryAppointment = (targetIndex: number) => {
|
||||
setAdditionalAppointments((current) =>
|
||||
current.map((appointment, index) => ({
|
||||
...appointment,
|
||||
@@ -774,15 +811,17 @@ function UserDetailPage() {
|
||||
});
|
||||
|
||||
const onSubmit = async (data: UserFormValues) => {
|
||||
// Filter out undefined/null/empty strings from metadata
|
||||
const cleanMetadata = Object.fromEntries(
|
||||
Object.entries(data.metadata).map(([tenantId, fields]) => {
|
||||
const cleanFields = Object.fromEntries(
|
||||
Object.entries(fields).filter(
|
||||
([_, v]) => v !== undefined && v !== null && v !== "",
|
||||
),
|
||||
);
|
||||
return [tenantId, cleanFields];
|
||||
Object.entries(data.metadata ?? {}).flatMap(([key, value]) => {
|
||||
const cleanedValue = cleanMetadataValue(value);
|
||||
if (
|
||||
cleanedValue === undefined ||
|
||||
cleanedValue === null ||
|
||||
cleanedValue === ""
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [[key, cleanedValue]];
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -792,19 +831,11 @@ function UserDetailPage() {
|
||||
sub_email: rawSubEmail,
|
||||
...safeMetadata
|
||||
} = cleanMetadata;
|
||||
|
||||
// Parse sub_email
|
||||
let sub_email: string[] = [];
|
||||
if (typeof rawSubEmail === "string" && rawSubEmail.trim() !== "") {
|
||||
sub_email = rawSubEmail
|
||||
.split(/[;,\n\r\t]/)
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e.includes("@"));
|
||||
}
|
||||
const subEmail = normalizeSubEmails(rawSubEmail);
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
...safeMetadata,
|
||||
...(sub_email.length > 0 ? { sub_email } : { sub_email: [] }),
|
||||
...(subEmail.length > 0 ? { sub_email: subEmail } : { sub_email: [] }),
|
||||
};
|
||||
|
||||
const payload: UserUpdateRequest = {
|
||||
@@ -813,7 +844,7 @@ function UserDetailPage() {
|
||||
};
|
||||
// email cannot be updated directly via this API in current backend implementation,
|
||||
// so we delete it from payload if it spread
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
delete payload.email;
|
||||
payload.role = undefined;
|
||||
|
||||
@@ -989,8 +1020,7 @@ function UserDetailPage() {
|
||||
<Mail size={14} className="text-primary/70" />
|
||||
{user.email}
|
||||
</div>
|
||||
{user.metadata?.sub_email &&
|
||||
Array.isArray(user.metadata.sub_email) &&
|
||||
{Array.isArray(user.metadata?.sub_email) &&
|
||||
user.metadata.sub_email.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
|
||||
<Mail size={14} className="text-primary/40" />
|
||||
@@ -1167,8 +1197,7 @@ function UserDetailPage() {
|
||||
onClick={() => {
|
||||
const value = newSubEmail.trim().replace(/,/g, "");
|
||||
if (
|
||||
value &&
|
||||
value.includes("@") &&
|
||||
value?.includes("@") &&
|
||||
!currentSubEmails.includes(value)
|
||||
) {
|
||||
setValue(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
LayoutDashboard,
|
||||
@@ -268,7 +267,7 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
});
|
||||
|
||||
function UserListPage() {
|
||||
const navigate = useNavigate();
|
||||
const _navigate = useNavigate();
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
||||
@@ -563,7 +562,7 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleApplyBulkStatus = () => {
|
||||
const _handleApplyBulkStatus = () => {
|
||||
if (selectedUserIds.length === 0 || !selectedBulkStatus) return;
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: selectedUserIds,
|
||||
@@ -571,7 +570,7 @@ function UserListPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplyBulkPermission = () => {
|
||||
const _handleApplyBulkPermission = () => {
|
||||
if (selectedUserIds.length === 0 || !selectedBulkPermission) return;
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: selectedUserIds,
|
||||
@@ -594,7 +593,7 @@ function UserListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (userId: string, userName: string) => {
|
||||
const _handleDelete = (userId: string, userName: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
bulkUpdateUsers,
|
||||
fetchAllTenants,
|
||||
fetchGroups,
|
||||
type GroupSummary,
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
@@ -49,7 +47,7 @@ export function UserBulkMoveGroupModal({
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const _queryClient = useQueryClient();
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", "all"],
|
||||
|
||||
@@ -102,7 +102,7 @@ export function isHanmacFamilyTenant<T extends TenantFilterTarget>(
|
||||
tenants: T[],
|
||||
hanmacFamilyTenantId?: string,
|
||||
) {
|
||||
if (!tenant || !tenant.id) return false;
|
||||
if (!tenant?.id) return false;
|
||||
|
||||
const rootTenantId = resolveHanmacFamilyTenantId(
|
||||
tenants,
|
||||
|
||||
@@ -354,10 +354,12 @@ function applySecondaryEmailMetadata(
|
||||
value: string,
|
||||
) {
|
||||
const emails = splitEmailTokens(value);
|
||||
item.metadata.sub_email = uniqueEmails([
|
||||
...metadataEmailList(item.metadata.sub_email),
|
||||
const uniqueSecondaryEmails = uniqueEmails([
|
||||
...metadataEmailList(item.metadata.secondary_emails),
|
||||
...emails,
|
||||
]);
|
||||
item.metadata.sub_email = value;
|
||||
item.metadata.secondary_emails = uniqueSecondaryEmails;
|
||||
addWorksmobileAliasEmails(item, emails);
|
||||
}
|
||||
|
||||
|
||||
185
adminfront/src/lib/adminApi.contract.test.ts
Normal file
185
adminfront/src/lib/adminApi.contract.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiClient = {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
const fetchAllCursorPages = vi.fn(async () => ({
|
||||
items: [{ id: "tenant-1", name: "Tenant", slug: "tenant" }],
|
||||
total: 1,
|
||||
}));
|
||||
|
||||
vi.mock("./apiClient", () => ({
|
||||
default: apiClient,
|
||||
}));
|
||||
|
||||
vi.mock("./auth", () => ({
|
||||
userManager: {
|
||||
getUser: vi.fn(async () => ({ access_token: "access-token" })),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../common/core/pagination", () => ({
|
||||
fetchAllCursorPages,
|
||||
}));
|
||||
|
||||
describe("adminApi endpoint contracts", () => {
|
||||
beforeEach(() => {
|
||||
apiClient.get.mockReset();
|
||||
apiClient.post.mockReset();
|
||||
apiClient.put.mockReset();
|
||||
apiClient.patch.mockReset();
|
||||
apiClient.delete.mockReset();
|
||||
|
||||
apiClient.get.mockResolvedValue({
|
||||
data: { ok: true },
|
||||
headers: { "content-disposition": 'attachment; filename="export.csv"' },
|
||||
});
|
||||
apiClient.post.mockResolvedValue({ data: { ok: true } });
|
||||
apiClient.put.mockResolvedValue({ data: { ok: true } });
|
||||
apiClient.patch.mockResolvedValue({ data: { ok: true } });
|
||||
apiClient.delete.mockResolvedValue({ data: { ok: true } });
|
||||
fetchAllCursorPages.mockClear();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("routes read APIs to their documented admin endpoints", async () => {
|
||||
const adminApi = await import("./adminApi");
|
||||
|
||||
await adminApi.fetchAuditLogs(10, "cursor-a");
|
||||
await adminApi.fetchAdminOverviewStats();
|
||||
await adminApi.fetchDataIntegrityReport();
|
||||
await adminApi.fetchOrphanUserLoginIDs();
|
||||
await adminApi.fetchUserProjectionStatus();
|
||||
await adminApi.fetchAdminRPUsageDaily({
|
||||
days: 30,
|
||||
period: "week",
|
||||
tenantId: "tenant-1",
|
||||
});
|
||||
await adminApi.fetchTenants(25, 50, "parent-1", "cursor-b");
|
||||
await adminApi.fetchAllTenants({ pageSize: 200, parentId: "parent-1" });
|
||||
await adminApi.fetchTenant("tenant-1");
|
||||
await adminApi.fetchTenantAdmins("tenant-1");
|
||||
await adminApi.fetchTenantOwners("tenant-1");
|
||||
await adminApi.fetchGroups("tenant-1");
|
||||
await adminApi.fetchGroup("tenant-1", "group-1");
|
||||
await adminApi.fetchGroupRoles("tenant-1", "group-1");
|
||||
await adminApi.fetchApiKeys(20, 40);
|
||||
await adminApi.fetchUsers(30, 60, "admin", "tenant");
|
||||
await adminApi.fetchUser("user-1");
|
||||
await adminApi.fetchWorksmobileOverview("tenant-1");
|
||||
await adminApi.fetchWorksmobileComparison("tenant-1", true);
|
||||
await adminApi.downloadWorksmobileInitialPasswordsCSV("tenant-1");
|
||||
await adminApi.fetchPasswordPolicy();
|
||||
await adminApi.fetchUserRpHistory("user-1");
|
||||
await adminApi.fetchMe();
|
||||
await adminApi.fetchRelyingParties("tenant-1");
|
||||
await adminApi.fetchAllRelyingParties();
|
||||
await adminApi.fetchRelyingParty("client-1");
|
||||
await adminApi.fetchRPOwners("client-1");
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", {
|
||||
params: { limit: 10, cursor: "cursor-a" },
|
||||
});
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/tenants", {
|
||||
params: {
|
||||
limit: 25,
|
||||
offset: 50,
|
||||
parentId: "parent-1",
|
||||
cursor: "cursor-b",
|
||||
},
|
||||
});
|
||||
expect(fetchAllCursorPages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: "/v1/admin/tenants",
|
||||
pageSize: 200,
|
||||
params: { parentId: "parent-1" },
|
||||
}),
|
||||
);
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/comparison",
|
||||
{ params: { includeMatched: true } },
|
||||
);
|
||||
expect(await adminApi.exportTenantsCSV(true, "parent-1")).toMatchObject({
|
||||
filename: "export.csv",
|
||||
});
|
||||
expect(
|
||||
await adminApi.exportUsersCSV("admin", "tenant", true),
|
||||
).toMatchObject({
|
||||
filename: "export.csv",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes mutation APIs to their documented admin endpoints", async () => {
|
||||
const adminApi = await import("./adminApi");
|
||||
|
||||
await adminApi.deleteOrphanUserLoginIDs(["orphan-1"]);
|
||||
await adminApi.reconcileUserProjection();
|
||||
await adminApi.resetUserProjection();
|
||||
await adminApi.createTenant({ name: "Tenant", slug: "tenant" });
|
||||
await adminApi.updateTenant("tenant-1", { status: "inactive" });
|
||||
await adminApi.deleteTenant("tenant-1");
|
||||
await adminApi.deleteTenantsBulk(["tenant-1"]);
|
||||
await adminApi.importTenantsCSV(new File(["name"], "tenants.csv"));
|
||||
await adminApi.approveTenant("tenant-1");
|
||||
await adminApi.addTenantAdmin("tenant-1", "user-1");
|
||||
await adminApi.removeTenantAdmin("tenant-1", "user-1");
|
||||
await adminApi.addTenantOwner("tenant-1", "user-1");
|
||||
await adminApi.removeTenantOwner("tenant-1", "user-1");
|
||||
await adminApi.createGroup("tenant-1", { name: "Group" });
|
||||
await adminApi.deleteGroup("tenant-1", "group-1");
|
||||
await adminApi.addGroupMember("tenant-1", "group-1", "user-1");
|
||||
await adminApi.removeGroupMember("tenant-1", "group-1", "user-1");
|
||||
await adminApi.assignGroupRole("tenant-1", "group-1", "tenant-2", "owner");
|
||||
await adminApi.removeGroupRole("tenant-1", "group-1", "tenant-2", "owner");
|
||||
await adminApi.createApiKey({ name: "key", scopes: ["read"] });
|
||||
await adminApi.updateApiKeyScopes("key-1", { scopes: ["write"] });
|
||||
await adminApi.rotateApiKeySecret("key-1");
|
||||
await adminApi.deleteApiKey("key-1");
|
||||
await adminApi.createUser({ email: "user@example.com", name: "User" });
|
||||
await adminApi.bulkCreateUsers([
|
||||
{ email: "user@example.com", name: "User", metadata: {} },
|
||||
]);
|
||||
await adminApi.enqueueWorksmobileBackfillDryRun("tenant-1");
|
||||
await adminApi.enqueueWorksmobileOrgUnitSync("tenant-1", "org/unit");
|
||||
await adminApi.enqueueWorksmobileOrgUnitDelete("tenant-1", "org/unit");
|
||||
await adminApi.enqueueWorksmobileUserSync("tenant-1", "user-1");
|
||||
await adminApi.retryWorksmobileJob("tenant-1", "job-1");
|
||||
await adminApi.bulkUpdateUsers({ userIds: ["user-1"], status: "inactive" });
|
||||
await adminApi.bulkDeleteUsers(["user-1"]);
|
||||
await adminApi.updateUser("user-1", { status: "active" });
|
||||
await adminApi.deleteUser("user-1");
|
||||
await adminApi.createRelyingParty("tenant-1", {
|
||||
client_name: "RP",
|
||||
redirect_uris: ["https://rp.example/callback"],
|
||||
});
|
||||
await adminApi.updateRelyingParty("client-1", {
|
||||
client_name: "RP",
|
||||
redirect_uris: ["https://rp.example/callback"],
|
||||
});
|
||||
await adminApi.deleteRelyingParty("client-1");
|
||||
await adminApi.addRPOwner("client-1", "User:user-1");
|
||||
await adminApi.removeRPOwner("client-1", "User:user-1");
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||
"/v1/admin/integrity/orphan-user-login-ids",
|
||||
{ data: { ids: ["orphan-1"] } },
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/projections/users/reconcile",
|
||||
);
|
||||
expect(apiClient.put).toHaveBeenCalledWith("/v1/admin/users/user-1", {
|
||||
status: "active",
|
||||
});
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/tenants/tenant-1/worksmobile/orgunits/org%2Funit/sync",
|
||||
);
|
||||
expect(apiClient.delete).toHaveBeenCalledWith(
|
||||
"/v1/admin/relying-parties/client-1/owners/User:user-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ export function buildTenantFullTree(
|
||||
});
|
||||
}
|
||||
|
||||
const visitedDuringBuild = new Set<string>();
|
||||
const _visitedDuringBuild = new Set<string>();
|
||||
// Build initial children relations and prevent simple cycles
|
||||
for (const t of allTenants) {
|
||||
if (t.parentId && t.parentId !== t.id) {
|
||||
|
||||
Reference in New Issue
Block a user