7d9ec39b5d
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>
159 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|