Files
invyone/frontend/components/admin/RoleFormModal.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

384 lines
14 KiB
TypeScript

"use client";
import React, { useState, useCallback, useEffect, useMemo } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { roleAPI, RoleGroup } from "@/lib/api/role";
import { useAuth } from "@/hooks/useAuth";
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { companyAPI } from "@/lib/api/company";
interface RoleFormModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
editingRole?: RoleGroup | null;
}
/**
* 권한 그룹 생성/수정 모달
*
* 기능:
* - 권한 그룹 생성 (authName, authCode, company_code)
* - 권한 그룹 수정 (authName, authCode, status)
* - 유효성 검사
*
* shadcn/ui 표준 모달 디자인 적용
*/
export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleFormModalProps) {
const { user: currentUser } = useAuth();
const isEditMode = !!editingRole;
// 최고 관리자 여부
const isSuperAdmin = currentUser?.company_code === "*" && currentUser?.user_type === "SUPER_ADMIN";
// 폼 데이터
const [formData, setFormData] = useState({
authName: "",
authCode: "",
company_code: currentUser?.company_code || "",
status: "active",
});
// 상태 관리
const [isLoading, setIsLoading] = useState(false);
const [showAlert, setShowAlert] = useState(false);
const [alertMessage, setAlertMessage] = useState("");
const [alertType, setAlertType] = useState<"success" | "error" | "info">("info");
// 회사 목록 (최고 관리자용)
const [companies, setCompanies] = useState<Array<{ company_code: string; company_name: string }>>([]);
const [isLoadingCompanies, setIsLoadingCompanies] = useState(false);
const [companyComboOpen, setCompanyComboOpen] = useState(false);
// 폼 유효성 검사
const isFormValid = useMemo(() => {
return formData.authName.trim() !== "" && formData.authCode.trim() !== "" && formData.company_code.trim() !== "";
}, [formData]);
// 알림 표시
const displayAlert = useCallback((message: string, type: "success" | "error" | "info") => {
setAlertMessage(message);
setAlertType(type);
setShowAlert(true);
setTimeout(() => setShowAlert(false), 3000);
}, []);
// 회사 목록 로드 (최고 관리자만)
const loadCompanies = useCallback(async () => {
if (!isSuperAdmin) return;
setIsLoadingCompanies(true);
try {
// companyAPI.getList()는 Promise<Company[]>를 반환하므로 직접 사용
const companies = await companyAPI.getList();
console.log("📋 회사 목록 로드 성공:", companies);
setCompanies(companies);
} catch (error) {
console.error("❌ 회사 목록 로드 오류:", error);
displayAlert("회사 목록을 불러오는데 실패했습니다.", "error");
} finally {
setIsLoadingCompanies(false);
}
}, [isSuperAdmin, displayAlert]);
// 초기화
useEffect(() => {
if (isOpen) {
// 최고 관리자이고 생성 모드일 때만 회사 목록 로드
if (isSuperAdmin && !isEditMode) {
loadCompanies();
}
if (isEditMode && editingRole) {
// 수정 모드: 기존 데이터 로드
setFormData({
authName: editingRole.auth_name || "",
authCode: editingRole.auth_code || "",
company_code: editingRole.company_code || "",
status: editingRole.status || "active",
});
} else {
// 생성 모드: 초기화
setFormData({
authName: "",
authCode: "",
company_code: currentUser?.company_code || "",
status: "active",
});
}
setShowAlert(false);
}
}, [isOpen, isEditMode, editingRole, currentUser?.company_code, isSuperAdmin, loadCompanies]);
// 입력 핸들러
const handleInputChange = useCallback((field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
// 제출 핸들러
const handleSubmit = useCallback(async () => {
if (!isFormValid) {
displayAlert("모든 필수 항목을 입력해주세요.", "error");
return;
}
setIsLoading(true);
try {
let response;
if (isEditMode && editingRole) {
// 수정 — cross-tenant 모드에선 editingRole.company_code 가 그 회사 DB 라우팅 키
response = await roleAPI.update(editingRole.objid, {
auth_name: formData.authName,
auth_code: formData.authCode,
status: formData.status,
company_code: editingRole.company_code,
});
} else {
// 생성
response = await roleAPI.create({
auth_name: formData.authName,
auth_code: formData.authCode,
company_code: formData.company_code,
});
}
if (response.success) {
displayAlert(isEditMode ? "권한 그룹이 수정되었습니다." : "권한 그룹이 생성되었습니다.", "success");
setTimeout(() => {
onClose();
onSuccess?.();
}, 1500);
} else {
displayAlert(response.message || "작업에 실패했습니다.", "error");
}
} catch (error) {
console.error("권한 그룹 저장 오류:", error);
displayAlert("권한 그룹 저장 중 오류가 발생했습니다.", "error");
} finally {
setIsLoading(false);
}
}, [isFormValid, isEditMode, editingRole, formData, onClose, onSuccess, displayAlert]);
// Enter 키 핸들러
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && isFormValid && !isLoading) {
handleSubmit();
}
},
[isFormValid, isLoading, handleSubmit],
);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{isEditMode ? "권한 그룹 수정" : "권한 그룹 생성"}</DialogTitle>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 권한 그룹명 */}
<div>
<Label htmlFor="authName" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="authName"
value={formData.authName}
onChange={(e) => handleInputChange("authName", e.target.value)}
onKeyDown={handleKeyDown}
placeholder="예: 영업팀 권한"
className="h-8 text-xs sm:h-10 sm:text-sm"
disabled={isLoading}
/>
</div>
{/* 권한 코드 */}
<div>
<Label htmlFor="authCode" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="authCode"
value={formData.authCode}
onChange={(e) => handleInputChange("authCode", e.target.value)}
onKeyDown={handleKeyDown}
placeholder="예: SALES_TEAM (영문/숫자/언더스코어만)"
className="h-8 text-xs sm:h-10 sm:text-sm"
disabled={isLoading}
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
. , , .
</p>
</div>
{/* 회사 (수정 모드에서는 비활성화) */}
{isEditMode ? (
<div>
<Label htmlFor="company_code" className="text-xs sm:text-sm">
</Label>
<Input
id="company_code"
value={formData.company_code}
disabled
className="bg-muted h-8 cursor-not-allowed text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs"> .</p>
</div>
) : (
<div>
<Label htmlFor="company_code" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
{isSuperAdmin ? (
<>
<Popover open={companyComboOpen} onOpenChange={setCompanyComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={companyComboOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoading || isLoadingCompanies}
>
{formData.company_code
? companies.find((company) => company.company_code === formData.company_code)?.company_name ||
formData.company_code
: "회사 선택..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="회사 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
{isLoadingCompanies ? "로딩 중..." : "회사를 찾을 수 없습니다."}
</CommandEmpty>
<CommandGroup>
{companies.map((company) => (
<CommandItem
key={company.company_code}
value={`${company.company_code} ${company.company_name}`}
onSelect={() => {
handleInputChange("company_code", company.company_code);
setCompanyComboOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.company_code === company.company_code ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{company.company_name}</span>
<span className="text-muted-foreground text-[10px]">{company.company_code}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
.
</p>
</>
) : (
<>
<Input
id="company_code"
value={formData.company_code}
disabled
className="bg-muted h-8 cursor-not-allowed text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
.
</p>
</>
)}
</div>
)}
{/* 상태 (수정 모드에서만 표시) */}
{isEditMode && (
<div>
<Label htmlFor="status" className="text-xs sm:text-sm">
</Label>
<Select value={formData.status} onValueChange={(value) => handleInputChange("status", value)}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active"></SelectItem>
<SelectItem value="inactive"></SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 알림 메시지 */}
{showAlert && (
<div
className={`rounded-lg border p-3 text-sm ${
alertType === "success"
? "border-green-300 bg-emerald-50 text-emerald-800"
: alertType === "error"
? "border-destructive/50 bg-destructive/10 text-destructive"
: "border-primary/40 bg-primary/10 text-primary"
}`}
>
<div className="flex items-start gap-2">
{alertType === "error" && <AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />}
<span className="text-xs sm:text-sm">{alertMessage}</span>
</div>
</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
onClick={handleSubmit}
disabled={isLoading || !isFormValid}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? "처리중..." : isEditMode ? "수정" : "생성"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}