Files
invyone/frontend/components/admin/RoleDeleteModal.tsx
T
hjjeong 7d9ec39b5d feat(cross-tenant): SUPER_ADMIN 의 회사별 권한관리 WRITE (Phase 2)
Phase 1(사용자관리) 패턴을 권한관리에 동일 적용. 권한 그룹 CRUD,
멤버 토글, 메뉴 권한 토글 모두 회사 컨텍스트 임시 전환 후 처리.

신규 백엔드
- crosstenant/CrossTenantRoleController.java
  /api/admin/cross-tenant/roles/** — 8개 endpoint
  · POST       — 권한 그룹 생성 (body.company_code 필수)
  · PUT  /{id} — 권한 그룹 수정 (body.company_code 필수)
  · DELETE /{id}?company_code= — 삭제
  · GET  /{id}/workspace?company_code= — 그룹 + 멤버 + 메뉴 통합 로드
  · GET  /menus/all?company_code= — 회사 메뉴 트리 (권한 설정용)
  · POST   /{id}/members/{userId}?company_code= — 멤버 1명 추가
  · DELETE /{id}/members/{userId}?company_code= — 멤버 1명 제거
  · PATCH  /{id}/menu-permissions/{menuObjid} — 토글
  CrossTenantExecutor 재사용. 기존 RoleController 무수정 (회귀 0).

  중요: @RequestAttribute("user_id") 가 토큰 없을 때 missing 에러로 500
  떨어지는 문제 — required=false 로 가드까지 안전하게 도달하도록.

프론트
- lib/api/role.ts — 7개 메서드(create/update/delete/getWorkspace/
  getAllMenus/addSingleMember/removeSingleMember/toggleMenuPermission)에
  isCrossTenantMode() 분기 + companyCode 인자 추가
- RoleFormModal — update 시 editingRole.company_code 같이 전달
- RoleDeleteModal — delete 시 role.company_code 같이 전달
- rolesList/page.tsx — loadWorkspace / addSingleMember / removeSingleMember /
  toggleMenuPermission 호출 시 selectedRole.company_code 전달

검증 (curl, SUPER_ADMIN 토큰):
- 토큰 없음 → 403 super_admin_required
- POST 권한 그룹 (TEST02) → 201, /roles fan-out 에 by={TEST01:1, TEST02:1}
- DELETE → 200, fan-out by={TEST01:1} 로 복귀

미구현 (Phase 2 후속, 별도 작업):
- 일괄 멤버 추가/제거/diff (PUT/POST /members)
- 메뉴 권한 일괄 설정 (PUT /menu-permissions)
- 사용자별 권한 그룹 조회

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:45:55 +09:00

159 lines
5.5 KiB
TypeScript

"use client";
import React, { useState, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { roleAPI, RoleGroup } from "@/lib/api/role";
import { AlertTriangle } from "lucide-react";
interface RoleDeleteModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
role: RoleGroup | null;
}
/**
* 권한 그룹 삭제 확인 모달
*
* 기능:
* - 권한 그룹 삭제 확인
* - CASCADE 삭제 경고 (멤버, 메뉴 권한)
*
* shadcn/ui 표준 확인 모달 디자인
*/
export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDeleteModalProps) {
const [isLoading, setIsLoading] = useState(false);
const [showAlert, setShowAlert] = useState(false);
const [alertMessage, setAlertMessage] = useState("");
const [alertType, setAlertType] = useState<"success" | "error">("error");
// 알림 표시
const displayAlert = useCallback((message: string, type: "success" | "error") => {
setAlertMessage(message);
setAlertType(type);
setShowAlert(true);
setTimeout(() => setShowAlert(false), 3000);
}, []);
// 삭제 핸들러
const handleDelete = useCallback(async () => {
if (!role) return;
setIsLoading(true);
try {
// cross-tenant 모드에선 role.company_code 가 그 회사 DB 라우팅 키
const response = await roleAPI.delete(role.objid, role.company_code);
if (response.success) {
displayAlert("권한 그룹이 삭제되었습니다.", "success");
setTimeout(() => {
onClose();
onSuccess?.();
}, 1500);
} else {
displayAlert(response.message || "삭제에 실패했습니다.", "error");
}
} catch (error) {
console.error("권한 그룹 삭제 오류:", error);
displayAlert("권한 그룹 삭제 중 오류가 발생했습니다.", "error");
} finally {
setIsLoading(false);
}
}, [role, onClose, onSuccess, displayAlert]);
if (!role) return null;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 경고 메시지 */}
<div className="rounded-lg border border-orange-300 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600" />
<div className="space-y-2">
<p className="text-sm font-semibold text-orange-900"> ?</p>
<p className="text-xs text-orange-800">
. :
</p>
<ul className="list-inside list-disc space-y-1 text-xs text-orange-800">
<li> ({role.member_count || 0})</li>
<li> ({role.menu_count || 0})</li>
</ul>
</div>
</div>
</div>
{/* 삭제할 권한 그룹 정보 */}
<div className="bg-muted/50 rounded-lg border p-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"> </span>
<span className="font-medium">{role.auth_name}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"> </span>
<span className="font-mono font-medium">{role.auth_code}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{role.company_code}</span>
</div>
{role.member_names && (
<div className="border-t pt-2">
<span className="text-muted-foreground text-xs">:</span>
<p className="mt-1 text-xs">{role.member_names}</p>
</div>
)}
</div>
</div>
{/* 알림 메시지 */}
{showAlert && (
<div
className={`rounded-lg border p-3 text-sm ${
alertType === "success"
? "border-green-300 bg-emerald-50 text-emerald-800"
: "border-destructive/50 bg-destructive/10 text-destructive"
}`}
>
{alertMessage}
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? "삭제중..." : "삭제"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}