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

371 lines
12 KiB
TypeScript

"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { KeyRound, UserCircle2, Copy, Check, AlertTriangle, Eye, EyeOff, ArrowLeft } from "lucide-react";
import { getCompanyAdmin, resetAdminPassword } from "@/lib/api/provisioning";
import ModalShell, { ModalBtn } from "./ModalShell";
type Stage = "view" | "reset-warn" | "reset-done";
/**
* 관리자 계정 조회 + 비번 재설정 통합 모달.
* 모달 중첩 금지 — 한 ModalShell 안에서 stage 전환으로 처리.
*
* view : 관리자 정보 조회 (기본)
* reset-warn : 재설정 경고 + 확인
* reset-done : 새 비번 1회 표시 + 복사
*/
export default function AdminInfoModal({
companyCode,
companyName,
initialStage = "view",
onClose,
}: {
companyCode: string;
companyName?: string;
initialStage?: "view" | "reset-warn";
onClose: () => void;
}) {
const [stage, setStage] = useState<Stage>(initialStage);
const [result, setResult] = useState<{ admin_user_id: string; new_password: string } | null>(null);
const [showPw, setShowPw] = useState(false);
const [pwCopied, setPwCopied] = useState(false);
const qc = useQueryClient();
const { data, isLoading, refetch } = useQuery({
queryKey: ["company-admin", companyCode],
queryFn: () => getCompanyAdmin(companyCode),
staleTime: 5_000,
enabled: stage === "view",
});
const mutation = useMutation({
mutationFn: () => resetAdminPassword(companyCode),
onSuccess: (d) => {
if (d.error) return;
setResult({ admin_user_id: d.admin_user_id!, new_password: d.new_password! });
setStage("reset-done");
qc.invalidateQueries({ queryKey: ["company-admin", companyCode] });
qc.invalidateQueries({ queryKey: ["company-audit-log", companyCode] });
},
});
const admin = data || {};
// ─── 헤더 ───
const titleNode = (() => {
if (stage === "reset-warn" || stage === "reset-done") {
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<KeyRound size={18} strokeWidth={1.75} />
</span>
);
}
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<UserCircle2 size={18} strokeWidth={1.75} />
</span>
);
})();
// ─── 본문 ───
let body: React.ReactNode = null;
if (stage === "view") {
if (isLoading) {
body = <PadCenter> ...</PadCenter>;
} else if (!admin.found) {
body = <Warn> COMPANY_ADMIN .</Warn>;
} else {
body = (
<div style={{ display: "grid", gridTemplateColumns: "120px 1fr", rowGap: "0.6rem", fontSize: "0.8125rem" }}>
<Label> ID</Label>
<ValueMono>
{admin.user_id}
<CopyInline text={admin.user_id} />
</ValueMono>
<Label></Label>
<Value>{admin.user_name || "—"}</Value>
<Label></Label>
<Value>
<Badge color={admin.status === "active" ? "green" : "muted"}>{admin.status || "—"}</Badge>
</Value>
<Label> </Label>
<Value>
<Badge color={admin.force_password_change ? "amber" : "green"}>
{admin.force_password_change ? "필요 (미완료)" : "완료됨"}
</Badge>
</Value>
<Label></Label>
<ValueMono>{formatDate(admin.created_date)}</ValueMono>
</div>
);
}
}
if (stage === "reset-warn") {
body = (
<>
<div style={warnBoxStyle}>
<AlertTriangle size={18} color="var(--v5-amber)" strokeWidth={1.75} style={{ flexShrink: 0, marginTop: 2 }} />
<div style={{ fontSize: "0.82rem", color: "var(--v5-text)", lineHeight: 1.55 }}>
<b> </b>. 1 , .
<br />
.
</div>
</div>
{mutation.isError && (
<div style={{ fontSize: "0.8rem", color: "var(--v5-red)", marginTop: 10 }}>
: {(mutation.error as Error)?.message || "재설정 실패"}
</div>
)}
</>
);
}
if (stage === "reset-done" && result) {
body = (
<>
<div style={{
padding: "0.85rem 0.95rem",
background: "rgba(var(--v5-green-rgb), 0.08)",
border: "1px solid rgba(var(--v5-green-rgb), 0.35)",
borderRadius: 8,
marginBottom: "1rem",
fontSize: "0.82rem",
color: "var(--v5-text)",
}}>
. <b> </b>. .
</div>
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr", rowGap: "0.7rem", fontSize: "0.82rem" }}>
<Label> ID</Label>
<ValueMono>{result.admin_user_id}</ValueMono>
<Label> </Label>
<div style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "0.55rem 0.7rem",
background: "var(--v5-bg-subtle)",
border: "1px solid var(--v5-border)",
borderRadius: 7,
fontFamily: "var(--v5-font-mono)",
fontSize: "0.95rem",
fontWeight: 600,
color: "var(--v5-primary)",
letterSpacing: "0.05em",
}}>
<span style={{ flex: 1, userSelect: "all" }}>
{showPw ? result.new_password : "•".repeat(result.new_password.length)}
</span>
<button onClick={() => setShowPw(!showPw)} title={showPw ? "숨기기" : "표시"} style={iconBtnStyle}>
{showPw ? <EyeOff size={13} /> : <Eye size={13} />}
</button>
<button
onClick={() => {
navigator.clipboard?.writeText(result.new_password);
setPwCopied(true);
}}
title="복사"
style={{ ...iconBtnStyle, color: pwCopied ? "rgb(var(--v5-green-rgb))" : "var(--v5-text-sec)" }}
>
{pwCopied ? <Check size={13} /> : <Copy size={13} />}
</button>
</div>
<Label> </Label>
<Value> .</Value>
</div>
{!pwCopied && (
<div style={{
marginTop: 10,
padding: "0.5rem 0.7rem",
borderRadius: 6,
background: "rgba(var(--v5-amber-rgb), 0.1)",
color: "var(--v5-amber)",
fontSize: "0.74rem",
fontWeight: 600,
}}>
. .
</div>
)}
</>
);
}
// ─── 푸터 ───
let footer: React.ReactNode = null;
if (stage === "view") {
footer = (
<>
<ModalBtn variant="ghost" onClick={onClose}></ModalBtn>
<ModalBtn
variant="primary"
icon={<KeyRound size={13} strokeWidth={1.75} />}
onClick={() => setStage("reset-warn")}
disabled={!admin.found}
>
</ModalBtn>
</>
);
} else if (stage === "reset-warn") {
footer = (
<>
<ModalBtn
variant="ghost"
icon={<ArrowLeft size={13} strokeWidth={1.75} />}
onClick={() => (initialStage === "view" ? setStage("view") : onClose())}
disabled={mutation.isPending}
>
{initialStage === "view" ? "뒤로" : "취소"}
</ModalBtn>
<ModalBtn
variant="primary"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
icon={<KeyRound size={13} strokeWidth={1.75} />}
>
{mutation.isPending ? "재설정 중..." : "새 비밀번호 발급"}
</ModalBtn>
</>
);
} else if (stage === "reset-done") {
footer = (
<>
{initialStage === "view" && (
<ModalBtn
variant="ghost"
onClick={() => {
setStage("view");
setResult(null);
setShowPw(false);
setPwCopied(false);
refetch();
}}
>
</ModalBtn>
)}
<ModalBtn variant="primary" onClick={onClose}></ModalBtn>
</>
);
}
return (
<ModalShell
title={titleNode}
subtitle={companyName || companyCode}
onClose={onClose}
width={560}
footer={footer}
>
{body}
</ModalShell>
);
}
// ─── UI helpers ───
function Label({ children }: { children: React.ReactNode }) {
return <span style={{ color: "var(--v5-text-sec)", fontSize: "0.75rem", fontWeight: 500, paddingTop: 3 }}>{children}</span>;
}
function Value({ children }: { children: React.ReactNode }) {
return <span style={{ color: "var(--v5-text)", fontWeight: 500, fontSize: "0.8125rem" }}>{children}</span>;
}
function ValueMono({ children }: { children: React.ReactNode }) {
return (
<span style={{ color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)", fontSize: "0.82rem", display: "inline-flex", alignItems: "center", gap: 8 }}>
{children}
</span>
);
}
function PadCenter({ children }: { children: React.ReactNode }) {
return <div style={{ padding: "2rem", textAlign: "center", color: "var(--v5-text-sec)", fontSize: "0.85rem" }}>{children}</div>;
}
function Warn({ children }: { children: React.ReactNode }) {
return <div style={{ padding: "1rem 1.1rem", color: "var(--v5-amber)", fontSize: "0.85rem" }}>{children}</div>;
}
function Badge({ color, children }: { color: "green" | "amber" | "muted"; children: React.ReactNode }) {
const palette = {
green: { bg: "rgba(var(--v5-green-rgb),0.12)", fg: "rgb(var(--v5-green-rgb))" },
amber: { bg: "rgba(var(--v5-amber-rgb),0.15)", fg: "var(--v5-amber)" },
muted: { bg: "var(--v5-bg-subtle)", fg: "var(--v5-text-sec)" },
}[color];
return (
<span style={{
fontSize: "0.72rem",
padding: "3px 8px",
borderRadius: 4,
background: palette.bg,
color: palette.fg,
fontWeight: 600,
}}>{children}</span>
);
}
function CopyInline({ text }: { text: any }) {
const [copied, setCopied] = useState(false);
if (!text) return null;
return (
<button
onClick={() => {
navigator.clipboard?.writeText(String(text));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
title="복사"
style={{
background: "transparent",
border: "1px solid var(--v5-border)",
color: copied ? "rgb(var(--v5-green-rgb))" : "var(--v5-text-muted)",
borderRadius: 4,
padding: "2px 5px",
cursor: "pointer",
fontSize: 10,
}}
>
{copied ? <Check size={11} /> : <Copy size={11} />}
</button>
);
}
function formatDate(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);
}
}
const iconBtnStyle: React.CSSProperties = {
background: "transparent",
border: "1px solid var(--v5-border)",
color: "var(--v5-text-sec)",
borderRadius: 4,
padding: "3px 6px",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
};
const warnBoxStyle: React.CSSProperties = {
display: "flex",
gap: 10,
padding: "0.85rem 0.95rem",
background: "rgba(var(--v5-amber-rgb), 0.1)",
border: "1px solid rgba(var(--v5-amber-rgb), 0.35)",
borderRadius: 8,
marginBottom: "0.2rem",
alignItems: "flex-start",
};