dev #46

Merged
kjs merged 344 commits from dev into main 2025-09-22 18:17:24 +09:00
5 changed files with 306 additions and 10 deletions
Showing only changes of commit 6f68fa5639 - Show all commits

View File

@ -2592,3 +2592,158 @@ export const deleteCompany = async (
});
}
};
/**
* POST /api/admin/users/reset-password
* API
* Java AdminController.resetUserPassword()
*/
export const resetUserPassword = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { userId, newPassword } = req.body;
logger.info("비밀번호 초기화 요청", { userId, user: req.user });
// 입력값 검증
if (!userId || !userId.trim()) {
res.status(400).json({
result: false,
msg: "사용자 ID가 필요합니다.",
});
return;
}
if (!newPassword || !newPassword.trim()) {
res.status(400).json({
success: false,
result: false,
message: "새 비밀번호가 필요합니다.",
msg: "새 비밀번호가 필요합니다.",
});
return;
}
// 비밀번호 길이 검증 (최소 4자)
if (newPassword.length < 4) {
res.status(400).json({
success: false,
result: false,
message: "비밀번호는 최소 4자 이상이어야 합니다.",
msg: "비밀번호는 최소 4자 이상이어야 합니다.",
});
return;
}
const client = new Client({ connectionString: config.databaseUrl });
try {
await client.connect();
// 1. 사용자 존재 여부 확인
const userCheckResult = await client.query(
"SELECT user_id, user_name FROM user_info WHERE user_id = $1",
[userId]
);
if (userCheckResult.rows.length === 0) {
res.status(404).json({
success: false,
result: false,
message: "사용자를 찾을 수 없습니다.",
msg: "사용자를 찾을 수 없습니다.",
});
return;
}
const currentUser = userCheckResult.rows[0];
// 2. 비밀번호 암호화 (기존 Java 로직과 동일)
let encryptedPassword: string;
try {
// EncryptUtil과 동일한 암호화 사용
const crypto = require("crypto");
const keyName = "ILJIAESSECRETKEY";
const algorithm = "aes-128-ecb";
// AES-128-ECB 암호화
const cipher = crypto.createCipher(algorithm, keyName);
let encrypted = cipher.update(newPassword, "utf8", "hex");
encrypted += cipher.final("hex");
encryptedPassword = encrypted.toUpperCase();
} catch (encryptError) {
logger.error("비밀번호 암호화 중 오류 발생", {
error: encryptError,
userId,
});
res.status(500).json({
success: false,
result: false,
message: "비밀번호 암호화 중 오류가 발생했습니다.",
msg: "비밀번호 암호화 중 오류가 발생했습니다.",
});
return;
}
// 3. 비밀번호 업데이트 실행
const updateResult = await client.query(
"UPDATE user_info SET user_password = $1 WHERE user_id = $2",
[encryptedPassword, userId]
);
if (updateResult.rowCount && updateResult.rowCount > 0) {
// 4. 이력 저장 (선택적)
try {
const writer = req.user?.userId || "system";
await client.query(
`
INSERT INTO user_info_history
(sabun, user_id, user_name, dept_code, dept_name, user_type_name, history_type, writer, regdate, status)
VALUES ('', $1, $2, '', '', '', '비밀번호 초기화', $3, NOW(), '')
`,
[userId, currentUser.user_name || userId, writer]
);
} catch (historyError) {
logger.warn("비밀번호 초기화 이력 저장 실패", {
error: historyError,
userId,
});
// 이력 저장 실패해도 비밀번호 초기화는 성공으로 처리
}
logger.info("비밀번호 초기화 성공", {
userId,
updatedBy: req.user?.userId,
});
res.json({
success: true,
result: true,
message: "비밀번호가 성공적으로 초기화되었습니다.",
msg: "비밀번호가 성공적으로 초기화되었습니다.",
});
} else {
res.status(400).json({
success: false,
result: false,
message: "사용자 정보를 찾을 수 없거나 비밀번호 변경에 실패했습니다.",
msg: "사용자 정보를 찾을 수 없거나 비밀번호 변경에 실패했습니다.",
});
}
} finally {
await client.end();
}
} catch (error) {
logger.error("비밀번호 초기화 중 오류 발생", {
error,
userId: req.body.userId,
});
res.status(500).json({
success: false,
result: false,
message: "비밀번호 초기화 중 시스템 오류가 발생했습니다.",
msg: "비밀번호 초기화 중 시스템 오류가 발생했습니다.",
});
}
};

View File

@ -11,6 +11,7 @@ import {
getUserInfo, // 사용자 상세 조회
getUserHistory, // 사용자 변경이력 조회
changeUserStatus, // 사용자 상태 변경
resetUserPassword, // 사용자 비밀번호 초기화
getDepartmentList, // 부서 목록 조회
checkDuplicateUserId, // 사용자 ID 중복 체크
saveUser, // 사용자 등록/수정
@ -45,6 +46,7 @@ router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
router.post("/users", saveUser); // 사용자 등록/수정
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화
// 부서 관리 API
router.get("/departments", getDepartmentList); // 부서 목록 조회

View File

@ -84,7 +84,7 @@ export function UserManagement() {
// 비밀번호 초기화 성공 핸들러
const handlePasswordResetSuccess = () => {
refreshData(); // 목록 새로고침
// refreshData(); // 비밀번호 변경은 목록에 영향을 주지 않으므로 새로고침 불필요
handlePasswordResetClose();
};

View File

@ -6,8 +6,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Eye, EyeOff } from "lucide-react";
// import { toast } from "react-hot-toast"; // 라이브러리 미설치로 alert 사용
import { userAPI } from "@/lib/api/user";
import { AlertModal, AlertType } from "@/components/common/AlertModal";
interface UserPasswordResetModalProps {
isOpen: boolean;
@ -24,6 +24,40 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// 알림 모달 상태
const [alertState, setAlertState] = useState<{
isOpen: boolean;
type: AlertType;
title: string;
message: string;
}>({
isOpen: false,
type: "info",
title: "",
message: "",
});
// 알림 모달 표시 헬퍼 함수
const showAlert = (type: AlertType, title: string, message: string) => {
setAlertState({
isOpen: true,
type,
title,
message,
});
};
// 알림 모달 닫기
const closeAlert = () => {
setAlertState((prev) => ({ ...prev, isOpen: false }));
// 성공 알림이 닫힐 때 메인 모달도 닫기
if (alertState.type === "success") {
handleClose();
onSuccess?.();
}
};
// 비밀번호 유효성 검사 (영문, 숫자, 특수문자만 허용)
const validatePassword = (password: string) => {
const regex = /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]*$/;
@ -37,17 +71,17 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
// 초기화 핸들러
const handleReset = useCallback(async () => {
if (!userId || !newPassword.trim()) {
alert("새 비밀번호를 입력해주세요.");
showAlert("warning", "입력 필요", "새 비밀번호를 입력해주세요.");
return;
}
if (!validatePassword(newPassword)) {
alert("비밀번호는 영문, 숫자, 특수문자만 사용할 수 있습니다.");
showAlert("warning", "형식 오류", "비밀번호는 영문, 숫자, 특수문자만 사용할 수 있습니다.");
return;
}
if (newPassword !== confirmPassword) {
alert("비밀번호가 일치하지 않습니다.");
showAlert("warning", "비밀번호 불일치", "비밀번호가 일치하지 않습니다.");
return;
}
@ -60,15 +94,14 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
});
if (response.success) {
alert("비밀번호가 성공적으로 초기화되었습니다.");
handleClose();
onSuccess?.();
showAlert("success", "초기화 완료", "비밀번호가 성공적으로 초기화되었습니다.");
// 성공 알림은 사용자가 확인 버튼을 눌러서 닫도록 함
} else {
alert(response.message || "비밀번호 초기화에 실패했습니다.");
showAlert("error", "초기화 실패", response.message || "비밀번호 초기화에 실패했습니다.");
}
} catch (error) {
console.error("비밀번호 초기화 오류:", error);
alert("비밀번호 초기화 중 오류가 발생했습니다.");
showAlert("error", "시스템 오류", "비밀번호 초기화 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
@ -183,6 +216,15 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
</Button>
</div>
</DialogContent>
{/* 알림 모달 */}
<AlertModal
isOpen={alertState.isOpen}
onClose={closeAlert}
type={alertState.type}
title={alertState.title}
message={alertState.message}
/>
</Dialog>
);
}

View File

@ -0,0 +1,97 @@
"use client";
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { CheckCircle, XCircle, AlertTriangle, Info } from "lucide-react";
export type AlertType = "success" | "error" | "warning" | "info";
interface AlertModalProps {
isOpen: boolean;
onClose: () => void;
type: AlertType;
title: string;
message: string;
confirmText?: string;
onConfirm?: () => void;
}
const alertConfig = {
success: {
icon: CheckCircle,
iconColor: "text-green-500",
titleColor: "text-green-700",
},
error: {
icon: XCircle,
iconColor: "text-red-500",
titleColor: "text-red-700",
},
warning: {
icon: AlertTriangle,
iconColor: "text-yellow-500",
titleColor: "text-yellow-700",
},
info: {
icon: Info,
iconColor: "text-blue-500",
titleColor: "text-blue-700",
},
};
export function AlertModal({
isOpen,
onClose,
type,
title,
message,
confirmText = "확인",
onConfirm,
}: AlertModalProps) {
const config = alertConfig[type];
const IconComponent = config.icon;
const handleConfirm = () => {
if (onConfirm) {
onConfirm();
}
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="mb-2 flex items-center gap-3">
<IconComponent className={`h-6 w-6 ${config.iconColor}`} />
<DialogTitle className={config.titleColor}>{title}</DialogTitle>
</div>
<DialogDescription className="text-left">{message}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={handleConfirm} className="w-full">
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// 편의를 위한 래퍼 함수들
export const SuccessModal = (props: Omit<AlertModalProps, "type">) => <AlertModal {...props} type="success" />;
export const ErrorModal = (props: Omit<AlertModalProps, "type">) => <AlertModal {...props} type="error" />;
export const WarningModal = (props: Omit<AlertModalProps, "type">) => <AlertModal {...props} type="warning" />;
export const InfoModal = (props: Omit<AlertModalProps, "type">) => <AlertModal {...props} type="info" />;