1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/users/UserDetailPage.tsx

2105 lines
80 KiB
TypeScript

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<UserUpdateRequest, "metadata"> & {
email: string;
metadata: Record<string, unknown> & {
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<string, unknown> {
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<UserFormValues>;
errors: FieldErrors<UserFormValues>;
}) {
if (schema.length === 0) return null;
return (
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm">
<div className="bg-muted/30 px-5 py-3 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<Building2 size={16} className="text-primary" />
<span className="text-sm font-bold uppercase tracking-tight">
{tenant.name}
</span>
</div>
<span className="text-[10px] font-mono opacity-50 bg-background px-2 py-0.5 rounded border">
{tenant.slug}
</span>
</div>
<div className="p-6 grid gap-6 md:grid-cols-2">
{schema.map((field) => (
<div key={field.key} className="space-y-2">
<Label
htmlFor={`metadata.${tenant.id}.${field.key}`}
className="text-xs font-semibold text-muted-foreground flex items-center gap-1"
>
{field.label}
{field.required && <span className="text-destructive">*</span>}
{field.adminOnly && (
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
Admin Only
</span>
)}
{field.isLoginId && (
<span className="ml-2 text-[9px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
{t("ui.admin.users.detail.form.is_login_id", "로그인 ID")}
</span>
)}
</Label>
<Input
id={`metadata.${tenant.id}.${field.key}`}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={field.type === "boolean" ? "w-5 h-5" : "h-10 text-sm"}
{...register(`metadata.${tenant.id}.${field.key}` as const, {
required: field.required
? t(
"msg.admin.users.detail.form.field_required",
"필수입니다.",
)
: false,
pattern: field.validation
? {
value: new RegExp(field.validation),
message: t(
"msg.admin.users.detail.form.invalid_format",
"형식이 올바르지 않습니다.",
),
}
: undefined,
})}
/>
{(
errors.metadata as unknown as Record<
string,
Record<string, { message?: string }>
>
)?.[tenant.id]?.[field.key] && (
<p className="text-[10px] text-destructive font-medium">
{
(
errors.metadata as unknown as Record<
string,
Record<string, { message?: string }>
>
)?.[tenant.id]?.[field.key]?.message
}
</p>
)}
</div>
))}
</div>
</div>
);
}
function UserDetailPage() {
const params = useParams<{ id: string }>();
const userId = params.id ?? "";
const navigate = useNavigate();
const queryClient = useQueryClient();
const [_error, _setError] = React.useState<string | null>(null);
const [_successMsg, _setSuccessMsg] = React.useState<string | null>(null);
const [isPasswordResetOpen, setIsPasswordResetOpen] = React.useState(false);
const [generatedPassword, setGeneratedPassword] = React.useState<
string | null
>(null);
const [passwordResetMode, setPasswordResetMode] =
React.useState<PasswordResetMode>("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<UserCategory>("external");
const [additionalAppointments, setAdditionalAppointments] = React.useState<
AppointmentDraft[]
>([]);
const [pickerTarget, setPickerTarget] = React.useState<PickerTarget | null>(
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<UserFormValues>({
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<HTMLInputElement>) => {
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<UserAppointment>,
) => {
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<string, unknown>;
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<string, string | number | boolean>
>) || {}),
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<string, unknown> = {
...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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (isError || !user) {
return (
<div className="rounded-md bg-destructive/15 p-6 text-center mt-6">
<p className="text-destructive font-medium">
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => navigate("/users")}
>
{t("ui.admin.users.detail.go_list", "목록으로 이동")}
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header with back button and actions */}
<div className="flex items-center justify-between">
<Button
variant="ghost"
onClick={() => navigate("/users")}
size="sm"
className="hover:bg-muted"
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t("ui.admin.users.detail.back", "목록으로")}
</Button>
{isAdmin && (
<Button variant="destructive" onClick={handleDelete} size="sm">
<Trash2 className="mr-2 h-4 w-4" />
{t("ui.admin.users.detail.delete", "사용자 삭제")}
</Button>
)}
</div>
{/* User Quick Summary Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-[var(--color-panel)] border border-border rounded-2xl p-8 shadow-sm">
<div className="flex items-center gap-6">
<div className="h-20 w-20 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
<Users size={40} />
</div>
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-extrabold tracking-tight">
{user.name}
</h1>
<Badge
variant="outline"
className="h-6 px-3 bg-blue-500/10 text-blue-600 border-blue-200 font-bold"
>
<Building2 size={12} className="mr-1.5" />
{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", "시스템 전역")}
</Badge>
<Badge
variant={user.status === "active" ? "default" : "secondary"}
className="h-6 px-3"
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-4 text-muted-foreground text-sm">
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
<Mail size={14} className="text-primary/70" />
{user.email}
</div>
{normalizeSubEmails(user.metadata?.sub_email).length > 0 && (
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
<Mail size={14} className="text-primary/40" />
<span className="text-[10px] font-bold">
+{normalizeSubEmails(user.metadata?.sub_email).length}
</span>
</div>
)}
{user.phone && (
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
<Shield size={14} className="text-primary/70" />
{user.phone}
</div>
)}
</div>
</div>
</div>
<div className="flex flex-col items-start md:items-end text-[10px] text-muted-foreground gap-1.5 uppercase tracking-widest font-bold opacity-70">
<p>
{t("ui.admin.users.detail.created_at", "가입일")}: {user.createdAt}
</p>
<p>
{t("ui.admin.users.detail.updated_at", "최근 수정")}:{" "}
{user.updatedAt}
</p>
</div>
</div>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full space-y-6"
>
<TabsList className="bg-muted/50 p-1.5 rounded-xl inline-flex w-full md:w-auto">
<TabsTrigger
value="info"
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
>
<Users size={16} className="mr-2" />
{t("ui.admin.users.detail.tabs.info", "기본 정보")}
</TabsTrigger>
<TabsTrigger
value="tenants"
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
>
<Building2 size={16} className="mr-2" />
{t("ui.admin.users.detail.tabs.tenants", "테넌트 프로필")}
</TabsTrigger>
<TabsTrigger
value="security"
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
>
<Shield size={16} className="mr-2" />
{t("ui.admin.users.detail.tabs.security", "보안 & 활동")}
</TabsTrigger>
</TabsList>
<form onSubmit={handleSubmit(onSubmit)}>
<TabsContent
value="info"
className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2"
>
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl overflow-hidden">
<CardHeader className="pb-4">
<CardTitle className="text-lg flex items-center gap-2">
<BadgeCheck size={18} className="text-primary" />
{t("ui.admin.users.detail.edit_title", "프로필 정보")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.detail.edit_subtitle",
"{{email}} 계정의 정보를 수정합니다.",
{ email: user.email },
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-8 p-8">
<div className="grid gap-8 md:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor="email"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t("ui.admin.users.detail.form.email", "이메일")}
</Label>
<Input
id="email"
type="email"
disabled={profileRole !== "super_admin"}
{...register("email", {
required: t(
"msg.admin.users.detail.email_required",
"이메일을 입력하세요.",
),
})}
className={
profileRole === "super_admin"
? "h-11 shadow-sm"
: "bg-muted/50 border-none font-medium h-11"
}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="name"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t("ui.admin.users.detail.form.name", "이름")}
</Label>
<Input
id="name"
{...register("name", {
required: t(
"msg.admin.users.detail.form.name_required",
"이름은 필수입니다.",
),
})}
className="h-11 shadow-sm"
/>
</div>
</div>
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
<div className="space-y-2">
<Label
htmlFor="phone"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t("ui.admin.users.detail.form.phone", "연락처")}
</Label>
<Input
id="phone"
{...register("phone")}
className="h-11 shadow-sm"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="metadata_employee_id"
className="text-xs font-bold uppercase text-muted-foreground"
>
</Label>
<Input
id="metadata_employee_id"
maxLength={20}
{...register("metadata.employee_id", {
setValueAs: (value) =>
typeof value === "string" ? value.trim() : value,
maxLength: {
value: 20,
message:
"Worksmobile 사번은 20자 이하로 입력해야 합니다.",
},
})}
className="h-11 shadow-sm"
/>
{errors.metadata?.employee_id && (
<p className="text-xs text-destructive">
{String(errors.metadata.employee_id.message)}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-1">
Worksmobile employeeNumber로 . 1~20
.
</p>
</div>
</div>
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
<div className="space-y-2 col-span-full">
<Label
htmlFor="sub_email_input"
className="text-xs font-bold uppercase text-muted-foreground flex items-center gap-2"
>
<Mail size={14} />
{t("ui.admin.users.detail.form.sub_email", "보조 이메일")}
</Label>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2 mb-1">
{currentSubEmails.map((email) => (
<Badge
key={email}
variant="secondary"
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium"
>
{email}
<button
type="button"
onClick={() => handleRemoveSubEmail(email)}
className="text-muted-foreground hover:text-foreground ml-1 rounded-full p-0.5 hover:bg-muted transition-colors"
>
<X size={12} />
</button>
</Badge>
))}
</div>
<div className="relative">
<Input
id="sub_email_input"
value={newSubEmail}
onChange={(e) => setNewSubEmail(e.target.value)}
onKeyDown={handleAddSubEmail}
className="h-11 shadow-sm pr-20"
placeholder={t(
"ui.admin.users.detail.form.sub_email_placeholder",
"추가할 이메일 입력 후 Enter",
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-1 top-1 h-9 text-xs font-bold"
data-testid="add-sub-email-btn"
onClick={() => {
const value = newSubEmail.trim().replace(/,/g, "");
if (
value?.includes("@") &&
!currentSubEmails.includes(value)
) {
setValue(
"metadata.sub_email",
[...currentSubEmails, value],
{ shouldDirty: true },
);
setNewSubEmail("");
}
}}
>
{t("ui.common.add", "추가")}
</Button>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
{t(
"msg.admin.users.detail.sub_email_help",
"* 보조 이메일로도 로그인이 가능하며 계정 찾기 등에 활용될 수 있습니다.",
)}
</p>
</div>
</div>
</div>
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
<div className="space-y-2">
<Label
htmlFor="status"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t("ui.admin.users.detail.form.status", "상태")}
</Label>
<Select
value={normalizeUserStatusValue(watchedStatus || "")}
onValueChange={(value) =>
setValue("status", normalizeUserStatusValue(value), {
shouldDirty: true,
})
}
>
<SelectTrigger id="status" className="h-11 shadow-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Tabs
value={userCategory}
onValueChange={handleUserCategoryChange}
className="space-y-4 pt-6 border-t border-dashed"
>
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
<TabsTrigger
value="hanmac"
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
>
</TabsTrigger>
<TabsTrigger
value="external"
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
>
</TabsTrigger>
<TabsTrigger
value="personal"
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
>
</TabsTrigger>
</TabsList>
</Tabs>
{userCategory === "external" && (
<div className="grid gap-8 md:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor="tenantSlug"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t(
"ui.admin.users.detail.form.tenant_slug",
"대표 소속 (Tenant Slug)",
)}
</Label>
<div className="relative">
<select
id="tenantSlug"
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
{...register("tenantSlug")}
disabled={
profile?.role === "tenant_admin" &&
selectableRepresentativeTenants.length <= 1
}
>
{selectableRepresentativeTenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
<BadgeCheck
size={14}
className="absolute right-3 top-[15px] text-primary pointer-events-none"
/>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
*{" "}
{t(
"msg.admin.users.detail.tenant_slug_help",
"사용자의 주된 정체성을 결정하는 대표 조직을 지정합니다.",
)}
</p>
</div>
</div>
)}
{userCategory === "hanmac" && (
<div className="space-y-4 rounded-md border p-4">
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">
{t(
"ui.admin.users.detail.form.additional_appointments",
"소속별 직급/직책/직무",
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.users.detail.form.additional_appointments_help",
"테넌트별 조직장 여부, 직급, 직책, 직무를 입력합니다.",
)}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addAppointment}
data-testid="add-appointment-btn"
>
<Plus className="mr-2 h-4 w-4" />
{t("ui.common.add", "추가")}
</Button>
</div>
{additionalAppointments.map((appointment, index) => (
<div
key={appointment.draftId}
data-testid={`detail-appointment-row-${index}`}
className="grid gap-3 rounded-md border p-3 lg:grid-cols-[minmax(280px,1.2fr)_minmax(280px,1fr)_auto] lg:items-end"
>
<div
className="space-y-2"
data-testid={`detail-appointment-tenant-owner-line-${index}`}
>
<Label>
{t(
"ui.admin.users.detail.form.appointment_tenant",
"소속 테넌트",
)}
</Label>
<div
className="flex items-center justify-between gap-3"
data-testid={`detail-appointment-tenant-owner-controls-${index}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<Button
type="button"
variant="outline"
className="min-w-0 max-w-full"
onClick={() =>
setPickerTarget({
kind: "appointment",
index,
})
}
disabled={isResolvingTenant}
data-testid={`detail-appointment-tenant-picker-${index}`}
>
<Building2 className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">
{appointment.tenantName ||
t(
"ui.admin.users.detail.form.pick_tenant",
"테넌트 선택",
)}
</span>
</Button>
{appointment.tenantSlug && (
<span className="truncate text-xs text-muted-foreground">
{appointment.tenantSlug}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap">
<label className="flex items-center gap-2 text-sm">
<Switch
checked={appointment.isPrimary === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isPrimary: checked === true,
})
}
disabled={appointment.isPrimary === true}
aria-label={t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_owner",
"대표 조직",
)}
</label>
<label className="flex items-center gap-2 text-sm">
<Switch
checked={appointment.isManager === true}
onCheckedChange={(checked) =>
updateAppointment(index, {
isManager: checked === true,
})
}
aria-label={t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
/>
{t(
"ui.admin.users.detail.form.appointment_manager",
"조직장",
)}
</label>
</div>
</div>
</div>
<div
className="grid gap-3 sm:grid-cols-3"
data-testid={`detail-appointment-position-line-${index}`}
>
<div className="space-y-2">
<Label
htmlFor={`detail-appointment-grade-${index}`}
>
{t(
"ui.admin.users.detail.form.grade",
"직급",
)}
</Label>
<select
id={`detail-appointment-grade-${index}`}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={appointment.grade ?? ""}
onChange={(event) =>
updateAppointment(index, {
grade: event.target.value || "",
})
}
>
<option value=""></option>
{getTenantGradeOptions(
appointment,
tenants,
).map((grade) => (
<option key={grade} value={grade}>
{grade}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label
htmlFor={`detail-appointment-job-title-${index}`}
>
{t(
"ui.admin.users.detail.form.job_title",
"직무",
)}
</Label>
<Input
id={`detail-appointment-job-title-${index}`}
value={appointment.jobTitle ?? ""}
onChange={(event) =>
updateAppointment(index, {
jobTitle: event.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label
htmlFor={`detail-appointment-position-${index}`}
>
{t(
"ui.admin.users.detail.form.position",
"직책",
)}
</Label>
<Input
id={`detail-appointment-position-${index}`}
value={appointment.position ?? ""}
onChange={(event) =>
updateAppointment(index, {
position: event.target.value,
})
}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeAppointment(index)}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">
{t("ui.common.delete", "삭제")}
</span>
</Button>
</div>
))}
</div>
</div>
</div>
)}
{userCategory === "personal" && (
<div className="rounded-md border bg-muted/30 p-4 text-sm">
{personalTenant
? `Personal (${personalTenant.slug})`
: "Personal 테넌트로 생성됩니다."}
</div>
)}
{userCategory === "external" && (
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
<div className="space-y-2">
<Label
htmlFor="department"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t(
"ui.admin.users.detail.form.department",
"부서/학과",
)}
</Label>
<Input
id="department"
{...register("department")}
className="h-11 shadow-sm"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="grade"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t("ui.admin.users.detail.form.grade", "직급")}
</Label>
<Input
id="grade"
{...register("grade")}
className="h-11 shadow-sm"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="position"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t("ui.admin.users.detail.form.position", "직책")}
</Label>
<Input
id="position"
{...register("position")}
className="h-11 shadow-sm"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="jobTitle"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t("ui.admin.users.detail.form.job_title", "직무")}
</Label>
<Input
id="jobTitle"
{...register("jobTitle")}
className="h-11 shadow-sm"
/>
</div>
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end pt-4">
<Button
type="submit"
disabled={mutation.isPending}
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
>
{mutation.isPending ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<Save className="mr-2 h-5 w-5" />
)}
<span className="text-base font-bold">
{t("ui.admin.users.detail.save", "저장하기")}
</span>
</Button>
</div>
</TabsContent>
<TabsContent
value="tenants"
className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2"
>
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
<CardHeader className="pb-4">
<CardTitle className="text-lg flex items-center gap-2">
<Building2 size={18} className="text-primary" />
{t(
"ui.admin.users.detail.custom_fields.multi_title",
"테넌트별 상세 프로필",
)}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.detail.tenants_desc",
"각 테넌트별로 정의된 커스텀 스키마 정보를 관리합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-8 p-8">
{userAffiliatedTenants.length === 0 ? (
<div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5">
<Building2 size={48} className="mx-auto mb-4 opacity-20" />
<p className="font-medium">
{t(
"msg.admin.users.detail.no_tenants",
"소속된 테넌트 정보가 없습니다.",
)}
</p>
</div>
) : (
<div className="grid gap-8">
{userAffiliatedTenants.map((t) => {
const tDetail = tenants.find(
(tenant) => tenant.id === t.id,
);
const schema = (tDetail?.config?.userSchema ||
[]) as UserSchemaField[];
return (
<TenantMetadataFields
key={t.id}
tenant={t}
schema={schema}
register={register}
errors={errors}
/>
);
})}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end pt-4">
<Button
type="submit"
disabled={mutation.isPending}
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
>
{mutation.isPending ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<Save className="mr-2 h-5 w-5" />
)}
<span className="text-base font-bold">
{t(
"ui.admin.users.detail.save_tenants",
"모든 테넌트 프로필 저장",
)}
</span>
</Button>
</div>
</TabsContent>
</form>
<TabsContent
value="security"
className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2"
>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl h-fit">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Key size={18} className="text-primary" />
{t("ui.admin.users.detail.password_title", "비밀번호 관리")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.detail.security_desc",
"비밀번호 초기화 및 보안 설정을 관리합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between rounded-2xl border bg-muted/20 px-6 py-6 transition-all hover:bg-muted/30">
<div className="space-y-1">
<p className="font-bold text-sm">
{t(
"ui.admin.users.detail.reset_password_label",
"비밀번호 초기화",
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.users.detail.reset_password_help",
"안전한 새 비밀번호로 교체합니다.",
)}
</p>
</div>
<Button
variant="outline"
onClick={handleOpenPasswordReset}
disabled={isSelf}
className="h-10 rounded-xl px-5 border-primary/20 hover:border-primary/50 hover:bg-primary/5"
>
<RefreshCw className="mr-2 h-4 w-4" />
{t("ui.admin.users.detail.reset_password", "초기화 도구")}
</Button>
</div>
{isSelf && (
<div className="rounded-xl bg-blue-500/5 border border-blue-500/10 px-5 py-4 text-sm text-blue-600 flex items-center gap-3">
<Shield size={18} className="shrink-0" />
<p className="leading-relaxed">
{t(
"msg.admin.users.detail.self_password_reset_blocked",
"보안을 위해 본인 계정은 사용자 포털에서만 변경 가능합니다.",
)}
</p>
</div>
)}
{isPasswordResetOpen && !generatedPassword && !isSelf && (
<div className="mt-4 p-6 border rounded-2xl bg-card shadow-sm animate-in zoom-in-95 duration-200">
<Tabs
value={passwordResetMode}
onValueChange={(v) =>
setPasswordResetMode(v as PasswordResetMode)
}
>
<TabsList className="grid w-full grid-cols-2 mb-6 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
<TabsTrigger
value="auto"
className="rounded-md data-[state=active]:bg-white data-[state=active]:text-primary data-[state=active]:shadow-sm font-bold py-2 text-gray-500"
>
{t("ui.admin.users.detail.reset_auto", "자동 생성")}
</TabsTrigger>
<TabsTrigger
value="manual"
className="rounded-md data-[state=active]:bg-white data-[state=active]:text-primary data-[state=active]:shadow-sm font-bold py-2 text-gray-500"
>
{t("ui.admin.users.detail.reset_manual", "직접 입력")}
</TabsTrigger>
</TabsList>
<TabsContent value="auto" className="space-y-4">
<div className="bg-muted/50 p-4 rounded-xl text-xs text-muted-foreground leading-relaxed">
{t(
"msg.admin.users.detail.reset_auto_desc",
"해킹이 어려운 복잡한 임시 비밀번호를 시스템이 즉시 생성합니다.",
)}
</div>
<Button
type="button"
onClick={() =>
setManualPassword(generateSecurePassword())
}
variant="secondary"
className="w-full h-11 rounded-xl font-bold"
>
{t(
"ui.admin.users.detail.generate_button",
"랜덤 비밀번호 생성",
)}
</Button>
</TabsContent>
<TabsContent value="manual" className="space-y-5 pt-2">
<div className="space-y-2">
<Label className="text-xs font-bold uppercase">
{t(
"ui.admin.users.detail.manual_password",
"새 비밀번호",
)}
</Label>
<div className="relative">
<Input
type={
isManualPasswordVisible ? "text" : "password"
}
value={manualPassword}
onChange={(e) =>
setManualPassword(e.target.value)
}
className="h-11 rounded-xl shadow-sm pr-12"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent text-muted-foreground"
onClick={() =>
setIsManualPasswordVisible(
!isManualPasswordVisible,
)
}
>
{isManualPasswordVisible ? (
<EyeOff size={18} />
) : (
<Eye size={18} />
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold uppercase">
{t(
"ui.admin.users.detail.manual_confirm",
"비밀번호 확인",
)}
</Label>
<Input
type="password"
value={manualPasswordConfirm}
onChange={(e) =>
setManualPasswordConfirm(e.target.value)
}
className="h-11 rounded-xl shadow-sm"
/>
</div>
</TabsContent>
{passwordResetError && (
<div className="flex items-center gap-2 text-xs text-destructive mt-4 p-3 bg-destructive/5 rounded-lg border border-destructive/10">
<Shield size={14} />
<span className="font-medium">
{passwordResetError}
</span>
</div>
)}
<div className="flex justify-end gap-3 mt-8">
<Button
variant="ghost"
type="button"
onClick={handleClosePasswordReset}
className="h-11 rounded-xl px-6 font-bold"
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
type="button"
onClick={handleExecutePasswordReset}
disabled={resetMutation.isPending}
className="h-11 rounded-xl px-8 font-bold shadow-md"
>
{resetMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t(
"ui.admin.users.detail.reset_execute",
"재설정 완료",
)}
</Button>
</div>
</Tabs>
</div>
)}
{generatedPassword && (
<div className="mt-4 p-8 bg-green-500/10 border border-green-500/20 rounded-2xl space-y-6 animate-in zoom-in-95">
<div className="flex items-center gap-3 text-green-700 font-extrabold text-lg">
<BadgeCheck size={28} className="text-green-600" />
{t(
"ui.admin.users.detail.password_done",
"성공적으로 초기화됨",
)}
</div>
<div className="p-5 bg-white border border-green-200 rounded-2xl flex items-center justify-between shadow-sm">
<code className="text-2xl font-mono tracking-widest text-primary selection:bg-primary selection:text-white">
{generatedPassword}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => {
navigator.clipboard.writeText(generatedPassword);
toast.success(
t("msg.common.copied", "복사되었습니다."),
);
}}
className="h-10 px-4 rounded-xl hover:bg-green-50 font-bold"
>
<Copy size={16} className="mr-2" />
{t("ui.common.copy", "복사")}
</Button>
</div>
<Button
className="w-full h-12 rounded-xl font-bold bg-green-600 hover:bg-green-700 shadow-md"
type="button"
onClick={handleClosePasswordReset}
>
{t("ui.common.close", "안전하게 도구 닫기")}
</Button>
</div>
)}
</CardContent>
</Card>
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<History size={18} className="text-primary" />
{t("ui.admin.users.detail.history_title", "서비스 이용 내역")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.detail.history_desc",
"최근 로그인한 연동 서비스(RP) 목록입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="p-8 pt-2">
{rpHistoryQuery.isLoading ? (
<div className="py-12 text-center text-muted-foreground animate-pulse">
{t("msg.common.loading", "불러오는 중...")}
</div>
) : !rpHistoryQuery.data || rpHistoryQuery.data.length === 0 ? (
<div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5">
<History size={40} className="mx-auto mb-4 opacity-10" />
<p className="text-sm font-medium">
{t(
"msg.admin.users.detail.no_history",
"아직 이용한 서비스가 없습니다.",
)}
</p>
</div>
) : (
<div className="grid gap-4">
{rpHistoryQuery.data.map((item) => (
<div
key={item.client_id || item.client_id}
className="flex items-center justify-between p-5 rounded-2xl border bg-card hover:border-primary/40 hover:shadow-md transition-all group"
>
<div className="flex flex-col gap-1.5">
<span className="font-extrabold text-base group-hover:text-primary transition-colors">
{item.client_name || item.client_id}
</span>
<span className="text-[11px] text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded w-fit">
{item.client_id}
</span>
</div>
<div className="text-right">
<Badge
variant="outline"
className="text-[10px] font-bold px-2 py-1 rounded-md border-primary/20"
>
{item.lastLoginAt}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
<Dialog
open={pickerTarget !== null}
onOpenChange={(open) => {
if (!open) setPickerTarget(null);
}}
>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.detail.form.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.users.detail.form.picker_description",
"org-chart에서 테넌트를 선택하면 사용자 소속에 반영됩니다.",
)}
</DialogDescription>
</DialogHeader>
<iframe
title={t("ui.admin.users.detail.form.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
/>
</DialogContent>
</Dialog>
</div>
);
}
export default UserDetailPage;