diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 76140dd5..3a9b7895 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -19,13 +19,6 @@ import * as React from "react"; import { useEffect, useRef, useState } from "react"; import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; -import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker"; -import { fetchMe } from "../../lib/adminApi"; -import { t } from "../../lib/i18n"; -import { - shouldAttemptSlidingSessionRenew, - shouldAttemptUnlimitedSessionRenew, -} from "../../lib/sessionSliding"; import { applyShellTheme, buildShellProfileSummary, @@ -35,6 +28,13 @@ import { shellLayoutClasses, writeShellSessionExpiryEnabled, } from "../../../../common/shell"; +import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker"; +import { fetchMe } from "../../lib/adminApi"; +import { t } from "../../lib/i18n"; +import { + shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, +} from "../../lib/sessionSliding"; import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; diff --git a/adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx b/adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx new file mode 100644 index 00000000..067142b2 --- /dev/null +++ b/adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx @@ -0,0 +1,72 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createApiKey } from "../../lib/adminApi"; +import ApiKeyCreatePage from "./ApiKeyCreatePage"; + +vi.mock("../../lib/adminApi", () => ({ + createApiKey: vi.fn(async () => ({ + apiKey: { + id: "api-key-id", + name: "org-context-client", + client_id: "client-id", + scopes: ["audit:read", "user:read", "org-context:read"], + status: "active", + createdAt: "2026-05-13T00:00:00Z", + }, + clientSecret: "secret", + })), +})); + +function renderPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + + + , + ); +} + +describe("ApiKeyCreatePage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders org-context:read as a selectable API key scope", () => { + renderPage(); + + expect(screen.getByText("조직 Context 조회")).toBeInTheDocument(); + expect(screen.getByText("ID: org-context:read")).toBeInTheDocument(); + }); + + it("includes org-context:read in the create request when selected", async () => { + const user = userEvent.setup(); + renderPage(); + + await user.type( + screen.getByLabelText("서비스 또는 목적 식별 이름"), + "org-context-client", + ); + await user.click(screen.getByRole("button", { name: /조직 Context 조회/ })); + await user.click(screen.getByRole("button", { name: /API 키 발급하기/ })); + + await waitFor(() => { + expect(createApiKey).toHaveBeenCalledWith( + expect.objectContaining({ + name: "org-context-client", + scopes: expect.arrayContaining(["org-context:read"]), + }), + ); + }); + }); +}); diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index d592d6f2..38f3554c 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -53,6 +53,17 @@ import { t } from "../../../lib/i18n"; type DialogMode = "owner" | "admin"; +function mergePendingMembers( + members: TenantAdmin[], + pendingMembers: TenantAdmin[], +) { + const existingIds = new Set(members.map((member) => member.id)); + return [ + ...members, + ...pendingMembers.filter((member) => !existingIds.has(member.id)), + ]; +} + export function TenantAdminsAndOwnersTab() { const auth = useAuth(); const currentUserId = auth.user?.profile.sub; @@ -60,6 +71,8 @@ export function TenantAdminsAndOwnersTab() { const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); const [dialogMode, setDialogMode] = useState(null); + const [pendingOwners, setPendingOwners] = useState([]); + const [pendingAdmins, setPendingAdmins] = useState([]); if (!tenantId) return null; @@ -95,18 +108,22 @@ export function TenantAdminsAndOwnersTab() { // Optimistically add to the list to prevent immediate double clicks const addedUser = searchResults.find((u) => u.id === userId); if (addedUser) { + const optimisticOwner = { + id: userId, + name: addedUser.name, + email: addedUser.email, + }; + setPendingOwners((old) => + old.some((owner) => owner.id === userId) + ? old + : [...old, optimisticOwner], + ); queryClient.setQueryData( ["tenant-owners", tenantId], (old) => { - if (!old) - return [ - { id: userId, name: addedUser.name, email: addedUser.email }, - ]; + if (!old) return [optimisticOwner]; if (old.some((o) => o.id === userId)) return old; - return [ - ...old, - { id: userId, name: addedUser.name, email: addedUser.email }, - ]; + return [...old, optimisticOwner]; }, ); } @@ -125,6 +142,7 @@ export function TenantAdminsAndOwnersTab() { setSearchTerm(""); }, onError: (err: AxiosError<{ error?: string }>, userId, context) => { + setPendingOwners((old) => old.filter((owner) => owner.id !== userId)); if (context?.previousOwners) { queryClient.setQueryData( ["tenant-owners", tenantId], @@ -148,6 +166,7 @@ export function TenantAdminsAndOwnersTab() { "tenant-owners", tenantId, ]); + setPendingOwners((old) => old.filter((owner) => owner.id !== userId)); queryClient.setQueryData( ["tenant-owners", tenantId], (old) => (old ? old.filter((o) => o.id !== userId) : []), @@ -194,18 +213,22 @@ export function TenantAdminsAndOwnersTab() { const addedUser = searchResults.find((u) => u.id === userId); if (addedUser) { + const optimisticAdmin = { + id: userId, + name: addedUser.name, + email: addedUser.email, + }; + setPendingAdmins((old) => + old.some((admin) => admin.id === userId) + ? old + : [...old, optimisticAdmin], + ); queryClient.setQueryData( ["tenant-admins", tenantId], (old) => { - if (!old) - return [ - { id: userId, name: addedUser.name, email: addedUser.email }, - ]; + if (!old) return [optimisticAdmin]; if (old.some((a) => a.id === userId)) return old; - return [ - ...old, - { id: userId, name: addedUser.name, email: addedUser.email }, - ]; + return [...old, optimisticAdmin]; }, ); } @@ -223,6 +246,7 @@ export function TenantAdminsAndOwnersTab() { setSearchTerm(""); }, onError: (err: AxiosError<{ error?: string }>, userId, context) => { + setPendingAdmins((old) => old.filter((admin) => admin.id !== userId)); if (context?.previousAdmins) { queryClient.setQueryData( ["tenant-admins", tenantId], @@ -246,6 +270,7 @@ export function TenantAdminsAndOwnersTab() { "tenant-admins", tenantId, ]); + setPendingAdmins((old) => old.filter((admin) => admin.id !== userId)); queryClient.setQueryData( ["tenant-admins", tenantId], (old) => (old ? old.filter((a) => a.id !== userId) : []), @@ -312,8 +337,10 @@ export function TenantAdminsAndOwnersTab() { } }; - const currentOwners = ownersQuery.data || []; - const currentAdmins = adminsQuery.data || []; + const serverOwners = ownersQuery.data || []; + const serverAdmins = adminsQuery.data || []; + const currentOwners = mergePendingMembers(serverOwners, pendingOwners); + const currentAdmins = mergePendingMembers(serverAdmins, pendingAdmins); const searchResults = usersQuery.data?.items || []; const isDialogOpen = dialogMode !== null; diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 25b9381d..fb06c7a8 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -22,10 +22,10 @@ import { import * as React from "react"; import { Link, useNavigate } from "react-router-dom"; import { - sortItems, - toggleSort, type SortConfig, type SortResolverMap, + sortItems, + toggleSort, } from "../../../../../common/core/utils"; import { RoleGuard } from "../../../components/auth/RoleGuard"; import { Badge } from "../../../components/ui/badge"; diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 590b50be..56cd3132 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -15,6 +15,12 @@ import { } from "lucide-react"; import * as React from "react"; import { Link, useNavigate } from "react-router-dom"; +import { + type SortConfig, + type SortResolverMap, + sortItems, + toggleSort, +} from "../../../../common/core/utils"; import { Button } from "../../components/ui/button"; import { Card, @@ -50,12 +56,6 @@ import { TableRow, } from "../../components/ui/table"; import { toast } from "../../components/ui/use-toast"; -import { - sortItems, - toggleSort, - type SortConfig, - type SortResolverMap, -} from "../../../../common/core/utils"; import { type UserSummary, bulkDeleteUsers, diff --git a/adminfront/src/lib/sort.test.ts b/adminfront/src/lib/sort.test.ts index 31394203..dde0d931 100644 --- a/adminfront/src/lib/sort.test.ts +++ b/adminfront/src/lib/sort.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import { + type SortConfig, compareNullableValues, sortItems, toggleSort, - type SortConfig, } from "../../../common/core/utils"; describe("shared sort helpers", () => { diff --git a/adminfront/tests/owners.spec.ts b/adminfront/tests/owners.spec.ts index eea69e9c..2cf41846 100644 --- a/adminfront/tests/owners.spec.ts +++ b/adminfront/tests/owners.spec.ts @@ -122,5 +122,10 @@ test.describe("Tenant Owners Management", () => { .locator("role=dialog") .getByRole("button", { name: /추가|Add/ }); await addButton.click(); + + await expect(page.getByText("소유자가 추가되었습니다.")).toBeVisible(); + await expect(page.locator("table").first()).toContainText("User Two"); + await page.waitForTimeout(1200); + await expect(page.locator("table").first()).toContainText("User Two"); }); });