forked from baron/baron-sso
기능 재배포
This commit is contained in:
@@ -19,13 +19,6 @@ import * as React from "react";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
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 {
|
import {
|
||||||
applyShellTheme,
|
applyShellTheme,
|
||||||
buildShellProfileSummary,
|
buildShellProfileSummary,
|
||||||
@@ -35,6 +28,13 @@ import {
|
|||||||
shellLayoutClasses,
|
shellLayoutClasses,
|
||||||
writeShellSessionExpiryEnabled,
|
writeShellSessionExpiryEnabled,
|
||||||
} from "../../../../common/shell";
|
} 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 LanguageSelector from "../common/LanguageSelector";
|
||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
|
|||||||
72
adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx
Normal file
72
adminfront/src/features/api-keys/ApiKeyCreatePage.test.tsx
Normal file
@@ -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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<ApiKeyCreatePage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,6 +53,17 @@ import { t } from "../../../lib/i18n";
|
|||||||
|
|
||||||
type DialogMode = "owner" | "admin";
|
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() {
|
export function TenantAdminsAndOwnersTab() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const currentUserId = auth.user?.profile.sub;
|
const currentUserId = auth.user?.profile.sub;
|
||||||
@@ -60,6 +71,8 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||||
|
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
|
||||||
|
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
|
||||||
|
|
||||||
if (!tenantId) return null;
|
if (!tenantId) return null;
|
||||||
|
|
||||||
@@ -95,18 +108,22 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
// Optimistically add to the list to prevent immediate double clicks
|
// Optimistically add to the list to prevent immediate double clicks
|
||||||
const addedUser = searchResults.find((u) => u.id === userId);
|
const addedUser = searchResults.find((u) => u.id === userId);
|
||||||
if (addedUser) {
|
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<TenantAdmin[]>(
|
queryClient.setQueryData<TenantAdmin[]>(
|
||||||
["tenant-owners", tenantId],
|
["tenant-owners", tenantId],
|
||||||
(old) => {
|
(old) => {
|
||||||
if (!old)
|
if (!old) return [optimisticOwner];
|
||||||
return [
|
|
||||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
|
||||||
];
|
|
||||||
if (old.some((o) => o.id === userId)) return old;
|
if (old.some((o) => o.id === userId)) return old;
|
||||||
return [
|
return [...old, optimisticOwner];
|
||||||
...old,
|
|
||||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -125,6 +142,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||||
|
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
|
||||||
if (context?.previousOwners) {
|
if (context?.previousOwners) {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["tenant-owners", tenantId],
|
["tenant-owners", tenantId],
|
||||||
@@ -148,6 +166,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
"tenant-owners",
|
"tenant-owners",
|
||||||
tenantId,
|
tenantId,
|
||||||
]);
|
]);
|
||||||
|
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
|
||||||
queryClient.setQueryData<TenantAdmin[]>(
|
queryClient.setQueryData<TenantAdmin[]>(
|
||||||
["tenant-owners", tenantId],
|
["tenant-owners", tenantId],
|
||||||
(old) => (old ? old.filter((o) => o.id !== userId) : []),
|
(old) => (old ? old.filter((o) => o.id !== userId) : []),
|
||||||
@@ -194,18 +213,22 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
|
|
||||||
const addedUser = searchResults.find((u) => u.id === userId);
|
const addedUser = searchResults.find((u) => u.id === userId);
|
||||||
if (addedUser) {
|
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<TenantAdmin[]>(
|
queryClient.setQueryData<TenantAdmin[]>(
|
||||||
["tenant-admins", tenantId],
|
["tenant-admins", tenantId],
|
||||||
(old) => {
|
(old) => {
|
||||||
if (!old)
|
if (!old) return [optimisticAdmin];
|
||||||
return [
|
|
||||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
|
||||||
];
|
|
||||||
if (old.some((a) => a.id === userId)) return old;
|
if (old.some((a) => a.id === userId)) return old;
|
||||||
return [
|
return [...old, optimisticAdmin];
|
||||||
...old,
|
|
||||||
{ id: userId, name: addedUser.name, email: addedUser.email },
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -223,6 +246,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
|
||||||
|
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
|
||||||
if (context?.previousAdmins) {
|
if (context?.previousAdmins) {
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
["tenant-admins", tenantId],
|
["tenant-admins", tenantId],
|
||||||
@@ -246,6 +270,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
"tenant-admins",
|
"tenant-admins",
|
||||||
tenantId,
|
tenantId,
|
||||||
]);
|
]);
|
||||||
|
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
|
||||||
queryClient.setQueryData<TenantAdmin[]>(
|
queryClient.setQueryData<TenantAdmin[]>(
|
||||||
["tenant-admins", tenantId],
|
["tenant-admins", tenantId],
|
||||||
(old) => (old ? old.filter((a) => a.id !== userId) : []),
|
(old) => (old ? old.filter((a) => a.id !== userId) : []),
|
||||||
@@ -312,8 +337,10 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentOwners = ownersQuery.data || [];
|
const serverOwners = ownersQuery.data || [];
|
||||||
const currentAdmins = adminsQuery.data || [];
|
const serverAdmins = adminsQuery.data || [];
|
||||||
|
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
|
||||||
|
const currentAdmins = mergePendingMembers(serverAdmins, pendingAdmins);
|
||||||
const searchResults = usersQuery.data?.items || [];
|
const searchResults = usersQuery.data?.items || [];
|
||||||
const isDialogOpen = dialogMode !== null;
|
const isDialogOpen = dialogMode !== null;
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ import {
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
sortItems,
|
|
||||||
toggleSort,
|
|
||||||
type SortConfig,
|
type SortConfig,
|
||||||
type SortResolverMap,
|
type SortResolverMap,
|
||||||
|
sortItems,
|
||||||
|
toggleSort,
|
||||||
} from "../../../../../common/core/utils";
|
} from "../../../../../common/core/utils";
|
||||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
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 { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -50,12 +56,6 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { toast } from "../../components/ui/use-toast";
|
import { toast } from "../../components/ui/use-toast";
|
||||||
import {
|
|
||||||
sortItems,
|
|
||||||
toggleSort,
|
|
||||||
type SortConfig,
|
|
||||||
type SortResolverMap,
|
|
||||||
} from "../../../../common/core/utils";
|
|
||||||
import {
|
import {
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
type SortConfig,
|
||||||
compareNullableValues,
|
compareNullableValues,
|
||||||
sortItems,
|
sortItems,
|
||||||
toggleSort,
|
toggleSort,
|
||||||
type SortConfig,
|
|
||||||
} from "../../../common/core/utils";
|
} from "../../../common/core/utils";
|
||||||
|
|
||||||
describe("shared sort helpers", () => {
|
describe("shared sort helpers", () => {
|
||||||
|
|||||||
@@ -122,5 +122,10 @@ test.describe("Tenant Owners Management", () => {
|
|||||||
.locator("role=dialog")
|
.locator("role=dialog")
|
||||||
.getByRole("button", { name: /추가|Add/ });
|
.getByRole("button", { name: /추가|Add/ });
|
||||||
await addButton.click();
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user