Files
invyone/frontend/hooks/useAuth.ts
T
gbpark 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>
2026-04-25 00:36:05 +09:00

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;