forked from baron/baron-sso
1748 lines
66 KiB
TypeScript
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>
|
|
);
|
|
}
|