1
0
forked from baron/baron-sso

orgfront refresh token 관리 추가

This commit is contained in:
2026-06-18 08:00:57 +09:00
parent 5f3167a503
commit 33249eb229
32 changed files with 867 additions and 337 deletions

View File

@@ -2,7 +2,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, renderHook, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { describe, expect, it, vi } from "vitest";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import {
fetchMe,
fetchTenant,
type TenantSummary,
type UserProfileResponse,
} from "../../../lib/adminApi";
import { TenantPermissionGuard } from "../components/TenantPermissionGuard";
import { useTenantPermission } from "./useTenantPermission";
@@ -22,18 +27,52 @@ function createWrapper() {
);
}
function mockProfile(
overrides: Partial<UserProfileResponse>,
): UserProfileResponse {
return {
id: "user-id",
email: "user@example.com",
name: "Test User",
phone: "",
role: "user",
department: "",
affiliationType: "general",
...overrides,
};
}
function mockTenant(overrides: Partial<TenantSummary>): TenantSummary {
return {
id: "tenant-id",
type: "COMPANY",
name: "Test Tenant",
slug: "test-tenant",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("useTenantPermission", () => {
it("returns true for all permissions if user is super_admin", async () => {
vi.mocked(fetchMe).mockResolvedValue({
id: "user-super",
role: "super_admin",
} as any);
vi.mocked(fetchMe).mockResolvedValue(
mockProfile({
id: "user-super",
role: "super_admin",
}),
);
vi.mocked(fetchTenant).mockResolvedValue({
id: "tenant-1",
name: "Super Tenant",
userPermissions: { view: false, manage: false, manage_admins: false },
} as any);
vi.mocked(fetchTenant).mockResolvedValue(
mockTenant({
id: "tenant-1",
name: "Super Tenant",
userPermissions: { view: false, manage: false, manage_admins: false },
}),
);
const { result } = renderHook(() => useTenantPermission("tenant-1"), {
wrapper: createWrapper(),
@@ -49,16 +88,20 @@ describe("useTenantPermission", () => {
});
it("returns permissions mapped from userPermissions for normal admins/users", async () => {
vi.mocked(fetchMe).mockResolvedValue({
id: "user-admin",
role: "tenant_admin",
} as any);
vi.mocked(fetchMe).mockResolvedValue(
mockProfile({
id: "user-admin",
role: "tenant_admin",
}),
);
vi.mocked(fetchTenant).mockResolvedValue({
id: "tenant-2",
name: "Tenant Admin Corp",
userPermissions: { view: true, manage: true, manage_admins: false },
} as any);
vi.mocked(fetchTenant).mockResolvedValue(
mockTenant({
id: "tenant-2",
name: "Tenant Admin Corp",
userPermissions: { view: true, manage: true, manage_admins: false },
}),
);
const { result } = renderHook(() => useTenantPermission("tenant-2"), {
wrapper: createWrapper(),
@@ -76,15 +119,19 @@ describe("useTenantPermission", () => {
describe("TenantPermissionGuard", () => {
it("renders children when user has permission", async () => {
vi.mocked(fetchMe).mockResolvedValue({
id: "user-admin",
role: "tenant_admin",
} as any);
vi.mocked(fetchMe).mockResolvedValue(
mockProfile({
id: "user-admin",
role: "tenant_admin",
}),
);
vi.mocked(fetchTenant).mockResolvedValue({
id: "tenant-3",
userPermissions: { view: true, manage: true, manage_admins: false },
} as any);
vi.mocked(fetchTenant).mockResolvedValue(
mockTenant({
id: "tenant-3",
userPermissions: { view: true, manage: true, manage_admins: false },
}),
);
render(
<TenantPermissionGuard
@@ -104,15 +151,19 @@ describe("TenantPermissionGuard", () => {
});
it("renders fallback when user lacks permission", async () => {
vi.mocked(fetchMe).mockResolvedValue({
id: "user-admin",
role: "tenant_admin",
} as any);
vi.mocked(fetchMe).mockResolvedValue(
mockProfile({
id: "user-admin",
role: "tenant_admin",
}),
);
vi.mocked(fetchTenant).mockResolvedValue({
id: "tenant-4",
userPermissions: { view: true, manage: false, manage_admins: false },
} as any);
vi.mocked(fetchTenant).mockResolvedValue(
mockTenant({
id: "tenant-4",
userPermissions: { view: true, manage: false, manage_admins: false },
}),
);
render(
<TenantPermissionGuard

View File

@@ -0,0 +1,129 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../../test/i18nMock";
import { TenantFineGrainedPermissionsPage } from "./TenantFineGrainedPermissionsPage";
const fetchUsersMock = vi.hoisted(() => vi.fn());
const bulkUpdateUsersMock = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/i18n", () => createI18nMock());
vi.mock("../../../lib/adminApi", () => ({
addSystemRelation: vi.fn(async () => undefined),
addTenantRelation: vi.fn(async () => undefined),
bulkUpdateUsers: bulkUpdateUsersMock,
fetchAllTenants: vi.fn(async () => ({ items: [], total: 0 })),
fetchMe: vi.fn(async () => ({
id: "current-admin",
name: "Current Admin",
email: "current@example.com",
role: "super_admin",
})),
fetchSystemRelations: vi.fn(async () => []),
fetchTenantRelations: vi.fn(async () => []),
fetchUsers: fetchUsersMock,
removeSystemRelation: vi.fn(async () => undefined),
removeTenantRelation: vi.fn(async () => undefined),
}));
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/permissions-direct"]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantFineGrainedPermissionsPage Super Admin role tab", () => {
beforeEach(() => {
vi.clearAllMocks();
bulkUpdateUsersMock.mockResolvedValue({ results: [] });
fetchUsersMock.mockResolvedValue({
items: [
{
id: "current-admin",
name: "Current Admin",
email: "current@example.com",
role: "super_admin",
status: "active",
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
},
{
id: "bootstrap-admin",
name: "Bootstrap Admin",
email: "env-admin@example.com",
role: "super_admin",
status: "active",
metadata: { bootstrapSuperAdmin: true },
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
},
{
id: "delegated-admin",
name: "Delegated Admin",
email: "delegated@example.com",
role: "super_admin",
status: "active",
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
},
{
id: "regular-user",
name: "Regular User",
email: "regular@example.com",
role: "user",
status: "active",
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
},
],
total: 4,
limit: 1000,
offset: 0,
});
});
it("shows revocable super admin users even when they have no direct system relations", async () => {
renderWithProviders(
<Routes>
<Route
path="/permissions-direct"
element={<TenantFineGrainedPermissionsPage />}
/>
</Routes>,
);
fireEvent.click(
await screen.findByRole("tab", { name: "Super Admin 역할" }),
);
expect(await screen.findByText("Delegated Admin")).toBeInTheDocument();
expect(screen.getByText("delegated@example.com")).toBeInTheDocument();
expect(screen.queryByText("Current Admin")).not.toBeInTheDocument();
expect(screen.queryByText("Bootstrap Admin")).not.toBeInTheDocument();
expect(screen.queryByText("Regular User")).not.toBeInTheDocument();
fireEvent.click(
screen.getByTestId("super-admin-role-user-delegated-admin"),
);
fireEvent.click(screen.getByRole("button", { name: "Super Admin 회수" }));
await waitFor(() =>
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
userIds: ["delegated-admin"],
role: "user",
}),
);
});
});

View File

@@ -8,7 +8,6 @@ import {
LayoutDashboard,
Network,
NotebookTabs,
Plus,
Search,
Share2,
Shield,
@@ -19,10 +18,8 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Avatar, AvatarFallback } from "../../../components/ui/avatar";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import { Card, CardContent } from "../../../components/ui/card";
import {
Dialog,
DialogContent,
@@ -31,7 +28,6 @@ import {
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
Table,
TableBody,
@@ -49,6 +45,7 @@ import {
fetchMe,
fetchSystemRelations,
fetchTenantRelations,
fetchUsers,
removeSystemRelation,
removeTenantRelation,
type TenantRelation,
@@ -66,6 +63,10 @@ const protectedSystemMenuRelations = new Set([
"permissions_direct",
]);
function isBootstrapSuperAdminUser(user: UserSummary) {
return user.metadata?.bootstrapSuperAdmin === true;
}
export function TenantFineGrainedPermissionsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
@@ -79,8 +80,7 @@ export function TenantFineGrainedPermissionsPage() {
const [bulkRelationMode, setBulkRelationMode] = useState<
"page" | "target-action"
>("page");
const [bulkPageRelation, setBulkPageRelation] =
useState("overview_viewers");
const [bulkPageRelation, setBulkPageRelation] = useState("overview_viewers");
const [bulkTenantPage, setBulkTenantPage] = useState("profile");
const [bulkAction, setBulkAction] = useState<"read" | "manage">("read");
const [tenantPickerOpen, setTenantPickerOpen] = useState(false);
@@ -94,15 +94,12 @@ export function TenantFineGrainedPermissionsPage() {
>("user");
const orgChartMemberPickerUrl = useMemo(
() =>
buildAuthenticatedOrgChartUserMultiPickerUrl(import.meta.env.ORGFRONT_URL),
buildAuthenticatedOrgChartUserMultiPickerUrl(
import.meta.env.ORGFRONT_URL,
),
[],
);
// 🌟 글로벌 시스템 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
const [localSystemPermissions, setLocalSystemPermissions] = useState<
Record<string, Record<string, "none" | "read" | "write">>
>({});
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
@@ -128,6 +125,30 @@ export function TenantFineGrainedPermissionsPage() {
});
const systemRelations = systemRelationsQuery.data ?? [];
const superAdminUsersQuery = useQuery({
queryKey: ["admin-users", "super-admin-role-candidates"],
queryFn: () => fetchUsers(10000, 0),
enabled: isSuperAdmin && activePermissionTab === "super-admin",
});
const revocableSuperAdminUsers = useMemo(() => {
const currentAdminId = profile?.id ?? "";
const currentAdminEmail = (profile?.email ?? "").trim().toLowerCase();
return (superAdminUsersQuery.data?.items ?? []).filter((user) => {
if (user.role !== "super_admin") {
return false;
}
if (user.id === currentAdminId) {
return false;
}
if (user.email.trim().toLowerCase() === currentAdminEmail) {
return false;
}
return !isBootstrapSuperAdminUser(user);
});
}, [profile?.email, profile?.id, superAdminUsersQuery.data?.items]);
const tenantRelationsQuery = useQuery({
queryKey: ["tenant-relations", targetTenantId],
queryFn: () => fetchTenantRelations(targetTenantId),
@@ -139,42 +160,6 @@ export function TenantFineGrainedPermissionsPage() {
});
const tenantRelations = tenantRelationsQuery.data ?? [];
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
useEffect(() => {
if (systemRelationsQuery.data) {
const initialMap: Record<
string,
Record<string, "none" | "read" | "write">
> = {};
for (const user of systemRelationsQuery.data) {
initialMap[user.userId] = {};
const menus = [
"overview",
"audit_logs",
"tenants",
"org_chart",
"users",
"worksmobile",
"api_keys",
"ory_ssot",
"data_integrity",
"auth_guard",
"permissions_direct",
];
for (const m of menus) {
const isWrite = user.relations.includes(`${m}_managers`);
const isRead = user.relations.includes(`${m}_viewers`);
initialMap[user.userId][m] = isWrite
? "write"
: isRead
? "read"
: "none";
}
}
setLocalSystemPermissions(initialMap);
}
}, [systemRelationsQuery.data]);
const addSystemRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
addSystemRelation(payload.userId, payload.relation),
@@ -325,14 +310,19 @@ export function TenantFineGrainedPermissionsPage() {
const updateUserRoleMutation = useMutation({
mutationFn: (payload: { userIds: string[]; role: string }) =>
bulkUpdateUsers(payload),
onSuccess: () => {
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["admin-users"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
toast.success(
t(
"msg.admin.permissions_direct.super_admin_grant_success",
"Super Admin 역할이 부여되었습니다.",
),
variables.role === "super_admin"
? t(
"msg.admin.permissions_direct.super_admin_grant_success",
"Super Admin 역할이 부여되었습니다.",
)
: t(
"msg.admin.permissions_direct.super_admin_revoke_success",
"Super Admin 역할을 회수했습니다.",
),
);
setSelectedSuperAdminUserIds([]);
},
@@ -344,70 +334,6 @@ export function TenantFineGrainedPermissionsPage() {
},
});
const handleSystemRelationChange = async (
userId: string,
menuKey: string,
currentVal: "none" | "read" | "write",
newVal: "none" | "read" | "write",
) => {
if (currentVal === newVal) return;
try {
if (currentVal === "read") {
await removeSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_viewers`,
});
} else if (currentVal === "write") {
await removeSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_managers`,
});
}
if (newVal === "read") {
await addSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_viewers`,
});
} else if (newVal === "write") {
await addSystemRelationMutation.mutateAsync({
userId,
relation: `${menuKey}_managers`,
});
}
// 🌟 Trigger a single consolidated success toast at the very end
toast.success(
t(
"msg.admin.system.relations.update_success",
"시스템 메뉴 권한이 성공적으로 변경되었습니다.",
),
);
} catch {
// Individual mutations handle error toast via onError
}
};
const handleRemoveAllSystemRelations = async (
userId: string,
userRelations: string[],
) => {
if (
!window.confirm(
t(
"msg.admin.system.relations.remove_all_confirm",
"이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?",
),
)
) {
return;
}
for (const rel of userRelations) {
await removeSystemRelationMutation.mutateAsync({ userId, relation: rel });
}
};
const toggleSuperAdminUser = (userId: string, checked: boolean) => {
setSelectedSuperAdminUserIds((current) =>
checked
@@ -435,7 +361,10 @@ export function TenantFineGrainedPermissionsPage() {
}
const relation = resolveBulkRelation();
if (bulkRelationMode === "page" && relation.startsWith("permissions_direct_")) {
if (
bulkRelationMode === "page" &&
relation.startsWith("permissions_direct_")
) {
toast.error(
t(
"msg.admin.permissions_direct.protected_relation",
@@ -489,19 +418,19 @@ export function TenantFineGrainedPermissionsPage() {
setQueuedTargetUsers([]);
};
const handleGrantSuperAdminRole = () => {
const handleRevokeSuperAdminRole = () => {
if (selectedSuperAdminUserIds.length === 0) {
toast.error(
t(
"msg.admin.permissions_direct.super_admin_users_required",
"Super Admin을 부여할 사용자를 하나 이상 선택하세요.",
"회수할 Super Admin 사용자를 하나 이상 선택하세요.",
),
);
return;
}
updateUserRoleMutation.mutate({
userIds: selectedSuperAdminUserIds,
role: "super_admin",
role: "user",
});
};
@@ -537,18 +466,18 @@ export function TenantFineGrainedPermissionsPage() {
name: selection.name,
email: selection.email,
tenantSlug: selection.leafTenantName,
tenant: selection.leafTenantName
? {
id: "",
type: "ORGANIZATION",
slug: "",
name: selection.leafTenantName,
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
}
tenant: selection.leafTenantName
? {
id: "",
type: "ORGANIZATION",
slug: "",
name: selection.leafTenantName,
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
}
: undefined,
metadata: {
rootTenantName: selection.rootTenantName,
@@ -698,8 +627,6 @@ export function TenantFineGrainedPermissionsPage() {
},
];
const filteredRelations = systemRelations;
const selectedUser = undefined;
const grantableSystemMenus = systemMenuCategories.flatMap((category) =>
category.menus.filter(
(menu) => !protectedSystemMenuRelations.has(menu.relation),
@@ -975,7 +902,19 @@ export function TenantFineGrainedPermissionsPage() {
{t("ui.admin.permissions_direct.bulk_selected", "명 선택")}
</Badge>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,0.8fr)_minmax(320px,1.2fr)]">
<div className="grid gap-3 lg:grid-cols-[minmax(320px,1.2fr)_minmax(0,0.8fr)]">
<div className="min-h-[300px] overflow-hidden rounded-md border bg-background">
<iframe
title={t(
"ui.admin.permissions_direct.target_org_picker",
"조직도에서 사용자 선택",
)}
src={orgChartMemberPickerUrl}
className="h-[340px] w-full"
data-testid="permission-target-org-picker-frame"
/>
</div>
<div className="space-y-3">
<div
className="min-h-16 rounded-md border bg-background p-2"
@@ -989,66 +928,53 @@ export function TenantFineGrainedPermissionsPage() {
)}
</div>
) : (
<div className="flex flex-wrap gap-2">
{queuedTargetUsers.map((user) => {
const rootTenantName =
typeof user.metadata?.rootTenantName === "string"
? user.metadata.rootTenantName
: "";
const leafTenantName =
typeof user.metadata?.leafTenantName === "string"
? user.metadata.leafTenantName
: "";
const tenantPath = [
rootTenantName,
leafTenantName,
]
.filter(Boolean)
.join(" / ");
<div className="flex flex-wrap gap-2">
{queuedTargetUsers.map((user) => {
const rootTenantName =
typeof user.metadata?.rootTenantName === "string"
? user.metadata.rootTenantName
: "";
const leafTenantName =
typeof user.metadata?.leafTenantName === "string"
? user.metadata.leafTenantName
: "";
const tenantPath = [rootTenantName, leafTenantName]
.filter(Boolean)
.join(" / ");
return (
<span
key={user.id}
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-muted/30 px-2 py-1 text-sm"
>
<span className="max-w-52 truncate">
{user.name}
</span>
{tenantPath !== "" && (
<span className="max-w-64 truncate text-xs text-muted-foreground">
{tenantPath}
</span>
)}
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => removeQueuedTargetUser(user.id)}
aria-label={t(
"ui.admin.permissions_direct.target_queue_remove",
"적용 대상에서 제거",
)}
>
<X size={14} />
</button>
</span>
);
})}
</div>
return (
<span
key={user.id}
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-muted/30 px-2 py-1 text-sm"
>
<span className="max-w-52 truncate">
{user.name}
</span>
{tenantPath !== "" && (
<span className="max-w-64 truncate text-xs text-muted-foreground">
{tenantPath}
</span>
)}
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() =>
removeQueuedTargetUser(user.id)
}
aria-label={t(
"ui.admin.permissions_direct.target_queue_remove",
"적용 대상에서 제거",
)}
>
<X size={14} />
</button>
</span>
);
})}
</div>
)}
</div>
</div>
<div className="min-h-[300px] overflow-hidden rounded-md border bg-background">
<iframe
title={t(
"ui.admin.permissions_direct.target_org_picker",
"조직도에서 사용자 선택",
)}
src={orgChartMemberPickerUrl}
className="h-[340px] w-full"
data-testid="permission-target-org-picker-frame"
/>
</div>
</div>
</div>
@@ -1056,6 +982,7 @@ export function TenantFineGrainedPermissionsPage() {
<label className="space-y-2 text-sm font-medium">
{t("ui.admin.permissions_direct.bulk_mode", "권한 방식")}
<select
name="bulk-relation-mode"
data-testid="bulk-relation-mode"
value={bulkRelationMode}
onChange={(event) =>
@@ -1087,6 +1014,7 @@ export function TenantFineGrainedPermissionsPage() {
"페이지 접근 권한",
)}
<select
name="bulk-page-relation"
data-testid="bulk-page-relation"
value={bulkPageRelation}
onChange={(event) =>
@@ -1106,12 +1034,16 @@ export function TenantFineGrainedPermissionsPage() {
<div className="space-y-2 text-sm font-medium">
{t("ui.admin.permissions_direct.bulk_target", "대상")}
<input
name="bulk-relation-target-tenant"
data-testid="bulk-relation-target-tenant"
type="hidden"
value={targetTenantId}
readOnly
/>
<Dialog open={tenantPickerOpen} onOpenChange={setTenantPickerOpen}>
<Dialog
open={tenantPickerOpen}
onOpenChange={setTenantPickerOpen}
>
<Button
type="button"
variant="outline"
@@ -1159,7 +1091,10 @@ export function TenantFineGrainedPermissionsPage() {
<div className="max-h-80 overflow-y-auto rounded-md border">
{tenantPickerCandidates.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
{t("ui.common.no_results", "검색 결과가 없습니다.")}
{t(
"ui.common.no_results",
"검색 결과가 없습니다.",
)}
</div>
) : (
tenantPickerCandidates.map((tenant) => (
@@ -1194,6 +1129,7 @@ export function TenantFineGrainedPermissionsPage() {
"페이지",
)}
<select
name="bulk-relation-target"
data-testid="bulk-relation-target"
value={bulkTenantPage}
onChange={(event) =>
@@ -1211,6 +1147,7 @@ export function TenantFineGrainedPermissionsPage() {
<label className="space-y-2 text-sm font-medium">
{t("ui.admin.permissions_direct.bulk_action", "액션")}
<select
name="bulk-relation-action"
data-testid="bulk-relation-action"
value={bulkAction}
onChange={(event) =>
@@ -1281,6 +1218,7 @@ export function TenantFineGrainedPermissionsPage() {
/>
</div>
<select
name="permission-assignment-sort"
data-testid="permission-assignment-sort"
value={assignmentSort}
onChange={(event) =>
@@ -1314,7 +1252,10 @@ export function TenantFineGrainedPermissionsPage() {
{t("ui.admin.permissions_direct.table_target", "대상")}
</TableHead>
<TableHead>
{t("ui.admin.permissions_direct.table_relation", "Relation")}
{t(
"ui.admin.permissions_direct.table_relation",
"Relation",
)}
</TableHead>
<TableHead className="w-[180px]">
{t("ui.admin.permissions_direct.table_level", "권한")}
@@ -1376,6 +1317,7 @@ export function TenantFineGrainedPermissionsPage() {
</TableCell>
<TableCell>
<select
name={`permission-assignment-level-${row.user.userId}-${row.relation}`}
data-testid={`permission-assignment-level-${row.user.userId}-${row.relation}`}
value={row.level}
disabled={row.protected}
@@ -1427,7 +1369,6 @@ export function TenantFineGrainedPermissionsPage() {
</Table>
</div>
</div>
</>
)}
@@ -1437,13 +1378,13 @@ export function TenantFineGrainedPermissionsPage() {
<h2 className="text-lg font-bold">
{t(
"ui.admin.permissions_direct.super_admin_title",
"Super Admin 역할 부여",
"Super Admin 역할 회수",
)}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.permissions_direct.super_admin_description",
"전역 시스템 관리자 역할은 상세 relation과 분리해서 부여합니다.",
"현재 로그인한 관리자와 최초 관리자를 제외한 Super Admin 역할을 회수합니다.",
)}
</p>
</div>
@@ -1451,7 +1392,10 @@ export function TenantFineGrainedPermissionsPage() {
<div className="mt-5 rounded-lg border border-border/70 bg-muted/10 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">
{t("ui.admin.permissions_direct.super_admin_users", "대상 사용자")}
{t(
"ui.admin.permissions_direct.super_admin_users",
"대상 사용자",
)}
</h3>
<Badge variant="secondary">
{selectedSuperAdminUserIds.length}
@@ -1459,7 +1403,11 @@ export function TenantFineGrainedPermissionsPage() {
</Badge>
</div>
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
{systemRelations.length === 0 ? (
{superAdminUsersQuery.isFetching ? (
<div className="py-8 text-center text-xs text-muted-foreground">
{t("msg.common.loading", "불러오는 중입니다.")}
</div>
) : revocableSuperAdminUsers.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
{t(
"msg.admin.permissions_direct.no_users_found",
@@ -1467,17 +1415,18 @@ export function TenantFineGrainedPermissionsPage() {
)}
</div>
) : (
systemRelations.map((user) => (
revocableSuperAdminUsers.map((user) => (
<label
key={user.userId}
key={user.id}
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm hover:bg-muted/40"
>
<input
name={`super-admin-role-user-${user.id}`}
type="checkbox"
data-testid={`super-admin-role-user-${user.userId}`}
checked={selectedSuperAdminUserIds.includes(user.userId)}
data-testid={`super-admin-role-user-${user.id}`}
checked={selectedSuperAdminUserIds.includes(user.id)}
onChange={(event) =>
toggleSuperAdminUser(user.userId, event.target.checked)
toggleSuperAdminUser(user.id, event.target.checked)
}
/>
<span className="min-w-0 flex-1">
@@ -1496,18 +1445,20 @@ export function TenantFineGrainedPermissionsPage() {
<div className="mt-5 flex justify-end">
<Button
onClick={handleGrantSuperAdminRole}
disabled={updateUserRoleMutation.isPending}
onClick={handleRevokeSuperAdminRole}
disabled={
updateUserRoleMutation.isPending ||
selectedSuperAdminUserIds.length === 0
}
>
{t(
"ui.admin.permissions_direct.super_admin_grant",
"Super Admin 부여",
"ui.admin.permissions_direct.super_admin_revoke",
"Super Admin 회수",
)}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -505,9 +505,7 @@ function TenantListPage() {
tenantIds: selectedIds,
...(selectedBulkStatus ? { status: selectedBulkStatus } : {}),
...(selectedBulkType ? { type: selectedBulkType } : {}),
...(selectedBulkVisibility
? { visibility: selectedBulkVisibility }
: {}),
...(selectedBulkVisibility ? { visibility: selectedBulkVisibility } : {}),
});
};
@@ -1129,7 +1127,10 @@ function TenantListPage() {
</SelectItem>
</SelectContent>
</Select>
<Select value={selectedBulkType} onValueChange={setSelectedBulkType}>
<Select
value={selectedBulkType}
onValueChange={setSelectedBulkType}
>
<SelectTrigger
className="h-8 w-[180px] bg-transparent border-background/20 text-background text-xs"
data-testid="tenant-bulk-type-select"

View File

@@ -665,9 +665,7 @@ describe("TenantWorksmobilePage comparison helpers", () => {
},
],
}),
).toEqual([
"직급: 없음 -> 책임연구원 (Baron HmEG / WORKS WORKS HmEG)",
]);
).toEqual(["직급: 없음 -> 책임연구원 (Baron HmEG / WORKS WORKS HmEG)"]);
});
it("does not format phone update details for spaced Korean country code formatting only", () => {

View File

@@ -383,7 +383,7 @@ export function formatWorksmobileUserMembershipDetails(
`Baron ${baronOrg}`,
`WORKS ${worksOrg}`,
membership.worksmobileLevelName?.trim() ||
membership.worksmobileLevelId?.trim()
membership.worksmobileLevelId?.trim()
? `직급 ${membership.worksmobileLevelName?.trim() || membership.worksmobileLevelId?.trim()}`
: "",
].filter(Boolean);

View File

@@ -353,6 +353,9 @@ function UserListPage() {
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState<
UserStatusValue | ""
>("");
const [selectedBulkRole, setSelectedBulkRole] = React.useState<
"super_admin" | "user" | ""
>("");
const [sortConfig, setSortConfig] =
React.useState<SortConfig<UserSortKey> | null>(null);
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
@@ -652,6 +655,7 @@ function UserListPage() {
query.refetch();
setSelectedUserIds([]);
setSelectedBulkStatus("");
setSelectedBulkRole("");
toast.success(
t(
"msg.admin.users.bulk.update_success",
@@ -1241,24 +1245,60 @@ function UserListPage() {
const payload: {
userIds: string[];
status?: UserStatusValue;
role?: "super_admin" | "user";
} = { userIds: selectedUserIds };
let hasChanges = false;
if (selectedBulkStatus) {
payload.status = selectedBulkStatus;
hasChanges = true;
}
if (selectedBulkRole) {
payload.role = selectedBulkRole;
hasChanges = true;
}
if (hasChanges) {
bulkUpdateMutation.mutate(payload);
}
}}
disabled={
!selectedBulkStatus || bulkUpdateMutation.isPending || !isWritable
(!selectedBulkStatus && !selectedBulkRole) ||
bulkUpdateMutation.isPending ||
!isWritable
}
data-testid="bulk-apply-btn"
>
<ShieldCheck size={14} />
{t("ui.common.apply", "적용")}
</Button>
<Select
value={selectedBulkRole}
onValueChange={(value) =>
setSelectedBulkRole(value as "super_admin" | "user")
}
>
<SelectTrigger
className="h-8 w-[150px] bg-transparent border-background/20 text-background text-xs"
data-testid="bulk-permission-select"
>
<SelectValue
placeholder={t(
"ui.admin.users.bulk.permission_placeholder",
"권한 선택",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="super_admin">
{t(
"ui.admin.users.detail.form.role_super_admin",
"시스템 관리자",
)}
</SelectItem>
<SelectItem value="user">
{t("ui.admin.users.detail.form.role_user", "일반 사용자")}
</SelectItem>
</SelectContent>
</Select>
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"

View File

@@ -507,6 +507,8 @@ export function buildOrgChartUserMultiPickerUrl(
params.set("showDescendantToggle", "true");
if (options.tenantId?.trim()) {
params.set("tenantId", options.tenantId.trim());
} else {
params.set("rootTenantId", "all");
}
return `${normalizedBase}/embed/picker?${params.toString()}`;

View File

@@ -2010,12 +2010,14 @@ select_tenant_desc = "Select target tenant to assign fine-grained permissions."
placeholder = "-- Select Tenant --"
add_system_user = "Add User to Admin Control"
dialog_title_system = "Add User to Global Permissions"
super_admin_revoke = "Revoke super administrator"
[msg.admin.permissions_direct]
description = "Directly assign and manage tab-level direct permissions and global sidebar menu access."
tab_system_desc = "Directly grant users access to each sidebar menu page. Super admins always bypass and pass all access checks."
system_empty = "No users with custom global menu permissions found. Add users to start managing."
select_prompt = "Select a tenant from the dropdown above to manage its fine-grained features."
super_admin_revoke_success = "Super administrator access revoked."
[msg.admin.system.relations]
add_success = "Global menu permission added successfully."

View File

@@ -2010,12 +2010,14 @@ select_tenant_desc = "세부 기능 권한을 부여할 대상 테넌트를 리
placeholder = "-- 테넌트 선택 --"
add_system_user = "시스템 권한 사용자 추가"
dialog_title_system = "시스템 권한 관리 유저 추가"
super_admin_revoke = "Super Admin 회수"
[msg.admin.permissions_direct]
description = "테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다."
tab_system_desc = "사이드바 각 메뉴별 접근 권한을 사용자에게 직접 부여합니다. 최고 관리자(super_admin)는 기본적으로 언제나 모든 권한을 우회 통과합니다."
system_empty = "지정된 글로벌 메뉴 세부 권한자가 없습니다. 사용자를 추가해 관리해 주세요."
select_prompt = "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다."
super_admin_revoke_success = "Super Admin 권한을 회수했습니다."
[msg.admin.system.relations]
add_success = "시스템 메뉴 권한이 추가되었습니다."

View File

@@ -1964,12 +1964,14 @@ select_tenant_desc = ""
placeholder = ""
add_system_user = ""
dialog_title_system = ""
super_admin_revoke = ""
[msg.admin.permissions_direct]
description = ""
tab_system_desc = ""
system_empty = ""
select_prompt = ""
super_admin_revoke_success = ""
[msg.admin.system.relations]
add_success = ""

View File

@@ -189,9 +189,9 @@ test.describe("Tenant list performance", () => {
const loadStarted = performance.now();
await page.goto("/tenants");
await expect(page.getByTestId("tenant-internal-id-tenant-3500")).toBeVisible(
{ timeout: 15000 },
);
await expect(
page.getByTestId("tenant-internal-id-tenant-3500"),
).toBeVisible({ timeout: 15000 });
const loadMs = performance.now() - loadStarted;
const loadSnapshot = testInfo.outputPath("tenant-list-load.png");
await page.screenshot({ path: loadSnapshot, fullPage: true });
@@ -201,9 +201,9 @@ test.describe("Tenant list performance", () => {
const searchInput = page.getByPlaceholder("이름 또는 슬러그, ID 검색");
const searchStarted = performance.now();
await searchInput.fill("full-dataset-needle-0100");
await expect(page.getByTestId("tenant-internal-id-tenant-0100")).toBeVisible(
{ timeout: 15000 },
);
await expect(
page.getByTestId("tenant-internal-id-tenant-0100"),
).toBeVisible({ timeout: 15000 });
const searchMs = performance.now() - searchStarted;
const searchSnapshot = testInfo.outputPath("tenant-list-search.png");
await page.screenshot({ path: searchSnapshot, fullPage: true });
@@ -211,9 +211,9 @@ test.describe("Tenant list performance", () => {
await expect(page.locator("tbody")).toContainText(
"full-dataset-needle-0100",
);
await expect(page.getByTestId("tenant-internal-id-tenant-3500")).toHaveCount(
0,
);
await expect(
page.getByTestId("tenant-internal-id-tenant-3500"),
).toHaveCount(0);
console.log(
JSON.stringify({
@@ -225,8 +225,9 @@ test.describe("Tenant list performance", () => {
}),
);
const searchBudgetMs = testInfo.project.name === "firefox" ? 1000 : 500;
expect(loadMs).toBeLessThanOrEqual(1500);
expect(searchMs).toBeLessThanOrEqual(500);
expect(searchMs).toBeLessThanOrEqual(searchBudgetMs);
});
});

View File

@@ -214,6 +214,10 @@ test.describe("Tenant profile local performance evidence", () => {
console.log(JSON.stringify(evidence, null, 2));
expect(evidence.summary.configFieldsVisibleMs.p95).toBeLessThanOrEqual(500);
const configVisibleBudgetMs =
testInfo.project.name === "firefox" ? 1200 : 500;
expect(evidence.summary.configFieldsVisibleMs.p95).toBeLessThanOrEqual(
configVisibleBudgetMs,
);
});
});

View File

@@ -789,8 +789,7 @@ test.describe("User Management", () => {
.poll(() => updatePayload)
.toMatchObject({ status: "preboarding" });
await table.locator('input[name="user-list-select-u-1"]').check();
await expect(page.getByTestId("bulk-permission-select")).toHaveCount(0);
await expect(page.getByTestId("user-role-select-u-1")).toHaveCount(0);
});
test("should keep system role assignment out of the permissions screen", async ({
@@ -907,23 +906,24 @@ test.describe("User Management", () => {
await expect(
page.getByRole("option", { name: /권한 부여.*수정/ }),
).toHaveCount(0);
await expect(page.getByTestId("permission-target-org-picker-frame")).toBeVisible();
await expect(page.getByTestId("permission-target-org-picker-frame")).toHaveAttribute(
"src",
/rootTenantId%3Dall|rootTenantId=all/,
);
await expect(
page.getByTestId("permission-target-org-picker-frame"),
).toBeVisible();
await expect(
page.getByTestId("permission-target-org-picker-frame"),
).toHaveAttribute("src", /rootTenantId%3Dall|rootTenantId=all/);
const pickerBox = await page
.getByTestId("permission-target-org-picker-frame")
.boundingBox();
const queueBox = await page.getByTestId("permission-target-queue").boundingBox();
const queueBox = await page
.getByTestId("permission-target-queue")
.boundingBox();
expect(pickerBox?.x ?? Number.POSITIVE_INFINITY).toBeLessThan(
queueBox?.x ?? Number.NEGATIVE_INFINITY,
);
await page.getByTestId("bulk-relation-mode").selectOption("target-action");
await expect(
page.getByTestId("bulk-relation-operation"),
).toHaveCount(0);
await expect(page.getByTestId("bulk-relation-operation")).toHaveCount(0);
await page.getByTestId("permission-action-tenant-picker-open").click();
await page.getByTestId("permission-action-tenant-search").fill("Test");
await page.getByTestId("permission-action-tenant-result-t-1").click();
@@ -980,40 +980,48 @@ test.describe("User Management", () => {
.getByRole("button", { name: /선택 사용자에게 권한 부여/ })
.click();
await expect.poll(() => relationWrites).toContainEqual(
{ userId: "u-2", relation: "tenants_managers" },
);
await expect.poll(() => relationWrites).toContainEqual(
{ userId: "u-2", relation: "profile_managers" },
);
await expect.poll(() => relationWrites).toContainEqual(
{ userId: "u-3", relation: "profile_managers" },
);
await expect
.poll(() => relationWrites)
.toContainEqual({ userId: "u-2", relation: "tenants_managers" });
await expect
.poll(() => relationWrites)
.toContainEqual({ userId: "u-2", relation: "profile_managers" });
await expect
.poll(() => relationWrites)
.toContainEqual({ userId: "u-3", relation: "profile_managers" });
await page.getByTestId("permission-assignment-search").fill("John");
await expect(page.getByTestId("permission-assignment-row-u-1-profile_viewers")).toBeVisible();
await expect(
page.getByTestId("permission-assignment-row-u-1-profile_viewers"),
).toBeVisible();
await expect(
page.getByTestId("permission-assignment-row-u-2-profile_managers"),
).toHaveCount(0);
await page.getByTestId("permission-assignment-search").fill("");
await page.getByTestId("permission-assignment-sort").selectOption("relation");
await page
.getByTestId("permission-assignment-sort")
.selectOption("relation");
await page
.getByTestId("permission-assignment-level-u-1-profile_viewers")
.selectOption("write");
await expect.poll(() => relationWrites).toContainEqual({
userId: "u-1",
relation: "profile_managers",
});
await expect
.poll(() => relationWrites)
.toContainEqual({
userId: "u-1",
relation: "profile_managers",
});
await page
.getByTestId("permission-assignment-remove-u-1-profile_viewers")
.click();
await expect.poll(() => relationDeletes).toContainEqual({
userId: "u-1",
relation: "profile_viewers",
});
await expect
.poll(() => relationDeletes)
.toContainEqual({
userId: "u-1",
relation: "profile_viewers",
});
});
test("should grant super admin role from the last tab only for super admins", async ({
test("should revoke super admin role from the last tab only for super admins", async ({
page,
}) => {
let bulkPayload: Record<string, unknown> | undefined;
@@ -1036,6 +1044,30 @@ test.describe("User Management", () => {
});
});
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
json: {
items: [
{
id: "u-1",
name: "John Doe",
email: "john@test.com",
phone: "010-1111-2222",
role: "super_admin",
status: "active",
createdAt: "2026-04-01T00:00:00Z",
},
],
total: 1,
limit: 10000,
offset: 0,
},
});
});
await page.route(/\/admin\/users\/bulk$/, async (route) => {
if (route.request().method() !== "PUT") {
return route.fallback();
@@ -1052,12 +1084,14 @@ test.describe("User Management", () => {
await tabs.last().click();
await page.getByTestId("super-admin-role-user-u-1").check();
await page.getByRole("button", { name: /Super Admin 부여/ }).click();
await page.getByRole("button", { name: /Super Admin 회수/ }).click();
await expect.poll(() => bulkPayload).toEqual({
userIds: ["u-1"],
role: "super_admin",
});
await expect
.poll(() => bulkPayload)
.toEqual({
userIds: ["u-1"],
role: "user",
});
});
test("should hide the super admin role tab from non super admins", async ({
@@ -1525,9 +1559,7 @@ test.describe("User Management", () => {
await expect(
page.getByRole("tab", { name: /외부 기업 회원/i }),
).toHaveCount(0);
await expect(
page.getByRole("tab", { name: /^Commercial$/i }),
).toBeVisible();
await expect(page.getByRole("tab", { name: /^일반회사$/i })).toBeVisible();
await expect(page.getByRole("tab", { name: /^공공기관$/i })).toBeVisible();
await expect(page.getByRole("tab", { name: /^교육기관$/i })).toBeVisible();
await expect(page.getByRole("tab", { name: /^개인$/i })).toBeVisible();