- Updated the `getEntityOptions` function to accept an optional `fields` parameter, allowing clients to specify additional columns to be retrieved. - Implemented logic to dynamically include extra columns in the SQL query based on the provided `fields`, improving flexibility in data retrieval. - Enhanced the response to indicate whether extra fields were included, facilitating better client-side handling of the data. - Added logging for authentication failures in the `AuthGuard` component to improve debugging and user experience. - Integrated auto-fill functionality in the `V2Select` component to automatically populate fields based on selected entity references, enhancing user interaction. - Updated the `ItemSearchModal` to support multi-selection of items, improving usability in item management scenarios.
226 lines
5.8 KiB
TypeScript
226 lines
5.8 KiB
TypeScript
/**
|
|
* 인증 이벤트 로거
|
|
* - 토큰 갱신/삭제/리다이렉트 발생 시 원인을 기록
|
|
* - localStorage에 저장하여 브라우저에서 확인 가능
|
|
* - 콘솔에서 window.__AUTH_LOG.show() 로 조회
|
|
*/
|
|
|
|
const STORAGE_KEY = "auth_debug_log";
|
|
const MAX_ENTRIES = 200;
|
|
|
|
export type AuthEventType =
|
|
| "TOKEN_SET"
|
|
| "TOKEN_REMOVED"
|
|
| "TOKEN_EXPIRED_DETECTED"
|
|
| "TOKEN_REFRESH_START"
|
|
| "TOKEN_REFRESH_SUCCESS"
|
|
| "TOKEN_REFRESH_FAIL"
|
|
| "REDIRECT_TO_LOGIN"
|
|
| "API_401_RECEIVED"
|
|
| "API_401_RETRY"
|
|
| "AUTH_CHECK_START"
|
|
| "AUTH_CHECK_SUCCESS"
|
|
| "AUTH_CHECK_FAIL"
|
|
| "AUTH_GUARD_BLOCK"
|
|
| "AUTH_GUARD_PASS"
|
|
| "MENU_LOAD_FAIL"
|
|
| "VISIBILITY_CHANGE"
|
|
| "MIDDLEWARE_REDIRECT";
|
|
|
|
interface AuthLogEntry {
|
|
timestamp: string;
|
|
event: AuthEventType;
|
|
detail: string;
|
|
tokenStatus: string;
|
|
url: string;
|
|
stack?: string;
|
|
}
|
|
|
|
function getTokenSummary(): string {
|
|
if (typeof window === "undefined") return "SSR";
|
|
|
|
const token = localStorage.getItem("authToken");
|
|
if (!token) return "없음";
|
|
|
|
try {
|
|
const payload = JSON.parse(atob(token.split(".")[1]));
|
|
const exp = payload.exp * 1000;
|
|
const now = Date.now();
|
|
const remainMs = exp - now;
|
|
|
|
if (remainMs <= 0) {
|
|
return `만료됨(${Math.abs(Math.round(remainMs / 60000))}분 전)`;
|
|
}
|
|
|
|
const remainMin = Math.round(remainMs / 60000);
|
|
const remainHour = Math.floor(remainMin / 60);
|
|
const min = remainMin % 60;
|
|
|
|
return `유효(${remainHour}h${min}m 남음, user:${payload.userId})`;
|
|
} catch {
|
|
return "파싱실패";
|
|
}
|
|
}
|
|
|
|
function getCallStack(): string {
|
|
try {
|
|
const stack = new Error().stack || "";
|
|
const lines = stack.split("\n").slice(3, 7);
|
|
return lines.map((l) => l.trim()).join(" <- ");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function writeLog(event: AuthEventType, detail: string) {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const entry: AuthLogEntry = {
|
|
timestamp: new Date().toISOString(),
|
|
event,
|
|
detail,
|
|
tokenStatus: getTokenSummary(),
|
|
url: window.location.pathname + window.location.search,
|
|
stack: getCallStack(),
|
|
};
|
|
|
|
// 콘솔 출력 (그룹)
|
|
const isError = ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK"].includes(event);
|
|
const logFn = isError ? console.warn : console.debug;
|
|
logFn(`[AuthLog] ${event}: ${detail} | 토큰: ${entry.tokenStatus} | ${entry.url}`);
|
|
|
|
// localStorage에 저장
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
const logs: AuthLogEntry[] = stored ? JSON.parse(stored) : [];
|
|
logs.push(entry);
|
|
|
|
// 최대 개수 초과 시 오래된 것 제거
|
|
while (logs.length > MAX_ENTRIES) {
|
|
logs.shift();
|
|
}
|
|
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(logs));
|
|
} catch {
|
|
// localStorage 공간 부족 등의 경우 무시
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 저장된 로그 조회
|
|
*/
|
|
function getLogs(): AuthLogEntry[] {
|
|
if (typeof window === "undefined") return [];
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
return stored ? JSON.parse(stored) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 로그 초기화
|
|
*/
|
|
function clearLogs() {
|
|
if (typeof window === "undefined") return;
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
}
|
|
|
|
/**
|
|
* 로그를 테이블 형태로 콘솔에 출력
|
|
*/
|
|
function showLogs(filter?: AuthEventType | "ERROR") {
|
|
const logs = getLogs();
|
|
|
|
if (logs.length === 0) {
|
|
console.log("[AuthLog] 저장된 로그가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
let filtered = logs;
|
|
if (filter === "ERROR") {
|
|
filtered = logs.filter((l) =>
|
|
["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK", "AUTH_CHECK_FAIL", "TOKEN_EXPIRED_DETECTED"].includes(l.event)
|
|
);
|
|
} else if (filter) {
|
|
filtered = logs.filter((l) => l.event === filter);
|
|
}
|
|
|
|
console.log(`\n[AuthLog] 총 ${filtered.length}건 (전체 ${logs.length}건)`);
|
|
console.log("─".repeat(120));
|
|
|
|
filtered.forEach((entry, i) => {
|
|
const time = entry.timestamp.replace("T", " ").split(".")[0];
|
|
console.log(
|
|
`${i + 1}. [${time}] ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}\n`
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 마지막 리다이렉트 원인 조회
|
|
*/
|
|
function getLastRedirectReason(): AuthLogEntry | null {
|
|
const logs = getLogs();
|
|
for (let i = logs.length - 1; i >= 0; i--) {
|
|
if (logs[i].event === "REDIRECT_TO_LOGIN") {
|
|
return logs[i];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 로그를 텍스트 파일로 다운로드
|
|
*/
|
|
function downloadLogs() {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const logs = getLogs();
|
|
if (logs.length === 0) {
|
|
console.log("[AuthLog] 저장된 로그가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
const text = logs
|
|
.map((entry, i) => {
|
|
const time = entry.timestamp.replace("T", " ").split(".")[0];
|
|
return `[${i + 1}] ${time} | ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}`;
|
|
})
|
|
.join("\n\n");
|
|
|
|
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `auth-debug-log_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-")}.txt`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
console.log("[AuthLog] 로그 파일 다운로드 완료");
|
|
}
|
|
|
|
// 전역 접근 가능하게 등록
|
|
if (typeof window !== "undefined") {
|
|
(window as any).__AUTH_LOG = {
|
|
show: showLogs,
|
|
errors: () => showLogs("ERROR"),
|
|
clear: clearLogs,
|
|
download: downloadLogs,
|
|
lastRedirect: getLastRedirectReason,
|
|
raw: getLogs,
|
|
};
|
|
}
|
|
|
|
export const AuthLogger = {
|
|
log: writeLog,
|
|
getLogs,
|
|
clearLogs,
|
|
showLogs,
|
|
downloadLogs,
|
|
getLastRedirectReason,
|
|
};
|
|
|
|
export default AuthLogger;
|