Merge pull request 'jskim-node' (#428) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/428
This commit is contained in:
commit
f9e243d439
|
|
@ -2504,7 +2504,9 @@ export const changeUserStatus = async (
|
|||
// 필수 파라미터 검증
|
||||
if (!userId || !status) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 ID와 상태는 필수입니다.",
|
||||
msg: "사용자 ID와 상태는 필수입니다.",
|
||||
});
|
||||
return;
|
||||
|
|
@ -2513,7 +2515,9 @@ export const changeUserStatus = async (
|
|||
// 상태 값 검증
|
||||
if (!["active", "inactive"].includes(status)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
|
||||
msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
|
||||
});
|
||||
return;
|
||||
|
|
@ -2528,7 +2532,9 @@ export const changeUserStatus = async (
|
|||
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
msg: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
|
|
@ -2549,6 +2555,12 @@ export const changeUserStatus = async (
|
|||
if (updateResult.length > 0) {
|
||||
// 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
|
||||
|
||||
// inactive로 변경 시 기존 JWT 토큰 무효화
|
||||
if (status === "inactive") {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||
}
|
||||
|
||||
logger.info("사용자 상태 변경 성공", {
|
||||
userId,
|
||||
oldStatus: currentUser.status,
|
||||
|
|
@ -2571,12 +2583,16 @@ export const changeUserStatus = async (
|
|||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: true,
|
||||
message: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 상태 변경에 실패했습니다.",
|
||||
msg: "사용자 상태 변경에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
|
|
@ -2587,7 +2603,9 @@ export const changeUserStatus = async (
|
|||
status: req.body.status,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "시스템 오류가 발생했습니다.",
|
||||
msg: "시스템 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
|
|
@ -2627,12 +2645,214 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 추가 유효성 검증
|
||||
|
||||
// 1. email 형식 검증 (값이 있는 경우만)
|
||||
if (userData.email && userData.email.trim() !== "") {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(userData.email.trim())) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이메일 형식이 올바르지 않습니다.",
|
||||
error: {
|
||||
code: "INVALID_EMAIL_FORMAT",
|
||||
details: `Invalid email format: ${userData.email}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. companyCode 존재 확인 (값이 있는 경우만)
|
||||
if (userData.companyCode && userData.companyCode.trim() !== "") {
|
||||
const companyExists = await queryOne<{ company_code: string }>(
|
||||
`SELECT company_code FROM company_mng WHERE company_code = $1`,
|
||||
[userData.companyCode.trim()]
|
||||
);
|
||||
if (!companyExists) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `존재하지 않는 회사 코드입니다: ${userData.companyCode}`,
|
||||
error: {
|
||||
code: "INVALID_COMPANY_CODE",
|
||||
details: `Company code not found: ${userData.companyCode}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. userType 유효값 검증 (값이 있는 경우만)
|
||||
if (userData.userType && userData.userType.trim() !== "") {
|
||||
const validUserTypes = ["SUPER_ADMIN", "COMPANY_ADMIN", "USER", "GUEST", "PARTNER"];
|
||||
if (!validUserTypes.includes(userData.userType.trim())) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `유효하지 않은 사용자 유형입니다: ${userData.userType}. 허용값: ${validUserTypes.join(", ")}`,
|
||||
error: {
|
||||
code: "INVALID_USER_TYPE",
|
||||
details: `Invalid userType: ${userData.userType}. Allowed: ${validUserTypes.join(", ")}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 비밀번호 최소 길이 검증 (신규 등록 시)
|
||||
if (!isUpdate && userData.userPassword && userData.userPassword.length < 4) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "비밀번호는 최소 4자 이상이어야 합니다.",
|
||||
error: {
|
||||
code: "PASSWORD_TOO_SHORT",
|
||||
details: "Password must be at least 4 characters long",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 비밀번호 암호화 (비밀번호가 제공된 경우에만)
|
||||
let encryptedPassword = null;
|
||||
if (userData.userPassword) {
|
||||
encryptedPassword = await EncryptUtil.encrypt(userData.userPassword);
|
||||
}
|
||||
|
||||
// PUT(수정) 요청 시 company_code / dept_code 변경 감지
|
||||
if (isUpdate) {
|
||||
const existingUser = await queryOne<{ company_code: string; dept_code: string }>(
|
||||
`SELECT company_code, dept_code FROM user_info WHERE user_id = $1`,
|
||||
[userData.userId]
|
||||
);
|
||||
|
||||
// company_code 변경 감지 → 이전 회사 권한 그룹 제거
|
||||
if (
|
||||
userData.companyCode &&
|
||||
existingUser &&
|
||||
existingUser.company_code &&
|
||||
existingUser.company_code !== userData.companyCode
|
||||
) {
|
||||
const oldCompanyCode = existingUser.company_code;
|
||||
logger.info("사용자 회사 코드 변경 감지 - 이전 회사 권한 그룹 제거", {
|
||||
userId: userData.userId,
|
||||
oldCompanyCode,
|
||||
newCompanyCode: userData.companyCode,
|
||||
});
|
||||
|
||||
// 이전 회사의 권한 그룹에서 해당 사용자 제거
|
||||
await query(
|
||||
`DELETE FROM authority_sub_user
|
||||
WHERE user_id = $1
|
||||
AND master_objid IN (
|
||||
SELECT objid FROM authority_master WHERE company_code = $2
|
||||
)`,
|
||||
[userData.userId, oldCompanyCode]
|
||||
);
|
||||
}
|
||||
|
||||
// dept_code 변경 감지 → 결재 템플릿/진행중 결재라인 경고 로그
|
||||
const newDeptCode = userData.deptCode || null;
|
||||
const oldDeptCode = existingUser?.dept_code || null;
|
||||
if (existingUser && oldDeptCode && newDeptCode && oldDeptCode !== newDeptCode) {
|
||||
logger.warn("사용자 부서 변경 감지 - 결재라인 영향 확인 시작", {
|
||||
userId: userData.userId,
|
||||
userName: userData.userName,
|
||||
oldDeptCode,
|
||||
newDeptCode,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1) 결재선 템플릿 스텝에서 해당 사용자가 결재자로 등록된 건 조회
|
||||
const templateSteps = await query<{
|
||||
template_id: number;
|
||||
step_order: number;
|
||||
approver_label: string | null;
|
||||
approver_dept_code: string | null;
|
||||
}>(
|
||||
`SELECT s.template_id, s.step_order, s.approver_label, s.approver_dept_code
|
||||
FROM approval_line_template_steps s
|
||||
WHERE s.approver_user_id = $1`,
|
||||
[userData.userId]
|
||||
);
|
||||
|
||||
if (templateSteps && templateSteps.length > 0) {
|
||||
logger.warn(
|
||||
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})가 결재선 템플릿 ${templateSteps.length}건에 결재자로 등록되어 있습니다. 수동 확인이 필요합니다.`,
|
||||
{
|
||||
userId: userData.userId,
|
||||
oldDeptCode,
|
||||
newDeptCode,
|
||||
affectedTemplates: templateSteps.map((s) => ({
|
||||
templateId: s.template_id,
|
||||
stepOrder: s.step_order,
|
||||
label: s.approver_label,
|
||||
currentDeptInStep: s.approver_dept_code,
|
||||
})),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 2) 진행중인 결재 요청에서 해당 사용자가 대기중 결재자인 건 조회
|
||||
const pendingLines = await query<{
|
||||
request_id: number;
|
||||
step_order: number;
|
||||
approver_dept: string | null;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT l.request_id, l.step_order, l.approver_dept, l.status
|
||||
FROM approval_lines l
|
||||
JOIN approval_requests r ON r.request_id = l.request_id
|
||||
WHERE l.approver_id = $1
|
||||
AND l.status = 'pending'
|
||||
AND r.status IN ('in_progress', 'pending')`,
|
||||
[userData.userId]
|
||||
);
|
||||
|
||||
if (pendingLines && pendingLines.length > 0) {
|
||||
logger.warn(
|
||||
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})에게 대기중인 결재 ${pendingLines.length}건이 있습니다. 수동 확인이 필요합니다.`,
|
||||
{
|
||||
userId: userData.userId,
|
||||
oldDeptCode,
|
||||
newDeptCode,
|
||||
pendingApprovals: pendingLines.map((l) => ({
|
||||
requestId: l.request_id,
|
||||
stepOrder: l.step_order,
|
||||
currentDeptInLine: l.approver_dept,
|
||||
})),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 감사 로그 기록
|
||||
auditLogService.log({
|
||||
companyCode: userData.companyCode || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DEPT_CHANGE_WARNING",
|
||||
resourceType: "USER",
|
||||
resourceId: userData.userId,
|
||||
resourceName: userData.userName,
|
||||
summary: `사용자 "${userData.userName}"의 부서 변경 (${oldDeptCode} → ${newDeptCode}). 결재 템플릿 ${templateSteps?.length || 0}건, 대기중 결재 ${pendingLines?.length || 0}건 영향 가능`,
|
||||
changes: {
|
||||
before: { deptCode: oldDeptCode },
|
||||
after: {
|
||||
deptCode: newDeptCode,
|
||||
affectedTemplateCount: templateSteps?.length || 0,
|
||||
pendingApprovalCount: pendingLines?.length || 0,
|
||||
},
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
} catch (approvalCheckError) {
|
||||
// 결재 테이블이 없는 환경에서도 사용자 저장은 계속 진행
|
||||
logger.warn("결재라인 영향 확인 중 오류 (사용자 저장은 계속 진행)", {
|
||||
error: approvalCheckError instanceof Error ? approvalCheckError.message : approvalCheckError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT)
|
||||
const updatePasswordClause = encryptedPassword ? "user_password = $4," : "";
|
||||
|
||||
|
|
@ -2688,6 +2908,12 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
|||
savedUser.regdate &&
|
||||
new Date(savedUser.regdate).getTime() < Date.now() - 1000;
|
||||
|
||||
// 기존 사용자의 비밀번호 변경 시 JWT 토큰 무효화
|
||||
if (encryptedPassword && isExistingUser) {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userData.userId);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료",
|
||||
{
|
||||
|
|
@ -3534,6 +3760,10 @@ export const resetUserPassword = async (
|
|||
if (updateResult.length > 0) {
|
||||
// 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
|
||||
|
||||
// 비밀번호 변경 후 기존 JWT 토큰 무효화
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||
|
||||
logger.info("비밀번호 초기화 성공", {
|
||||
userId,
|
||||
updatedBy: req.user?.userId,
|
||||
|
|
@ -4153,6 +4383,140 @@ export const saveUserWithDept = async (
|
|||
* GET /api/admin/users/:userId/with-dept
|
||||
* 사원 + 부서 정보 조회 API (수정 모달용)
|
||||
*/
|
||||
/**
|
||||
* DELETE /api/admin/users/:userId
|
||||
* 사용자 삭제 API (soft delete)
|
||||
* status = 'deleted', end_date = now() 설정
|
||||
* authority_sub_user 멤버십 제거, JWT 토큰 무효화
|
||||
*/
|
||||
export const deleteUser = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
// 1. userId 파라미터 검증
|
||||
if (!userId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 ID는 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 자기 자신 삭제 방지
|
||||
if (req.user?.userId === userId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "자기 자신은 삭제할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 사용자 존재 여부 확인
|
||||
const currentUser = await queryOne<any>(
|
||||
`SELECT user_id, user_name, status, company_code FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 삭제된 사용자 체크
|
||||
if (currentUser.status === "deleted") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "이미 삭제된 사용자입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. soft delete: status = 'deleted', end_date = now()
|
||||
const updateResult = await query<any>(
|
||||
`UPDATE user_info
|
||||
SET status = 'deleted', end_date = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING *`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (updateResult.length === 0) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 삭제에 실패했습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. authority_sub_user에서 해당 사용자 멤버십 제거
|
||||
await query(
|
||||
`DELETE FROM authority_sub_user WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 6. JWT 토큰 무효화
|
||||
try {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||
} catch (tokenError) {
|
||||
logger.warn("토큰 무효화 중 오류 (삭제는 정상 처리됨)", { userId, error: tokenError });
|
||||
}
|
||||
|
||||
logger.info("사용자 삭제(soft delete) 성공", {
|
||||
userId,
|
||||
userName: currentUser.user_name,
|
||||
deletedBy: req.user?.userId,
|
||||
});
|
||||
|
||||
// 7. 감사 로그 기록
|
||||
auditLogService.log({
|
||||
companyCode: currentUser.company_code || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "USER",
|
||||
resourceId: userId,
|
||||
resourceName: currentUser.user_name,
|
||||
summary: `사용자 "${currentUser.user_name}" (${userId}) 삭제 처리`,
|
||||
changes: {
|
||||
before: { status: currentUser.status },
|
||||
after: { status: "deleted" },
|
||||
fields: ["status", "end_date"],
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
// 8. 응답
|
||||
res.json({
|
||||
success: true,
|
||||
result: true,
|
||||
message: `사용자 "${currentUser.user_name}" (${userId})이(가) 삭제되었습니다.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("사용자 삭제 중 오류 발생", {
|
||||
error: error.message,
|
||||
userId: req.params.userId,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "시스템 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserWithDept = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
|
|
|
|||
|
|
@ -191,18 +191,30 @@ export const getLangKeys = async (
|
|||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 키 목록 조회 요청", {
|
||||
query: req.query,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
// company_code 필터링: 비관리자는 자기 회사 + 공통(*) 키만 조회 가능
|
||||
let effectiveCompanyCode = companyCode as string;
|
||||
if (userCompanyCode !== "*") {
|
||||
// 비관리자가 다른 회사의 데이터를 요청하면 자기 회사로 제한
|
||||
if (companyCode && companyCode !== userCompanyCode && companyCode !== "*") {
|
||||
effectiveCompanyCode = userCompanyCode || "";
|
||||
}
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const langKeys = await multiLangService.getLangKeys({
|
||||
companyCode: companyCode as string,
|
||||
companyCode: effectiveCompanyCode,
|
||||
menuCode: menuCode as string,
|
||||
keyType: keyType as string,
|
||||
searchText: searchText as string,
|
||||
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
|
||||
// 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환
|
||||
userCompanyCode: userCompanyCode,
|
||||
});
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
|
|
@ -235,9 +247,24 @@ export const getLangTexts = async (
|
|||
): Promise<void> => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (keyOwner && keyOwner !== "*" && keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 키에 접근할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const langTexts = await multiLangService.getLangTexts(parseInt(keyId));
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
|
|
@ -270,6 +297,7 @@ export const createLangKey = async (
|
|||
): Promise<void> => {
|
||||
try {
|
||||
const keyData: CreateLangKeyRequest = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 키 생성 요청", { keyData, user: req.user });
|
||||
|
||||
// 필수 입력값 검증
|
||||
|
|
@ -285,6 +313,26 @@ export const createLangKey = async (
|
|||
return;
|
||||
}
|
||||
|
||||
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
|
||||
if (keyData.companyCode === "*" && userCompanyCode !== "*") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 비관리자: 자기 회사 키만 생성 가능
|
||||
if (userCompanyCode !== "*" && keyData.companyCode !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 키를 생성할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const keyId = await multiLangService.createLangKey({
|
||||
...keyData,
|
||||
|
|
@ -323,10 +371,33 @@ export const updateLangKey = async (
|
|||
try {
|
||||
const { keyId } = req.params;
|
||||
const keyData: UpdateLangKeyRequest = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키는 수정 불가)
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (!keyOwner) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "다국어 키를 찾을 수 없습니다.",
|
||||
error: { code: "KEY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 키를 수정할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await multiLangService.updateLangKey(parseInt(keyId), {
|
||||
...keyData,
|
||||
updatedBy: req.user?.userId || "system",
|
||||
|
|
@ -362,9 +433,32 @@ export const deleteLangKey = async (
|
|||
): Promise<void> => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 키 삭제 요청", { keyId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키 삭제 불가)
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (!keyOwner) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "다국어 키를 찾을 수 없습니다.",
|
||||
error: { code: "KEY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 키를 삭제할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await multiLangService.deleteLangKey(parseInt(keyId));
|
||||
|
||||
const response: ApiResponse<string> = {
|
||||
|
|
@ -397,9 +491,32 @@ export const toggleLangKey = async (
|
|||
): Promise<void> => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 키인지 검증
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (!keyOwner) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "다국어 키를 찾을 수 없습니다.",
|
||||
error: { code: "KEY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 키를 변경할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await multiLangService.toggleLangKey(parseInt(keyId));
|
||||
|
||||
const response: ApiResponse<string> = {
|
||||
|
|
@ -433,6 +550,7 @@ export const saveLangTexts = async (
|
|||
try {
|
||||
const { keyId } = req.params;
|
||||
const textData: SaveLangTextsRequest = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user });
|
||||
|
||||
|
|
@ -454,6 +572,28 @@ export const saveLangTexts = async (
|
|||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (!keyOwner) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "다국어 키를 찾을 수 없습니다.",
|
||||
error: { code: "KEY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 텍스트를 수정할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await multiLangService.saveLangTexts(parseInt(keyId), {
|
||||
texts: textData.texts.map((text) => ({
|
||||
...text,
|
||||
|
|
|
|||
|
|
@ -472,6 +472,10 @@ export const addRoleMembers = async (
|
|||
req.user?.userId || "SYSTEM"
|
||||
);
|
||||
|
||||
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "권한 그룹 멤버 추가 성공",
|
||||
|
|
@ -568,6 +572,13 @@ export const updateRoleMembers = async (
|
|||
);
|
||||
}
|
||||
|
||||
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||
const allAffectedUsers = [...new Set([...toAdd, ...toRemove])];
|
||||
if (allAffectedUsers.length > 0) {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(allAffectedUsers);
|
||||
}
|
||||
|
||||
logger.info("권한 그룹 멤버 일괄 업데이트 성공", {
|
||||
masterObjid,
|
||||
added: toAdd.length,
|
||||
|
|
@ -646,6 +657,10 @@ export const removeRoleMembers = async (
|
|||
req.user?.userId || "SYSTEM"
|
||||
);
|
||||
|
||||
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "권한 그룹 멤버 제거 성공",
|
||||
|
|
@ -777,6 +792,18 @@ export const setMenuPermissions = async (
|
|||
req.user?.userId || "SYSTEM"
|
||||
);
|
||||
|
||||
// 해당 권한 그룹의 모든 멤버 JWT 토큰 무효화
|
||||
try {
|
||||
const members = await RoleService.getRoleMembers(authObjid);
|
||||
const memberIds = members.map((m: any) => m.userId);
|
||||
if (memberIds.length > 0) {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(memberIds);
|
||||
}
|
||||
} catch (invalidateError) {
|
||||
logger.warn("메뉴 권한 변경 후 토큰 무효화 실패 (권한 설정은 성공)", { invalidateError });
|
||||
}
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "메뉴 권한 설정 성공",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Request, Response, NextFunction } from "express";
|
|||
import { JwtUtils } from "../utils/jwtUtils";
|
||||
import { AuthenticatedRequest, PersonBean } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
import { TokenInvalidationService } from "../services/tokenInvalidationService";
|
||||
|
||||
// AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export
|
||||
export { AuthenticatedRequest } from "../types/auth";
|
||||
|
|
@ -22,11 +23,11 @@ declare global {
|
|||
* JWT 토큰 검증 미들웨어
|
||||
* 기존 세션 방식과 동일한 효과를 제공
|
||||
*/
|
||||
export const authenticateToken = (
|
||||
export const authenticateToken = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Authorization 헤더에서 토큰 추출
|
||||
const authHeader = req.get("Authorization");
|
||||
|
|
@ -46,6 +47,25 @@ export const authenticateToken = (
|
|||
// JWT 토큰 검증 및 사용자 정보 추출
|
||||
const userInfo: PersonBean = JwtUtils.verifyToken(token);
|
||||
|
||||
// token_version 검증 (JWT payload vs DB)
|
||||
const decoded = JwtUtils.decodeToken(token);
|
||||
const tokenVersion = decoded?.tokenVersion;
|
||||
|
||||
// tokenVersion이 undefined면 구버전 토큰이므로 통과 (하위 호환)
|
||||
if (tokenVersion !== undefined) {
|
||||
const dbVersion = await TokenInvalidationService.getUserTokenVersion(userInfo.userId);
|
||||
if (tokenVersion !== dbVersion) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "TOKEN_INVALIDATED",
|
||||
details: "보안 정책에 의해 재로그인이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 요청 객체에 사용자 정보 설정 (기존 PersonBean과 동일)
|
||||
req.user = userInfo;
|
||||
|
||||
|
|
@ -173,11 +193,11 @@ export const requireUserOrAdmin = (targetUserId: string) => {
|
|||
* 토큰 갱신 미들웨어
|
||||
* 토큰이 곧 만료될 경우 자동으로 갱신
|
||||
*/
|
||||
export const refreshTokenIfNeeded = (
|
||||
export const refreshTokenIfNeeded = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
|
@ -191,6 +211,16 @@ export const refreshTokenIfNeeded = (
|
|||
|
||||
// 1시간(3600초) 이내에 만료되는 경우 갱신
|
||||
if (timeUntilExpiry > 0 && timeUntilExpiry < 3600) {
|
||||
// 갱신 전 token_version 검증
|
||||
if (decoded.tokenVersion !== undefined) {
|
||||
const dbVersion = await TokenInvalidationService.getUserTokenVersion(decoded.userId);
|
||||
if (decoded.tokenVersion !== dbVersion) {
|
||||
// 무효화된 토큰은 갱신하지 않음
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newToken = JwtUtils.refreshToken(token);
|
||||
|
||||
// 새로운 토큰을 응답 헤더에 포함
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
saveUser, // 사용자 등록/수정
|
||||
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
|
||||
getUserWithDept, // 사원 + 부서 조회 (NEW!)
|
||||
deleteUser, // 사용자 삭제 (soft delete)
|
||||
getCompanyList,
|
||||
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
||||
getCompanyByCode, // 회사 단건 조회
|
||||
|
|
@ -62,6 +63,7 @@ router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
|||
router.put("/profile", updateProfile); // 프로필 수정
|
||||
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
||||
router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화
|
||||
router.delete("/users/:userId", deleteUser); // 사용자 삭제 (soft delete)
|
||||
|
||||
// 부서 관리 API
|
||||
router.get("/departments", getDepartmentList); // 부서 목록 조회
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ export type AuditAction =
|
|||
| "STATUS_CHANGE"
|
||||
| "BATCH_CREATE"
|
||||
| "BATCH_UPDATE"
|
||||
| "BATCH_DELETE";
|
||||
| "BATCH_DELETE"
|
||||
| "DEPT_CHANGE_WARNING";
|
||||
|
||||
export type AuditResourceType =
|
||||
| "MENU"
|
||||
|
|
|
|||
|
|
@ -134,12 +134,14 @@ export class AuthService {
|
|||
company_code: string | null;
|
||||
locale: string | null;
|
||||
photo: Buffer | null;
|
||||
token_version: number | null;
|
||||
}>(
|
||||
`SELECT
|
||||
sabun, user_id, user_name, user_name_eng, user_name_cn,
|
||||
dept_code, dept_name, position_code, position_name,
|
||||
email, tel, cell_phone, user_type, user_type_name,
|
||||
partner_objid, company_code, locale, photo
|
||||
partner_objid, company_code, locale, photo,
|
||||
COALESCE(token_version, 0) as token_version
|
||||
FROM user_info
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
|
|
@ -210,6 +212,7 @@ export class AuthService {
|
|||
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
|
||||
: undefined,
|
||||
locale: userInfo.locale || "KR",
|
||||
tokenVersion: userInfo.token_version ?? 0,
|
||||
// 권한 레벨 정보 추가 (3단계 체계)
|
||||
isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN",
|
||||
isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*",
|
||||
|
|
|
|||
|
|
@ -673,6 +673,22 @@ export class MultiLangService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 키의 소유 회사 코드 조회 (권한 검증용)
|
||||
*/
|
||||
async getKeyCompanyCode(keyId: number): Promise<string | null> {
|
||||
try {
|
||||
const result = await queryOne<{ company_code: string }>(
|
||||
`SELECT company_code FROM multi_lang_key_master WHERE key_id = $1`,
|
||||
[keyId]
|
||||
);
|
||||
return result?.company_code || null;
|
||||
} catch (error) {
|
||||
logger.error("키 소유 회사 코드 조회 실패:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다국어 키 목록 조회
|
||||
*/
|
||||
|
|
@ -688,6 +704,10 @@ export class MultiLangService {
|
|||
if (params.companyCode) {
|
||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||
values.push(params.companyCode);
|
||||
} else if (params.userCompanyCode && params.userCompanyCode !== "*") {
|
||||
// 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환
|
||||
whereConditions.push(`company_code IN ($${paramIndex++}, '*')`);
|
||||
values.push(params.userCompanyCode);
|
||||
}
|
||||
|
||||
// 메뉴 코드 필터
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { query } from "../database/db";
|
||||
import { query, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
|
|
@ -145,10 +145,19 @@ export class RoleService {
|
|||
writer: string;
|
||||
}): Promise<RoleGroup> {
|
||||
try {
|
||||
// 동일 회사 내 같은 이름의 권한 그룹 중복 체크
|
||||
const dupCheck = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count FROM authority_master WHERE company_code = $1 AND auth_name = $2`,
|
||||
[data.companyCode, data.authName]
|
||||
);
|
||||
if (dupCheck.length > 0 && parseInt(dupCheck[0].count, 10) > 0) {
|
||||
throw new Error(`동일 회사 내에 이미 같은 이름의 권한 그룹이 존재합니다: ${data.authName}`);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
|
||||
VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW())
|
||||
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
|
||||
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
|
||||
company_code AS "companyCode", status, writer, regdate
|
||||
`;
|
||||
|
||||
|
|
@ -460,35 +469,37 @@ export class RoleService {
|
|||
writer: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 기존 권한 삭제
|
||||
await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
|
||||
authObjid,
|
||||
]);
|
||||
|
||||
// 새로운 권한 삽입
|
||||
if (permissions.length > 0) {
|
||||
const values = permissions
|
||||
.map(
|
||||
(_, index) =>
|
||||
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const params = permissions.flatMap((p) => [
|
||||
p.menuObjid,
|
||||
p.createYn,
|
||||
p.readYn,
|
||||
p.updateYn,
|
||||
p.deleteYn,
|
||||
await transaction(async (client) => {
|
||||
// 기존 권한 삭제
|
||||
await client.query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
|
||||
authObjid,
|
||||
]);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
|
||||
VALUES ${values}
|
||||
`;
|
||||
// 새로운 권한 삽입
|
||||
if (permissions.length > 0) {
|
||||
const values = permissions
|
||||
.map(
|
||||
(_, index) =>
|
||||
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
await query(sql, [authObjid, ...params, writer]);
|
||||
}
|
||||
const params = permissions.flatMap((p) => [
|
||||
p.menuObjid,
|
||||
p.createYn,
|
||||
p.readYn,
|
||||
p.updateYn,
|
||||
p.deleteYn,
|
||||
]);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
|
||||
VALUES ${values}
|
||||
`;
|
||||
|
||||
await client.query(sql, [authObjid, ...params, writer]);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info("메뉴 권한 설정 성공", {
|
||||
authObjid,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
// JWT 토큰 무효화 서비스
|
||||
// user_info.token_version 기반으로 기존 JWT 토큰을 무효화
|
||||
|
||||
import { query } from "../database/db";
|
||||
import { cache } from "../utils/cache";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const TOKEN_VERSION_CACHE_TTL = 2 * 60 * 1000; // 2분 캐시
|
||||
|
||||
export class TokenInvalidationService {
|
||||
/**
|
||||
* 캐시 키 생성
|
||||
*/
|
||||
static cacheKey(userId: string): string {
|
||||
return `token_version:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 사용자의 토큰 무효화 (token_version +1)
|
||||
*/
|
||||
static async invalidateUserTokens(userId: string): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
cache.delete(this.cacheKey(userId));
|
||||
logger.info(`토큰 무효화: ${userId}`);
|
||||
} catch (error) {
|
||||
logger.error(`토큰 무효화 실패: ${userId}`, { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 사용자의 토큰 일괄 무효화
|
||||
*/
|
||||
static async invalidateMultipleUserTokens(userIds: string[]): Promise<void> {
|
||||
if (userIds.length === 0) return;
|
||||
try {
|
||||
const placeholders = userIds.map((_, i) => `$${i + 1}`).join(", ");
|
||||
await query(
|
||||
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id IN (${placeholders})`,
|
||||
userIds
|
||||
);
|
||||
userIds.forEach((id) => cache.delete(this.cacheKey(id)));
|
||||
logger.info(`토큰 일괄 무효화: ${userIds.length}명`);
|
||||
} catch (error) {
|
||||
logger.error(`토큰 일괄 무효화 실패`, { error, userIds });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 token_version 조회 (캐시 사용)
|
||||
*/
|
||||
static async getUserTokenVersion(userId: string): Promise<number> {
|
||||
const cacheKey = this.cacheKey(userId);
|
||||
const cached = cache.get<number>(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await query<{ token_version: number | null }>(
|
||||
`SELECT token_version FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
const version = result.length > 0 ? (result[0].token_version ?? 0) : 0;
|
||||
cache.set(cacheKey, version, TOKEN_VERSION_CACHE_TTL);
|
||||
return version;
|
||||
} catch (error) {
|
||||
logger.error(`token_version 조회 실패: ${userId}`, { error });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,7 @@ export interface PersonBean {
|
|||
companyName?: string; // 회사명 추가
|
||||
photo?: string;
|
||||
locale?: string;
|
||||
tokenVersion?: number; // JWT 토큰 무효화용 버전
|
||||
// 권한 레벨 정보 (3단계 체계)
|
||||
isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN')
|
||||
isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN')
|
||||
|
|
@ -98,6 +99,7 @@ export interface JwtPayload {
|
|||
companyName?: string; // 회사명 추가
|
||||
userType?: string;
|
||||
userTypeName?: string;
|
||||
tokenVersion?: number; // JWT 토큰 무효화용 버전
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
aud?: string;
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ export interface GetLangKeysParams {
|
|||
includeOverrides?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
userCompanyCode?: string; // 요청 사용자의 회사 코드 (비관리자 필터링용)
|
||||
}
|
||||
|
||||
export interface GetUserTextParams {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export class JwtUtils {
|
|||
companyName: userInfo.companyName, // 회사명 추가
|
||||
userType: userInfo.userType,
|
||||
userTypeName: userInfo.userTypeName,
|
||||
tokenVersion: userInfo.tokenVersion ?? 0,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, config.jwt.secret, {
|
||||
|
|
|
|||
|
|
@ -582,8 +582,22 @@ export default function ProductionPlanManagementPage() {
|
|||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await Promise.all(plannedIds.map((id) => deletePlan(id)));
|
||||
toast.success(`${plannedIds.length}건의 계획이 삭제되었습니다`);
|
||||
const results = await Promise.allSettled(plannedIds.map((id) => deletePlan(id)));
|
||||
const failedIds = plannedIds.filter((_, i) => results[i].status === "rejected");
|
||||
const succeededCount = plannedIds.length - failedIds.length;
|
||||
|
||||
if (failedIds.length === plannedIds.length) {
|
||||
// 전부 삭제 실패
|
||||
toast.error(`${failedIds.length}건 모두 삭제에 실패했습니다. 다시 시도해주세요.`);
|
||||
} else if (failedIds.length > 0) {
|
||||
// 일부 삭제 실패
|
||||
toast.warning(
|
||||
`${succeededCount}건 삭제 완료, ${failedIds.length}건 삭제 실패. 실패 항목을 다시 시도해주세요.`
|
||||
);
|
||||
} else {
|
||||
// 전부 성공
|
||||
toast.success(`${plannedIds.length}건의 계획이 삭제되었습니다`);
|
||||
}
|
||||
fetchPlans();
|
||||
} catch (err: any) {
|
||||
toast.error("삭제 실패: " + (err.message || ""));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -315,15 +315,15 @@ export default function ShippingPlanPage() {
|
|||
onCheckedChange={handleCheckAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px]">수주번호</TableHead>
|
||||
<TableHead className="w-[100px] text-center">납기일</TableHead>
|
||||
<TableHead className="w-[120px]">거래처</TableHead>
|
||||
<TableHead className="w-[100px]">품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-right">수주수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right">계획수량</TableHead>
|
||||
<TableHead className="w-[100px] text-center">출하계획일</TableHead>
|
||||
<TableHead className="w-[80px] text-center">상태</TableHead>
|
||||
<TableHead className="w-[10%]">수주번호</TableHead>
|
||||
<TableHead className="w-[8%] text-center">납기일</TableHead>
|
||||
<TableHead className="w-[12%]">거래처</TableHead>
|
||||
<TableHead className="w-[20%]">품목코드</TableHead>
|
||||
<TableHead className="w-[20%]">품목명</TableHead>
|
||||
<TableHead className="w-[7%] text-right">수주수량</TableHead>
|
||||
<TableHead className="w-[7%] text-right">계획수량</TableHead>
|
||||
<TableHead className="w-[8%] text-center">출하계획일</TableHead>
|
||||
<TableHead className="w-[6%] text-center">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
|
|||
|
|
@ -457,6 +457,13 @@ apiClient.interceptors.response.use(
|
|||
}
|
||||
}
|
||||
|
||||
// TOKEN_INVALIDATED → 재로그인 필요 (갱신 시도 없이 즉시)
|
||||
if (errorCode === "TOKEN_INVALIDATED") {
|
||||
authLog("REDIRECT_TO_LOGIN", `토큰 무효화 (보안 정책 변경) → 즉시 로그인 리다이렉트 (${url})`);
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
|
||||
authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`);
|
||||
redirectToLogin();
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@ export function FieldDetailSettingsModal({
|
|||
const [cascadingRelationOpen, setCascadingRelationOpen] = useState(false);
|
||||
const [parentFieldOpen, setParentFieldOpen] = useState(false);
|
||||
|
||||
// 기본 선택값용 옵션 목록 상태
|
||||
const [defaultValueCategoryValues, setDefaultValueCategoryValues] = useState<{value: string; label: string}[]>([]);
|
||||
const [defaultValueTableOptions, setDefaultValueTableOptions] = useState<{value: string; label: string}[]>([]);
|
||||
const [loadingDefaultValueOptions, setLoadingDefaultValueOptions] = useState(false);
|
||||
|
||||
// Combobox 열림 상태
|
||||
const [sourceTableOpen, setSourceTableOpen] = useState(false);
|
||||
const [targetColumnOpenMap, setTargetColumnOpenMap] = useState<Record<number, boolean>>({});
|
||||
|
|
@ -209,6 +214,69 @@ export function FieldDetailSettingsModal({
|
|||
loadCascadingRelations();
|
||||
}, [open]);
|
||||
|
||||
// 기본 선택값용: code 타입 카테고리 값 로드
|
||||
useEffect(() => {
|
||||
const loadCategoryValues = async () => {
|
||||
const categoryKey = localField.selectOptions?.categoryKey;
|
||||
if (!open || localField.selectOptions?.type !== "code" || !categoryKey) {
|
||||
setDefaultValueCategoryValues([]);
|
||||
return;
|
||||
}
|
||||
setLoadingDefaultValueOptions(true);
|
||||
try {
|
||||
const [tableName, columnName] = categoryKey.split(".");
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
if (response.data?.success && response.data?.data) {
|
||||
setDefaultValueCategoryValues(
|
||||
response.data.data.map((item: any) => ({
|
||||
value: item.valueCode || item.value_code,
|
||||
label: item.valueLabel || item.value_label,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setDefaultValueCategoryValues([]);
|
||||
}
|
||||
} catch {
|
||||
setDefaultValueCategoryValues([]);
|
||||
} finally {
|
||||
setLoadingDefaultValueOptions(false);
|
||||
}
|
||||
};
|
||||
loadCategoryValues();
|
||||
}, [open, localField.selectOptions?.type, localField.selectOptions?.categoryKey]);
|
||||
|
||||
// 기본 선택값용: table 타입 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadTableOptions = async () => {
|
||||
const opts = localField.selectOptions;
|
||||
if (!open || opts?.type !== "table" || !opts?.tableName || !opts?.valueColumn || !opts?.labelColumn) {
|
||||
setDefaultValueTableOptions([]);
|
||||
return;
|
||||
}
|
||||
setLoadingDefaultValueOptions(true);
|
||||
try {
|
||||
const response = await apiClient.post(`/table-management/tables/${opts.tableName}/data`, {
|
||||
page: 1,
|
||||
size: 200,
|
||||
autoFilter: { enabled: true, filterColumn: "company_code" },
|
||||
});
|
||||
const dataArray = response.data?.data?.data || response.data?.data || [];
|
||||
setDefaultValueTableOptions(
|
||||
dataArray.map((row: any) => ({
|
||||
value: String(row[opts.valueColumn!] || ""),
|
||||
label: String(row[opts.labelColumn!] || ""),
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
setDefaultValueTableOptions([]);
|
||||
} finally {
|
||||
setLoadingDefaultValueOptions(false);
|
||||
}
|
||||
};
|
||||
loadTableOptions();
|
||||
}, [open, localField.selectOptions?.type, localField.selectOptions?.tableName,
|
||||
localField.selectOptions?.valueColumn, localField.selectOptions?.labelColumn]);
|
||||
|
||||
// 관계 코드 선택 시 상세 설정 자동 채움
|
||||
const handleRelationCodeSelect = async (relationCode: string) => {
|
||||
if (!relationCode) return;
|
||||
|
|
@ -1181,6 +1249,80 @@ export function FieldDetailSettingsModal({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기본 선택값 설정 (cascading 제외) */}
|
||||
{(() => {
|
||||
const effectiveType = localField.selectOptions?.type || "static";
|
||||
if (effectiveType === "cascading") return null;
|
||||
return (
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium">기본 선택값</span>
|
||||
{/* static 타입 */}
|
||||
{effectiveType === "static" && (localField.selectOptions?.staticOptions?.length || 0) > 0 && (
|
||||
<Select
|
||||
value={localField.defaultValue || "_none_"}
|
||||
onValueChange={(value) => updateField({ defaultValue: value === "_none_" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[180px] text-xs">
|
||||
<SelectValue placeholder="선택 안함" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none_">선택 안함</SelectItem>
|
||||
{(localField.selectOptions?.staticOptions || []).map((opt, idx) => (
|
||||
<SelectItem key={`default-${idx}`} value={opt.value}>
|
||||
{opt.label || opt.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* code 타입 */}
|
||||
{effectiveType === "code" && defaultValueCategoryValues.length > 0 && (
|
||||
<Select
|
||||
value={localField.defaultValue || "_none_"}
|
||||
onValueChange={(value) => updateField({ defaultValue: value === "_none_" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[180px] text-xs">
|
||||
<SelectValue placeholder="선택 안함" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none_">선택 안함</SelectItem>
|
||||
{defaultValueCategoryValues.map((cv) => (
|
||||
<SelectItem key={cv.value} value={cv.value}>
|
||||
{cv.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* table 타입 */}
|
||||
{effectiveType === "table" && defaultValueTableOptions.length > 0 && (
|
||||
<Select
|
||||
value={localField.defaultValue || "_none_"}
|
||||
onValueChange={(value) => updateField({ defaultValue: value === "_none_" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[180px] text-xs">
|
||||
<SelectValue placeholder="선택 안함" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none_">선택 안함</SelectItem>
|
||||
{defaultValueTableOptions.map((opt, idx) => (
|
||||
<SelectItem key={`default-table-${idx}`} value={opt.value}>
|
||||
{opt.label} ({opt.value})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{loadingDefaultValueOptions && (
|
||||
<span className="text-[9px] text-muted-foreground">로딩 중...</span>
|
||||
)}
|
||||
</div>
|
||||
<HelpText>폼이 열릴 때 자동으로 선택될 기본값을 설정합니다</HelpText>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -266,7 +266,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -308,7 +307,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -342,7 +340,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
|
@ -3058,7 +3055,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
||||
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/react-reconciler": "^0.32.0",
|
||||
|
|
@ -3712,7 +3708,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
||||
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.6"
|
||||
},
|
||||
|
|
@ -3807,7 +3802,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
|
|
@ -4121,7 +4115,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
|
|
@ -6622,7 +6615,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -6633,7 +6625,6 @@
|
|||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -6676,7 +6667,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
|
|
@ -6759,7 +6749,6 @@
|
|||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
|
|
@ -7392,7 +7381,6 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -8543,8 +8531,7 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
|
|
@ -8866,7 +8853,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -9626,7 +9612,6 @@
|
|||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -9715,7 +9700,6 @@
|
|||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
|
|
@ -9817,7 +9801,6 @@
|
|||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
|
|
@ -10989,7 +10972,6 @@
|
|||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
|
|
@ -11770,8 +11752,7 @@
|
|||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
|
|
@ -13110,7 +13091,6 @@
|
|||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
|
|
@ -13404,7 +13384,6 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
|
|
@ -13434,7 +13413,6 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
|
|
@ -13483,7 +13461,6 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
|
|
@ -13687,7 +13664,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -13757,7 +13733,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -13808,7 +13783,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
|
@ -13841,8 +13815,7 @@
|
|||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
|
|
@ -14150,7 +14123,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
|
|
@ -14173,8 +14145,7 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/recharts/node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -15204,8 +15175,7 @@
|
|||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
|
|
@ -15293,7 +15263,6 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -15642,7 +15611,6 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
Loading…
Reference in New Issue