68f85f3736
- 첫 로그인 비번 강제 변경 (RUN_082): FORCE_PASSWORD_CHANGE 컬럼, ForcePasswordChangeGuardFilter, /auth/change-password API + 페이지 - 테넌트 일관성 가드: TenantConsistencyGuardFilter 로 JWT.company_code ↔ 서브도메인 company_code 대조, CompanyResolver 가 (db_name, company_code) 동시 반환 - 회사 관리 확장 (RUN_083 audit log, RUN_084 lifecycle 컬럼): CompanyAdmin/Members/Templates/Lifecycle/AuditLog 서비스 + CompanyMgmtController + SuperAdminGuard - 회사 관리 UI: CompanyAccordionRow 탭화 + 모달 4종 (AdminInfo/Deactivate/Delete/RecopyTemplates) + AuditLogDrawer + csvExport - 프로비저닝 마법사: force_password_change 토글 반영 - 프론트 인증: storage 이벤트 멀티탭 동기화, 403 errorCode (PASSWORD_CHANGE_REQUIRED / CROSS_TENANT_REJECTED / TENANT_NOT_RESOLVED) 전역 리다이렉트 - 기타: StartupSchemaMigrator, OS별 도커 기동 스크립트, CLAUDE.md 트래킹 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { apiCall } from "@/lib/api/client";
|
|
import { AuthLogger } from "@/lib/authLogger";
|
|
import { TokenManager } from "@/lib/auth/tokenManager";
|
|
|
|
interface UserInfo {
|
|
user_id?: string;
|
|
user_name?: string;
|
|
userNameEng?: string;
|
|
userNameCn?: string;
|
|
deptCode?: string;
|
|
dept_name?: string;
|
|
positionCode?: string;
|
|
position_name?: string;
|
|
email?: string;
|
|
tel?: string;
|
|
cellPhone?: string;
|
|
user_type?: string;
|
|
userTypeName?: string;
|
|
authName?: string;
|
|
partnerCd?: string;
|
|
locale?: string;
|
|
isAdmin: boolean;
|
|
sabun?: string;
|
|
photo?: string | null;
|
|
company_code?: string;
|
|
force_password_change?: boolean;
|
|
}
|
|
|
|
interface AuthStatus {
|
|
isLoggedIn: boolean;
|
|
isAdmin: boolean;
|
|
userId?: string;
|
|
deptCode?: string;
|
|
}
|
|
|
|
interface ApiResponse<T = any> {
|
|
success: boolean;
|
|
message: string;
|
|
data?: T;
|
|
errorCode?: string;
|
|
}
|
|
|
|
/**
|
|
* 인증 상태 관리 훅
|
|
* - 401 처리는 client.ts의 응답 인터셉터에서 통합 관리
|
|
* - 이 훅은 상태 관리와 사용자 정보 조회에만 집중
|
|
*/
|
|
export const useAuth = () => {
|
|
const router = useRouter();
|
|
|
|
const [user, setUser] = useState<UserInfo | null>(null);
|
|
const [authStatus, setAuthStatus] = useState<AuthStatus>({
|
|
isLoggedIn: false,
|
|
isAdmin: false,
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const initializedRef = useRef(false);
|
|
|
|
/**
|
|
* 현재 사용자 정보 조회
|
|
*/
|
|
const fetchCurrentUser = useCallback(async (): Promise<UserInfo | null> => {
|
|
try {
|
|
const response = await apiCall<UserInfo>("GET", "/auth/me");
|
|
|
|
if (response.success && response.data) {
|
|
// 사용자 로케일 정보 조회
|
|
try {
|
|
const localeResponse = await apiCall<string>("GET", "/admin/user-locale");
|
|
if (localeResponse.success && localeResponse.data) {
|
|
const userLocale = localeResponse.data;
|
|
(window as any).__GLOBAL_USER_LANG = userLocale;
|
|
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
|
|
localStorage.setItem("userLocale", userLocale);
|
|
localStorage.setItem("userLocaleLoaded", "true");
|
|
}
|
|
} catch {
|
|
(window as any).__GLOBAL_USER_LANG = "KR";
|
|
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
|
|
localStorage.setItem("userLocale", "KR");
|
|
localStorage.setItem("userLocaleLoaded", "true");
|
|
}
|
|
|
|
const data = response.data;
|
|
return {
|
|
...data,
|
|
user_type: data.user_type,
|
|
company_code: data.company_code,
|
|
user_id: data.user_id,
|
|
user_name: data.user_name,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* 인증 상태 확인
|
|
*/
|
|
const checkAuthStatus = useCallback(async (): Promise<AuthStatus> => {
|
|
try {
|
|
const response = await apiCall<AuthStatus>("GET", "/auth/status");
|
|
if (response.success && response.data) {
|
|
return {
|
|
isLoggedIn: (response.data as any).isAuthenticated || response.data.isLoggedIn || false,
|
|
isAdmin: response.data.isAdmin || false,
|
|
};
|
|
}
|
|
|
|
return { isLoggedIn: false, isAdmin: false };
|
|
} catch {
|
|
return { isLoggedIn: false, isAdmin: false };
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* 사용자 데이터 새로고침
|
|
* - 백엔드 /auth/me 가 성공해야만 로그인 상태 유지
|
|
* - 실패하면 비인증으로 전환 (이전에는 토큰 페이로드를 그대로 신뢰하던 fallback 이 있었으나
|
|
* 백엔드 401 을 무시하고 클라이언트 신뢰 상태로 우기는 보안 결함이라 제거)
|
|
*/
|
|
const refreshUserData = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
const token = TokenManager.getToken();
|
|
if (!token) {
|
|
AuthLogger.log("AUTH_CHECK_FAIL", "refreshUserData: 토큰 없음");
|
|
setUser(null);
|
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
// 만료된 토큰이라도 apiClient 요청 인터셉터가 자동 갱신하므로 여기서 차단하지 않음
|
|
|
|
AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작");
|
|
|
|
setAuthStatus({
|
|
isLoggedIn: true,
|
|
isAdmin: false,
|
|
});
|
|
|
|
// /auth/me 성공 = 인증 확인 완료. /auth/status는 보조 정보(isAdmin)만 참조
|
|
// 두 API를 Promise.all로 호출 시, 토큰 만료 타이밍에 따라
|
|
// /auth/me는 401→갱신→성공, /auth/status는 200 isAuthenticated:false를 반환하는
|
|
// 레이스 컨디션이 발생할 수 있으므로, isLoggedIn 판단은 /auth/me 성공 여부로 결정
|
|
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
|
|
|
|
if (userInfo) {
|
|
setUser(userInfo);
|
|
|
|
const isAdminFromUser = userInfo.user_id === "plm_admin" || userInfo.user_type === "ADMIN";
|
|
const finalAuthStatus = {
|
|
isLoggedIn: true,
|
|
isAdmin: authStatusData.isAdmin || isAdminFromUser,
|
|
};
|
|
|
|
setAuthStatus(finalAuthStatus);
|
|
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.user_id}, 인증: ${finalAuthStatus.isLoggedIn}`);
|
|
} else {
|
|
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 비인증 전환");
|
|
TokenManager.removeToken();
|
|
setUser(null);
|
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
|
}
|
|
} catch {
|
|
AuthLogger.log("AUTH_CHECK_FAIL", "API 호출 예외 → 비인증 전환");
|
|
TokenManager.removeToken();
|
|
setUser(null);
|
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
|
setError("사용자 정보를 불러오는데 실패했습니다.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [fetchCurrentUser, checkAuthStatus]);
|
|
|
|
/**
|
|
* 회사 전환 처리 (Invyone 관리자 전용)
|
|
*/
|
|
const switchCompany = useCallback(
|
|
async (companyCode: string): Promise<{ success: boolean; message: string }> => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const response = await apiCall<any>("POST", "/auth/switch-company", {
|
|
company_code: companyCode,
|
|
});
|
|
|
|
if (response.success && response.data?.token) {
|
|
TokenManager.setToken(response.data.token);
|
|
|
|
return {
|
|
success: true,
|
|
message: response.message || "회사 전환에 성공했습니다.",
|
|
};
|
|
} else {
|
|
return {
|
|
success: false,
|
|
message: response.message || "회사 전환에 실패했습니다.",
|
|
};
|
|
}
|
|
} catch (error: any) {
|
|
const errorMessage = error.message || "회사 전환 중 오류가 발생했습니다.";
|
|
setError(errorMessage);
|
|
|
|
return {
|
|
success: false,
|
|
message: errorMessage,
|
|
};
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
/**
|
|
* 로그아웃 처리
|
|
*/
|
|
const logout = useCallback(async (): Promise<boolean> => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
const response = await apiCall("POST", "/auth/logout");
|
|
|
|
TokenManager.removeToken();
|
|
|
|
localStorage.removeItem("userLocale");
|
|
localStorage.removeItem("userLocaleLoaded");
|
|
(window as any).__GLOBAL_USER_LANG = undefined;
|
|
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
|
|
|
|
setUser(null);
|
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
|
setError(null);
|
|
|
|
router.push("/login");
|
|
|
|
return response.success;
|
|
} catch {
|
|
TokenManager.removeToken();
|
|
|
|
localStorage.removeItem("userLocale");
|
|
localStorage.removeItem("userLocaleLoaded");
|
|
(window as any).__GLOBAL_USER_LANG = undefined;
|
|
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
|
|
|
|
setUser(null);
|
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
|
router.push("/login");
|
|
|
|
return false;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [router]);
|
|
|
|
/**
|
|
* 메뉴 접근 권한 확인
|
|
*/
|
|
const checkMenuAuth = useCallback(async (menuUrl: string): Promise<boolean> => {
|
|
try {
|
|
const response = await apiCall<{ menuUrl: string; hasAuth: boolean }>("GET", "/auth/menu-auth");
|
|
|
|
if (response.success && response.data) {
|
|
return response.data.hasAuth;
|
|
}
|
|
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* 초기 인증 상태 확인
|
|
*/
|
|
useEffect(() => {
|
|
if (initializedRef.current) return;
|
|
initializedRef.current = true;
|
|
|
|
if (typeof window === "undefined") return;
|
|
|
|
// 로그인 페이지에서는 인증 상태 확인하지 않음
|
|
if (window.location.pathname === "/login") {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const token = TokenManager.getToken();
|
|
|
|
if (token) {
|
|
// 유효/만료 모두 refreshUserData로 처리
|
|
// apiClient 요청 인터셉터가 만료 토큰을 자동 갱신하므로 여기서 삭제하지 않음
|
|
const isExpired = TokenManager.isTokenExpired(token);
|
|
AuthLogger.log(
|
|
"AUTH_CHECK_START",
|
|
`초기 인증 확인: 토큰 ${isExpired ? "만료됨 → 갱신 시도" : "유효"} (경로: ${window.location.pathname})`,
|
|
);
|
|
setAuthStatus({
|
|
isLoggedIn: true,
|
|
isAdmin: false,
|
|
});
|
|
refreshUserData();
|
|
} else {
|
|
AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`);
|
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
return {
|
|
user,
|
|
authStatus,
|
|
loading,
|
|
error,
|
|
|
|
isLoggedIn: authStatus.isLoggedIn,
|
|
isAdmin: authStatus.isAdmin,
|
|
forcePasswordChange: user?.force_password_change === true,
|
|
userId: user?.user_id,
|
|
userName: user?.user_name,
|
|
companyCode: user?.company_code,
|
|
|
|
logout,
|
|
switchCompany,
|
|
checkMenuAuth,
|
|
refreshUserData,
|
|
|
|
clearError: () => setError(null),
|
|
};
|
|
};
|
|
|
|
export default useAuth;
|