1
0
forked from baron/baron-sso

custom claim 타입보정 UI. 대표테넌트 노출 보정

This commit is contained in:
2026-06-11 11:27:11 +09:00
parent 0bb3ccb850
commit f60b15a17b
37 changed files with 2952 additions and 417 deletions

View File

@@ -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();

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

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -2051,7 +2051,7 @@ function UserDetailPage() {
<CardDescription>
{t(
"msg.admin.users.detail.custom_claims.description",
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
)}
</CardDescription>
</div>

View File

@@ -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 () => {

View File

@@ -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 {
@@ -467,10 +528,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) {
@@ -1019,8 +1080,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
@@ -1151,27 +1213,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 */}

View File

@@ -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."

View File

@@ -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 = "형식이 올바르지 않습니다."

View File

@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
const tenants = [
{
id: "seed-hanmac",
id: "038326b6-954a-48a7-a85f-efd83f62b82a",
name: "한맥가족",
slug: "hanmac-family",
type: "COMPANY_GROUP",
@@ -13,6 +13,19 @@ const tenants = [
createdAt: "",
updatedAt: "",
},
{
id: "5a03efd2-e62f-4243-800d-58334bf48b2f",
name: "한라산업개발",
slug: "hallasanup",
type: "COMPANY",
description: "네이버웍스 한라 HALLA_DOMAIN_ID",
status: "active",
domains: ["hallasanup.com"],
memberCount: 0,
parentId: "038326b6-954a-48a7-a85f-efd83f62b82a",
createdAt: "",
updatedAt: "",
},
{
id: "normal-tenant",
name: "일반 테넌트",
@@ -96,11 +109,21 @@ test.describe("Seed tenant protection", () => {
}) => {
await page.goto("/tenants");
const seedRow = page.getByRole("row", { name: /한맥가족/ });
const seedRow = page.getByRole("row").filter({
has: page.getByRole("link", { name: "한맥가족", exact: true }),
});
await expect(seedRow.getByRole("checkbox")).toHaveCount(0);
await expect(seedRow.getByText("초기 설정")).toBeVisible();
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
const hallaRow = page.getByRole("row").filter({
has: page.getByRole("link", { name: "한라산업개발", exact: true }),
});
await expect(hallaRow.getByRole("checkbox")).toHaveCount(0);
await expect(hallaRow.getByText("초기 설정")).toBeVisible();
const normalRow = page.getByRole("row").filter({
has: page.getByRole("link", { name: "일반 테넌트", exact: true }),
});
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
});

View File

@@ -293,6 +293,94 @@ test.describe("User Management", () => {
});
});
test("should hide private representative tenants in the user list row", async ({
page,
}) => {
await page.route(/\/admin\/tenants(\?.*)?$/, async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
json: {
items: [
{
id: "tenant-private",
name: "비공개 팀",
slug: "private-team",
type: "USER_GROUP",
status: "active",
config: { visibility: "private" },
},
{
id: "tenant-public",
name: "공개 팀",
slug: "public-team",
type: "USER_GROUP",
status: "active",
config: { visibility: "public" },
},
],
total: 2,
limit: 100,
offset: 0,
},
});
});
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
json: {
items: [
{
id: "u-private",
name: "Private Primary User",
email: "private-primary@example.com",
phone: "010-0000-0000",
loginId: "private-primary",
role: "user",
status: "active",
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: "비공개 팀",
},
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
},
],
total: 1,
limit: 50,
offset: 0,
},
});
});
await page.goto("/users");
const row = page.getByRole("row").filter({
hasText: "Private Primary User",
});
await expect(row).toContainText("공개 팀");
await expect(row).not.toContainText("비공개 팀");
});
test("should successfully edit a user's Login ID", async ({ page }) => {
await page.goto("/users/u-1");