1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/1058-adminfront-tab-rebac-permissions

This commit is contained in:
2026-06-12 20:28:18 +09:00
148 changed files with 11895 additions and 2024 deletions

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
filterTenantsByScope,
filterTenantViewRowsBySearch,
getTenantSearchMatchIds,
getTenantViewRows,
resolveTenantSelectionIds,
@@ -97,4 +98,17 @@ describe("TenantListPage tenant list helpers", () => {
]);
expect(getTenantSearchMatchIds(treeRows, "platform")).toEqual(["team-1"]);
});
it("filters displayed tenant rows to direct matches only", () => {
const treeRows = getTenantViewRows(
tenants.filter((item) => item.id !== "company-2"),
"tree",
"",
true,
);
expect(
filterTenantViewRowsBySearch(treeRows, "team-1").map((row) => row.id),
).toEqual(["team-1"]);
});
});

View File

@@ -107,6 +107,7 @@ import {
} from "../utils/tenantCsvImport";
import {
filterTenantsByScope,
filterTenantViewRowsBySearch,
getTenantSearchMatchIds,
getTenantViewRows,
resolveTenantSelectionIds,
@@ -1667,11 +1668,12 @@ const TenantHierarchyView: React.FC<{
const flattenedRows = React.useMemo(() => {
if (viewMode === "table") {
return sortItems(
const rows = sortItems(
getTenantViewRows(tenants, "table", scopeTenantId, !!search),
sortConfig,
tenantSortResolvers,
);
return filterTenantViewRowsBySearch(rows, search);
}
const result: TenantViewRow[] = [];
@@ -1692,7 +1694,7 @@ const TenantHierarchyView: React.FC<{
}
};
collect(subTree, 0);
return result;
return filterTenantViewRowsBySearch(result, search);
}, [
expandedIds,
scopeTenantId,

View File

@@ -7,6 +7,7 @@ import TenantUsersPage from "./TenantUsersPage";
const exportUsersCSVMock = vi.hoisted(() => vi.fn());
const updateUserMock = vi.hoisted(() => vi.fn());
const bulkUpdateUsersMock = vi.hoisted(() => vi.fn());
const fetchUsersMock = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/i18n", () => createI18nMock());
@@ -18,6 +19,7 @@ vi.mock("../../../lib/adminApi", () => ({
slug: "tech-planning",
})),
fetchUsers: fetchUsersMock,
bulkUpdateUsers: bulkUpdateUsersMock,
exportUsersCSV: exportUsersCSVMock,
updateUser: updateUserMock,
}));
@@ -26,8 +28,7 @@ function renderTenantUsersPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
const result = render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/tenants/tenant-team-id/users"]}>
<Routes>
@@ -39,12 +40,15 @@ function renderTenantUsersPage() {
</MemoryRouter>
</QueryClientProvider>,
);
return { ...result, queryClient };
}
describe("TenantUsersPage export", () => {
beforeEach(() => {
exportUsersCSVMock.mockReset();
updateUserMock.mockReset();
bulkUpdateUsersMock.mockReset();
fetchUsersMock.mockReset();
fetchUsersMock.mockResolvedValue({
items: [
@@ -64,10 +68,12 @@ describe("TenantUsersPage export", () => {
}),
filename: "users_export_20260609.csv",
});
updateUserMock.mockResolvedValue({});
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
"blob:tenant-users-export",
);
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
bulkUpdateUsersMock.mockResolvedValue({ results: [] });
});
it("exports only the currently opened tenant users by tenant slug", async () => {
@@ -135,14 +141,121 @@ describe("TenantUsersPage export", () => {
fireEvent.click(screen.getByTestId("tenant-member-add-submit-btn"));
await waitFor(() => {
expect(updateUserMock).toHaveBeenCalledWith("user-2", {
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
userIds: ["user-2", "user-3"],
tenantSlug: "tech-planning",
isAddTenant: true,
});
expect(updateUserMock).toHaveBeenCalledWith("user-3", {
});
expect(updateUserMock).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ isAddTenant: true }),
);
});
it("queues orgfront multi picker users and adds them with one bulk request", async () => {
fetchUsersMock
.mockResolvedValueOnce({
items: [
{
id: "existing-user",
name: "Existing",
email: "existing@example.com",
role: "user",
status: "active",
},
],
total: 1,
})
.mockResolvedValue({ items: [], total: 0 });
renderTenantUsersPage();
const addButton = await screen.findByTestId(
"tenant-member-add-existing-btn",
);
await waitFor(() => expect(addButton).not.toBeDisabled());
fireEvent.click(addButton);
const picker = await screen.findByTitle("조직도에서 구성원 선택");
expect(decodeURIComponent(picker.getAttribute("src") ?? "")).toContain(
"/embed/picker?mode=multiple&select=user",
);
fireEvent(
window,
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
mode: "multiple",
selections: [
{ type: "tenant", id: "team-1", name: "플랫폼팀" },
{
type: "user",
id: "picked-user-1",
name: "Picked One",
email: "picked1@example.com",
},
{
type: "user",
id: "picked-user-2",
name: "Picked Two",
},
{
type: "user",
id: "existing-user",
name: "Existing",
email: "existing@example.com",
},
],
},
},
}),
);
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
"Picked One",
);
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
"Picked Two",
);
expect(screen.getByTestId("tenant-member-add-queue")).not.toHaveTextContent(
"Existing",
);
fireEvent.click(screen.getByTestId("tenant-member-add-submit-btn"));
await waitFor(() => {
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
userIds: ["picked-user-1", "picked-user-2"],
tenantSlug: "tech-planning",
isAddTenant: true,
});
});
});
it("removes a member from the tenant and invalidates the user detail cache", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
const { queryClient } = renderTenantUsersPage();
queryClient.setQueryData(["user", "user-1"], {
id: "user-1",
name: "Alice",
});
await screen.findByText("Alice");
fireEvent.click(screen.getByTestId("tenant-member-remove-user-1"));
await waitFor(() => {
expect(updateUserMock).toHaveBeenCalledWith("user-1", {
tenantSlug: "tech-planning",
isRemoveTenant: true,
});
});
expect(queryClient.getQueryState(["user", "user-1"])?.isInvalidated).toBe(
true,
);
confirmSpy.mockRestore();
});
});

View File

@@ -40,6 +40,7 @@ import {
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
bulkUpdateUsers,
exportUsersCSV,
fetchTenant,
fetchUsers,
@@ -47,6 +48,10 @@ import {
updateUser,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
buildAuthenticatedOrgChartUserMultiPickerUrl,
parseOrgChartUserSelections,
} from "../../users/orgChartPicker";
function TenantUsersPage() {
const params = useParams<{ tenantId: string }>();
@@ -56,6 +61,13 @@ function TenantUsersPage() {
const [addMembersOpen, setAddMembersOpen] = React.useState(false);
const [memberSearch, setMemberSearch] = React.useState("");
const [queuedMembers, setQueuedMembers] = React.useState<UserSummary[]>([]);
const orgChartMemberPickerUrl = React.useMemo(
() =>
buildAuthenticatedOrgChartUserMultiPickerUrl(
import.meta.env.ORGFRONT_URL,
),
[],
);
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
const tenantQuery = useQuery({
@@ -103,7 +115,7 @@ function TenantUsersPage() {
const removeTenantMutation = useMutation({
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
onSuccess: () => {
onSuccess: (_result, variables) => {
toast.success(
t(
"msg.admin.tenants.members.remove_success",
@@ -111,6 +123,8 @@ function TenantUsersPage() {
),
);
usersQuery.refetch();
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", variables.userId] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
},
onError: (err: AxiosError<{ error?: string }>) => {
@@ -124,11 +138,11 @@ function TenantUsersPage() {
const addMembersMutation = useMutation({
mutationFn: async (members: UserSummary[]) => {
if (!tenantSlug || members.length === 0) return;
await Promise.all(
members.map((member) =>
updateUser(member.id, { tenantSlug, isAddTenant: true }),
),
);
await bulkUpdateUsers({
userIds: members.map((member) => member.id),
tenantSlug,
isAddTenant: true,
});
},
onSuccess: () => {
const count = queuedMembers.length;
@@ -179,11 +193,27 @@ function TenantUsersPage() {
);
const searchResults = memberSearchQuery.data?.items ?? [];
const queueMembers = React.useCallback(
(members: UserSummary[]) => {
setQueuedMembers((current) => {
const blockedIds = new Set([
...existingUserIds,
...current.map((member) => member.id),
]);
const next = [...current];
for (const member of members) {
if (blockedIds.has(member.id)) continue;
blockedIds.add(member.id);
next.push(member);
}
return next;
});
},
[existingUserIds],
);
const queueMember = (member: UserSummary) => {
if (existingUserIds.has(member.id) || queuedUserIds.has(member.id)) {
return;
}
setQueuedMembers((current) => [...current, member]);
queueMembers([member]);
};
const removeQueuedMember = (memberId: string) => {
@@ -192,6 +222,30 @@ function TenantUsersPage() {
);
};
React.useEffect(() => {
if (!addMembersOpen) return;
const onMessage = (event: MessageEvent) => {
const selections = parseOrgChartUserSelections(event.data);
if (selections.length === 0) return;
queueMembers(
selections.map((selection) => ({
id: selection.id,
name: selection.name,
email: selection.email,
role: "user",
status: "active",
createdAt: "",
updatedAt: "",
})),
);
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [addMembersOpen, queueMembers]);
return (
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
@@ -244,7 +298,7 @@ function TenantUsersPage() {
</div>
</CardHeader>
<Dialog open={addMembersOpen} onOpenChange={setAddMembersOpen}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
@@ -256,73 +310,86 @@ function TenantUsersPage() {
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={memberSearch}
onChange={(event) => setMemberSearch(event.target.value)}
className="h-9 pl-9"
placeholder={t(
"ui.admin.tenants.members.search_placeholder",
"이름 또는 이메일 검색",
)}
data-testid="tenant-member-search-input"
/>
</div>
<div className="rounded-md border">
<div className="max-h-56 overflow-auto">
{memberSearchTerm.length < 2 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{t(
"ui.admin.tenants.members.search_min_length",
"두 글자 이상 입력하세요.",
)}
</div>
) : memberSearchQuery.isFetching ? (
<div className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
{t("ui.common.searching", "검색 중...")}
</div>
) : searchResults.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{t("ui.common.no_results", "검색 결과가 없습니다.")}
</div>
) : (
<div className="divide-y">
{searchResults.map((user) => {
const disabled =
existingUserIds.has(user.id) ||
queuedUserIds.has(user.id);
return (
<button
key={user.id}
type="button"
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
onClick={() => queueMember(user)}
>
<span className="min-w-0">
<span className="block truncate font-medium">
{user.name}
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,1.2fr)]">
<div className="space-y-3">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={memberSearch}
onChange={(event) => setMemberSearch(event.target.value)}
className="h-9 pl-9"
placeholder={t(
"ui.admin.tenants.members.search_placeholder",
"이름 또는 이메일 검색",
)}
data-testid="tenant-member-search-input"
/>
</div>
<div className="rounded-md border">
<div className="max-h-56 overflow-auto">
{memberSearchTerm.length < 2 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{t(
"ui.admin.tenants.members.search_min_length",
"두 글자 이상 입력하세요.",
)}
</div>
) : memberSearchQuery.isFetching ? (
<div className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
{t("ui.common.searching", "검색 중...")}
</div>
) : searchResults.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{t("ui.common.no_results", "검색 결과가 없습니다.")}
</div>
) : (
<div className="divide-y">
{searchResults.map((user) => {
const disabled =
existingUserIds.has(user.id) ||
queuedUserIds.has(user.id);
return (
<button
key={user.id}
type="button"
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
onClick={() => queueMember(user)}
>
<span className="min-w-0">
<span className="block truncate font-medium">
{user.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{user.email}
</span>
</span>
<span className="block truncate text-xs text-muted-foreground">
{user.email}
</span>
</span>
<Plus size={16} className="flex-shrink-0" />
</button>
);
})}
</div>
)}
<Plus size={16} className="flex-shrink-0" />
</button>
);
})}
</div>
)}
</div>
</div>
</div>
<div className="min-h-[360px] overflow-hidden rounded-md border">
<iframe
title={t(
"ui.admin.tenants.members.org_picker_title",
"조직도에서 구성원 선택",
)}
src={orgChartMemberPickerUrl}
className="h-[420px] w-full"
data-testid="tenant-member-org-picker-frame"
/>
</div>
<div
className="min-h-20 rounded-md border bg-muted/20 p-3"
className="min-h-20 rounded-md border bg-muted/20 p-3 lg:col-span-2"
data-testid="tenant-member-add-queue"
>
{queuedMembers.length === 0 ? (
@@ -398,12 +465,15 @@ function TenantUsersPage() {
<TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead>
<TableHead className="text-right">
{t("ui.common.actions", "작업")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-20">
<TableCell colSpan={5} className="text-center py-20">
<div className="flex flex-col items-center gap-2">
<Loader2
className="animate-spin text-muted-foreground"
@@ -418,7 +488,7 @@ function TenantUsersPage() {
) : users.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
colSpan={5}
className="text-center py-8 text-muted-foreground"
>
{t(
@@ -460,6 +530,23 @@ function TenantUsersPage() {
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
aria-label={t(
"ui.admin.tenants.members.remove",
"구성원 제외",
)}
data-testid={`tenant-member-remove-${user.id}`}
onClick={(event) => {
event.stopPropagation();
_handleRemoveMember(user.id, user.name);
}}
>
<X size={16} />
</Button>
</TableCell>
</TableRow>
))
)}

View File

@@ -656,7 +656,7 @@ export function TenantWorksmobilePage() {
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
updateActionLabel="선택 구성원 업데이트 적용"
onCreateSelected={(ids, initialPassword) =>
createSelectedMutation.mutate({
createSelectedMutation.mutateAsync({
resourceKind: "users",
ids,
initialPassword,
@@ -1031,7 +1031,7 @@ function ComparisonTable({
actionLabel: string;
updateActionLabel?: string;
actionDisabled: boolean;
onCreateSelected: (ids: string[], initialPassword?: string) => void;
onCreateSelected: (ids: string[], initialPassword?: string) => unknown;
onUpdateSelected?: (ids: string[]) => void;
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
deleteActionLabel?: string;
@@ -1222,13 +1222,17 @@ function ComparisonTable({
onUpdateSelected(selectedUpdateUserIds);
};
const confirmInitialPassword = () => {
const confirmInitialPassword = async () => {
const password = initialPassword.trim();
if (!password) {
toast.error("WORKS 초기 비밀번호를 입력해 주세요.");
return;
}
onCreateSelected(pendingInitialPasswordIds, password);
try {
await onCreateSelected(pendingInitialPasswordIds, password);
} catch {
return;
}
setInitialPasswordOpen(false);
setInitialPassword("");
setPendingInitialPasswordIds([]);
@@ -1383,7 +1387,11 @@ function ComparisonTable({
>
</Button>
<Button type="button" onClick={confirmInitialPassword}>
<Button
type="button"
onClick={confirmInitialPassword}
disabled={actionDisabled}
>
</Button>
</DialogFooter>

View File

@@ -26,6 +26,14 @@ export function getTenantSearchMatchIds(
.map((row) => row.id);
}
export function filterTenantViewRowsBySearch<T extends TenantViewRow>(
rows: T[],
search: string,
) {
if (!search.trim()) return rows;
return rows.filter((row) => tenantMatchesListSearch(row, search));
}
function collectTenantTreeRows(
nodes: TenantNode[],
depth: number,