feat: Enhance entity options retrieval with additional fields support
- 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.
This commit is contained in:
225
frontend/lib/authLogger.ts
Normal file
225
frontend/lib/authLogger.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 인증 이벤트 로거
|
||||
* - 토큰 갱신/삭제/리다이렉트 발생 시 원인을 기록
|
||||
* - 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;
|
||||
Reference in New Issue
Block a user