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

187 lines
5.5 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
/**
* 회사 관리 모달 공용 셸 — v5 토큰/글로우 따름. 반투명/blur 금지.
*
* ⚠ createPortal 로 document.body 에 렌더링.
* 이유: accordion 의 부모에 transform 이 걸려 있으면 position:fixed 가 viewport 가 아니라
* 그 transform 부모 기준으로 포지셔닝됨 (CSS containing-block 규칙). Portal 로 탈출.
*/
export default function ModalShell({
title,
subtitle,
onClose,
width = 520,
children,
footer,
}: {
title: React.ReactNode;
subtitle?: React.ReactNode;
onClose: () => void;
width?: number;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
// 헤더는 항상 테마 primary 색의 아주 연한 그라데이션 (variant 구분 없음).
const headerBg =
"linear-gradient(90deg, rgba(var(--v5-primary-rgb), 0.05), transparent 70%)";
const shell = (
<div
role="dialog"
aria-modal="true"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 90,
background: "rgba(6, 5, 14, 0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
animation: "modalOverlayIn 0.18s ease-out",
}}
>
<style>{`
@keyframes modalOverlayIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes modalShellIn {
from { opacity: 0; transform: translateY(10px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`}</style>
<div
style={{
width: `min(${width}px, 100%)`,
maxHeight: "calc(100vh - 48px)",
display: "flex",
flexDirection: "column",
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
borderRadius: 14,
overflow: "hidden",
boxShadow: "0 12px 32px rgba(0, 0, 0, 0.28), 0 0 18px rgba(var(--v5-primary-rgb), 0.06)",
animation: "modalShellIn 0.28s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
<div
style={{
padding: "0.95rem 1.2rem",
background: headerBg,
borderBottom: "1px solid var(--v5-border)",
display: "flex",
alignItems: "center",
gap: "0.75rem",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "1.05rem", fontWeight: 800, letterSpacing: "-0.01em", color: "var(--v5-text)" }}>
{title}
</div>
{subtitle && (
<div style={{ fontSize: "0.75rem", color: "var(--v5-text-sec)", marginTop: 3, fontWeight: 500 }}>
{subtitle}
</div>
)}
</div>
<button
onClick={onClose}
aria-label="닫기"
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>
<div style={{ flex: 1, overflow: "auto", padding: "1.1rem 1.2rem" }}>{children}</div>
{footer && (
<div
style={{
padding: "0.8rem 1.2rem",
borderTop: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
display: "flex",
alignItems: "center",
gap: "0.55rem",
justifyContent: "flex-end",
}}
>
{footer}
</div>
)}
</div>
</div>
);
return createPortal(shell, document.body);
}
export function ModalBtn({
children,
onClick,
variant = "secondary",
disabled,
icon,
}: {
children: React.ReactNode;
onClick?: () => void;
variant?: "primary" | "secondary" | "ghost" | "danger";
disabled?: boolean;
icon?: React.ReactNode;
}) {
const map: Record<string, React.CSSProperties> = {
primary: { background: "var(--v5-primary)", color: "#fff", borderColor: "var(--v5-primary)" },
danger: { background: "var(--v5-red)", color: "#fff", borderColor: "var(--v5-red)" },
secondary: {
background: "var(--v5-surface-solid)",
color: "var(--v5-text)",
borderColor: "var(--v5-border)",
},
ghost: { background: "transparent", color: "var(--v5-text-sec)", borderColor: "transparent" },
};
return (
<button
onClick={disabled ? undefined : onClick}
disabled={disabled}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
height: 34,
padding: "0 0.9rem",
borderRadius: 8,
fontSize: "0.8125rem",
fontWeight: 600,
border: "1px solid transparent",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
fontFamily: "inherit",
whiteSpace: "nowrap",
transition: "all 0.15s ease",
...map[variant],
}}
>
{icon}
{children}
</button>
);
}