forked from baron/baron-sso
i18n, adminfront, devfront: 'make code-check' 통과를 위한 번역 싱크 엔진 개선, 룰 오브 훅 정합성 교정 및 테스트 레이스 컨디션 해결
This commit is contained in:
@@ -1,45 +1,27 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { AxiosError } from "axios";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
fetchAllTenants,
|
||||
fetchMe,
|
||||
fetchUsers,
|
||||
fetchSystemRelations,
|
||||
addSystemRelation,
|
||||
removeSystemRelation,
|
||||
type TenantRelation,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { TenantFineGrainedPermissionsTab } from "./TenantFineGrainedPermissionsTab";
|
||||
import {
|
||||
ShieldCheck,
|
||||
Search,
|
||||
Plus,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Settings,
|
||||
Shield,
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
Network,
|
||||
Database,
|
||||
Users,
|
||||
KeyRound,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Network,
|
||||
NotebookTabs,
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, 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,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Card, CardContent } from "../../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -48,24 +30,33 @@ import {
|
||||
DialogTitle,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Avatar, AvatarFallback } from "../../../components/ui/avatar";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import { Separator } from "../../../components/ui/separator";
|
||||
import { Switch } from "../../../components/ui/switch";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
addSystemRelation,
|
||||
fetchAllTenants,
|
||||
fetchMe,
|
||||
fetchSystemRelations,
|
||||
fetchUsers,
|
||||
removeSystemRelation,
|
||||
type TenantRelation,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
export function TenantFineGrainedPermissionsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<"tenant" | "system">("system");
|
||||
const [selectedTenantId, setSelectedTenantId] = useState("");
|
||||
const [activeTab, _setActiveTab] = useState<"tenant" | "system">("system");
|
||||
const [_selectedTenantId, _setSelectedTenantId] = useState("");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [activeUserId, setActiveUserId] = useState<string | null>(null);
|
||||
const [userSearchTerm, setUserSearchTerm] = useState("");
|
||||
|
||||
// 🌟 글로벌 시스템 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
|
||||
const [localSystemPermissions, setLocalSystemPermissions] = useState<Record<string, Record<string, "none" | "read" | "write">>>({});
|
||||
const [localSystemPermissions, setLocalSystemPermissions] = useState<
|
||||
Record<string, Record<string, "none" | "read" | "write">>
|
||||
>({});
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
@@ -74,26 +65,13 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
|
||||
const isSuperAdmin = profile?.role === "super_admin";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["tenants", "list-all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
enabled: isSuperAdmin,
|
||||
});
|
||||
|
||||
const tenants = isSuperAdmin
|
||||
const _tenants = isSuperAdmin
|
||||
? (tenantsQuery.data?.items ?? [])
|
||||
: (profile?.manageableTenants ?? []);
|
||||
|
||||
@@ -108,18 +86,33 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
|
||||
useEffect(() => {
|
||||
if (systemRelationsQuery.data) {
|
||||
const initialMap: Record<string, Record<string, "none" | "read" | "write">> = {};
|
||||
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"
|
||||
"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";
|
||||
initialMap[user.userId][m] = isWrite
|
||||
? "write"
|
||||
: isRead
|
||||
? "read"
|
||||
: "none";
|
||||
}
|
||||
}
|
||||
setLocalSystemPermissions(initialMap);
|
||||
@@ -131,30 +124,41 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
addSystemRelation(payload.userId, payload.relation),
|
||||
onMutate: async (newRelation) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["system-relations"] });
|
||||
const previousRelations = queryClient.getQueryData<TenantRelation[]>(["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;
|
||||
});
|
||||
});
|
||||
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);
|
||||
queryClient.setQueryData(
|
||||
["system-relations"],
|
||||
context.previousRelations,
|
||||
);
|
||||
}
|
||||
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Quiet mutate
|
||||
@@ -174,28 +178,41 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
removeSystemRelation(payload.userId, payload.relation),
|
||||
onMutate: async (targetRelation) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["system-relations"] });
|
||||
const previousRelations = queryClient.getQueryData<TenantRelation[]>(["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;
|
||||
});
|
||||
});
|
||||
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);
|
||||
queryClient.setQueryData(
|
||||
["system-relations"],
|
||||
context.previousRelations,
|
||||
);
|
||||
}
|
||||
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.common.error", "오류가 발생했습니다."),
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Quiet mutate
|
||||
@@ -220,26 +237,53 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
|
||||
try {
|
||||
if (currentVal === "read") {
|
||||
await removeSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_viewers` });
|
||||
await removeSystemRelationMutation.mutateAsync({
|
||||
userId,
|
||||
relation: `${menuKey}_viewers`,
|
||||
});
|
||||
} else if (currentVal === "write") {
|
||||
await removeSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_managers` });
|
||||
await removeSystemRelationMutation.mutateAsync({
|
||||
userId,
|
||||
relation: `${menuKey}_managers`,
|
||||
});
|
||||
}
|
||||
|
||||
if (newVal === "read") {
|
||||
await addSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_viewers` });
|
||||
await addSystemRelationMutation.mutateAsync({
|
||||
userId,
|
||||
relation: `${menuKey}_viewers`,
|
||||
});
|
||||
} else if (newVal === "write") {
|
||||
await addSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_managers` });
|
||||
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", "시스템 메뉴 권한이 성공적으로 변경되었습니다."));
|
||||
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", "이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?"))) {
|
||||
const handleRemoveAllSystemRelations = async (
|
||||
userId: string,
|
||||
userRelations: string[],
|
||||
) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.system.relations.remove_all_confirm",
|
||||
"이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const rel of userRelations) {
|
||||
@@ -268,36 +312,133 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
// Categorized system menus with descriptions and icons
|
||||
const systemMenuCategories = [
|
||||
{
|
||||
title: t("ui.admin.permissions_direct.cat_dashboard", "핵심 대시보드 및 분석"),
|
||||
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 },
|
||||
]
|
||||
{
|
||||
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 },
|
||||
]
|
||||
{
|
||||
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", "인프라 연동 및 보안"),
|
||||
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 },
|
||||
]
|
||||
{
|
||||
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", "아이덴티티 및 게이트 관리"),
|
||||
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 },
|
||||
]
|
||||
}
|
||||
{
|
||||
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.filter(
|
||||
@@ -308,6 +449,19 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
|
||||
const selectedUser = systemRelations.find((r) => r.userId === activeUserId);
|
||||
|
||||
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">
|
||||
@@ -325,204 +479,260 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
|
||||
{/* 시스템 메뉴 권한 (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>
|
||||
{/* 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>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</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"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
className="text-destructive border-destructive/20 hover:bg-destructive/10"
|
||||
onClick={() =>
|
||||
handleRemoveAllSystemRelations(
|
||||
selectedUser.userId,
|
||||
selectedUser.relations,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t(
|
||||
"ui.admin.permissions_direct.revoke_all",
|
||||
"모든 권한 회수",
|
||||
)}
|
||||
</Button>
|
||||
</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"
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
{(menu.relation === "ory_ssot" ||
|
||||
menu.relation === "data_integrity") && (
|
||||
<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={
|
||||
menu.relation === "ory_ssot" ||
|
||||
menu.relation === "data_integrity"
|
||||
}
|
||||
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>
|
||||
<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
|
||||
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>
|
||||
{(menu.relation === "ory_ssot" || menu.relation === "data_integrity") && (
|
||||
<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={menu.relation === "ory_ssot" || menu.relation === "data_integrity"}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* User Search Dialog for System relations */}
|
||||
<Dialog
|
||||
@@ -537,7 +747,10 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{t("ui.admin.permissions_direct.dialog_title_system", "시스템 권한 관리 유저 추가")}
|
||||
{t(
|
||||
"ui.admin.permissions_direct.dialog_title_system",
|
||||
"시스템 권한 관리 유저 추가",
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
|
||||
Reference in New Issue
Block a user