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

266 lines
6.8 KiB
TypeScript

"use client";
import { Loader2, CheckCircle2, XCircle } from "lucide-react";
/**
* 마법사 전용 공용 입력 컴포넌트 (시안 v2 포팅).
* - Field: label + hint + child input wrapper
* - TextInput: 텍스트 입력 (mono, prefix/suffix, status 테두리 글로우)
* - CheckAvailBadge: subdomain/db_prefix 실시간 검증 인디케이터
*/
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "invalid";
export function Field({
label,
hint,
required,
full,
error,
children,
}: {
label: string;
hint?: React.ReactNode;
required?: boolean;
full?: boolean;
error?: string;
children: React.ReactNode;
}) {
return (
<div style={{ gridColumn: full ? "1 / -1" : "auto", display: "flex", flexDirection: "column", gap: 5 }}>
<div
style={{
fontSize: "0.75rem",
color: error ? "var(--v5-red)" : "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
letterSpacing: "0.08em",
textTransform: "uppercase",
marginBottom: 3,
display: "flex",
alignItems: "center",
gap: 6,
fontWeight: 600,
}}
>
<span>{label}</span>
{required && (
<span style={{ color: "var(--v5-red)", fontFamily: "sans-serif", fontSize: "0.9rem" }}></span>
)}
{hint && (
<span
style={{
color: "var(--v5-text-muted)",
fontFamily: "inherit",
textTransform: "none",
letterSpacing: 0,
marginLeft: "auto",
fontSize: "0.72rem",
fontWeight: 500,
}}
>
{hint}
</span>
)}
</div>
{children}
{error && (
<div style={{ fontSize: "0.72rem", color: "var(--v5-red)", fontFamily: "var(--v5-font-mono)" }}>{error}</div>
)}
</div>
);
}
type TextInputStatus = "ok" | "err" | "warn";
export function TextInput({
value,
onChange,
placeholder,
mono,
prefix,
suffix,
readOnly,
type = "text",
status,
size = "md",
}: {
value: string;
onChange?: (v: string) => void;
placeholder?: string;
mono?: boolean;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
readOnly?: boolean;
type?: string;
status?: TextInputStatus;
size?: "sm" | "md";
}) {
const border =
status === "ok"
? "rgb(var(--v5-green-rgb))"
: status === "err"
? "var(--v5-red)"
: status === "warn"
? "var(--v5-amber)"
: "var(--v5-border)";
const glow =
status === "ok"
? "0 0 0 3px rgba(var(--v5-green-rgb), 0.12)"
: status === "err"
? "0 0 0 3px rgba(var(--v5-red-rgb), 0.15)"
: status === "warn"
? "0 0 0 3px rgba(var(--v5-amber-rgb), 0.12)"
: "none";
return (
<div
style={{
display: "flex",
alignItems: "stretch",
border: `1px solid ${border}`,
borderRadius: 7,
background: readOnly ? "var(--v5-bg-subtle)" : "var(--v5-surface-solid)",
overflow: "hidden",
boxShadow: glow,
transition: "all 0.18s ease",
}}
>
{prefix && (
<div
style={{
display: "flex",
alignItems: "center",
padding: "0 0.65rem",
background: "var(--v5-bg-subtle)",
borderRight: "1px solid var(--v5-border)",
fontFamily: "var(--v5-font-mono)",
fontSize: "0.78rem",
color: "var(--v5-text-muted)",
fontWeight: 500,
}}
>
{prefix}
</div>
)}
<input
type={type}
value={value || ""}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
readOnly={readOnly}
style={{
flex: 1,
padding: size === "sm" ? "0.4rem 0.55rem" : "0.55rem 0.7rem",
background: "transparent",
border: 0,
outline: "none",
fontSize: size === "sm" ? "0.78rem" : "0.88rem",
color: "var(--v5-text)",
fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
}}
/>
{suffix && (
<div
style={{
display: "flex",
alignItems: "center",
padding: "0 0.65rem",
background: "var(--v5-bg-subtle)",
borderLeft: "1px solid var(--v5-border)",
fontFamily: "var(--v5-font-mono)",
fontSize: "0.78rem",
color: "var(--v5-text-muted)",
fontWeight: 500,
}}
>
{suffix}
</div>
)}
</div>
);
}
export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?: string }) {
if (!value || status === "idle")
return <span style={{ fontSize: "0.72rem", color: "var(--v5-text-muted)" }}> </span>;
if (status === "checking")
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
fontSize: "0.72rem",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
fontWeight: 500,
}}
>
<Loader2 size={12} className="spin" />
</span>
);
if (status === "available")
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: "0.72rem",
color: "rgb(var(--v5-green-rgb))",
fontWeight: 600,
}}
>
<CheckCircle2 size={13} />
</span>
);
if (status === "taken")
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: "0.72rem",
color: "var(--v5-red)",
fontWeight: 600,
}}
>
<XCircle size={13} />
</span>
);
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: "0.72rem",
color: "var(--v5-amber)",
fontWeight: 600,
}}
>
<XCircle size={13} />
</span>
);
}
/** TextInput 의 status prop 과 매핑 */
export function availToInputStatus(a: AvailStatus): TextInputStatus | undefined {
if (a === "available") return "ok";
if (a === "taken" || a === "invalid") return "err";
return undefined;
}
export function genPassword(length = 12): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789@#$";
const arr = new Uint32Array(length);
if (typeof window !== "undefined" && window.crypto) {
window.crypto.getRandomValues(arr);
}
let out = "";
for (let i = 0; i < length; i++) {
out += chars[arr[i] % chars.length];
}
return out;
}