forked from baron/baron-sso
custom claim 타입보정 UI. 대표테넌트 노출 보정
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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2051,7 +2051,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 {
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 = "형식이 올바르지 않습니다."
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user