366 lines
10 KiB
TypeScript
366 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import {
|
|
Eye,
|
|
EyeOff,
|
|
RefreshCw,
|
|
Copy,
|
|
Check,
|
|
AlertTriangle,
|
|
Lock,
|
|
Shield,
|
|
KeyRound,
|
|
} from "lucide-react";
|
|
import { genPassword } from "./fields";
|
|
|
|
/**
|
|
* Step 3 · 초기 관리자 계정 (시안 v2 포팅).
|
|
* 단일 테두리 카드 안에 4개 row: USER_ID / INITIAL_PASSWORD / USER_TYPE / FORCE_PW_CHANGE
|
|
*
|
|
* 로직 (유지):
|
|
* - user_id = {db_prefix}_admin (자동)
|
|
* - initial_password 자동 생성, 재생성, 복사, 보기 토글
|
|
* - force_password_change 기본 ON
|
|
* - valid = password 길이 8+
|
|
*/
|
|
export default function Step3Admin({
|
|
state,
|
|
setState,
|
|
onValidChange,
|
|
}: {
|
|
state: Record<string, any>;
|
|
setState: (patch: Record<string, any>) => void;
|
|
onValidChange: (valid: boolean) => void;
|
|
}) {
|
|
const [visible, setVisible] = useState(true);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const userId = state.db_prefix ? `${state.db_prefix}_admin` : "—";
|
|
|
|
useEffect(() => {
|
|
if (!state.initial_password) {
|
|
setState({ initial_password: genPassword(12) });
|
|
}
|
|
if (state.force_password_change === undefined) {
|
|
setState({ force_password_change: true });
|
|
}
|
|
}, []); // eslint-disable-line
|
|
|
|
useEffect(() => {
|
|
onValidChange(!!state.initial_password && state.initial_password.length >= 8);
|
|
}, [state.initial_password, onValidChange]);
|
|
|
|
const pw: string = state.initial_password || "";
|
|
|
|
function regen() {
|
|
setState({ initial_password: genPassword(12) });
|
|
setCopied(false);
|
|
}
|
|
async function copy() {
|
|
try {
|
|
await navigator.clipboard.writeText(pw);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1400);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
const passwordActions = (
|
|
<div style={{ display: "flex", gap: 5 }}>
|
|
<IconBtn title={visible ? "숨기기" : "보기"} onClick={() => setVisible((v) => !v)}>
|
|
{visible ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
</IconBtn>
|
|
<IconBtn title="재생성" onClick={regen}>
|
|
<RefreshCw size={14} />
|
|
</IconBtn>
|
|
<button
|
|
onClick={copy}
|
|
title="복사"
|
|
className={`wiz-btn wiz-btn-${copied ? "primary" : "cyan"}`}
|
|
style={{
|
|
height: 36,
|
|
padding: "0 0.85rem",
|
|
borderRadius: 8,
|
|
border: `1px solid ${copied ? "rgb(var(--v5-green-rgb))" : "var(--v5-cyan)"}`,
|
|
background: copied ? "rgb(var(--v5-green-rgb))" : "var(--v5-cyan)",
|
|
color: "#fff",
|
|
cursor: "pointer",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 5,
|
|
fontSize: "0.78rem",
|
|
fontWeight: 700,
|
|
boxShadow: copied
|
|
? "0 0 8px rgba(var(--v5-green-rgb), 0.25)"
|
|
: "0 0 6px rgba(var(--v5-cyan-rgb), 0.2)",
|
|
fontFamily: "inherit",
|
|
}}
|
|
>
|
|
{copied ? <Check size={13} /> : <Copy size={13} />}
|
|
{copied ? "복사됨" : "복사"}
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="wiz-step"
|
|
style={{
|
|
padding: "1.6rem 1.8rem",
|
|
maxWidth: 920,
|
|
animation: "wizSlideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontSize: "0.72rem",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.18em",
|
|
textTransform: "uppercase",
|
|
color: "var(--v5-cyan)",
|
|
marginBottom: 8,
|
|
fontFamily: "var(--v5-font-mono)",
|
|
}}
|
|
>
|
|
03 · 관리자 계정
|
|
</div>
|
|
<h2 style={{ fontSize: "1.4rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
|
|
초기 관리자 계정
|
|
</h2>
|
|
<div style={{ fontSize: "0.85rem", color: "var(--v5-text-sec)", marginTop: 6, maxWidth: 640, fontWeight: 500, lineHeight: 1.55 }}>
|
|
시스템이 자동으로{" "}
|
|
<code style={{ fontFamily: "var(--v5-font-mono)", color: "var(--v5-primary)", fontSize: "0.82rem" }}>COMPANY_ADMIN</code> 권한의 관리자 계정을
|
|
생성합니다. 생성된 비밀번호는{" "}
|
|
<b style={{ color: "var(--v5-text)" }}>이 화면을 벗어나면 다시 볼 수 없으며</b>, DB 에는 BCrypt 해시만 저장됩니다.
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
marginTop: "1.4rem",
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 11,
|
|
background: "var(--v5-surface-solid)",
|
|
overflow: "hidden",
|
|
boxShadow: "0 0 12px rgba(var(--v5-cyan-rgb), 0.03)",
|
|
}}
|
|
>
|
|
<Row
|
|
label="USER_ID"
|
|
sub="자동 · 읽기전용"
|
|
trailing={<Lock size={12} color="var(--v5-text-muted)" />}
|
|
>
|
|
<ReadBox mono>{userId}</ReadBox>
|
|
</Row>
|
|
|
|
<Row label="INITIAL_PASSWORD" sub="무작위 12자" trailing={passwordActions}>
|
|
<div
|
|
style={{
|
|
padding: "0.6rem 0.8rem",
|
|
background: "rgba(var(--v5-cyan-rgb), 0.05)",
|
|
border: "1px solid rgba(var(--v5-cyan-rgb), 0.28)",
|
|
borderRadius: 7,
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontSize: "1rem",
|
|
fontWeight: 700,
|
|
color: "var(--v5-cyan)",
|
|
letterSpacing: "0.03em",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<KeyRound size={16} />
|
|
<span style={{ flex: 1 }}>{visible ? pw : "•".repeat(pw.length || 12)}</span>
|
|
</div>
|
|
</Row>
|
|
|
|
<Row label="USER_TYPE" sub="고정 · 수정 불가">
|
|
<span
|
|
style={{
|
|
padding: "0.32rem 0.7rem",
|
|
background: "rgba(var(--v5-primary-rgb), 0.1)",
|
|
color: "var(--v5-primary)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontSize: "0.78rem",
|
|
fontWeight: 700,
|
|
borderRadius: 999,
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 5,
|
|
}}
|
|
>
|
|
<Shield size={12} />
|
|
COMPANY_ADMIN
|
|
</span>
|
|
</Row>
|
|
|
|
<Row label="FORCE_PW_CHANGE" sub="권장" last>
|
|
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }}>
|
|
<Switch
|
|
on={!!state.force_password_change}
|
|
onChange={() => setState({ force_password_change: !state.force_password_change })}
|
|
/>
|
|
<span style={{ fontSize: "0.85rem", color: "var(--v5-text)", fontWeight: 500 }}>
|
|
첫 로그인 시 비밀번호 변경 강제
|
|
</span>
|
|
</label>
|
|
</Row>
|
|
</div>
|
|
|
|
{/* 경고 배너 */}
|
|
<div
|
|
style={{
|
|
marginTop: "1.15rem",
|
|
padding: "0.9rem 1.05rem",
|
|
background: "rgba(var(--v5-red-rgb), 0.04)",
|
|
border: "1px solid rgba(var(--v5-red-rgb), 0.25)",
|
|
borderRadius: 9,
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<AlertTriangle size={16} color="var(--v5-red)" style={{ marginTop: 2, flexShrink: 0 }} />
|
|
<div style={{ fontSize: "0.82rem", color: "var(--v5-text)", lineHeight: 1.55, fontWeight: 500 }}>
|
|
<b>이 비밀번호는 다시 볼 수 없습니다.</b>
|
|
<span style={{ color: "var(--v5-text-sec)" }}>
|
|
{" "}
|
|
생성 후 DB 에는 BCrypt 해시만 저장됩니다. 지금 복사하여 안전한 채널 (1Password, Keeper 등) 로 회사에 전달하세요.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({
|
|
label,
|
|
sub,
|
|
children,
|
|
trailing,
|
|
last,
|
|
}: {
|
|
label: string;
|
|
sub?: string;
|
|
children: React.ReactNode;
|
|
trailing?: React.ReactNode;
|
|
last?: boolean;
|
|
}) {
|
|
return (
|
|
<div
|
|
style={{
|
|
padding: "1.05rem 1.25rem",
|
|
display: "grid",
|
|
gridTemplateColumns: "160px 1fr auto",
|
|
alignItems: "center",
|
|
gap: "1.15rem",
|
|
borderBottom: last ? "none" : "1px solid var(--v5-border)",
|
|
}}
|
|
>
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: "0.68rem",
|
|
letterSpacing: "0.1em",
|
|
textTransform: "uppercase",
|
|
color: "var(--v5-text-muted)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontWeight: 700,
|
|
}}
|
|
>
|
|
{label}
|
|
</div>
|
|
{sub && <div style={{ fontSize: "0.72rem", color: "var(--v5-text-muted)", marginTop: 3, fontWeight: 500 }}>{sub}</div>}
|
|
</div>
|
|
<div>{children}</div>
|
|
<div>{trailing}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function IconBtn({
|
|
onClick,
|
|
children,
|
|
title,
|
|
}: {
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
title: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
title={title}
|
|
className="wiz-iconbtn"
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 8,
|
|
border: "1px solid var(--v5-border)",
|
|
background: "var(--v5-surface-solid)",
|
|
color: "var(--v5-text-sec)",
|
|
cursor: "pointer",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ReadBox({ children, mono }: { children: React.ReactNode; mono?: boolean }) {
|
|
return (
|
|
<div
|
|
style={{
|
|
padding: "0.6rem 0.75rem",
|
|
background: "var(--v5-bg-subtle)",
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 7,
|
|
fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
|
|
fontSize: "0.9rem",
|
|
fontWeight: 500,
|
|
color: "var(--v5-text)",
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Switch({ on, onChange }: { on: boolean; onChange: () => void }) {
|
|
return (
|
|
<div
|
|
onClick={onChange}
|
|
style={{
|
|
width: 42,
|
|
height: 22,
|
|
borderRadius: 12,
|
|
background: on ? "var(--v5-cyan)" : "var(--v5-bg-subtle)",
|
|
border: `1px solid ${on ? "var(--v5-cyan)" : "var(--v5-border)"}`,
|
|
position: "relative",
|
|
transition: "all 0.25s cubic-bezier(0.4,0,0.2,1)",
|
|
boxShadow: on ? "0 0 6px rgba(var(--v5-cyan-rgb), 0.2)" : "none",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: 1,
|
|
left: on ? 21 : 1,
|
|
width: 18,
|
|
height: 18,
|
|
borderRadius: "50%",
|
|
background: "#fff",
|
|
transition: "left 0.25s cubic-bezier(0.4,0,0.2,1)",
|
|
boxShadow: "0 1px 3px rgba(0,0,0,0.25)",
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|