forked from baron/baron-sso
테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거
This commit is contained in:
77
adminfront/src/lib/adminApi.test.ts
Normal file
77
adminfront/src/lib/adminApi.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiClient = {
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("./apiClient", () => ({
|
||||
default: apiClient,
|
||||
}));
|
||||
|
||||
describe("adminApi user tenant payloads", () => {
|
||||
beforeEach(() => {
|
||||
apiClient.post.mockReset();
|
||||
apiClient.put.mockReset();
|
||||
});
|
||||
|
||||
it("sends tenantSlug without remapping it to companyCode when creating a user", async () => {
|
||||
const { createUser } = await import("./adminApi");
|
||||
apiClient.post.mockResolvedValue({ data: {} });
|
||||
|
||||
await createUser({
|
||||
email: "user@test.com",
|
||||
name: "Test User",
|
||||
tenantSlug: "test-tenant",
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/v1/admin/users",
|
||||
expect.objectContaining({ tenantSlug: "test-tenant" }),
|
||||
);
|
||||
expect(apiClient.post.mock.calls[0][1]).not.toHaveProperty("companyCode");
|
||||
});
|
||||
|
||||
it("sends tenantSlug without remapping it to companyCode when updating a user", async () => {
|
||||
const { updateUser } = await import("./adminApi");
|
||||
apiClient.put.mockResolvedValue({ data: {} });
|
||||
|
||||
await updateUser("user-id", { tenantSlug: "new-tenant" });
|
||||
|
||||
expect(apiClient.put).toHaveBeenCalledWith(
|
||||
"/v1/admin/users/user-id",
|
||||
expect.objectContaining({ tenantSlug: "new-tenant" }),
|
||||
);
|
||||
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
|
||||
});
|
||||
|
||||
it("keeps tenantSlug payloads unchanged for bulk user APIs", async () => {
|
||||
const { bulkCreateUsers, bulkUpdateUsers } = await import("./adminApi");
|
||||
apiClient.post.mockResolvedValue({ data: {} });
|
||||
apiClient.put.mockResolvedValue({ data: {} });
|
||||
|
||||
await bulkCreateUsers([
|
||||
{
|
||||
email: "user@test.com",
|
||||
name: "Test User",
|
||||
tenantSlug: "test-tenant",
|
||||
metadata: {},
|
||||
},
|
||||
]);
|
||||
await bulkUpdateUsers({
|
||||
userIds: ["user-id"],
|
||||
tenantSlug: "new-tenant",
|
||||
});
|
||||
|
||||
expect(apiClient.post.mock.calls[0][1].users[0]).toMatchObject({
|
||||
tenantSlug: "test-tenant",
|
||||
});
|
||||
expect(apiClient.post.mock.calls[0][1].users[0]).not.toHaveProperty(
|
||||
"companyCode",
|
||||
);
|
||||
expect(apiClient.put.mock.calls[0][1]).toMatchObject({
|
||||
tenantSlug: "new-tenant",
|
||||
});
|
||||
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { fetchAllCursorPages } from "../../../common/core/pagination";
|
||||
import apiClient from "./apiClient";
|
||||
import { userManager } from "./auth";
|
||||
|
||||
export type AuditLog = {
|
||||
event_id: string;
|
||||
@@ -51,6 +53,9 @@ export type TenantListResponse = {
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
cursor?: string;
|
||||
nextCursor?: string;
|
||||
next_cursor?: string;
|
||||
};
|
||||
|
||||
export type TenantUpdateRequest = {
|
||||
@@ -195,16 +200,73 @@ export async function fetchAdminRPUsageDaily({
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
|
||||
export async function fetchTenants(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
parentId?: string,
|
||||
cursor?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<TenantListResponse>(
|
||||
"/v1/admin/tenants",
|
||||
{
|
||||
params: { limit, offset, parentId },
|
||||
params: { limit, offset, parentId, cursor },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
function getAdminApiBaseUrl() {
|
||||
if (
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE
|
||||
) {
|
||||
return "http://playwright-mock/api";
|
||||
}
|
||||
|
||||
return import.meta.env.VITE_ADMIN_API_BASE ?? "/api";
|
||||
}
|
||||
|
||||
async function buildAdminRequestHeaders() {
|
||||
const headers: Record<string, string> = {};
|
||||
const user = await userManager.getUser();
|
||||
const sessionToken =
|
||||
user?.access_token || window.localStorage.getItem("admin_session");
|
||||
|
||||
if (sessionToken) {
|
||||
headers.Authorization = `Bearer ${sessionToken}`;
|
||||
}
|
||||
|
||||
const tenantId = window.localStorage.getItem("admin_tenant");
|
||||
if (tenantId) {
|
||||
headers["X-Tenant-ID"] = tenantId;
|
||||
}
|
||||
|
||||
const isMockRoleEnabled =
|
||||
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
|
||||
const mockRole = window.localStorage.getItem("X-Mock-Role");
|
||||
if (isMockRoleEnabled && mockRole) {
|
||||
headers["X-Test-Role"] = mockRole;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function fetchAllTenants({
|
||||
pageSize = 100,
|
||||
parentId,
|
||||
}: {
|
||||
pageSize?: number;
|
||||
parentId?: string;
|
||||
} = {}) {
|
||||
return fetchAllCursorPages<TenantSummary>({
|
||||
baseUrl: getAdminApiBaseUrl(),
|
||||
path: "/v1/admin/tenants",
|
||||
pageSize,
|
||||
params: { parentId },
|
||||
headers: await buildAdminRequestHeaders(),
|
||||
}) as Promise<TenantListResponse>;
|
||||
}
|
||||
|
||||
export async function fetchTenant(tenantId: string) {
|
||||
const { data } = await apiClient.get<TenantSummary>(
|
||||
`/v1/admin/tenants/${tenantId}`,
|
||||
@@ -440,6 +502,10 @@ export type ApiKeyCreateResponse = {
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
export type ApiKeyUpdateScopesRequest = {
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
export async function fetchApiKeys(limit = 50, offset = 0) {
|
||||
const { data } = await apiClient.get<ApiKeyListResponse>(
|
||||
"/v1/admin/api-keys",
|
||||
@@ -458,6 +524,24 @@ export async function createApiKey(payload: ApiKeyCreateRequest) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateApiKeyScopes(
|
||||
apiKeyId: string,
|
||||
payload: ApiKeyUpdateScopesRequest,
|
||||
) {
|
||||
const { data } = await apiClient.patch<ApiKeySummary>(
|
||||
`/v1/admin/api-keys/${apiKeyId}`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function rotateApiKeySecret(apiKeyId: string) {
|
||||
const { data } = await apiClient.post<ApiKeyCreateResponse>(
|
||||
`/v1/admin/api-keys/${apiKeyId}/secret/rotate`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteApiKey(apiKeyId: string) {
|
||||
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
|
||||
}
|
||||
@@ -678,17 +762,9 @@ export async function fetchUser(userId: string) {
|
||||
}
|
||||
|
||||
export async function createUser(payload: UserCreateRequest) {
|
||||
// Map tenantSlug to companyCode for backend compatibility
|
||||
const requestPayload: UserCreateRequest & { companyCode?: string } = {
|
||||
...payload,
|
||||
};
|
||||
if (payload.tenantSlug !== undefined) {
|
||||
requestPayload.companyCode = payload.tenantSlug;
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<UserCreateResponse>(
|
||||
"/v1/admin/users",
|
||||
requestPayload,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -714,16 +790,9 @@ export async function exportUsersCSV(
|
||||
}
|
||||
|
||||
export async function bulkCreateUsers(users: BulkUserItem[]) {
|
||||
const mappedUsers = users.map((u) => {
|
||||
const mapped: BulkUserItem & { companyCode?: string } = { ...u };
|
||||
if (u.tenantSlug !== undefined) {
|
||||
mapped.companyCode = u.tenantSlug;
|
||||
}
|
||||
return mapped;
|
||||
});
|
||||
const { data } = await apiClient.post<BulkUserResponse>(
|
||||
"/v1/admin/users/bulk",
|
||||
{ users: mappedUsers },
|
||||
{ users },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -810,13 +879,7 @@ export async function bulkUpdateUsers(payload: {
|
||||
grade?: string;
|
||||
jobTitle?: string;
|
||||
}) {
|
||||
const requestPayload: typeof payload & { companyCode?: string } = {
|
||||
...payload,
|
||||
};
|
||||
if (payload.tenantSlug !== undefined) {
|
||||
requestPayload.companyCode = payload.tenantSlug;
|
||||
}
|
||||
const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload);
|
||||
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -828,16 +891,9 @@ export async function bulkDeleteUsers(userIds: string[]) {
|
||||
}
|
||||
|
||||
export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
||||
const requestPayload: UserUpdateRequest & { companyCode?: string } = {
|
||||
...payload,
|
||||
};
|
||||
if (payload.tenantSlug !== undefined) {
|
||||
requestPayload.companyCode = payload.tenantSlug;
|
||||
}
|
||||
|
||||
const { data } = await apiClient.put<UserSummary>(
|
||||
`/v1/admin/users/${userId}`,
|
||||
requestPayload,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import axios from "axios";
|
||||
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
||||
import { userManager } from "./auth";
|
||||
|
||||
let isRedirectingToLogin = false;
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE
|
||||
@@ -50,10 +53,13 @@ apiClient.interceptors.response.use(
|
||||
// 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다.
|
||||
await userManager.removeUser();
|
||||
|
||||
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
|
||||
const isLoginPath = window.location.pathname === "/login";
|
||||
|
||||
if (!isAuthPath && !isLoginPath) {
|
||||
if (
|
||||
shouldStartLoginRedirect({
|
||||
pathname: window.location.pathname,
|
||||
isRedirecting: isRedirectingToLogin,
|
||||
})
|
||||
) {
|
||||
isRedirectingToLogin = true;
|
||||
console.info(
|
||||
"[apiClient] Redirecting to /login from",
|
||||
window.location.pathname,
|
||||
|
||||
71
adminfront/src/lib/cursorFetch.test.ts
Normal file
71
adminfront/src/lib/cursorFetch.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchAllCursorPages,
|
||||
fetchAllCursorPagesMainThread,
|
||||
} from "../../../common/core/pagination";
|
||||
|
||||
describe("common cursor pagination fetch", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("follows nextCursor until the API reports the final page", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
items: [{ id: "tenant-1" }],
|
||||
nextCursor: "cursor-1",
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
items: [{ id: "tenant-2" }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await fetchAllCursorPagesMainThread<{ id: string }>({
|
||||
baseUrl: "/api",
|
||||
path: "/v1/admin/tenants",
|
||||
pageSize: 1,
|
||||
params: { parentId: "parent-1" },
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
|
||||
expect(response.items).toEqual([{ id: "tenant-1" }, { id: "tenant-2" }]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0][0].toString()).toContain(
|
||||
"/api/v1/admin/tenants?parentId=parent-1&limit=1&offset=0",
|
||||
);
|
||||
expect(fetchMock.mock.calls[1][0].toString()).toContain("cursor=cursor-1");
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({
|
||||
headers: { Authorization: "Bearer token" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the main thread path during browser test mode", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
items: [{ id: "tenant-1" }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
Object.defineProperty(window, "_IS_TEST_MODE", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const response = await fetchAllCursorPages<{ id: string }>({
|
||||
baseUrl: "/api",
|
||||
path: "/v1/admin/tenants",
|
||||
});
|
||||
|
||||
expect(response.items).toEqual([{ id: "tenant-1" }]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
37
adminfront/src/lib/loginRedirectGuard.test.ts
Normal file
37
adminfront/src/lib/loginRedirectGuard.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
||||
|
||||
describe("shouldStartLoginRedirect", () => {
|
||||
it("blocks redirects while a login redirect is already in flight", () => {
|
||||
expect(
|
||||
shouldStartLoginRedirect({
|
||||
pathname: "/users",
|
||||
isRedirecting: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks redirects from auth callback and login paths", () => {
|
||||
expect(
|
||||
shouldStartLoginRedirect({
|
||||
pathname: "/auth/callback",
|
||||
isRedirecting: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldStartLoginRedirect({
|
||||
pathname: "/login",
|
||||
isRedirecting: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a redirect from protected app paths", () => {
|
||||
expect(
|
||||
shouldStartLoginRedirect({
|
||||
pathname: "/tenants",
|
||||
isRedirecting: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user