import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ArrowLeft, BadgeCheck, Building2, Copy, Eye, EyeOff, History, Key, Loader2, Mail, Plus, RefreshCw, Save, Shield, Trash2, Users, } from "lucide-react"; import * as React from "react"; import { type FieldErrors, type UseFormRegister, useForm, } from "react-hook-form"; import { useNavigate, useParams } from "react-router-dom"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../../components/ui/card"; import { Checkbox } from "../../components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { Switch } from "../../components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "../../components/ui/tabs"; import { toast } from "../../components/ui/use-toast"; import { type TenantSummary, type UserAppointment, type UserUpdateRequest, createTenant, deleteUser, fetchMe, fetchPasswordPolicy, fetchTenant, fetchTenants, fetchUser, fetchUserRpHistory, updateUser, } from "../../lib/adminApi"; import type { PasswordPolicyResponse } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { generateSecurePassword } from "../../lib/utils"; import { type OrgChartTenantSelection, buildAuthenticatedOrgChartTenantPickerUrl, filterNonHanmacFamilyTenants, isHanmacFamilyTenant, isHanmacFamilyUser, parseOrgChartTenantSelection, } from "./orgChartPicker"; type UserSchemaField = { key: string; label?: string; type?: "text" | "number" | "boolean" | "date"; required?: boolean; adminOnly?: boolean; validation?: string; isLoginId?: boolean; }; type UserFormValues = Omit & { metadata: Record>; }; type UserType = "hanmac" | "external" | "personal"; type PasswordResetMode = "generated" | "manual"; type PickerTarget = { kind: "appointment"; index: number }; type AppointmentDraft = UserAppointment & { draftId: string; }; const PASSWORD_RESET_MIN_LENGTH = 12; function createDraftId() { return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`; } async function resolveTenantSelection( selection: OrgChartTenantSelection, tenants: TenantSummary[], ) { const cached = tenants.find((tenant) => tenant.id === selection.id); if (cached) { return { id: cached.id, name: cached.name, slug: cached.slug, }; } const tenant = await fetchTenant(selection.id); return { id: tenant.id, name: tenant.name, slug: tenant.slug, }; } function createEmptyAppointment(): AppointmentDraft { return { draftId: createDraftId(), tenantId: "", tenantName: "", tenantSlug: "", isOwner: false, jobTitle: "", position: "", }; } function validateManualPassword( password: string, policy?: PasswordPolicyResponse, ) { if (password.trim().length === 0) { return t( "msg.admin.users.detail.password_manual_required", "비밀번호를 입력해 주세요.", ); } const minLength = policy?.minLength ?? PASSWORD_RESET_MIN_LENGTH; if (password.length < minLength) { return t( "msg.userfront.reset.error.min_length", "비밀번호는 최소 {{count}}자 이상이어야 합니다.", { count: String(minLength) }, ); } const hasLower = /[a-z]/.test(password); const hasUpper = /[A-Z]/.test(password); const hasNumber = /[0-9]/.test(password); const hasSymbol = /[\W_]/.test(password); let typeCount = 0; if (hasLower) typeCount++; if (hasUpper) typeCount++; if (hasNumber) typeCount++; if (hasSymbol) typeCount++; const minTypes = policy?.minCharacterTypes ?? 0; if (minTypes > 0 && typeCount < minTypes) { return t( "msg.userfront.reset.error.min_types", "비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.", { count: String(minTypes) }, ); } if ((policy?.lowercase ?? true) && !hasLower) { return t( "msg.userfront.reset.error.lowercase", "최소 1개 이상의 소문자를 포함해야 합니다.", ); } if ((policy?.uppercase ?? false) && !hasUpper) { return t( "msg.userfront.reset.error.uppercase", "최소 1개 이상의 대문자를 포함해야 합니다.", ); } if ((policy?.number ?? true) && !hasNumber) { return t( "msg.userfront.reset.error.number", "최소 1개 이상의 숫자를 포함해야 합니다.", ); } if ((policy?.nonAlphanumeric ?? true) && !hasSymbol) { return t( "msg.userfront.reset.error.symbol", "최소 1개 이상의 특수문자를 포함해야 합니다.", ); } return null; } function TenantMetadataFields({ tenant, schema, register, errors, }: { tenant: { id: string; name: string; slug: string }; schema: UserSchemaField[]; register: UseFormRegister; errors: FieldErrors; }) { if (schema.length === 0) return null; return (
{tenant.name}
{tenant.slug}
{schema.map((field) => (
{( errors.metadata as unknown as Record< string, Record > )?.[tenant.id]?.[field.key] && (

{ ( errors.metadata as unknown as Record< string, Record > )?.[tenant.id]?.[field.key]?.message }

)}
))}
); } function UserDetailPage() { const params = useParams<{ id: string }>(); const userId = params.id ?? ""; const navigate = useNavigate(); const queryClient = useQueryClient(); const [error, setError] = React.useState(null); const [successMsg, setSuccessMsg] = React.useState(null); const [isPasswordResetOpen, setIsPasswordResetOpen] = React.useState(false); const [generatedPassword, setGeneratedPassword] = React.useState< string | null >(null); const [passwordResetMode, setPasswordResetMode] = React.useState("generated"); const [manualPassword, setManualPassword] = React.useState(""); const [manualPasswordConfirm, setManualPasswordConfirm] = React.useState(""); const [isManualPasswordVisible, setIsManualPasswordVisible] = React.useState(false); const [passwordResetError, setPasswordResetError] = React.useState< string | null >(null); const [isHanmacFamily, setIsHanmacFamily] = React.useState(false); const [userType, setUserType] = React.useState("external"); const [additionalAppointments, setAdditionalAppointments] = React.useState< AppointmentDraft[] >([]); const [pickerTarget, setPickerTarget] = React.useState( null, ); const [isResolvingTenant, setIsResolvingTenant] = React.useState(false); const [activeTab, setActiveTab] = React.useState("info"); const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, }); const { data: user, isLoading, isError, } = useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId), enabled: userId.length > 0 && userId !== "new", }); const { data: tenantsData } = useQuery({ queryKey: ["tenants", { limit: 100 }], queryFn: () => fetchTenants(100, 0), }); const tenants = React.useMemo( () => tenantsData?.items ?? [], [tenantsData?.items], ); const rpHistoryQuery = useQuery({ queryKey: ["user-rp-history", userId], queryFn: () => fetchUserRpHistory(userId), enabled: !!userId && userId !== "new", }); const { data: passwordPolicy } = useQuery({ queryKey: ["password-policy"], queryFn: fetchPasswordPolicy, }); const { register, handleSubmit, reset, watch, setValue, formState: { errors }, } = useForm({ defaultValues: { name: "", phone: "", role: "user", status: "active", tenantSlug: "", department: "", position: "", jobTitle: "", metadata: {}, }, }); const isAdmin = profile?.role === "super_admin" || profile?.role === "tenant_admin"; const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id); const watchedStatus = watch("status"); const resetMutation = useMutation({ mutationFn: (newPass: string) => updateUser(userId, { password: newPass }), onSuccess: (_, newPass) => { setGeneratedPassword(newPass); setPasswordResetError(null); toast.success( t( "msg.admin.users.detail.password_generated", "사용자 비밀번호가 성공적으로 재설정되었습니다.", ), ); }, onError: (err: AxiosError<{ error?: string }>) => { const message = err.response?.data?.error || t( "msg.admin.users.detail.update_error", "수정에 실패했습니다.", ); setPasswordResetError(message); toast.error(message); }, }); const handleOpenPasswordReset = () => { if (isSelf) return; setIsPasswordResetOpen(true); setGeneratedPassword(null); setPasswordResetMode("generated"); setManualPassword(""); setManualPasswordConfirm(""); setIsManualPasswordVisible(false); setPasswordResetError(null); }; const handleClosePasswordReset = () => { setIsPasswordResetOpen(false); setGeneratedPassword(null); setPasswordResetError(null); }; const handleExecutePasswordReset = () => { if (isSelf) return; let newPass = manualPassword; if (passwordResetMode === "manual") { const vErr = validateManualPassword(manualPassword, passwordPolicy); if (vErr) { setPasswordResetError(vErr); return; } if (manualPassword !== manualPasswordConfirm) { setPasswordResetError( t( "msg.userfront.reset.error.mismatch", "비밀번호가 일치하지 않습니다.", ), ); return; } } else { newPass = generateSecurePassword(); } resetMutation.mutate(newPass); }; const hanmacFamilyTenantId = React.useMemo(() => { const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID; if (typeof envTenantId === "string" && envTenantId.trim()) { return envTenantId.trim(); } return ( tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "" ); }, [tenants]); const personalTenant = React.useMemo( () => tenants.find( (tenant) => tenant.slug === "personal" || (tenant.type === "PERSONAL" && tenant.name.toLowerCase() === "personal"), ), [tenants], ); const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( import.meta.env.ORGFRONT_URL, { tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined, }, ); const applyTenantSelection = React.useCallback( async (selection: OrgChartTenantSelection, target: PickerTarget) => { setIsResolvingTenant(true); try { const tenant = await resolveTenantSelection(selection, tenants); setAdditionalAppointments((current) => current.map((appointment, index) => index === target.index ? { ...appointment, tenantId: tenant.id, tenantName: tenant.name, tenantSlug: tenant.slug, } : appointment, ), ); setPickerTarget(null); } catch (_) { toast.error( t( "msg.admin.users.detail.tenant_resolve_failed", "선택한 테넌트 정보를 불러오지 못했습니다.", ), ); } finally { setIsResolvingTenant(false); } }, [tenants], ); React.useEffect(() => { if (!pickerTarget) return; const onMessage = (event: MessageEvent) => { const selection = parseOrgChartTenantSelection(event.data); if (!selection) return; void applyTenantSelection(selection, pickerTarget); }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [applyTenantSelection, pickerTarget]); const addAppointment = () => { setAdditionalAppointments((current) => [ ...current, createEmptyAppointment(), ]); }; const updateAppointment = ( index: number, patch: Partial, ) => { setAdditionalAppointments((current) => current.map((appointment, currentIndex) => currentIndex === index ? { ...appointment, ...patch } : appointment, ), ); }; const removeAppointment = (index: number) => { setAdditionalAppointments((current) => current.filter((_, currentIndex) => currentIndex !== index), ); }; const handleUserTypeChange = (value: string) => { const nextType = value as UserType; setUserType(nextType); setIsHanmacFamily(nextType === "hanmac"); if (nextType !== "hanmac") { setAdditionalAppointments([]); } }; const ensurePersonalTenant = async () => { if (personalTenant) return personalTenant; const tenant = await createTenant({ name: "Personal", slug: "personal", type: "PERSONAL", status: "active", }); queryClient.invalidateQueries({ queryKey: ["tenants"] }); return tenant; }; React.useEffect(() => { if (user) { const metadata = (user.metadata ?? {}) as Record; const rawAppointments = metadata.additionalAppointments; const primaryFromMetadata = typeof metadata.primaryTenantId === "string" ? { id: metadata.primaryTenantId, name: typeof metadata.primaryTenantName === "string" ? metadata.primaryTenantName : user.tenant?.name || user.companyCode || "", slug: user.companyCode, } : null; const fallbackAppointment = primaryFromMetadata ?? (user.tenant ? { id: user.tenant.id, name: user.tenant.name, slug: user.tenant.slug, } : null); reset({ name: user.name, phone: user.phone || "", role: user.role, status: user.status, tenantSlug: user.companyCode || user.joinedTenants?.find( (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP", )?.slug || "", department: user.department || "", position: user.position || "", jobTitle: user.jobTitle || "", metadata: (user.metadata as unknown as Record< string, Record >) || {}, }); const isUserHanmacFamily = isHanmacFamilyUser( user, tenants, hanmacFamilyTenantId, ); const resolvedUserType = metadata.userType === "personal" || user.companyCode === "personal" ? "personal" : isUserHanmacFamily ? "hanmac" : "external"; setUserType(resolvedUserType); setIsHanmacFamily(resolvedUserType === "hanmac"); const familyFallbackTenants = [ ...(user.joinedTenants ?? []), ...(user.tenant ? [user.tenant] : []), ].filter( (tenant, index, allTenants) => allTenants.findIndex((item) => item.id === tenant.id) === index && isHanmacFamilyTenant( tenant, tenants, hanmacFamilyTenantId, ), ); setAdditionalAppointments( Array.isArray(rawAppointments) ? (rawAppointments as UserAppointment[]).map( (appointment) => ({ ...appointment, draftId: createDraftId(), }), ) : isUserHanmacFamily ? familyFallbackTenants.length > 0 ? familyFallbackTenants.map((tenant) => ({ draftId: createDraftId(), tenantId: tenant.id, tenantName: tenant.name, tenantSlug: tenant.slug, isOwner: metadata.primaryTenantIsOwner === true && tenant.id === fallbackAppointment?.id, jobTitle: user.jobTitle, position: user.position, })) : fallbackAppointment ? [ { draftId: createDraftId(), tenantId: fallbackAppointment.id, tenantName: fallbackAppointment.name, tenantSlug: fallbackAppointment.slug, isOwner: metadata.primaryTenantIsOwner === true, jobTitle: user.jobTitle, position: user.position, }, ] : [] : [], ); } }, [hanmacFamilyTenantId, tenants, user, reset]); const mutation = useMutation({ mutationFn: (data: UserUpdateRequest) => updateUser(userId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["users"] }); queryClient.invalidateQueries({ queryKey: ["user", userId] }); toast.success(t("msg.info.saved_success", "저장되었습니다.")); }, onError: (err: AxiosError<{ error?: string }>) => { toast.error( err.response?.data?.error || t("err.common.unknown", "오류가 발생했습니다."), ); }, }); const deleteMutation = useMutation({ mutationFn: () => deleteUser(userId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["users"] }); toast.success( t( "msg.admin.users.detail.delete_success", "사용자가 삭제되었습니다.", ), ); navigate("/users"); }, }); const onSubmit = async (data: UserFormValues) => { // Filter out undefined/null/empty strings from metadata const cleanMetadata = Object.fromEntries( Object.entries(data.metadata).map(([tenantId, fields]) => { const cleanFields = Object.fromEntries( Object.entries(fields).filter( ([_, v]) => v !== undefined && v !== null && v !== "", ), ); return [tenantId, cleanFields]; }), ); const metadata: Record = { ...cleanMetadata, hanmacFamily: userType === "hanmac" && isHanmacFamily, userType, }; const profileData = { ...data }; profileData.role = undefined; const payload: UserUpdateRequest = { ...profileData, metadata, }; if (userType === "personal") { try { const tenant = await ensurePersonalTenant(); payload.tenantSlug = tenant.slug; payload.department = undefined; payload.position = undefined; payload.jobTitle = undefined; payload.metadata = { ...metadata, personalTenantId: tenant.id, }; } catch (_) { toast.error("Personal 테넌트를 준비하지 못했습니다."); return; } } if (userType === "hanmac") { const appointments = additionalAppointments .filter((appointment) => appointment.tenantId) .map((appointment) => ({ tenantId: appointment.tenantId, tenantSlug: appointment.tenantSlug, tenantName: appointment.tenantName, isOwner: appointment.isOwner, jobTitle: appointment.jobTitle, position: appointment.position, })); payload.tenantSlug = undefined; payload.department = undefined; payload.position = undefined; payload.jobTitle = undefined; payload.additionalAppointments = appointments; payload.metadata = { ...metadata, additionalAppointments: appointments, }; } mutation.mutate(payload); }; const handleDelete = () => { if ( window.confirm( t("msg.admin.users.detail.delete_confirm", "삭제하시겠습니까?"), ) ) { deleteMutation.mutate(); } }; const userAffiliatedTenants = React.useMemo(() => { const joined = user?.joinedTenants || []; const primary = user?.tenant; const all = [...joined]; if (primary && !joined.some((t) => t.id === primary.id)) { all.unshift(primary); } return all; }, [user?.joinedTenants, user?.tenant]); const selectableRepresentativeTenants = React.useMemo( () => filterNonHanmacFamilyTenants( userAffiliatedTenants, hanmacFamilyTenantId, ), [userAffiliatedTenants, hanmacFamilyTenantId], ); if (isLoading) { return (
); } if (isError || !user) { return (

{t( "msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.", )}

); } return (
{/* Header with back button and actions */}
{isAdmin && ( )}
{/* User Quick Summary Header */}

{user.name}

{user.tenant?.name || user.companyCode || user.joinedTenants?.find( (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP", )?.name || t( "ui.admin.users.detail.form.tenant_global", "시스템 전역", )} {t( `ui.common.status.${user.status}`, user.status, )}
{user.email}
{user.phone && (
{user.phone}
)}

{t("ui.admin.users.detail.created_at", "가입일")}:{" "} {user.createdAt}

{t("ui.admin.users.detail.updated_at", "최근 수정")}:{" "} {user.updatedAt}

{t("ui.admin.users.detail.tabs.info", "기본 정보")} {t( "ui.admin.users.detail.tabs.tenants", "테넌트 프로필", )} {t( "ui.admin.users.detail.tabs.security", "보안 & 활동", )}
{t( "ui.admin.users.detail.edit_title", "프로필 정보", )} {t( "msg.admin.users.detail.edit_subtitle", "{{email}} 계정의 정보를 수정합니다.", { email: user.email }, )}
setValue( "status", checked ? "active" : "inactive", ) } /> {t( `ui.common.status.${watchedStatus}`, watchedStatus || "inactive", )}
한맥가족 구성원 외부 기업 회원 개인 회원 {userType === "external" && (

*{" "} {t( "msg.admin.users.detail.tenant_slug_help", "사용자의 주된 정체성을 결정하는 대표 조직을 지정합니다.", )}

)} {userType === "hanmac" && (

{t( "ui.admin.users.detail.form.additional_appointments", "소속별 직급/직무", )}

{t( "msg.admin.users.detail.form.additional_appointments_help", "테넌트별 조직장 여부, 직무, 직급을 입력합니다.", )}

{additionalAppointments.map( (appointment, index) => (
{appointment.tenantSlug && ( { appointment.tenantSlug } )}
updateAppointment( index, { jobTitle: event .target .value, }, ) } />
updateAppointment( index, { position: event .target .value, }, ) } />
), )}
)} {userType === "personal" && (
{personalTenant ? `Personal (${personalTenant.slug})` : "Personal 테넌트로 생성됩니다."}
)} {userType === "external" && (
)}
{t( "ui.admin.users.detail.custom_fields.multi_title", "테넌트별 상세 프로필", )} {t( "msg.admin.users.detail.tenants_desc", "각 테넌트별로 정의된 커스텀 스키마 정보를 관리합니다.", )} {userAffiliatedTenants.length === 0 ? (

{t( "msg.admin.users.detail.no_tenants", "소속된 테넌트 정보가 없습니다.", )}

) : (
{userAffiliatedTenants.map((t) => { const tDetail = tenants.find( (tenant) => tenant.id === t.id, ); const schema = (tDetail?.config ?.userSchema || []) as UserSchemaField[]; return ( ); })}
)}
{t( "ui.admin.users.detail.password_title", "비밀번호 관리", )} {t( "msg.admin.users.detail.security_desc", "비밀번호 초기화 및 보안 설정을 관리합니다.", )}

{t( "ui.admin.users.detail.reset_password_label", "비밀번호 초기화", )}

{t( "msg.admin.users.detail.reset_password_help", "안전한 새 비밀번호로 교체합니다.", )}

{isSelf && (

{t( "msg.admin.users.detail.self_password_reset_blocked", "보안을 위해 본인 계정은 사용자 포털에서만 변경 가능합니다.", )}

)} {isPasswordResetOpen && !generatedPassword && !isSelf && (
setPasswordResetMode( v as PasswordResetMode, ) } > {t( "ui.admin.users.detail.reset_auto", "자동 생성", )} {t( "ui.admin.users.detail.reset_manual", "직접 입력", )}
{t( "msg.admin.users.detail.reset_auto_desc", "해킹이 어려운 복잡한 임시 비밀번호를 시스템이 즉시 생성합니다.", )}
setManualPassword( e.target .value, ) } className="h-11 rounded-xl shadow-sm pr-12" />
setManualPasswordConfirm( e.target .value, ) } className="h-11 rounded-xl shadow-sm" />
{passwordResetError && (
{passwordResetError}
)}
)} {generatedPassword && (
{t( "ui.admin.users.detail.password_done", "성공적으로 초기화됨", )}
{generatedPassword}
)}
{t( "ui.admin.users.detail.history_title", "서비스 이용 내역", )} {t( "msg.admin.users.detail.history_desc", "최근 로그인한 연동 서비스(RP) 목록입니다.", )} {rpHistoryQuery.isLoading ? (
{t( "msg.common.loading", "불러오는 중...", )}
) : !rpHistoryQuery.data || rpHistoryQuery.data.length === 0 ? (

{t( "msg.admin.users.detail.no_history", "아직 이용한 서비스가 없습니다.", )}

) : (
{rpHistoryQuery.data.map((item) => (
{item.client_name || item.client_id} {item.client_id}
{item.lastLoginAt}
))}
)}
{ if (!open) setPickerTarget(null); }} > {t( "ui.admin.users.detail.form.pick_tenant", "테넌트 선택", )} {t( "msg.admin.users.detail.form.picker_description", "org-chart에서 테넌트를 선택하면 사용자 소속에 반영됩니다.", )}