forked from baron/baron-sso
Merge branch 'dev' into feature/1058-adminfront-tab-rebac-permissions
This commit is contained in:
@@ -18,6 +18,11 @@ const notify = () => {
|
||||
};
|
||||
|
||||
const toastBase = (message: string, type: ToastType = "success") => {
|
||||
if (
|
||||
toasts.some((toast) => toast.message === message && toast.type === type)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
toasts = [...toasts, { id, message, type }];
|
||||
notify();
|
||||
|
||||
@@ -16,6 +16,7 @@ const exportUsersCSVMock = vi.hoisted(() =>
|
||||
filename: "users_export_20260609.csv",
|
||||
})),
|
||||
);
|
||||
const bulkUpdateUsersMock = vi.hoisted(() => vi.fn(async () => ({ results: [] })));
|
||||
|
||||
const tenants = [
|
||||
{
|
||||
@@ -127,6 +128,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
})),
|
||||
updateTenant: vi.fn(async () => tenants[2]),
|
||||
updateUser: vi.fn(async () => users[2]),
|
||||
bulkUpdateUsers: bulkUpdateUsersMock,
|
||||
exportTenantsCSV: vi.fn(async () => ({
|
||||
blob: new Blob(["name,slug"]),
|
||||
filename: "tenants.csv",
|
||||
@@ -227,4 +229,48 @@ describe("admin tenant tab coverage smoke", () => {
|
||||
expect(exportUsersCSVMock).toHaveBeenCalledWith("", "gpdtdc", false);
|
||||
});
|
||||
});
|
||||
|
||||
it("queues searched users and bulk adds them to the selected organization", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/organization"
|
||||
element={<TenantUserGroupsTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/organization",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Member User")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /멤버 추가/ }));
|
||||
fireEvent.change(screen.getByTestId("tenant-org-member-search-input"), {
|
||||
target: { value: "user" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("tenant-org-member-search-btn"));
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByTestId("tenant-org-member-search-result-user-owner"),
|
||||
);
|
||||
fireEvent.click(
|
||||
await screen.findByTestId("tenant-org-member-search-result-user-admin"),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("tenant-org-member-add-queue")).toHaveTextContent(
|
||||
"Owner User",
|
||||
);
|
||||
expect(screen.getByTestId("tenant-org-member-add-queue")).toHaveTextContent(
|
||||
"Admin User",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("tenant-org-member-add-submit-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
|
||||
userIds: ["user-owner", "user-admin"],
|
||||
tenantSlug: "gpdtdc",
|
||||
isAddTenant: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
deleteOrphanUserLoginIDs,
|
||||
fetchDataIntegrityReport,
|
||||
fetchMe,
|
||||
fetchOrySSOTSystemStatus,
|
||||
fetchOrphanUserLoginIDs,
|
||||
fetchOrySSOTSystemStatus,
|
||||
flushIdentityCache,
|
||||
} from "../../lib/adminApi";
|
||||
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSeedTenantSlugs, isSeedTenant } from "./protectedTenants";
|
||||
import { getSeedTenantIds, isSeedTenant } from "./protectedTenants";
|
||||
|
||||
describe("protectedTenants", () => {
|
||||
it("marks tenants from seed-tenant.csv as protected", () => {
|
||||
expect(getSeedTenantSlugs()).toEqual(
|
||||
expect.arrayContaining(["hanmac-family", "personal"]),
|
||||
it("marks tenants from seed-tenant.csv as protected by UUID", () => {
|
||||
expect(getSeedTenantIds()).toEqual(
|
||||
expect.arrayContaining([
|
||||
"038326b6-954a-48a7-a85f-efd83f62b82a",
|
||||
"5a03efd2-e62f-4243-800d-58334bf48b2f",
|
||||
"9607eb7b-04d2-42ab-80fe-780fe21c7e8f",
|
||||
]),
|
||||
);
|
||||
expect(isSeedTenant({ slug: "hanmac-family" })).toBe(true);
|
||||
expect(isSeedTenant({ slug: "normal-tenant" })).toBe(false);
|
||||
expect(
|
||||
isSeedTenant({
|
||||
id: "5a03efd2-e62f-4243-800d-58334bf48b2f",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSeedTenant({
|
||||
id: "5A03EFD2-E62F-4243-800D-58334BF48B2F",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isSeedTenant({ id: "normal-tenant" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,16 +4,15 @@ import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
|
||||
import type { TenantSummary } from "../../../lib/adminApi";
|
||||
import { parseTenantCSV } from "./tenantCsvImport";
|
||||
|
||||
const seedTenantSlugs = new Set(
|
||||
parseTenantCSV(seedTenantCSVRaw)
|
||||
.map((row) => row.slug.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
const seedTenants = parseTenantCSV(seedTenantCSVRaw);
|
||||
const seedTenantIds = new Set(
|
||||
seedTenants.map((row) => row.tenantId.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
|
||||
export function isSeedTenant(tenant: Pick<TenantSummary, "slug">): boolean {
|
||||
return seedTenantSlugs.has(tenant.slug.trim().toLowerCase());
|
||||
export function isSeedTenant(tenant: Pick<TenantSummary, "id">): boolean {
|
||||
return seedTenantIds.has(tenant.id.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function getSeedTenantSlugs(): string[] {
|
||||
return Array.from(seedTenantSlugs);
|
||||
export function getSeedTenantIds(): string[] {
|
||||
return Array.from(seedTenantIds);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
bulkUpdateUsers,
|
||||
exportTenantsCSV,
|
||||
exportUsersCSV,
|
||||
fetchAllTenants,
|
||||
@@ -72,6 +73,10 @@ import {
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
|
||||
import {
|
||||
buildAuthenticatedOrgChartUserMultiPickerUrl,
|
||||
parseOrgChartUserSelections,
|
||||
} from "../../users/orgChartPicker";
|
||||
|
||||
// --- Icons & Helpers ---
|
||||
const getTenantIcon = (type?: string) => {
|
||||
@@ -224,8 +229,10 @@ const MemberTable: React.FC<{
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
updateUser(userId, { tenantSlug, isRemoveTenant: true }),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_result, userId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user", userId] });
|
||||
toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다."));
|
||||
refetch();
|
||||
},
|
||||
@@ -297,7 +304,12 @@ const MemberTable: React.FC<{
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
data-testid={`tenant-org-member-actions-${user.id}`}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -314,6 +326,7 @@ const MemberTable: React.FC<{
|
||||
{t("ui.common.move_org", "타 조직으로 이동")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid={`tenant-org-member-remove-${user.id}`}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
@@ -635,9 +648,11 @@ function TenantUserGroupsTab() {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsUserAddOpen(true)}
|
||||
data-testid="tenant-org-member-add-open-btn"
|
||||
>
|
||||
<UserPlus size={16} className="mr-2" />
|
||||
{t("ui.admin.users.list.add", "멤버 추가")}
|
||||
@@ -869,8 +884,19 @@ const UserAddDialog: React.FC<{
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [queuedUsers, setQueuedUsers] = useState<UserSummary[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const orgChartMemberPickerUrl = React.useMemo(
|
||||
() =>
|
||||
buildAuthenticatedOrgChartUserMultiPickerUrl(
|
||||
import.meta.env.ORGFRONT_URL,
|
||||
),
|
||||
[],
|
||||
);
|
||||
const queuedUserIds = React.useMemo(
|
||||
() => new Set(queuedUsers.map((user) => user.id)),
|
||||
[queuedUsers],
|
||||
);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!userSearch) return;
|
||||
@@ -886,12 +912,22 @@ const UserAddDialog: React.FC<{
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedUserId) return;
|
||||
if (queuedUsers.length === 0) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updateUser(selectedUserId, { tenantSlug });
|
||||
await bulkUpdateUsers({
|
||||
userIds: queuedUsers.map((user) => user.id),
|
||||
tenantSlug,
|
||||
isAddTenant: true,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
||||
toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다."));
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.tenants.members.add_success",
|
||||
"{{count}}명의 구성원이 추가되었습니다.",
|
||||
{ count: queuedUsers.length },
|
||||
),
|
||||
);
|
||||
onOpenChange(false);
|
||||
resetFields();
|
||||
} catch (err) {
|
||||
@@ -908,9 +944,54 @@ const UserAddDialog: React.FC<{
|
||||
const resetFields = () => {
|
||||
setUserSearch("");
|
||||
setSearchResults([]);
|
||||
setSelectedUserId(null);
|
||||
setQueuedUsers([]);
|
||||
};
|
||||
|
||||
const queueUsers = React.useCallback((users: UserSummary[]) => {
|
||||
setQueuedUsers((current) => {
|
||||
const blockedIds = new Set(current.map((user) => user.id));
|
||||
const next = [...current];
|
||||
for (const user of users) {
|
||||
if (blockedIds.has(user.id)) continue;
|
||||
blockedIds.add(user.id);
|
||||
next.push(user);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const queueUser = (user: UserSummary) => {
|
||||
queueUsers([user]);
|
||||
};
|
||||
|
||||
const removeQueuedUser = (userId: string) => {
|
||||
setQueuedUsers((current) => current.filter((user) => user.id !== userId));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const selections = parseOrgChartUserSelections(event.data);
|
||||
if (selections.length === 0) return;
|
||||
|
||||
queueUsers(
|
||||
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);
|
||||
}, [open, queueUsers]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -919,7 +1000,7 @@ const UserAddDialog: React.FC<{
|
||||
if (!v) resetFields();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.users.create.title", "멤버 추가")}
|
||||
@@ -929,52 +1010,103 @@ const UserAddDialog: React.FC<{
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이메일 검색...",
|
||||
)}
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching}
|
||||
>
|
||||
<Search size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="h-60 border rounded-md">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{searchResults?.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${selectedUserId === user.id ? "bg-primary/5" : ""}`}
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{user.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<div className="grid gap-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,1.2fr)]">
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이메일 검색...",
|
||||
)}
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
data-testid="tenant-org-member-search-input"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching}
|
||||
data-testid="tenant-org-member-search-btn"
|
||||
>
|
||||
<Search size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="h-60 rounded-md border">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{searchResults?.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
data-testid={`tenant-org-member-search-result-${user.id}`}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${queuedUserIds.has(user.id) ? "bg-primary/5 opacity-60" : ""}`}
|
||||
onClick={() => queueUser(user)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{user.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
{queuedUserIds.has(user.id) && (
|
||||
<ChevronRight size={16} className="text-primary" />
|
||||
)}
|
||||
</div>
|
||||
{selectedUserId === user.id && (
|
||||
<ChevronRight size={16} className="text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</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-org-member-picker-frame"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="min-h-16 rounded-md border bg-muted/20 p-3 lg:col-span-2"
|
||||
data-testid="tenant-org-member-add-queue"
|
||||
>
|
||||
{queuedUsers.length === 0 ? (
|
||||
<div className="flex h-10 items-center justify-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.admin.tenants.members.queue_empty",
|
||||
"추가할 구성원을 선택하세요.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{queuedUsers.map((user) => (
|
||||
<span
|
||||
key={user.id}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-background px-2 py-1 text-sm"
|
||||
>
|
||||
<span className="max-w-52 truncate">{user.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => removeQueuedUser(user.id)}
|
||||
aria-label={t(
|
||||
"ui.admin.tenants.members.queue_remove",
|
||||
"추가 명단에서 제거",
|
||||
)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
@@ -982,7 +1114,8 @@ const UserAddDialog: React.FC<{
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAssign}
|
||||
disabled={isSubmitting || !selectedUserId}
|
||||
disabled={isSubmitting || queuedUsers.length === 0}
|
||||
data-testid="tenant-org-member-add-submit-btn"
|
||||
>
|
||||
{t("ui.common.add", "배정")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import GlobalCustomClaimsPage from "./GlobalCustomClaimsPage";
|
||||
|
||||
const fetchGlobalCustomClaimDefinitionsMock = vi.hoisted(() => vi.fn());
|
||||
const updateGlobalCustomClaimDefinitionsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchGlobalCustomClaimDefinitions: fetchGlobalCustomClaimDefinitionsMock,
|
||||
updateGlobalCustomClaimDefinitions: updateGlobalCustomClaimDefinitionsMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ui/use-toast", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function renderGlobalCustomClaimsPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<GlobalCustomClaimsPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("GlobalCustomClaimsPage", () => {
|
||||
beforeEach(() => {
|
||||
fetchGlobalCustomClaimDefinitionsMock.mockReset();
|
||||
fetchGlobalCustomClaimDefinitionsMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
key: "locale",
|
||||
label: "Locale",
|
||||
valueType: "text",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
updateGlobalCustomClaimDefinitionsMock.mockReset();
|
||||
updateGlobalCustomClaimDefinitionsMock.mockResolvedValue({ items: [] });
|
||||
});
|
||||
|
||||
it("forces user read permission on when user write permission is enabled", async () => {
|
||||
renderGlobalCustomClaimsPage();
|
||||
|
||||
const readSelect = await screen.findByTestId(
|
||||
"global-claim-definition-read-permission-locale",
|
||||
);
|
||||
const writeSelect = await screen.findByTestId(
|
||||
"global-claim-definition-write-permission-locale",
|
||||
);
|
||||
|
||||
expect(readSelect).toHaveValue("admin_only");
|
||||
expect(writeSelect).toHaveValue("admin_only");
|
||||
|
||||
fireEvent.change(writeSelect, { target: { value: "user_and_admin" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readSelect).toHaveValue("user_and_admin");
|
||||
expect(writeSelect).toHaveValue("user_and_admin");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /저장|Save/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateGlobalCustomClaimDefinitionsMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(updateGlobalCustomClaimDefinitionsMock.mock.calls[0][0]).toEqual({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
key: "locale",
|
||||
readPermission: "user_and_admin",
|
||||
writePermission: "user_and_admin",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ function toDrafts(items: GlobalCustomClaimDefinition[]): ClaimDraft[] {
|
||||
|
||||
function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] {
|
||||
return drafts
|
||||
.map((draft) => normalizeClaimDraftPermissions(draft))
|
||||
.map((draft) => ({
|
||||
key: draft.key.trim(),
|
||||
label: draft.label.trim(),
|
||||
@@ -63,6 +64,16 @@ function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] {
|
||||
.filter((draft) => draft.key.length > 0);
|
||||
}
|
||||
|
||||
function normalizeClaimDraftPermissions(draft: ClaimDraft): ClaimDraft {
|
||||
if (draft.writePermission !== "user_and_admin") {
|
||||
return draft;
|
||||
}
|
||||
return {
|
||||
...draft,
|
||||
readPermission: "user_and_admin",
|
||||
};
|
||||
}
|
||||
|
||||
function permissionLabel(permission: GlobalCustomClaimPermission) {
|
||||
return permission === "user_and_admin"
|
||||
? t(
|
||||
@@ -116,7 +127,9 @@ export default function GlobalCustomClaimsPage() {
|
||||
const updateClaim = (id: string, patch: Partial<ClaimDraft>) => {
|
||||
setDrafts((current) =>
|
||||
current.map((draft) =>
|
||||
draft.id === id ? { ...draft, ...patch } : draft,
|
||||
draft.id === id
|
||||
? normalizeClaimDraftPermissions({ ...draft, ...patch })
|
||||
: draft,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -140,7 +153,7 @@ export default function GlobalCustomClaimsPage() {
|
||||
)}
|
||||
description={t(
|
||||
"msg.admin.users.global_custom_claims.description",
|
||||
"모든 RP에 공통 적용할 사용자 claim 정의와 읽기/쓰기 권한 기본값을 관리합니다.",
|
||||
"모든 RP에 공통 적용할 사용자 claim 정의와 사용자의 읽기/쓰기 권한 기본값을 관리합니다. 쓰기 허용 시 읽기도 자동으로 허용됩니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
@@ -185,7 +198,7 @@ export default function GlobalCustomClaimsPage() {
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.users.global_custom_claims.registry",
|
||||
"정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다.",
|
||||
"정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다. 읽기/쓰기는 관리자 권한이 아니라 사용자가 본인 claim 값을 조회하거나 수정할 수 있는지에 대한 설정입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -7,27 +7,14 @@ import UserDetailPage from "./UserDetailPage";
|
||||
|
||||
const updateUserMock = vi.hoisted(() => vi.fn());
|
||||
const profileRoleMock = vi.hoisted(() => ({ role: "super_admin" }));
|
||||
const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
|
||||
const fetchUserMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
deleteUser: vi.fn(),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
fetchAllTenants: fetchAllTenantsMock,
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-user",
|
||||
role: profileRoleMock.role,
|
||||
@@ -48,42 +35,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
})),
|
||||
fetchPasswordPolicy: vi.fn(async () => ({ minLength: 12 })),
|
||||
fetchTenant: vi.fn(),
|
||||
fetchUser: vi.fn(async () => ({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
name: "사용자",
|
||||
phone: "01012345678",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "hanmac",
|
||||
tenant: {
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
joinedTenants: [],
|
||||
metadata: {
|
||||
employee_id: {
|
||||
"0": "h",
|
||||
"1": "j",
|
||||
"2": "k",
|
||||
"3": "w",
|
||||
"4": "o",
|
||||
"5": "n",
|
||||
},
|
||||
global_custom_claims: {
|
||||
contract_date: "2026-06-09",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
})),
|
||||
fetchUser: fetchUserMock,
|
||||
fetchUserRpHistory: vi.fn(async () => []),
|
||||
updateUser: updateUserMock,
|
||||
}));
|
||||
@@ -108,6 +60,60 @@ describe("UserDetailPage Worksmobile employee number", () => {
|
||||
beforeEach(() => {
|
||||
updateUserMock.mockReset();
|
||||
updateUserMock.mockResolvedValue({});
|
||||
fetchAllTenantsMock.mockReset();
|
||||
fetchAllTenantsMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
fetchUserMock.mockReset();
|
||||
fetchUserMock.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
name: "사용자",
|
||||
phone: "01012345678",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "hanmac",
|
||||
tenant: {
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
joinedTenants: [],
|
||||
metadata: {
|
||||
employee_id: {
|
||||
"0": "h",
|
||||
"1": "j",
|
||||
"2": "k",
|
||||
"3": "w",
|
||||
"4": "o",
|
||||
"5": "n",
|
||||
},
|
||||
global_custom_claims: {
|
||||
contract_date: "2026-06-09",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
});
|
||||
profileRoleMock.role = "super_admin";
|
||||
});
|
||||
|
||||
@@ -168,6 +174,111 @@ describe("UserDetailPage Worksmobile employee number", () => {
|
||||
expect(payload.metadata).not.toHaveProperty("employee_id");
|
||||
});
|
||||
|
||||
it("shows non-private appointment tenants from metadata and hides private tenants", async () => {
|
||||
fetchAllTenantsMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-public",
|
||||
type: "USER_GROUP",
|
||||
name: "공개 TF",
|
||||
slug: "public-tf",
|
||||
description: "",
|
||||
status: "active",
|
||||
config: { visibility: "public" },
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-internal",
|
||||
type: "USER_GROUP",
|
||||
name: "내부 조직",
|
||||
slug: "internal-team",
|
||||
description: "",
|
||||
status: "active",
|
||||
config: { visibility: "internal" },
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tenant-private",
|
||||
type: "USER_GROUP",
|
||||
name: "비공개 조직",
|
||||
slug: "private-team",
|
||||
description: "",
|
||||
status: "active",
|
||||
config: { visibility: "private" },
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 4,
|
||||
});
|
||||
fetchUserMock.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
name: "사용자",
|
||||
phone: "01012345678",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "hanmac",
|
||||
tenant: {
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
joinedTenants: [],
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantId: "tenant-public",
|
||||
tenantSlug: "public-tf",
|
||||
tenantName: "공개 TF",
|
||||
},
|
||||
{
|
||||
tenantId: "tenant-internal",
|
||||
tenantSlug: "internal-team",
|
||||
tenantName: "내부 조직",
|
||||
},
|
||||
{
|
||||
tenantId: "tenant-private",
|
||||
tenantSlug: "private-team",
|
||||
tenantName: "비공개 조직",
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
});
|
||||
|
||||
renderUserDetailPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /테넌트 프로필/ }));
|
||||
|
||||
expect(await screen.findByText("공개 TF")).toBeInTheDocument();
|
||||
expect(screen.getByText("내부 조직")).toBeInTheDocument();
|
||||
expect(screen.queryByText("비공개 조직")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("only allows editing per-user values for globally defined custom claims", async () => {
|
||||
renderUserDetailPage();
|
||||
|
||||
@@ -208,4 +319,79 @@ describe("UserDetailPage Worksmobile employee number", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves per-user global custom claim permissions instead of overwriting them from definitions", async () => {
|
||||
fetchUserMock.mockResolvedValueOnce({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
name: "사용자",
|
||||
phone: "01012345678",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "hanmac",
|
||||
tenant: {
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
joinedTenants: [],
|
||||
metadata: {
|
||||
global_custom_claims: {
|
||||
contract_date: "2026-06-09",
|
||||
},
|
||||
global_custom_claim_types: {
|
||||
contract_date: "date",
|
||||
},
|
||||
global_custom_claim_permissions: {
|
||||
contract_date: {
|
||||
readPermission: "user_and_admin",
|
||||
writePermission: "user_and_admin",
|
||||
},
|
||||
},
|
||||
},
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
});
|
||||
|
||||
renderUserDetailPage();
|
||||
|
||||
const tab = await screen.findByTestId("global-custom-claim-tab");
|
||||
fireEvent.click(tab);
|
||||
const valueInput = await screen.findByTestId(
|
||||
"global-custom-claim-value-contract_date",
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("사용자 및 관리자 가능").length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
|
||||
fireEvent.change(valueInput, { target: { value: "2026-07-01" } });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /사용자 Claim 값 저장/ }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||
expect(updateUserMock).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
global_custom_claims: expect.objectContaining({
|
||||
contract_date: "2026-07-01",
|
||||
}),
|
||||
global_custom_claim_permissions: expect.objectContaining({
|
||||
contract_date: {
|
||||
readPermission: "user_and_admin",
|
||||
writePermission: "user_and_admin",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,6 +141,15 @@ function isMetadataRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeCustomClaimPermission(
|
||||
value: unknown,
|
||||
fallback: CustomClaimPermission,
|
||||
): CustomClaimPermission {
|
||||
return value === "admin_only" || value === "user_and_admin"
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function cleanMetadataValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
@@ -209,9 +218,18 @@ function createGlobalCustomClaimRows(
|
||||
const rawClaims = isMetadataRecord(metadata.global_custom_claims)
|
||||
? metadata.global_custom_claims
|
||||
: {};
|
||||
const rawPermissions = isMetadataRecord(
|
||||
metadata.global_custom_claim_permissions,
|
||||
)
|
||||
? metadata.global_custom_claim_permissions
|
||||
: {};
|
||||
|
||||
return definitions.map((definition, index) => {
|
||||
const value = rawClaims[definition.key];
|
||||
const rawPermission = rawPermissions[definition.key];
|
||||
const permission: Record<string, unknown> = isMetadataRecord(rawPermission)
|
||||
? rawPermission
|
||||
: {};
|
||||
return {
|
||||
id: `${definition.key}-${index}`,
|
||||
key: definition.key,
|
||||
@@ -224,8 +242,14 @@ function createGlobalCustomClaimRows(
|
||||
? ""
|
||||
: JSON.stringify(value),
|
||||
valueType: definition.valueType,
|
||||
readPermission: definition.readPermission,
|
||||
writePermission: definition.writePermission,
|
||||
readPermission: normalizeCustomClaimPermission(
|
||||
permission.readPermission,
|
||||
definition.readPermission,
|
||||
),
|
||||
writePermission: normalizeCustomClaimPermission(
|
||||
permission.writePermission,
|
||||
definition.writePermission,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -291,6 +315,48 @@ async function resolveTenantSelection(
|
||||
};
|
||||
}
|
||||
|
||||
function getTenantVisibility(tenant?: TenantSummary) {
|
||||
const value = tenant?.config?.visibility;
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "public";
|
||||
}
|
||||
|
||||
function isPrivateTenant(tenant?: TenantSummary) {
|
||||
return getTenantVisibility(tenant) === "private";
|
||||
}
|
||||
|
||||
function appointmentTenantsFromMetadata(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
tenants: TenantSummary[],
|
||||
) {
|
||||
const rawAppointments = metadata?.additionalAppointments;
|
||||
if (!Array.isArray(rawAppointments)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawAppointments
|
||||
.map((raw) => {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const appointment = raw as Record<string, unknown>;
|
||||
const tenantId =
|
||||
typeof appointment.tenantId === "string" ? appointment.tenantId : "";
|
||||
const tenantSlug =
|
||||
typeof appointment.tenantSlug === "string"
|
||||
? appointment.tenantSlug
|
||||
: typeof appointment.slug === "string"
|
||||
? appointment.slug
|
||||
: "";
|
||||
return tenants.find(
|
||||
(tenant) =>
|
||||
(tenantId && tenant.id === tenantId) ||
|
||||
(tenantSlug && tenant.slug === tenantSlug),
|
||||
);
|
||||
})
|
||||
.filter((tenant): tenant is TenantSummary => Boolean(tenant))
|
||||
.filter((tenant) => !isPrivateTenant(tenant));
|
||||
}
|
||||
|
||||
function createEmptyAppointment(): AppointmentDraft {
|
||||
return {
|
||||
draftId: createDraftId(),
|
||||
@@ -385,8 +451,6 @@ function TenantMetadataFields({
|
||||
register: UseFormRegister<UserFormValues>;
|
||||
errors: FieldErrors<UserFormValues>;
|
||||
}) {
|
||||
if (schema.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm">
|
||||
<div className="bg-muted/30 px-5 py-3 border-b border-border flex items-center justify-between">
|
||||
@@ -401,74 +465,85 @@ function TenantMetadataFields({
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-6 grid gap-6 md:grid-cols-2">
|
||||
{schema.map((field) => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label
|
||||
htmlFor={`metadata.${tenant.id}.${field.key}`}
|
||||
className="text-xs font-semibold text-muted-foreground flex items-center gap-1"
|
||||
>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive">*</span>}
|
||||
{field.adminOnly && (
|
||||
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
|
||||
Admin Only
|
||||
</span>
|
||||
)}
|
||||
{field.isLoginId && (
|
||||
<span className="ml-2 text-[9px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
|
||||
{t("ui.admin.users.detail.form.is_login_id", "로그인 ID")}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id={`metadata.${tenant.id}.${field.key}`}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "date"
|
||||
? "date"
|
||||
: field.type === "boolean"
|
||||
? "checkbox"
|
||||
: "text"
|
||||
}
|
||||
className={field.type === "boolean" ? "w-5 h-5" : "h-10 text-sm"}
|
||||
{...register(`metadata.${tenant.id}.${field.key}` as const, {
|
||||
required: field.required
|
||||
? t(
|
||||
"msg.admin.users.detail.form.field_required",
|
||||
"필수입니다.",
|
||||
)
|
||||
: false,
|
||||
pattern: field.validation
|
||||
? {
|
||||
value: new RegExp(field.validation),
|
||||
message: t(
|
||||
"msg.admin.users.detail.form.invalid_format",
|
||||
"형식이 올바르지 않습니다.",
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
})}
|
||||
/>
|
||||
{(
|
||||
errors.metadata as unknown as Record<
|
||||
string,
|
||||
Record<string, { message?: string }>
|
||||
>
|
||||
)?.[tenant.id]?.[field.key] && (
|
||||
<p className="text-[10px] text-destructive font-medium">
|
||||
{
|
||||
(
|
||||
errors.metadata as unknown as Record<
|
||||
string,
|
||||
Record<string, { message?: string }>
|
||||
>
|
||||
)?.[tenant.id]?.[field.key]?.message
|
||||
}
|
||||
</p>
|
||||
{schema.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground md:col-span-2">
|
||||
{t(
|
||||
"msg.admin.users.detail.tenant_schema_empty",
|
||||
"이 테넌트에 설정된 프로필 필드가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</p>
|
||||
) : (
|
||||
schema.map((field) => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label
|
||||
htmlFor={`metadata.${tenant.id}.${field.key}`}
|
||||
className="text-xs font-semibold text-muted-foreground flex items-center gap-1"
|
||||
>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive">*</span>}
|
||||
{field.adminOnly && (
|
||||
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
|
||||
Admin Only
|
||||
</span>
|
||||
)}
|
||||
{field.isLoginId && (
|
||||
<span className="ml-2 text-[9px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
|
||||
{t("ui.admin.users.detail.form.is_login_id", "로그인 ID")}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id={`metadata.${tenant.id}.${field.key}`}
|
||||
type={
|
||||
field.type === "number"
|
||||
? "number"
|
||||
: field.type === "date"
|
||||
? "date"
|
||||
: field.type === "boolean"
|
||||
? "checkbox"
|
||||
: "text"
|
||||
}
|
||||
className={
|
||||
field.type === "boolean" ? "w-5 h-5" : "h-10 text-sm"
|
||||
}
|
||||
{...register(`metadata.${tenant.id}.${field.key}` as const, {
|
||||
required: field.required
|
||||
? t(
|
||||
"msg.admin.users.detail.form.field_required",
|
||||
"필수입니다.",
|
||||
)
|
||||
: false,
|
||||
pattern: field.validation
|
||||
? {
|
||||
value: new RegExp(field.validation),
|
||||
message: t(
|
||||
"msg.admin.users.detail.form.invalid_format",
|
||||
"형식이 올바르지 않습니다.",
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
})}
|
||||
/>
|
||||
{(
|
||||
errors.metadata as unknown as Record<
|
||||
string,
|
||||
Record<string, { message?: string }>
|
||||
>
|
||||
)?.[tenant.id]?.[field.key] && (
|
||||
<p className="text-[10px] text-destructive font-medium">
|
||||
{
|
||||
(
|
||||
errors.metadata as unknown as Record<
|
||||
string,
|
||||
Record<string, { message?: string }>
|
||||
>
|
||||
)?.[tenant.id]?.[field.key]?.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1103,12 +1178,30 @@ function UserDetailPage() {
|
||||
const userAffiliatedTenants = React.useMemo(() => {
|
||||
const joined = user?.joinedTenants || [];
|
||||
const primary = user?.tenant;
|
||||
const all = [...joined];
|
||||
if (primary && !joined.some((t) => t.id === primary.id)) {
|
||||
const appointmentTenants = appointmentTenantsFromMetadata(
|
||||
user?.metadata as Record<string, unknown> | undefined,
|
||||
tenants,
|
||||
);
|
||||
const all = joined.filter((tenant) => {
|
||||
const fullTenant = tenants.find((item) => item.id === tenant.id);
|
||||
return !isPrivateTenant(fullTenant ?? tenant);
|
||||
});
|
||||
if (
|
||||
primary &&
|
||||
!isPrivateTenant(
|
||||
tenants.find((tenant) => tenant.id === primary.id) ?? primary,
|
||||
) &&
|
||||
!all.some((t) => t.id === primary.id)
|
||||
) {
|
||||
all.unshift(primary);
|
||||
}
|
||||
for (const tenant of appointmentTenants) {
|
||||
if (!all.some((item) => item.id === tenant.id)) {
|
||||
all.push(tenant);
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}, [user?.joinedTenants, user?.tenant]);
|
||||
}, [tenants, user?.joinedTenants, user?.metadata, user?.tenant]);
|
||||
const selectableRepresentativeTenants = React.useMemo(
|
||||
() =>
|
||||
filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId),
|
||||
@@ -1962,7 +2055,7 @@ function UserDetailPage() {
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.users.detail.custom_claims.description",
|
||||
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
|
||||
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ const users = Array.from({ length: 200 }, (_, index) => ({
|
||||
}));
|
||||
|
||||
const fetchUsersMock = vi.hoisted(() => vi.fn());
|
||||
const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
|
||||
const searchRenderBudgetMs =
|
||||
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 300;
|
||||
|
||||
@@ -34,10 +35,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
name: "Admin",
|
||||
email: "admin@example.com",
|
||||
})),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
|
||||
total: 1,
|
||||
})),
|
||||
fetchAllTenants: fetchAllTenantsMock,
|
||||
fetchTenant: vi.fn(async () => ({
|
||||
id: "tenant-1",
|
||||
name: "한맥",
|
||||
@@ -108,6 +106,11 @@ describe("UserListPage search rendering", () => {
|
||||
beforeEach(() => {
|
||||
selectRenderCounter.count = 0;
|
||||
fetchUsersMock.mockReset();
|
||||
fetchAllTenantsMock.mockReset();
|
||||
fetchAllTenantsMock.mockResolvedValue({
|
||||
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
|
||||
total: 1,
|
||||
});
|
||||
fetchUsersMock.mockImplementation(
|
||||
async (_limit: number, _offset: number, search?: string) => {
|
||||
const normalizedSearch = search?.trim().toLowerCase();
|
||||
@@ -157,7 +160,7 @@ describe("UserListPage search rendering", () => {
|
||||
expect(content).toHaveClass("flex", "h-full", "items-center");
|
||||
});
|
||||
|
||||
it("renders additional tenant appointments in the tenant column", async () => {
|
||||
it("does not render private additional tenant appointments in the tenant column", async () => {
|
||||
fetchUsersMock.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
@@ -183,7 +186,63 @@ describe("UserListPage search rendering", () => {
|
||||
expect(
|
||||
await screen.findByText("Additional Tenant User"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("비공개 팀")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("한맥").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByText("비공개 팀")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("excludes private tenants when choosing the representative tenant for the user list", async () => {
|
||||
fetchAllTenantsMock.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: "tenant-private",
|
||||
name: "비공개 팀",
|
||||
slug: "private-team",
|
||||
config: { visibility: "private" },
|
||||
},
|
||||
{
|
||||
id: "tenant-public",
|
||||
name: "공개 팀",
|
||||
slug: "public-team",
|
||||
config: { visibility: "public" },
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
fetchUsersMock.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
...users[0],
|
||||
name: "Private Primary User",
|
||||
tenantSlug: "private-team",
|
||||
tenant: {
|
||||
id: "tenant-private",
|
||||
name: "비공개 팀",
|
||||
slug: "private-team",
|
||||
config: { visibility: "private" },
|
||||
},
|
||||
joinedTenants: [
|
||||
{
|
||||
id: "tenant-public",
|
||||
name: "공개 팀",
|
||||
slug: "public-team",
|
||||
config: { visibility: "public" },
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
primaryTenantId: "tenant-private",
|
||||
primaryTenantSlug: "private-team",
|
||||
primaryTenantName: "비공개 팀",
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
renderUserListPage();
|
||||
|
||||
expect(await screen.findByText("Private Primary User")).toBeInTheDocument();
|
||||
expect(screen.getByText("공개 팀")).toBeInTheDocument();
|
||||
expect(screen.queryByText("비공개 팀")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("centers the initial loading message across the user table", async () => {
|
||||
|
||||
@@ -151,50 +151,111 @@ function assignableSystemRoleValue(role?: string | null) {
|
||||
return isSuperAdminRole(role) ? "super_admin" : "user";
|
||||
}
|
||||
|
||||
function collectAdditionalTenantLabels(user: UserSummary) {
|
||||
const primaryKeys = new Set(
|
||||
[user.tenant?.id, user.tenant?.slug, user.tenantSlug]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.toLowerCase()),
|
||||
type RepresentativeTenantCandidate = {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function tenantVisibility(tenant?: RepresentativeTenantCandidate) {
|
||||
const visibility = tenant?.config?.visibility;
|
||||
return typeof visibility === "string" ? visibility.trim() : "";
|
||||
}
|
||||
|
||||
function findTenantCandidate(
|
||||
candidate: RepresentativeTenantCandidate,
|
||||
tenants: TenantSummary[],
|
||||
) {
|
||||
const id = candidate.id?.toLowerCase() ?? "";
|
||||
const slug = candidate.slug?.toLowerCase() ?? "";
|
||||
if (!id && !slug) return undefined;
|
||||
return tenants.find(
|
||||
(tenant) =>
|
||||
(id && tenant.id.toLowerCase() === id) ||
|
||||
(slug && tenant.slug.toLowerCase() === slug),
|
||||
);
|
||||
const labels: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addLabel = (
|
||||
tenantId?: unknown,
|
||||
tenantSlug?: unknown,
|
||||
tenantName?: unknown,
|
||||
) => {
|
||||
const id = typeof tenantId === "string" ? tenantId.trim() : "";
|
||||
const slug = typeof tenantSlug === "string" ? tenantSlug.trim() : "";
|
||||
const name = typeof tenantName === "string" ? tenantName.trim() : "";
|
||||
const key = (id || slug || name).toLowerCase();
|
||||
if (!key || primaryKeys.has(key) || seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
labels.push(name || slug || id);
|
||||
};
|
||||
}
|
||||
|
||||
function isPrivateTenantCandidate(
|
||||
candidate: RepresentativeTenantCandidate,
|
||||
tenants: TenantSummary[],
|
||||
) {
|
||||
const tenant = findTenantCandidate(candidate, tenants) ?? candidate;
|
||||
return tenantVisibility(tenant) === "private";
|
||||
}
|
||||
|
||||
function candidateLabel(candidate: RepresentativeTenantCandidate) {
|
||||
return candidate.name || candidate.slug || candidate.id || "";
|
||||
}
|
||||
|
||||
function metadataTenantCandidate(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): RepresentativeTenantCandidate | null {
|
||||
const id = stringValue(metadata?.primaryTenantId);
|
||||
const slug = stringValue(metadata?.primaryTenantSlug);
|
||||
const name = stringValue(metadata?.primaryTenantName);
|
||||
if (!id && !slug && !name) return null;
|
||||
return { id, slug, name };
|
||||
}
|
||||
|
||||
function appointmentTenantCandidate(
|
||||
appointment: unknown,
|
||||
): RepresentativeTenantCandidate | null {
|
||||
if (!appointment || typeof appointment !== "object") return null;
|
||||
const value = appointment as Record<string, unknown>;
|
||||
const id = stringValue(value.tenantId);
|
||||
const slug = stringValue(value.tenantSlug ?? value.slug);
|
||||
const name = stringValue(value.tenantName ?? value.name);
|
||||
if (!id && !slug && !name) return null;
|
||||
return { id, slug, name };
|
||||
}
|
||||
|
||||
function resolveRepresentativeTenantLabel(
|
||||
user: UserSummary,
|
||||
tenants: TenantSummary[],
|
||||
) {
|
||||
const candidates: RepresentativeTenantCandidate[] = [];
|
||||
const knownTenants = [
|
||||
...(user.tenant ? [user.tenant] : []),
|
||||
...(user.joinedTenants ?? []),
|
||||
...tenants,
|
||||
];
|
||||
const primaryFromMetadata = metadataTenantCandidate(user.metadata);
|
||||
if (primaryFromMetadata) candidates.push(primaryFromMetadata);
|
||||
if (user.tenant) candidates.push(user.tenant);
|
||||
|
||||
for (const tenant of user.joinedTenants ?? []) {
|
||||
addLabel(tenant.id, tenant.slug, tenant.name);
|
||||
candidates.push(tenant);
|
||||
}
|
||||
|
||||
const appointments = user.metadata?.additionalAppointments;
|
||||
if (Array.isArray(appointments)) {
|
||||
for (const appointment of appointments) {
|
||||
if (!appointment || typeof appointment !== "object") {
|
||||
if (
|
||||
appointment &&
|
||||
typeof appointment === "object" &&
|
||||
(appointment as Record<string, unknown>).isPrimary !== true
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const value = appointment as Record<string, unknown>;
|
||||
addLabel(
|
||||
value.tenantId,
|
||||
value.tenantSlug ?? value.slug,
|
||||
value.tenantName ?? value.name,
|
||||
);
|
||||
const candidate = appointmentTenantCandidate(appointment);
|
||||
if (candidate) candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
if (user.tenantSlug) candidates.push({ slug: user.tenantSlug });
|
||||
|
||||
return labels;
|
||||
const representative = candidates.find(
|
||||
(candidate) =>
|
||||
candidateLabel(candidate) &&
|
||||
!isPrivateTenantCandidate(candidate, knownTenants),
|
||||
);
|
||||
|
||||
return candidateLabel(representative ?? {});
|
||||
}
|
||||
|
||||
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
|
||||
@@ -468,10 +529,10 @@ function UserListPage() {
|
||||
name_email: (user) =>
|
||||
`${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`,
|
||||
tenant_dept: (user) =>
|
||||
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${collectAdditionalTenantLabels(user).join(" ")} ${user.department ?? ""}`,
|
||||
`${resolveRepresentativeTenantLabel(user, tenants)} ${user.department ?? ""}`,
|
||||
},
|
||||
),
|
||||
[userSchema],
|
||||
[tenants, userSchema],
|
||||
);
|
||||
const items = React.useMemo(() => {
|
||||
if (!sortConfig) {
|
||||
@@ -1028,8 +1089,9 @@ function UserListPage() {
|
||||
virtualRows.map((virtualRow) => {
|
||||
const user = items[virtualRow.index];
|
||||
if (!user) return null;
|
||||
const additionalTenantLabels =
|
||||
collectAdditionalTenantLabels(user);
|
||||
const representativeTenantLabel =
|
||||
resolveRepresentativeTenantLabel(user, tenants) ||
|
||||
t("ui.common.unassigned", "미배정");
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -1161,27 +1223,13 @@ function UserListPage() {
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{user.tenant?.name ||
|
||||
user.tenantSlug ||
|
||||
t("ui.common.unassigned", "미배정")}
|
||||
{representativeTenantLabel}
|
||||
</span>
|
||||
{user.department && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department}
|
||||
</span>
|
||||
)}
|
||||
{additionalTenantLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{additionalTenantLabels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="max-w-40 truncate rounded border bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
|
||||
@@ -2,11 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
buildAuthenticatedOrgChartUrl,
|
||||
buildAuthenticatedOrgChartUserMultiPickerUrl,
|
||||
buildOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
getTenantGradeOptions,
|
||||
isHanmacFamilyUser,
|
||||
parseOrgChartTenantSelection,
|
||||
parseOrgChartUserSelections,
|
||||
} from "./orgChartPicker";
|
||||
|
||||
describe("orgChartPicker", () => {
|
||||
@@ -49,6 +51,16 @@ describe("orgChartPicker", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("builds an authenticated multi picker URL for tenant member selection", () => {
|
||||
expect(
|
||||
buildAuthenticatedOrgChartUserMultiPickerUrl(
|
||||
"https://orgchart.example.com",
|
||||
),
|
||||
).toBe(
|
||||
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dmultiple%26select%3Duser%26width%3D720%26height%3D640%26includeInternal%3Dtrue%26includeDescendants%3Dtrue%26showDescendantToggle%3Dtrue",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the admin chart navigation URL with internal visibility enabled", () => {
|
||||
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
|
||||
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue",
|
||||
@@ -98,6 +110,39 @@ describe("orgChartPicker", () => {
|
||||
expect(parseOrgChartTenantSelection({ type: "other" })).toBeNull();
|
||||
});
|
||||
|
||||
it("parses user selections from orgfront multi picker messages", () => {
|
||||
expect(
|
||||
parseOrgChartUserSelections({
|
||||
type: "orgfront:picker:confirm",
|
||||
payload: {
|
||||
mode: "multiple",
|
||||
selections: [
|
||||
{ type: "tenant", id: "tenant-1", name: "기술기획" },
|
||||
{
|
||||
type: "user",
|
||||
id: "user-1",
|
||||
name: "홍길동",
|
||||
email: "hong@example.com",
|
||||
},
|
||||
{ type: "user", id: "user-2", name: "김영희" },
|
||||
{ type: "user", id: "", name: "잘못된 사용자" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "user-1",
|
||||
name: "홍길동",
|
||||
email: "hong@example.com",
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
name: "김영희",
|
||||
email: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters Hanmac family subtree and system tenants from non-family tenant choices", () => {
|
||||
const visibleTenants = filterNonHanmacFamilyTenants(
|
||||
[
|
||||
|
||||
@@ -3,6 +3,12 @@ export type OrgChartTenantSelection = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type OrgChartUserSelection = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type TenantFilterTarget = {
|
||||
id?: string;
|
||||
tenantId?: string;
|
||||
@@ -31,6 +37,7 @@ type OrgChartPickerMessage = {
|
||||
type?: unknown;
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
email?: unknown;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
@@ -317,6 +324,26 @@ export function buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
|
||||
}
|
||||
|
||||
export function buildOrgChartUserMultiPickerUrl(baseUrl?: string) {
|
||||
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
|
||||
const params = new URLSearchParams({
|
||||
mode: "multiple",
|
||||
select: "user",
|
||||
width: "720",
|
||||
height: "640",
|
||||
});
|
||||
params.set("includeInternal", "true");
|
||||
params.set("includeDescendants", "true");
|
||||
params.set("showDescendantToggle", "true");
|
||||
|
||||
return `${normalizedBase}/embed/picker?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildAuthenticatedOrgChartUserMultiPickerUrl(baseUrl?: string) {
|
||||
const pickerUrl = buildOrgChartUserMultiPickerUrl("");
|
||||
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
|
||||
}
|
||||
|
||||
export function buildAuthenticatedOrgChartUrl(
|
||||
baseUrl?: string,
|
||||
options: OrgChartLoginOptions = { includeInternal: true },
|
||||
@@ -360,3 +387,33 @@ export function parseOrgChartTenantSelection(
|
||||
name: selection.name,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOrgChartUserSelections(
|
||||
message: unknown,
|
||||
): OrgChartUserSelection[] {
|
||||
const data = message as OrgChartPickerMessage;
|
||||
if (data?.type !== "orgfront:picker:confirm") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (data.payload?.selections ?? [])
|
||||
.filter(
|
||||
(
|
||||
selection,
|
||||
): selection is {
|
||||
type: "user";
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
} =>
|
||||
selection?.type === "user" &&
|
||||
typeof selection.id === "string" &&
|
||||
typeof selection.name === "string" &&
|
||||
selection.id.trim() !== "",
|
||||
)
|
||||
.map((selection) => ({
|
||||
id: selection.id,
|
||||
name: selection.name,
|
||||
email: typeof selection.email === "string" ? selection.email : "",
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1213,6 +1213,7 @@ export async function bulkUpdateUsers(payload: {
|
||||
status?: string;
|
||||
role?: string;
|
||||
tenantSlug?: string;
|
||||
isAddTenant?: boolean;
|
||||
department?: string;
|
||||
position?: string;
|
||||
grade?: string;
|
||||
|
||||
@@ -348,9 +348,13 @@ update_error = "Failed to User Edit."
|
||||
update_success = "Update Success"
|
||||
|
||||
[msg.admin.users.detail.custom_claims]
|
||||
description = "Manage this user's values for globally defined custom claims. Add claim definitions and change types only from the global settings screen."
|
||||
description = "Manage this user's values for globally defined custom claims. Read/Write indicates whether the user may view or update their own claim value. Add claim definitions and change types only from the global settings screen."
|
||||
empty = "No global custom claims have been defined."
|
||||
|
||||
[msg.admin.users.global_custom_claims]
|
||||
description = "Manage user claim definitions shared across all RPs and the default user read/write permissions. Enabling write also enables read."
|
||||
registry = "Only defined claim keys are available in per-user global claim values. Read/Write is a user self-service permission, not an administrator permission."
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = "Required."
|
||||
invalid_format = "Invalid format."
|
||||
@@ -1208,6 +1212,7 @@ title = "API Key Registry"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
delete_selected = "Delete Selected"
|
||||
org_picker_title = "Select Organization"
|
||||
view_org_chart = "View Full Org Chart"
|
||||
direct_label = "Direct"
|
||||
list_title = "Member Management"
|
||||
|
||||
@@ -353,9 +353,13 @@ update_success = "사용자 정보가 수정되었습니다."
|
||||
self_delete_blocked = "본인 계정은 삭제할 수 없습니다."
|
||||
|
||||
[msg.admin.users.detail.custom_claims]
|
||||
description = "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다."
|
||||
description = "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다."
|
||||
empty = "전역으로 정의된 custom claim이 없습니다."
|
||||
|
||||
[msg.admin.users.global_custom_claims]
|
||||
description = "모든 RP에 공통 적용할 사용자 claim 정의와 사용자의 읽기/쓰기 권한 기본값을 관리합니다. 쓰기 허용 시 읽기도 자동으로 허용됩니다."
|
||||
registry = "정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다. 읽기/쓰기는 관리자 권한이 아니라 사용자가 본인 claim 값을 조회하거나 수정할 수 있는지에 대한 설정입니다."
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = "필수입니다."
|
||||
invalid_format = "형식이 올바르지 않습니다."
|
||||
@@ -1212,6 +1216,7 @@ title = "API 키 레지스트리"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
delete_selected = "선택 삭제"
|
||||
org_picker_title = "조직 선택"
|
||||
view_org_chart = "전체 조직도 보기"
|
||||
direct_label = "직속"
|
||||
list_title = "구성원 관리"
|
||||
|
||||
@@ -1225,6 +1225,7 @@ title = ""
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
delete_selected = ""
|
||||
org_picker_title = ""
|
||||
view_org_chart = ""
|
||||
direct_label = ""
|
||||
list_title = ""
|
||||
|
||||
Reference in New Issue
Block a user