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");
});
});