forked from baron/baron-sso
1989 lines
101 KiB
TypeScript
1989 lines
101 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,
|
|
} 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<UserUpdateRequest, "metadata"> & {
|
|
metadata: Record<string, Record<string, string | number | boolean>>;
|
|
};
|
|
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<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 [isHanmacFamily, setIsHanmacFamily] = React.useState(false);
|
|
const [userType, setUserType] = React.useState<UserType>("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", { 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<UserFormValues>({
|
|
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<UserAppointment>,
|
|
) => {
|
|
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<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.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<string, string | number | boolean>
|
|
>) || {},
|
|
});
|
|
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<string, unknown> = {
|
|
...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 (
|
|
<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.companyCode ||
|
|
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>
|
|
{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"
|
|
value={user.email}
|
|
disabled
|
|
className="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>
|
|
|
|
<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>
|
|
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
|
|
<Switch
|
|
id="status"
|
|
checked={
|
|
watchedStatus === "active"
|
|
}
|
|
onCheckedChange={(checked) =>
|
|
setValue(
|
|
"status",
|
|
checked
|
|
? "active"
|
|
: "inactive",
|
|
)
|
|
}
|
|
/>
|
|
<span className="text-sm text-muted-foreground">
|
|
{t(
|
|
`ui.common.status.${watchedStatus}`,
|
|
watchedStatus || "inactive",
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs
|
|
value={userType}
|
|
onValueChange={handleUserTypeChange}
|
|
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>
|
|
|
|
{userType === "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>
|
|
)}
|
|
|
|
{userType === "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}
|
|
>
|
|
<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 flex-wrap items-center gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() =>
|
|
setPickerTarget(
|
|
{
|
|
kind: "appointment",
|
|
index,
|
|
},
|
|
)
|
|
}
|
|
disabled={
|
|
isResolvingTenant
|
|
}
|
|
>
|
|
<Building2 className="mr-2 h-4 w-4" />
|
|
{appointment.tenantName ||
|
|
t(
|
|
"ui.admin.users.detail.form.pick_tenant",
|
|
"테넌트 선택",
|
|
)}
|
|
</Button>
|
|
{appointment.tenantSlug && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{
|
|
appointment.tenantSlug
|
|
}
|
|
</span>
|
|
)}
|
|
<label className="flex items-center gap-3 text-sm">
|
|
<Checkbox
|
|
checked={
|
|
appointment.isOwner
|
|
}
|
|
onCheckedChange={(
|
|
checked,
|
|
) =>
|
|
updateAppointment(
|
|
index,
|
|
{
|
|
isOwner:
|
|
checked ===
|
|
true,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
{t(
|
|
"ui.admin.users.detail.form.appointment_owner",
|
|
"조직장",
|
|
)}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="grid gap-3 sm:grid-cols-2"
|
|
data-testid={`detail-appointment-position-line-${index}`}
|
|
>
|
|
<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>
|
|
)}
|
|
|
|
{userType === "personal" && (
|
|
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
|
{personalTenant
|
|
? `Personal (${personalTenant.slug})`
|
|
: "Personal 테넌트로 생성됩니다."}
|
|
</div>
|
|
)}
|
|
|
|
{userType === "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="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;
|