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>
371 lines
12 KiB
TypeScript
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",
|
|
};
|