Files
invyone/frontend/components/admin/provisioning/wizard/Step3Admin.tsx
T
gbpark 8c861144dc
Build & Deploy to K8s / build-and-deploy (push) Successful in 6s
서브도메인 배포 작업
2026-04-24 17:49:16 +09:00

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>
);
}