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, X, } 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../../components/ui/select"; import { Switch } from "../../components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "../../components/ui/tabs"; import { toast } from "../../components/ui/use-toast"; import type { PasswordPolicyResponse } from "../../lib/adminApi"; import { deleteUser, fetchAllTenants, fetchMe, fetchPasswordPolicy, fetchTenant, fetchUser, fetchUserRpHistory, type TenantSummary, type UserAppointment, type UserUpdateRequest, updateUser, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { normalizeAdminRole } from "../../lib/roles"; import { generateSecurePassword } from "../../lib/utils"; import { buildAuthenticatedOrgChartTenantPickerUrl, filterNonHanmacFamilyTenants, getTenantGradeOptions, isHanmacFamilyTenant, isHanmacFamilyUser, type OrgChartTenantSelection, parseOrgChartTenantSelection, } from "./orgChartPicker"; import type { UserSchemaField } from "./userSchemaFields"; import { normalizeUserStatusValue, userStatusLabel, userStatusValues, } from "./userStatus"; import { resolvePersonalTenant } from "./utils/personalTenant"; type UserFormValues = Omit & { email: string; metadata: Record & { employee_id?: string; sub_email?: string | string[]; }; }; type UserCategory = "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 isMetadataRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function cleanMetadataValue(value: unknown): unknown { if (Array.isArray(value)) { return value .filter((item): item is string => typeof item === "string") .map((item) => item.trim()) .filter(Boolean); } if (isMetadataRecord(value)) { return Object.fromEntries( Object.entries(value).filter( ([_, fieldValue]) => fieldValue !== undefined && fieldValue !== null && fieldValue !== "", ), ); } return value; } function normalizeEmployeeIDMetadataValue(value: unknown) { if (typeof value === "string" || typeof value === "number") { return String(value).trim(); } if (!isMetadataRecord(value)) { return ""; } const entries = Object.entries(value) .map(([key, fieldValue]) => ({ index: Number(key), value: typeof fieldValue === "string" ? fieldValue : "", })) .filter((entry) => Number.isInteger(entry.index) && entry.value.length > 0) .sort((a, b) => a.index - b.index); if (entries.length === 0) { return ""; } return entries .map((entry) => entry.value) .join("") .trim(); } function normalizeSubEmails(value: unknown): string[] { if (Array.isArray(value)) { return value .filter((item): item is string => typeof item === "string") .map((item) => item.trim()) .filter((item) => item.includes("@")); } if (typeof value === "string" && value.trim() !== "") { return value .split(/[;,\n\r\t]/) .map((email) => email.trim()) .filter((email) => email.includes("@")); } return []; } 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: "", isPrimary: false, isOwner: false, isAdmin: false, isManager: false, grade: "", 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 [userCategory, setUserCategory] = 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", "all"], queryFn: () => fetchAllTenants(), }); 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: "", grade: "", position: "", jobTitle: "", metadata: {}, }, }); const profileRole = normalizeAdminRole(profile?.role); const isAdmin = profileRole === "super_admin" || profileRole === "tenant_admin"; const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id); const watchedStatus = watch("status"); const [newSubEmail, setNewSubEmail] = React.useState(""); const currentSubEmails = (watch("metadata.sub_email") as string[]) || []; const handleAddSubEmail = (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === "," || e.key === " ") { e.preventDefault(); const value = newSubEmail.trim().replace(/,/g, ""); if (value?.includes("@") && !currentSubEmails.includes(value)) { setValue("metadata.sub_email", [...currentSubEmails, value], { shouldDirty: true, }); setNewSubEmail(""); } } }; const handleRemoveSubEmail = (emailToRemove: string) => { setValue( "metadata.sub_email", currentSubEmails.filter((e) => e !== emailToRemove), { shouldDirty: true }, ); }; 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( () => resolvePersonalTenant(tenants), [tenants], ); const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( import.meta.env.ORGFRONT_URL, { tenantId: userCategory === "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) => { if (currentIndex === index) { return { ...appointment, ...patch }; } if (patch.isPrimary === true) { return { ...appointment, isPrimary: false }; } return appointment; }), ); }; const removeAppointment = (index: number) => { setAdditionalAppointments((current) => current.filter((_, currentIndex) => currentIndex !== index), ); }; const _setPrimaryAppointment = (targetIndex: number) => { setAdditionalAppointments((current) => current.map((appointment, index) => ({ ...appointment, isPrimary: index === targetIndex, })), ); }; const handleUserCategoryChange = (value: string) => { const nextCategory = value as UserCategory; setUserCategory(nextCategory); if (nextCategory !== "hanmac") { setAdditionalAppointments([]); } }; const ensurePersonalTenant = async () => { return personalTenant; }; 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.tenantSlug || "", slug: user.tenantSlug, } : null; const fallbackAppointment = primaryFromMetadata ?? (user.tenant ? { id: user.tenant.id, name: user.tenant.name, slug: user.tenant.slug, } : null); reset({ email: user.email || "", name: user.name, phone: user.phone || "", role: user.role, status: normalizeUserStatusValue(user.status), tenantSlug: user.tenantSlug || user.joinedTenants?.find( (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP", )?.slug || "", department: user.department || "", grade: user.grade || "", position: user.position || "", jobTitle: user.jobTitle || "", metadata: { ...((user.metadata as unknown as Record< string, Record >) || {}), employee_id: normalizeEmployeeIDMetadataValue( user.metadata?.employee_id, ), sub_email: Array.isArray(user.metadata?.sub_email) ? user.metadata.sub_email : typeof user.metadata?.sub_email === "string" ? user.metadata.sub_email .split(/[;,\n\r\t]/) .map((e) => e.trim()) .filter((e) => e.includes("@")) : [], } as UserFormValues["metadata"], }); const isUserHanmacFamily = isHanmacFamilyUser( user, tenants, hanmacFamilyTenantId, ); const isPersonalUser = user.tenantSlug === personalTenant.slug || user.tenant?.id === personalTenant.id || user.tenant?.slug === personalTenant.slug || metadata.personalTenantId === personalTenant.id; const resolvedUserCategory = isPersonalUser ? "personal" : isUserHanmacFamily ? "hanmac" : "external"; setUserCategory(resolvedUserCategory); 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, isPrimary: appointment.isPrimary === true || appointment.tenantId === primaryFromMetadata?.id, isOwner: appointment.isOwner === true, isAdmin: appointment.isAdmin === true, isManager: appointment.isManager === true, draftId: createDraftId(), })) : isUserHanmacFamily ? familyFallbackTenants.length > 0 ? familyFallbackTenants.map((tenant) => ({ draftId: createDraftId(), tenantId: tenant.id, tenantName: tenant.name, tenantSlug: tenant.slug, isPrimary: tenant.id === fallbackAppointment?.id, isOwner: metadata.primaryTenantIsOwner === true && tenant.id === fallbackAppointment?.id, isAdmin: false, isManager: false, grade: user.grade, jobTitle: user.jobTitle, position: user.position, })) : fallbackAppointment ? [ { draftId: createDraftId(), tenantId: fallbackAppointment.id, tenantName: fallbackAppointment.name, tenantSlug: fallbackAppointment.slug, isPrimary: true, isOwner: metadata.primaryTenantIsOwner === true, isAdmin: false, isManager: false, grade: user.grade, jobTitle: user.jobTitle, position: user.position, }, ] : [] : [], ); } }, [hanmacFamilyTenantId, personalTenant, 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) => { const cleanMetadata = Object.fromEntries( Object.entries(data.metadata ?? {}).flatMap(([key, value]) => { const cleanedValue = cleanMetadataValue(value); if ( cleanedValue === undefined || cleanedValue === null || cleanedValue === "" ) { return []; } return [[key, cleanedValue]]; }), ); const { hanmacFamily: _hanmacFamily, userType: _userType, sub_email: rawSubEmail, ...safeMetadata } = cleanMetadata; const subEmail = normalizeSubEmails(rawSubEmail); const metadata: Record = { ...safeMetadata, ...(subEmail.length > 0 ? { sub_email: subEmail } : { sub_email: [] }), }; const employeeID = String(data.metadata?.employee_id ?? "").trim(); if (employeeID) { metadata.employee_id = employeeID; } else { delete metadata.employee_id; } const payload: UserUpdateRequest = { ...data, metadata, }; if (profileRole !== "super_admin") { delete payload.email; } else { payload.email = data.email.trim(); } payload.role = undefined; if (userCategory === "personal") { try { const tenant = await ensurePersonalTenant(); payload.tenantSlug = tenant.slug; payload.department = undefined; payload.grade = undefined; payload.position = undefined; payload.jobTitle = undefined; payload.metadata = { ...metadata, personalTenantId: tenant.id, }; } catch (_) { toast.error("Personal 테넌트를 준비하지 못했습니다."); return; } } if (userCategory === "hanmac") { const appointments = additionalAppointments .filter((appointment) => appointment.tenantId) .map((appointment) => ({ tenantId: appointment.tenantId, tenantSlug: appointment.tenantSlug, tenantName: appointment.tenantName, isPrimary: appointment.isPrimary === true, ...(appointment.isOwner === true ? { isOwner: true } : {}), ...(appointment.isAdmin === true ? { isAdmin: true } : {}), ...(appointment.isManager === true ? { isManager: true } : {}), grade: appointment.grade, jobTitle: appointment.jobTitle, position: appointment.position, })); const primary = appointments.find((a) => a.isPrimary); if (primary) { payload.tenantSlug = primary.tenantSlug; payload.primaryTenantId = primary.tenantId; payload.primaryTenantName = primary.tenantName; metadata.primaryTenantId = primary.tenantId; metadata.primaryTenantSlug = primary.tenantSlug; metadata.primaryTenantName = primary.tenantName; } else { payload.tenantSlug = undefined; } payload.department = undefined; payload.grade = undefined; payload.position = undefined; payload.jobTitle = undefined; payload.additionalAppointments = appointments; payload.metadata = { ...metadata, additionalAppointments: appointments, primaryTenantId: primary?.tenantId, primaryTenantName: primary?.tenantName, primaryTenantSlug: primary?.tenantSlug, }; payload.tenantSlug = primary?.tenantSlug; payload.primaryTenantId = primary?.tenantId; payload.primaryTenantName = primary?.tenantName; } 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.tenantSlug || 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}
{normalizeSubEmails(user.metadata?.sub_email).length > 0 && (
+{normalizeSubEmails(user.metadata?.sub_email).length}
)} {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 }, )}
typeof value === "string" ? value.trim() : value, maxLength: { value: 20, message: "Worksmobile 사번은 20자 이하로 입력해야 합니다.", }, })} className="h-11 shadow-sm" /> {errors.metadata?.employee_id && (

{String(errors.metadata.employee_id.message)}

)}

Worksmobile employeeNumber로 전송됩니다. 1~20자만 허용됩니다.

{currentSubEmails.map((email) => ( {email} ))}
setNewSubEmail(e.target.value)} onKeyDown={handleAddSubEmail} className="h-11 shadow-sm pr-20" placeholder={t( "ui.admin.users.detail.form.sub_email_placeholder", "추가할 이메일 입력 후 Enter", )} />

{t( "msg.admin.users.detail.sub_email_help", "* 보조 이메일로도 로그인이 가능하며 계정 찾기 등에 활용될 수 있습니다.", )}

한맥가족 구성원 외부 기업 회원 개인 회원 {userCategory === "external" && (

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

)} {userCategory === "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, }) } />
))}
)} {userCategory === "personal" && (
{personalTenant ? `Personal (${personalTenant.slug})` : "Personal 테넌트로 생성됩니다."}
)} {userCategory === "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에서 테넌트를 선택하면 사용자 소속에 반영됩니다.", )}