Files
invyone/frontend/components/admin/provisioning/AuditLogDrawer.tsx
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

230 lines
8.8 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { X, FileText, CheckCircle2, AlertTriangle } from "lucide-react";
import { getCompanyAuditLog, getGlobalAuditLog } from "@/lib/api/provisioning";
/**
* 회사 관리 감사 로그 드로어. 오른쪽에서 슬라이드.
* companyCode 를 주면 특정 회사, 안 주면 전체.
*/
const ACTION_META: Record<string, { label: string; color: string }> = {
COMPANY_CREATE: { label: "회사 생성", color: "rgb(var(--v5-green-rgb))" },
COMPANY_CREATE_FAILED: { label: "생성 실패", color: "var(--v5-red)" },
COMPANY_DEACTIVATE: { label: "비활성화", color: "var(--v5-amber)" },
COMPANY_REACTIVATE: { label: "재활성화", color: "rgb(var(--v5-green-rgb))" },
COMPANY_DELETE: { label: "영구 삭제", color: "var(--v5-red)" },
ADMIN_PASSWORD_RESET: { label: "비번 재설정", color: "var(--v5-primary)" },
TEMPLATES_RECOPY: { label: "템플릿 재복제", color: "var(--v5-cyan)" },
};
export default function AuditLogDrawer({
companyCode,
title,
onClose,
}: {
companyCode?: string;
title?: string;
onClose: () => void;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { data, isLoading } = useQuery({
queryKey: companyCode ? ["company-audit-log", companyCode] : ["global-audit-log"],
queryFn: () => (companyCode ? getCompanyAuditLog(companyCode, 1, 100) : getGlobalAuditLog(1, 100)),
staleTime: 5_000,
});
const rows: Record<string, any>[] = data?.data || [];
if (!mounted) return null;
const drawer = (
<div
role="dialog"
aria-modal="true"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed",
inset: 0,
zIndex: 95,
background: "rgba(6, 5, 14, 0.45)",
display: "flex",
justifyContent: "flex-end",
animation: "drawerOverlayIn 0.16s ease-out",
}}
>
<style>{`
@keyframes drawerOverlayIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes drawerSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
`}</style>
<div
style={{
width: "min(620px, 100%)",
height: "100%",
background: "var(--v5-bg)",
borderLeft: "1px solid var(--v5-border)",
display: "flex",
flexDirection: "column",
animation: "drawerSlideIn 0.28s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
{/* header */}
<div style={{
padding: "0.95rem 1.2rem",
background: "var(--v5-surface-solid)",
borderBottom: "1px solid var(--v5-border)",
display: "flex",
alignItems: "center",
gap: "0.75rem",
}}>
<FileText size={18} strokeWidth={1.75} color="var(--v5-primary)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "1rem", fontWeight: 800, letterSpacing: "-0.01em" }}>
{title || (companyCode ? `감사 로그 · ${companyCode}` : "전체 감사 로그")}
</div>
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-sec)", marginTop: 2, fontFamily: "var(--v5-font-mono)" }}>
{rows.length} · {data?.total ?? 0}
</div>
</div>
<button
onClick={onClose}
style={{
width: 30, height: 30, borderRadius: 7,
border: "1px solid var(--v5-border)",
background: "transparent",
color: "var(--v5-text-sec)",
cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
<X size={14} />
</button>
</div>
{/* body */}
<div style={{ flex: 1, overflowY: "auto", padding: "0.8rem 1rem" }}>
{isLoading && (
<div style={{ padding: "2rem", textAlign: "center", color: "var(--v5-text-sec)", fontSize: "0.85rem" }}>
...
</div>
)}
{!isLoading && rows.length === 0 && (
<div style={{ padding: "2rem", textAlign: "center", color: "var(--v5-text-muted)", fontSize: "0.85rem" }}>
.
</div>
)}
{!isLoading && rows.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
{rows.map((r) => {
const meta = ACTION_META[r.action] || { label: r.action, color: "var(--v5-text-sec)" };
const ok = r.success !== false && r.success !== "false";
return (
<div
key={r.id}
style={{
border: "1px solid var(--v5-border)",
borderLeft: `3px solid ${meta.color}`,
borderRadius: 7,
background: "var(--v5-surface-solid)",
padding: "0.55rem 0.8rem",
display: "grid",
gridTemplateColumns: "16px 1fr auto",
gap: 10,
alignItems: "flex-start",
}}
>
<div style={{ paddingTop: 3 }}>
{ok ? (
<CheckCircle2 size={13} color="rgb(var(--v5-green-rgb))" strokeWidth={2} />
) : (
<AlertTriangle size={13} color="var(--v5-red)" strokeWidth={1.75} />
)}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span style={{ fontSize: "0.82rem", fontWeight: 700, color: meta.color }}>{meta.label}</span>
{!companyCode && (
<span style={{ fontSize: "0.72rem", color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)" }}>
{r.company_code}
</span>
)}
{r.target && (
<span style={{
fontSize: "0.7rem",
padding: "1px 6px",
borderRadius: 3,
background: "var(--v5-bg-subtle)",
color: "var(--v5-text-sec)",
fontFamily: "var(--v5-font-mono)",
}}>
{r.target}
</span>
)}
</div>
{r.details && Object.keys(r.details).length > 0 && (
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-sec)", marginTop: 3, fontFamily: "var(--v5-font-mono)" }}>
{formatDetails(r.details)}
</div>
)}
{r.error_message && (
<div style={{ fontSize: "0.72rem", color: "var(--v5-red)", marginTop: 3 }}>
{r.error_message}
</div>
)}
</div>
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-muted)", fontFamily: "var(--v5-font-mono)", whiteSpace: "nowrap" }}>
<div>{formatTime(r.created_at)}</div>
<div style={{ textAlign: "right", marginTop: 2 }}>{r.actor_user_id || "—"}</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
return createPortal(drawer, document.body);
}
function formatDetails(details: any): string {
try {
const parts: string[] = [];
for (const [k, v] of Object.entries(details)) {
if (v === null || v === undefined || v === "") continue;
if (Array.isArray(v)) {
parts.push(`${k}=[${v.join(",")}]`);
} else if (typeof v === "object") {
parts.push(`${k}=${JSON.stringify(v)}`);
} else {
parts.push(`${k}=${v}`);
}
}
const joined = parts.join(" · ");
return joined.length > 200 ? joined.slice(0, 200) + "…" : joined;
} catch {
return String(details);
}
}
function formatTime(v: any): string {
if (v == null) return "—";
try {
let d: Date;
if (typeof v === "number") d = new Date(v);
else {
const s = String(v);
d = /^\d{10,}$/.test(s) ? new Date(Number(s)) : new Date(s);
}
if (isNaN(d.getTime())) return String(v);
return d.toISOString().replace("T", " ").slice(0, 19);
} catch {
return String(v);
}
}