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 = ""