1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx

1748 lines
66 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Building2,
Database,
Key,
KeyRound,
LayoutDashboard,
Network,
NotebookTabs,
Plus,
Search,
Share2,
Shield,
ShieldCheck,
Trash2,
Users,
X,
} 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,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
addSystemRelation,
addTenantRelation,
bulkUpdateUsers,
fetchAllTenants,
fetchMe,
fetchSystemRelations,
fetchTenantRelations,
removeSystemRelation,
removeTenantRelation,
type TenantRelation,
type UserSummary,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
buildAuthenticatedOrgChartUserMultiPickerUrl,
parseOrgChartUserSelections,
} from "../../users/orgChartPicker";
const protectedSystemMenuRelations = new Set([
"ory_ssot",
"data_integrity",
"permissions_direct",
]);
export function TenantFineGrainedPermissionsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [activeTab, _setActiveTab] = useState<"tenant" | "system">("system");
const [activePermissionTab, setActivePermissionTab] = useState<
"direct" | "super-admin"
>("direct");
const [_selectedTenantId, _setSelectedTenantId] = useState("");
const [targetTenantId, setTargetTenantId] = useState("");
const [queuedTargetUsers, setQueuedTargetUsers] = useState<UserSummary[]>([]);
const [bulkRelationMode, setBulkRelationMode] = useState<
"page" | "target-action"
>("page");
const [bulkPageRelation, setBulkPageRelation] =
useState("overview_viewers");
const [bulkTenantPage, setBulkTenantPage] = useState("profile");
const [bulkAction, setBulkAction] = useState<"read" | "manage">("read");
const [tenantPickerOpen, setTenantPickerOpen] = useState(false);
const [tenantPickerSearch, setTenantPickerSearch] = useState("");
const [selectedSuperAdminUserIds, setSelectedSuperAdminUserIds] = useState<
string[]
>([]);
const [assignmentSearchTerm, setAssignmentSearchTerm] = useState("");
const [assignmentSort, setAssignmentSort] = useState<
"user" | "relation" | "level"
>("user");
const orgChartMemberPickerUrl = useMemo(
() =>
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,
});
const isSuperAdmin = profile?.role === "super_admin";
const tenantsQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
enabled: isSuperAdmin,
});
const tenants = isSuperAdmin
? (tenantsQuery.data?.items ?? [])
: (profile?.manageableTenants ?? []);
// System Relations (Admin Control) Queries & Mutations
const systemRelationsQuery = useQuery({
queryKey: ["system-relations"],
queryFn: fetchSystemRelations,
enabled: isSuperAdmin && activeTab === "system",
});
const systemRelations = systemRelationsQuery.data ?? [];
const tenantRelationsQuery = useQuery({
queryKey: ["tenant-relations", targetTenantId],
queryFn: () => fetchTenantRelations(targetTenantId),
enabled:
isSuperAdmin &&
activePermissionTab === "direct" &&
bulkRelationMode === "target-action" &&
targetTenantId.length > 0,
});
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),
onMutate: async (newRelation) => {
await queryClient.cancelQueries({ queryKey: ["system-relations"] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"system-relations",
]);
queryClient.setQueryData<TenantRelation[]>(
["system-relations"],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(
["system-relations"],
context.previousRelations,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
}, 500);
},
});
const removeSystemRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
removeSystemRelation(payload.userId, payload.relation),
onMutate: async (targetRelation) => {
await queryClient.cancelQueries({ queryKey: ["system-relations"] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"system-relations",
]);
queryClient.setQueryData<TenantRelation[]>(
["system-relations"],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter(
(r) => r !== targetRelation.relation,
),
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(
["system-relations"],
context.previousRelations,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
}, 500);
},
});
const addTenantRelationMutation = useMutation({
mutationFn: (payload: {
tenantId: string;
userId: string;
relation: string;
}) => addTenantRelation(payload.tenantId, payload.userId, payload.relation),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ["tenant-relations", variables.tenantId],
});
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const removeTenantRelationMutation = useMutation({
mutationFn: (payload: {
tenantId: string;
userId: string;
relation: string;
}) =>
removeTenantRelation(payload.tenantId, payload.userId, payload.relation),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ["tenant-relations", variables.tenantId],
});
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const updateUserRoleMutation = useMutation({
mutationFn: (payload: { userIds: string[]; role: string }) =>
bulkUpdateUsers(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-users"] });
queryClient.invalidateQueries({ queryKey: ["me"] });
toast.success(
t(
"msg.admin.permissions_direct.super_admin_grant_success",
"Super Admin 역할이 부여되었습니다.",
),
);
setSelectedSuperAdminUserIds([]);
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
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
? [...new Set([...current, userId])]
: current.filter((id) => id !== userId),
);
};
const resolveBulkRelation = () => {
if (bulkRelationMode === "page") {
return bulkPageRelation;
}
return `${bulkTenantPage}_${bulkAction === "manage" ? "managers" : "viewers"}`;
};
const handleBulkRelationSubmit = async () => {
if (queuedTargetUsers.length === 0) {
toast.error(
t(
"msg.admin.permissions_direct.bulk_users_required",
"권한을 적용할 사용자를 하나 이상 선택하세요.",
),
);
return;
}
const relation = resolveBulkRelation();
if (bulkRelationMode === "page" && relation.startsWith("permissions_direct_")) {
toast.error(
t(
"msg.admin.permissions_direct.protected_relation",
"권한 부여 화면 접근 권한은 Super Admin 전용입니다.",
),
);
return;
}
if (bulkRelationMode === "target-action" && !targetTenantId) {
toast.error(
t(
"msg.admin.permissions_direct.target_tenant_required",
"대상 테넌트를 선택하세요.",
),
);
return;
}
for (const user of queuedTargetUsers) {
if (bulkRelationMode === "page") {
await addSystemRelationMutation.mutateAsync({
userId: user.id,
relation,
});
} else {
const currentSystemRelations =
systemRelations.find((item) => item.userId === user.id)?.relations ??
[];
const requiredPageAccess =
bulkAction === "manage" ? "tenants_managers" : "tenants_viewers";
if (!currentSystemRelations.includes(requiredPageAccess)) {
await addSystemRelationMutation.mutateAsync({
userId: user.id,
relation: requiredPageAccess,
});
}
await addTenantRelationMutation.mutateAsync({
tenantId: targetTenantId,
userId: user.id,
relation,
});
}
}
toast.success(
t(
"msg.admin.permissions_direct.bulk_grant_success",
"선택 사용자에게 권한을 부여했습니다.",
),
);
setQueuedTargetUsers([]);
};
const handleGrantSuperAdminRole = () => {
if (selectedSuperAdminUserIds.length === 0) {
toast.error(
t(
"msg.admin.permissions_direct.super_admin_users_required",
"Super Admin을 부여할 사용자를 하나 이상 선택하세요.",
),
);
return;
}
updateUserRoleMutation.mutate({
userIds: selectedSuperAdminUserIds,
role: "super_admin",
});
};
const queueTargetUsers = useCallback((users: UserSummary[]) => {
setQueuedTargetUsers((current) => {
const next = [...current];
const ids = new Set(current.map((user) => user.id));
for (const user of users) {
if (ids.has(user.id)) continue;
ids.add(user.id);
next.push(user);
}
return next;
});
}, []);
const removeQueuedTargetUser = (userId: string) => {
setQueuedTargetUsers((current) =>
current.filter((user) => user.id !== userId),
);
};
useEffect(() => {
if (activePermissionTab !== "direct") return;
const onMessage = (event: MessageEvent) => {
const selections = parseOrgChartUserSelections(event.data);
if (selections.length === 0) return;
queueTargetUsers(
selections.map((selection) => ({
id: selection.id,
name: selection.name,
email: selection.email,
tenantSlug: selection.leafTenantName,
tenant: selection.leafTenantName
? {
id: "",
slug: "",
name: selection.leafTenantName,
createdAt: "",
updatedAt: "",
}
: undefined,
metadata: {
rootTenantName: selection.rootTenantName,
leafTenantName: selection.leafTenantName,
},
role: "user",
status: "active",
createdAt: "",
updatedAt: "",
})),
);
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [activePermissionTab, queueTargetUsers]);
// Categorized system menus with descriptions and icons
const systemMenuCategories = [
{
title: t(
"ui.admin.permissions_direct.cat_dashboard",
"핵심 대시보드 및 분석",
),
menus: [
{
label: t("ui.admin.nav.overview", "개요"),
relation: "overview",
desc: t(
"msg.admin.permissions_direct.desc_overview",
"바론 전체 사양 및 시스템 상태 개요 정보",
),
icon: LayoutDashboard,
},
{
label: t("ui.admin.nav.audit_logs", "감사 로그"),
relation: "audit_logs",
desc: t(
"msg.admin.permissions_direct.desc_audit_logs",
"시스템 전역 보안 감사 및 접속 이력 로그",
),
icon: NotebookTabs,
},
],
},
{
title: t("ui.admin.permissions_direct.cat_resources", "핵심 리소스 관리"),
menus: [
{
label: t("ui.admin.nav.tenants", "테넌트"),
relation: "tenants",
desc: t(
"msg.admin.permissions_direct.desc_tenants",
"고객 테넌트 목록, 신규 부모-자식 테넌트 관리",
),
icon: Building2,
},
{
label: t("ui.admin.nav.org_chart", "조직도"),
relation: "org_chart",
desc: t(
"msg.admin.permissions_direct.desc_org_chart",
"조직도 가시화 및 트리 배치 확인",
),
icon: Network,
},
{
label: t("ui.admin.nav.users", "사용자"),
relation: "users",
desc: t(
"msg.admin.permissions_direct.desc_users",
"가입 사용자 목록, 승인 및 커스텀 클레임 수동 주입",
),
icon: Users,
},
],
},
{
title: t(
"ui.admin.permissions_direct.cat_integrations",
"인프라 연동 및 보안",
),
menus: [
{
label: t("ui.admin.nav.worksmobile", "Worksmobile"),
relation: "worksmobile",
desc: t(
"msg.admin.permissions_direct.desc_worksmobile",
"라인웍스 연동 및 사내 임직원 패스워드 강제 동기화",
),
icon: Share2,
},
{
label: t("ui.admin.nav.api_keys", "API 키"),
relation: "api_keys",
desc: t(
"msg.admin.permissions_direct.desc_api_keys",
"조직도 연동을 위한 전역 서드파티 토큰 관리",
),
icon: Key,
},
],
},
{
title: t(
"ui.admin.permissions_direct.cat_system",
"아이덴티티 및 게이트 관리",
),
menus: [
{
label: t("ui.admin.nav.ory_ssot", "Ory SSOT 시스템"),
relation: "ory_ssot",
desc: t(
"msg.admin.permissions_direct.desc_ory_ssot",
"Redis 아이덴티티 미러 캐시 및 PostgreSQL read model 정합성 갱신",
),
icon: Database,
},
{
label: t("ui.admin.nav.data_integrity", "데이터 정합성"),
relation: "data_integrity",
desc: t(
"msg.admin.permissions_direct.desc_data_integrity",
"고아 레코드 검출 및 DB 정합성 최종 검증기",
),
icon: ShieldCheck,
},
{
label: t("ui.admin.nav.auth_guard", "인증 가드"),
relation: "auth_guard",
desc: t(
"msg.admin.permissions_direct.desc_auth_guard",
"정책엔진 기준으로 Keto ReBAC 관계 검증 시뮬레이터",
),
icon: KeyRound,
},
{
label: t("ui.admin.nav.permissions_direct", "권한 부여"),
relation: "permissions_direct",
desc: t(
"msg.admin.permissions_direct.desc_permissions_direct",
"본 사이드바 메뉴 세부 권한 격자 및 테넌트 인가 설정 패널",
),
icon: Shield,
},
],
},
];
const filteredRelations = systemRelations;
const selectedUser = undefined;
const grantableSystemMenus = systemMenuCategories.flatMap((category) =>
category.menus.filter(
(menu) => !protectedSystemMenuRelations.has(menu.relation),
),
);
const menuByRelation = new Map(
systemMenuCategories
.flatMap((category) => category.menus)
.map((menu) => [menu.relation, menu]),
);
const pageRelationOptions = grantableSystemMenus.flatMap((menu) => [
{
label: `${menu.label} - ${t("ui.common.read", "조회")}`,
value: `${menu.relation}_viewers`,
},
{
label: `${menu.label} - ${t("ui.common.write", "수정")}`,
value: `${menu.relation}_managers`,
},
]);
const tenantPermissionPages = [
{
value: "profile",
label: t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필"),
},
{
value: "permissions",
label: t("ui.admin.tenants.detail.tab_permissions", "권한 관리"),
},
{
value: "organization",
label: t("ui.admin.tenants.detail.tab_organization", "조직 관리"),
},
{
value: "schema",
label: t("ui.admin.tenants.detail.tab_schema", "사용자 스키마"),
},
];
const selectedTargetTenant = tenants.find(
(tenant) => tenant.id === targetTenantId,
);
const tenantPickerCandidates = tenants.filter((tenant) => {
const query = tenantPickerSearch.trim().toLowerCase();
if (!query) return true;
return (
tenant.name.toLowerCase().includes(query) ||
tenant.slug.toLowerCase().includes(query)
);
});
const permissionAssignmentRows = systemRelations.flatMap((user) =>
user.relations.map((relation) => {
const level = relation.endsWith("_managers") ? "write" : "read";
const target = relation.replace(/_(viewers|managers)$/, "");
const menu = menuByRelation.get(target);
return {
scope: "system" as const,
user,
relation,
target,
level,
label: menu?.label ?? target,
tenantId: "",
tenantName: t("ui.admin.permissions_direct.scope_system", "전역"),
protected: protectedSystemMenuRelations.has(target),
};
}),
);
const tenantPermissionPageByValue = new Map(
tenantPermissionPages.map((page) => [page.value, page.label]),
);
const tenantPermissionAssignmentRows = tenantRelations.flatMap((user) =>
user.relations.map((relation) => {
const level = relation.endsWith("_managers") ? "write" : "read";
const target = relation.replace(/_(viewers|managers)$/, "");
return {
scope: "tenant" as const,
user,
relation,
target,
level,
label: tenantPermissionPageByValue.get(target) ?? target,
tenantId: targetTenantId,
tenantName:
selectedTargetTenant?.name ??
t("ui.admin.permissions_direct.scope_tenant", "테넌트"),
protected: false,
};
}),
);
const allPermissionAssignmentRows =
bulkRelationMode === "target-action"
? tenantPermissionAssignmentRows
: permissionAssignmentRows;
const filteredPermissionAssignmentRows = allPermissionAssignmentRows
.filter((row) => {
const query = assignmentSearchTerm.trim().toLowerCase();
if (!query) return true;
return (
row.user.name.toLowerCase().includes(query) ||
row.user.email.toLowerCase().includes(query) ||
row.relation.toLowerCase().includes(query) ||
row.label.toLowerCase().includes(query)
);
})
.sort((a, b) => {
if (assignmentSort === "relation") {
const relationCompare = a.label.localeCompare(b.label);
if (relationCompare !== 0) return relationCompare;
}
if (assignmentSort === "level") {
const levelCompare = a.level.localeCompare(b.level);
if (levelCompare !== 0) return levelCompare;
}
return a.user.name.localeCompare(b.user.name);
});
const handleAssignmentLevelChange = async (
scope: "system" | "tenant",
tenantId: string,
userId: string,
relation: string,
nextLevel: "none" | "read" | "write",
) => {
const target = relation.replace(/_(viewers|managers)$/, "");
if (scope === "system" && protectedSystemMenuRelations.has(target)) return;
if (scope === "system") {
await removeSystemRelationMutation.mutateAsync({ userId, relation });
} else {
await removeTenantRelationMutation.mutateAsync({
tenantId,
userId,
relation,
});
}
if (nextLevel === "read") {
if (scope === "system") {
await addSystemRelationMutation.mutateAsync({
userId,
relation: `${target}_viewers`,
});
} else {
await addTenantRelationMutation.mutateAsync({
tenantId,
userId,
relation: `${target}_viewers`,
});
}
} else if (nextLevel === "write") {
if (scope === "system") {
await addSystemRelationMutation.mutateAsync({
userId,
relation: `${target}_managers`,
});
} else {
await addTenantRelationMutation.mutateAsync({
tenantId,
userId,
relation: `${target}_managers`,
});
}
}
};
const handleAssignmentRemove = async (
scope: "system" | "tenant",
tenantId: string,
userId: string,
relation: string,
) => {
if (scope === "system") {
await removeSystemRelationMutation.mutateAsync({ userId, relation });
} else {
await removeTenantRelationMutation.mutateAsync({
tenantId,
userId,
relation,
});
}
};
if (profile && !isSuperAdmin) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-lg font-bold">
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<Button onClick={() => navigate("/")}>
{t("ui.common.go_home", "홈으로 이동")}
</Button>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-1">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<ShieldCheck className="h-8 w-8 text-primary" />
{t("ui.admin.nav.permissions_direct", "권한 부여")}
</h1>
<p className="text-muted-foreground">
{t(
"msg.admin.permissions_direct.description",
"테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다.",
)}
</p>
</div>
<div
role="tablist"
aria-label={t("ui.admin.permissions_direct.tabs", "권한 부여 탭")}
className="flex flex-wrap gap-2 border-b border-border"
>
<button
type="button"
role="tab"
aria-selected={activePermissionTab === "direct"}
className={`px-4 py-2 text-sm font-semibold border-b-2 transition-colors ${
activePermissionTab === "direct"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActivePermissionTab("direct")}
>
{t("ui.admin.permissions_direct.tab_direct", "상세 권한")}
</button>
{isSuperAdmin && (
<button
type="button"
role="tab"
aria-selected={activePermissionTab === "super-admin"}
className={`px-4 py-2 text-sm font-semibold border-b-2 transition-colors ${
activePermissionTab === "super-admin"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActivePermissionTab("super-admin")}
>
{t(
"ui.admin.permissions_direct.tab_super_admin",
"Super Admin 역할",
)}
</button>
)}
</div>
{activePermissionTab === "direct" && (
<>
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="flex flex-col gap-1">
<h2 className="text-lg font-bold">
{t(
"ui.admin.permissions_direct.bulk_title",
"다중 사용자 권한 부여 및 회수",
)}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.permissions_direct.bulk_description",
"페이지별 접근 권한 또는 대상+액션 권한을 선택한 사용자들에게 동시에 적용합니다.",
)}
</p>
</div>
<div className="mt-5 grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<div className="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.bulk_users", "적용 대상")}
</h3>
<Badge variant="secondary">
{queuedTargetUsers.length}
{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="space-y-3">
<div
className="min-h-16 rounded-md border bg-background p-2"
data-testid="permission-target-queue"
>
{queuedTargetUsers.length === 0 ? (
<div className="flex h-12 items-center justify-center text-sm text-muted-foreground">
{t(
"ui.admin.permissions_direct.target_queue_empty",
"적용할 사용자를 선택하세요.",
)}
</div>
) : (
<div className="flex flex-wrap gap-2">
{queuedTargetUsers.map((user) => (
<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>
{(user.metadata?.rootTenantName ||
user.metadata?.leafTenantName) && (
<span className="max-w-64 truncate text-xs text-muted-foreground">
{[user.metadata?.rootTenantName, user.metadata?.leafTenantName]
.filter(Boolean)
.join(" / ")}
</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>
<div className="grid content-start gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm font-medium">
{t("ui.admin.permissions_direct.bulk_mode", "권한 방식")}
<select
data-testid="bulk-relation-mode"
value={bulkRelationMode}
onChange={(event) =>
setBulkRelationMode(
event.target.value as "page" | "target-action",
)
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
>
<option value="page">
{t(
"ui.admin.permissions_direct.bulk_mode_page",
"페이지 접근 권한",
)}
</option>
<option value="target-action">
{t(
"ui.admin.permissions_direct.bulk_mode_target_action",
"대상+액션 권한",
)}
</option>
</select>
</label>
{bulkRelationMode === "page" ? (
<label className="space-y-2 text-sm font-medium md:col-span-2">
{t(
"ui.admin.permissions_direct.bulk_page_relation",
"페이지 접근 권한",
)}
<select
data-testid="bulk-page-relation"
value={bulkPageRelation}
onChange={(event) =>
setBulkPageRelation(event.target.value)
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
>
{pageRelationOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
) : (
<>
<div className="space-y-2 text-sm font-medium">
{t("ui.admin.permissions_direct.bulk_target", "대상")}
<input
data-testid="bulk-relation-target-tenant"
type="hidden"
value={targetTenantId}
readOnly
/>
<Dialog open={tenantPickerOpen} onOpenChange={setTenantPickerOpen}>
<Button
type="button"
variant="outline"
className="h-10 w-full justify-start"
data-testid="permission-action-tenant-picker-open"
onClick={() => setTenantPickerOpen(true)}
>
{selectedTargetTenant?.name ??
t(
"ui.admin.permissions_direct.target_tenant_required_option",
"테넌트를 선택하세요",
)}
</Button>
<DialogContent className="max-w-[520px]">
<DialogHeader>
<DialogTitle>
{t(
"ui.admin.permissions_direct.target_tenant_picker_title",
"대상 테넌트 선택",
)}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.permissions_direct.target_tenant_picker_desc",
"권한을 부여할 테넌트를 검색해 선택합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={tenantPickerSearch}
onChange={(event) =>
setTenantPickerSearch(event.target.value)
}
className="h-9 pl-8"
data-testid="permission-action-tenant-search"
placeholder={t(
"ui.common.search",
"이름 또는 이메일 검색...",
)}
/>
</div>
<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", "검색 결과가 없습니다.")}
</div>
) : (
tenantPickerCandidates.map((tenant) => (
<button
key={tenant.id}
type="button"
className="flex w-full flex-col gap-1 border-b border-border/40 px-3 py-2 text-left text-sm last:border-b-0 hover:bg-muted/50"
data-testid={`permission-action-tenant-result-${tenant.id}`}
onClick={() => {
setTargetTenantId(tenant.id);
setTenantPickerOpen(false);
setTenantPickerSearch("");
}}
>
<span className="font-medium">
{tenant.name}
</span>
<span className="text-xs text-muted-foreground">
{tenant.slug}
</span>
</button>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
<label className="space-y-2 text-sm font-medium">
{t(
"ui.admin.permissions_direct.bulk_tenant_page",
"페이지",
)}
<select
data-testid="bulk-relation-target"
value={bulkTenantPage}
onChange={(event) =>
setBulkTenantPage(event.target.value)
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
>
{tenantPermissionPages.map((page) => (
<option key={page.value} value={page.value}>
{page.label}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm font-medium">
{t("ui.admin.permissions_direct.bulk_action", "액션")}
<select
data-testid="bulk-relation-action"
value={bulkAction}
onChange={(event) =>
setBulkAction(event.target.value as "read" | "manage")
}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
>
<option value="read">
{t("ui.common.read", "조회")}
</option>
<option value="manage">
{t("ui.common.write", "수정")}
</option>
</select>
</label>
</>
)}
<div className="flex items-end">
<Button
className="w-full"
onClick={handleBulkRelationSubmit}
disabled={
addSystemRelationMutation.isPending ||
removeSystemRelationMutation.isPending
}
>
{t(
"ui.admin.permissions_direct.bulk_submit_grant",
"선택 사용자에게 권한 부여",
)}
</Button>
</div>
</div>
</div>
</div>
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-lg font-bold">
{t(
"ui.admin.permissions_direct.assignment_table_title",
"부여된 권한",
)}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.permissions_direct.assignment_table_desc",
"사용자별로 부여된 권한을 검색, 정렬, 수정, 회수합니다.",
)}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_180px] lg:min-w-[520px]">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
data-testid="permission-assignment-search"
value={assignmentSearchTerm}
onChange={(event) =>
setAssignmentSearchTerm(event.target.value)
}
className="h-9 pl-8"
placeholder={t(
"ui.admin.permissions_direct.assignment_search",
"사용자, 이메일, 권한 검색",
)}
/>
</div>
<select
data-testid="permission-assignment-sort"
value={assignmentSort}
onChange={(event) =>
setAssignmentSort(
event.target.value as "user" | "relation" | "level",
)
}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
>
<option value="user">
{t("ui.admin.permissions_direct.sort_user", "사용자순")}
</option>
<option value="relation">
{t("ui.admin.permissions_direct.sort_relation", "권한순")}
</option>
<option value="level">
{t("ui.admin.permissions_direct.sort_level", "수준순")}
</option>
</select>
</div>
</div>
<div className="mt-4 overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.admin.permissions_direct.table_user", "사용자")}
</TableHead>
<TableHead>
{t("ui.admin.permissions_direct.table_target", "대상")}
</TableHead>
<TableHead>
{t("ui.admin.permissions_direct.table_relation", "Relation")}
</TableHead>
<TableHead className="w-[180px]">
{t("ui.admin.permissions_direct.table_level", "권한")}
</TableHead>
<TableHead className="w-[100px] text-right">
{t("ui.common.action", "작업")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPermissionAssignmentRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="py-10 text-center text-sm text-muted-foreground"
>
{t(
"msg.admin.permissions_direct.assignment_empty",
"부여된 권한이 없습니다.",
)}
</TableCell>
</TableRow>
) : (
filteredPermissionAssignmentRows.map((row) => (
<TableRow
key={`${row.user.userId}-${row.relation}`}
data-testid={`permission-assignment-row-${row.user.userId}-${row.relation}`}
>
<TableCell>
<div className="min-w-0">
<div className="truncate font-medium">
{row.user.name}
</div>
<div className="truncate text-xs text-muted-foreground">
{row.user.email}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span>{row.label}</span>
{row.protected && (
<Badge variant="secondary" className="text-xs">
{t(
"ui.admin.permissions_direct.super_admin_only",
"Super Admin 전용",
)}
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">
{row.tenantName}
</div>
</div>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{row.relation}
</TableCell>
<TableCell>
<select
data-testid={`permission-assignment-level-${row.user.userId}-${row.relation}`}
value={row.level}
disabled={row.protected}
onChange={(event) =>
handleAssignmentLevelChange(
row.scope,
row.tenantId,
row.user.userId,
row.relation,
event.target.value as "none" | "read" | "write",
)
}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="read">
{t("ui.common.read", "조회")}
</option>
<option value="write">
{t("ui.common.write", "수정")}
</option>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
</select>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
data-testid={`permission-assignment-remove-${row.user.userId}-${row.relation}`}
disabled={row.protected}
onClick={() =>
handleAssignmentRemove(
row.scope,
row.tenantId,
row.user.userId,
row.relation,
)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
{false && (
<>
{/* 시스템 메뉴 권한 (Admin Control) Split Screen Panel */}
<div className="flex flex-col lg:flex-row gap-6 h-[720px] border border-border rounded-xl bg-card overflow-hidden shadow-sm">
{/* Left Panel: User List */}
<div className="w-full lg:w-80 border-r border-border flex flex-col bg-muted/10 h-full">
<div className="p-4 border-b border-border space-y-3 flex-shrink-0">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm text-foreground">
{t("ui.admin.permissions_direct.user_list", "대상 사용자")} (
{filteredRelations.length})
</h3>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "이름 또는 이메일 검색...")}
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
name="user-search"
className="pl-8 h-8 text-xs"
/>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{filteredRelations.length === 0 ? (
<div className="p-6 text-center text-xs text-muted-foreground">
{t(
"msg.admin.permissions_direct.no_users_found",
"등록된 사용자가 없습니다.",
)}
</div>
) : (
filteredRelations.map((user) => {
const isSelected = activeUserId === user.userId;
const activeCount = user.relations.length;
return (
<button
type="button"
key={user.userId}
onClick={() => setActiveUserId(user.userId)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-all ${
isSelected
? "bg-primary/10 text-primary border-l-4 border-primary shadow-sm"
: "hover:bg-muted/50 text-foreground"
}`}
>
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-8 w-8 border border-border">
<AvatarFallback className="bg-primary/10 text-primary font-bold text-xs uppercase">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className="text-sm font-semibold truncate">
{user.name}
</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[150px]">
{user.email}
</span>
</div>
</div>
<Badge
variant={isSelected ? "default" : "secondary"}
className="text-[9px] px-1.5 py-0.5"
>
{activeCount}
</Badge>
</button>
);
})
)}
</div>
</ScrollArea>
</div>
{/* Right Panel: Toggle settings grid */}
<div className="flex-1 flex flex-col h-full bg-background">
{selectedUser ? (
<>
{/* User Detail Header */}
<div className="p-5 border-b border-border flex items-center justify-between flex-shrink-0 bg-muted/5">
<div className="flex items-center gap-4">
<Avatar className="h-11 w-11 border">
<AvatarFallback className="bg-primary/5 text-primary font-extrabold text-sm uppercase">
{selectedUser.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<h2 className="text-lg font-bold flex items-center gap-2">
{selectedUser.name}
<Badge variant="outline" className="text-xs font-normal">
{selectedUser.relations.length}{" "}
{t("ui.admin.permissions_direct.allowed", "개 허용됨")}
</Badge>
</h2>
<span className="text-xs text-muted-foreground">
{selectedUser.email}
</span>
</div>
</div>
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/20 hover:bg-destructive/10"
onClick={() =>
handleRemoveAllSystemRelations(
selectedUser.userId,
selectedUser.relations,
)
}
>
<Trash2 className="h-4 w-4 mr-2" />
{t(
"ui.admin.permissions_direct.revoke_all",
"모든 권한 회수",
)}
</Button>
</div>
{/* Categorized Toggle Grid */}
<ScrollArea className="flex-1">
<div className="p-6 space-y-6">
{systemMenuCategories.map((category) => (
<div key={category.title} className="space-y-3">
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
{category.title}
</h4>
<Card className="border border-border/60 shadow-none bg-card">
<CardContent className="p-0 divide-y divide-border/40">
{category.menus.map((menu) => {
const isWrite = selectedUser.relations.includes(
`${menu.relation}_managers`,
);
const isRead = selectedUser.relations.includes(
`${menu.relation}_viewers`,
);
const serverValue: "none" | "read" | "write" =
isWrite ? "write" : isRead ? "read" : "none";
const permissionValue =
localSystemPermissions[selectedUser.userId]?.[
menu.relation
] ?? serverValue;
const Icon = menu.icon;
return (
<div
key={menu.relation}
className="flex items-center justify-between p-4 hover:bg-muted/10 transition-colors"
>
<div className="flex items-start gap-4 pr-4 min-w-0">
<div className="p-2 rounded-lg bg-secondary/50 text-foreground flex-shrink-0 mt-0.5">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{menu.label}
</span>
{protectedSystemMenuRelations.has(
menu.relation,
) && (
<Badge
variant="secondary"
className="text-[10px] py-0.5 px-1.5 font-semibold text-destructive bg-destructive/10 border-destructive/20"
>
{t(
"ui.admin.permissions_direct.super_admin_only",
"Super Admin 전용",
)}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground line-clamp-1">
{menu.desc}
</span>
</div>
</div>
<select
name={`system-menu-permission-${menu.relation}`}
value={permissionValue}
disabled={protectedSystemMenuRelations.has(
menu.relation,
)}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
// 🌟 1단계: 로컬 임시 상태 즉시 갱신 (0ms 반응 보장)
setLocalSystemPermissions((prev) => ({
...prev,
[selectedUser.userId]: {
...(prev[selectedUser.userId] ?? {}),
[menu.relation]: nextVal,
},
}));
// 🌟 2단계: 백그라운드 비동기 API 요청 수행
handleSystemRelationChange(
selectedUser.userId,
menu.relation,
permissionValue,
nextVal,
);
}}
className="flex h-9 w-[180px] rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</div>
);
})}
</CardContent>
</Card>
</div>
))}
</div>
</ScrollArea>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center text-muted-foreground bg-muted/5 gap-3">
<ShieldCheck className="h-12 w-12 text-muted-foreground opacity-30" />
<div>
<h3 className="text-sm font-semibold text-foreground">
{t(
"ui.admin.permissions_direct.no_user_selected",
"사용자가 선택되지 않았습니다.",
)}
</h3>
<p className="text-xs mt-1">
{t(
"msg.admin.permissions_direct.no_user_selected_desc",
"왼쪽의 사용자 리스트에서 권한을 변경할 인원을 선택해 주세요.",
)}
</p>
</div>
</div>
)}
</div>
</div>
</>
)}
</>
)}
{activePermissionTab === "super-admin" && isSuperAdmin && (
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="flex flex-col gap-1">
<h2 className="text-lg font-bold">
{t(
"ui.admin.permissions_direct.super_admin_title",
"Super Admin 역할 부여",
)}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.permissions_direct.super_admin_description",
"전역 시스템 관리자 역할은 상세 relation과 분리해서 부여합니다.",
)}
</p>
</div>
<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", "대상 사용자")}
</h3>
<Badge variant="secondary">
{selectedSuperAdminUserIds.length}
{t("ui.admin.permissions_direct.bulk_selected", "명 선택")}
</Badge>
</div>
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
{systemRelations.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground">
{t(
"msg.admin.permissions_direct.no_users_found",
"등록된 사용자가 없습니다.",
)}
</div>
) : (
systemRelations.map((user) => (
<label
key={user.userId}
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm hover:bg-muted/40"
>
<input
type="checkbox"
data-testid={`super-admin-role-user-${user.userId}`}
checked={selectedSuperAdminUserIds.includes(user.userId)}
onChange={(event) =>
toggleSuperAdminUser(user.userId, event.target.checked)
}
/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{user.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{user.email}
</span>
</span>
</label>
))
)}
</div>
</div>
<div className="mt-5 flex justify-end">
<Button
onClick={handleGrantSuperAdminRole}
disabled={updateUserRoleMutation.isPending}
>
{t(
"ui.admin.permissions_direct.super_admin_grant",
"Super Admin 부여",
)}
</Button>
</div>
</div>
)}
</div>
);
}