jskim-node #5
@@ -2696,6 +2696,35 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// SUPER_ADMIN 권한 부여는 최고관리자만 가능
|
||||
const requestUser = req.user;
|
||||
const isRequesterSuperAdmin = requestUser?.companyCode === "*" && requestUser?.userType === "SUPER_ADMIN";
|
||||
|
||||
if (userData.userType.trim() === "SUPER_ADMIN" && !isRequesterSuperAdmin) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자 권한은 최고 관리자만 부여할 수 있습니다.",
|
||||
error: { code: "FORBIDDEN_SUPER_ADMIN_GRANT" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 SUPER_ADMIN 사용자의 권한은 최고관리자만 변경 가능
|
||||
if (isUpdate && !isRequesterSuperAdmin) {
|
||||
const targetUser = await queryOne<{ user_type: string }>(
|
||||
`SELECT user_type FROM user_info WHERE user_id = $1`,
|
||||
[userData.userId?.trim()]
|
||||
);
|
||||
if (targetUser?.user_type === "SUPER_ADMIN") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자의 권한은 다른 최고 관리자만 변경할 수 있습니다.",
|
||||
error: { code: "FORBIDDEN_SUPER_ADMIN_MODIFY" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 비밀번호 최소 길이 검증 (신규 등록 시)
|
||||
|
||||
@@ -415,30 +415,27 @@ export default function MenuPage() {
|
||||
};
|
||||
|
||||
const loadMenus = async (showLoading = true) => {
|
||||
// console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
|
||||
try {
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
// 선택된 메뉴 타입에 해당하는 메뉴만 로드
|
||||
if (selectedMenuType === "admin") {
|
||||
const adminResponse = await menuApi.getAdminMenusForManagement();
|
||||
if (adminResponse.success && adminResponse.data) {
|
||||
setLocalAdminMenus(adminResponse.data);
|
||||
}
|
||||
} else {
|
||||
const userResponse = await menuApi.getUserMenusForManagement();
|
||||
if (userResponse.success && userResponse.data) {
|
||||
setLocalUserMenus(userResponse.data);
|
||||
}
|
||||
|
||||
// 양쪽 메뉴를 모두 로드 (카운트 표시용)
|
||||
const [adminResponse, userResponse] = await Promise.all([
|
||||
menuApi.getAdminMenusForManagement(),
|
||||
menuApi.getUserMenusForManagement(),
|
||||
]);
|
||||
|
||||
if (adminResponse.success && adminResponse.data) {
|
||||
setLocalAdminMenus(adminResponse.data);
|
||||
}
|
||||
|
||||
if (userResponse.success && userResponse.data) {
|
||||
setLocalUserMenus(userResponse.data);
|
||||
}
|
||||
|
||||
// 전역 메뉴 상태도 업데이트 (좌측 사이드바용)
|
||||
await refreshMenus();
|
||||
// console.log("📋 메뉴 목록 조회 성공");
|
||||
} catch (error) {
|
||||
// console.error("❌ 메뉴 목록 조회 실패:", error);
|
||||
toast.error(getUITextSync("message.error.load.menu.list"));
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
|
||||
@@ -13,14 +13,16 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
* 사용자 권한 관리 페이지
|
||||
* URL: /admin/userAuth
|
||||
*
|
||||
* 최고 관리자만 접근 가능
|
||||
* 관리자(최고관리자 + 회사관리자) 접근 가능
|
||||
* 사용자별 권한 레벨(SUPER_ADMIN, COMPANY_ADMIN, USER 등) 관리
|
||||
* 회사관리자는 SUPER_ADMIN 권한 부여 불가
|
||||
*/
|
||||
export default function UserAuthPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
// 최고 관리자 여부
|
||||
// 관리자 여부
|
||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||
const isAdmin = isSuperAdmin || currentUser?.userType === "COMPANY_ADMIN";
|
||||
|
||||
// 상태 관리
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
@@ -49,6 +51,9 @@ export default function UserAuthPage() {
|
||||
const response = await userAPI.getList({
|
||||
page,
|
||||
size: paginationInfo.pageSize,
|
||||
...(currentUser?.userType === "COMPANY_ADMIN" && currentUser?.companyCode
|
||||
? { companyCode: currentUser.companyCode }
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
@@ -103,21 +108,21 @@ export default function UserAuthPage() {
|
||||
loadUsers(page);
|
||||
};
|
||||
|
||||
// 최고 관리자가 아닌 경우
|
||||
if (!isSuperAdmin) {
|
||||
// 관리자가 아닌 경우
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">사용자 권한 관리</h1>
|
||||
<p className="text-muted-foreground text-sm">사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)</p>
|
||||
<p className="text-muted-foreground text-sm">사용자별 권한 레벨을 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
||||
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">접근 권한 없음</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||
권한 관리는 최고 관리자만 접근할 수 있습니다.
|
||||
권한 관리는 관리자만 접근할 수 있습니다.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
뒤로 가기
|
||||
@@ -136,7 +141,7 @@ export default function UserAuthPage() {
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">사용자 권한 관리</h1>
|
||||
<p className="text-muted-foreground text-sm">사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)</p>
|
||||
<p className="text-muted-foreground text-sm">사용자별 권한 레벨을 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
@@ -171,6 +176,7 @@ export default function UserAuthPage() {
|
||||
onClose={handleAuthEditClose}
|
||||
onSuccess={handleAuthEditSuccess}
|
||||
user={authEditModal.user}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,14 +20,16 @@ interface UserAuthEditModalProps {
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
user: any | null;
|
||||
currentUser?: any | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 권한 변경 모달
|
||||
*
|
||||
* 권한 레벨만 변경 가능 (최고 관리자 전용)
|
||||
* 권한 레벨만 변경 가능 (관리자 전용)
|
||||
* 회사관리자는 SUPER_ADMIN 권한 부여 불가
|
||||
*/
|
||||
export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuthEditModalProps) {
|
||||
export function UserAuthEditModal({ isOpen, onClose, onSuccess, user, currentUser }: UserAuthEditModalProps) {
|
||||
const [selectedUserType, setSelectedUserType] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
@@ -79,7 +81,19 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
|
||||
},
|
||||
];
|
||||
|
||||
const selectedOption = userTypeOptions.find((opt) => opt.value === selectedUserType);
|
||||
// 현재 사용자가 SUPER_ADMIN인지 확인
|
||||
const isCurrentUserSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||
|
||||
// COMPANY_ADMIN은 SUPER_ADMIN 옵션 제외
|
||||
const availableOptions = isCurrentUserSuperAdmin
|
||||
? userTypeOptions
|
||||
: userTypeOptions.filter((opt) => opt.value !== "SUPER_ADMIN");
|
||||
|
||||
// 대상 사용자가 SUPER_ADMIN이면 COMPANY_ADMIN은 변경 불가
|
||||
const isTargetSuperAdmin = user?.userType === "SUPER_ADMIN";
|
||||
const canEdit = isCurrentUserSuperAdmin || !isTargetSuperAdmin;
|
||||
|
||||
const selectedOption = availableOptions.find((opt) => opt.value === selectedUserType);
|
||||
|
||||
// 권한 변경 여부 확인
|
||||
const isUserTypeChanged = user && selectedUserType !== user.userType;
|
||||
@@ -160,22 +174,32 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
|
||||
<Label htmlFor="userType" className="text-sm font-medium">
|
||||
새로운 권한 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select value={selectedUserType} onValueChange={setSelectedUserType}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="권한 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={option.color}>{option.icon}</span>
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedOption && <p className="text-muted-foreground text-xs">{selectedOption.description}</p>}
|
||||
{!canEdit ? (
|
||||
<div className="rounded-lg border border-orange-300 bg-amber-50 p-3">
|
||||
<p className="text-xs text-orange-800">
|
||||
최고 관리자의 권한은 다른 최고 관리자만 변경할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select value={selectedUserType} onValueChange={setSelectedUserType}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="권한 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={option.color}>{option.icon}</span>
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedOption && <p className="text-muted-foreground text-xs">{selectedOption.description}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SUPER_ADMIN 경고 */}
|
||||
@@ -206,7 +230,7 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !isUserTypeChanged}
|
||||
disabled={isLoading || !isUserTypeChanged || !canEdit}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isLoading ? "처리중..." : showConfirmation ? "확인 및 저장" : "저장"}
|
||||
|
||||
@@ -459,6 +459,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
||||
value={formData.userId}
|
||||
onChange={(e) => handleInputChange("userId", e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete="off"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
@@ -511,6 +512,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
||||
value={formData.userPassword}
|
||||
onChange={(e) => handleInputChange("userPassword", e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete="new-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -155,6 +155,7 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="새 비밀번호를 입력하세요"
|
||||
disabled={isLoading}
|
||||
autoComplete="new-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
@@ -181,6 +182,7 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="비밀번호를 다시 입력하세요"
|
||||
disabled={isLoading}
|
||||
autoComplete="new-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -81,6 +81,7 @@ export function UserToolbar({
|
||||
value={searchFilter.searchValue || ""}
|
||||
onChange={(e) => handleV2SearchChange(e.target.value)}
|
||||
disabled={isAdvancedSearchMode}
|
||||
autoComplete="off"
|
||||
className={`h-10 pl-10 text-sm ${
|
||||
isSearching ? "border-primary ring-2 ring-primary/20" : ""
|
||||
} ${isAdvancedSearchMode ? "cursor-not-allowed bg-muted text-muted-foreground" : ""}`}
|
||||
|
||||
Reference in New Issue
Block a user