forked from baron/baron-sso
Format adminfront code check targets
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
"node_modules",
|
||||
"tsconfig*.json",
|
||||
"test-results",
|
||||
"test-results.nobody-backup",
|
||||
"playwright-report"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ describe("DomainTagInput", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("example.com"), "samaneng.com ");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("example.com"),
|
||||
"samaneng.com ",
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
|
||||
@@ -71,8 +71,7 @@ export function normalizeSchemaField(field: unknown): SchemaField {
|
||||
type,
|
||||
required: Boolean(source.required),
|
||||
adminOnly: Boolean(source.adminOnly),
|
||||
validation:
|
||||
typeof source.validation === "string" ? source.validation : "",
|
||||
validation: typeof source.validation === "string" ? source.validation : "",
|
||||
unsigned: Boolean(source.unsigned),
|
||||
isLoginId,
|
||||
indexed: isLoginId || Boolean(source.indexed),
|
||||
|
||||
@@ -54,6 +54,9 @@ export function formatDomainConflictMessage(
|
||||
const tenantName =
|
||||
"tenant" in conflict
|
||||
? conflict.tenant.name
|
||||
: conflict.tenantName || conflict.tenantSlug || conflict.tenantId || "다른";
|
||||
: conflict.tenantName ||
|
||||
conflict.tenantSlug ||
|
||||
conflict.tenantId ||
|
||||
"다른";
|
||||
return `${conflict.domain} 도메인은 ${tenantName} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
|
||||
}
|
||||
|
||||
@@ -129,7 +129,10 @@ export function buildTenantImportPreview(
|
||||
candidates[0] && candidates[0].score >= 0.95
|
||||
? candidates[0].tenantId
|
||||
: "",
|
||||
defaultCreateSlug: suggestUniqueTenantSlug(row.slug || row.name, tenants),
|
||||
defaultCreateSlug: suggestUniqueTenantSlug(
|
||||
row.slug || row.name,
|
||||
tenants,
|
||||
),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@@ -148,10 +151,7 @@ export function serializeTenantImportCSV(
|
||||
const sortedRows = [...previewRows].sort(
|
||||
(a, b) => a.row.rowNumber - b.row.rowNumber,
|
||||
);
|
||||
const targetTenantIds = buildTargetTenantIds(
|
||||
sortedRows,
|
||||
selectedTenantIds,
|
||||
);
|
||||
const targetTenantIds = buildTargetTenantIds(sortedRows, selectedTenantIds);
|
||||
|
||||
for (const preview of sortedRows) {
|
||||
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
|
||||
@@ -241,7 +241,9 @@ function remapParentTenantId(
|
||||
return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId;
|
||||
}
|
||||
if (parentTenantSlug) {
|
||||
return targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? "";
|
||||
return (
|
||||
targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? ""
|
||||
);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -164,9 +164,7 @@ function UserCreatePage() {
|
||||
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
||||
return envTenantId.trim();
|
||||
}
|
||||
return (
|
||||
tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? ""
|
||||
);
|
||||
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
||||
}, [tenants]);
|
||||
const nonHanmacFamilyTenants = React.useMemo(
|
||||
() => filterNonHanmacFamilyTenants(tenants, hanmacFamilyTenantId),
|
||||
@@ -291,9 +289,7 @@ function UserCreatePage() {
|
||||
) => {
|
||||
setAdditionalAppointments((current) =>
|
||||
current.map((appointment, currentIndex) =>
|
||||
currentIndex === index
|
||||
? { ...appointment, ...patch }
|
||||
: appointment,
|
||||
currentIndex === index ? { ...appointment, ...patch } : appointment,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -339,10 +335,7 @@ function UserCreatePage() {
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
setError(
|
||||
err.response?.data?.error ||
|
||||
t(
|
||||
"msg.admin.users.create.error",
|
||||
"사용자 생성에 실패했습니다.",
|
||||
),
|
||||
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -494,24 +487,15 @@ function UserCreatePage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border border-dashed px-4 py-3">
|
||||
<span className="font-mono text-sm">
|
||||
{generatedPassword}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCopyPassword}
|
||||
>
|
||||
<span className="font-mono text-sm">{generatedPassword}</span>
|
||||
<Button size="sm" variant="outline" onClick={onCopyPassword}>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||
{t("ui.common.copy", "복사")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => navigate("/users")}>
|
||||
{t(
|
||||
"ui.admin.users.create.go_list",
|
||||
"목록으로 이동",
|
||||
)}
|
||||
{t("ui.admin.users.create.go_list", "목록으로 이동")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -531,10 +515,7 @@ function UserCreatePage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
@@ -543,10 +524,7 @@ function UserCreatePage() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
{t(
|
||||
"ui.admin.users.create.form.email",
|
||||
"이메일",
|
||||
)}
|
||||
{t("ui.admin.users.create.form.email", "이메일")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -571,25 +549,15 @@ function UserCreatePage() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">
|
||||
{t(
|
||||
"ui.admin.users.create.form.password",
|
||||
"비밀번호",
|
||||
)}
|
||||
{t("ui.admin.users.create.form.password", "비밀번호")}
|
||||
</Label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoPassword}
|
||||
onChange={(event) =>
|
||||
setAutoPassword(
|
||||
event.target.checked,
|
||||
)
|
||||
}
|
||||
onChange={(event) => setAutoPassword(event.target.checked)}
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.users.create.form.auto_password",
|
||||
"자동 생성",
|
||||
)}
|
||||
{t("ui.admin.users.create.form.auto_password", "자동 생성")}
|
||||
</label>
|
||||
</div>
|
||||
<Input
|
||||
@@ -618,10 +586,7 @@ function UserCreatePage() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
{t(
|
||||
"ui.admin.users.create.form.name",
|
||||
"이름",
|
||||
)}
|
||||
{t("ui.admin.users.create.form.name", "이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
@@ -645,10 +610,7 @@ function UserCreatePage() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">
|
||||
{t(
|
||||
"ui.admin.users.create.form.phone",
|
||||
"전화번호",
|
||||
)}
|
||||
{t("ui.admin.users.create.form.phone", "전화번호")}
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
@@ -661,10 +623,7 @@ function UserCreatePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={userType}
|
||||
onValueChange={handleUserTypeChange}
|
||||
>
|
||||
<Tabs value={userType} onValueChange={handleUserTypeChange}>
|
||||
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
||||
<TabsTrigger
|
||||
value="hanmac"
|
||||
@@ -686,42 +645,26 @@ function UserCreatePage() {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="external"
|
||||
className="mt-4 space-y-4"
|
||||
>
|
||||
<TabsContent value="external" className="mt-4 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenantSlug">
|
||||
대표소속
|
||||
</Label>
|
||||
<Label htmlFor="tenantSlug">대표소속</Label>
|
||||
<select
|
||||
id="tenantSlug"
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...register("tenantSlug")}
|
||||
disabled={
|
||||
profile?.role === "tenant_admin"
|
||||
}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
{nonHanmacFamilyTenants.map(
|
||||
(tenant) => (
|
||||
<option
|
||||
key={tenant.id}
|
||||
value={tenant.slug}
|
||||
>
|
||||
{tenant.name} (
|
||||
{tenant.slug})
|
||||
{nonHanmacFamilyTenants.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.slug}>
|
||||
{tenant.name} ({tenant.slug})
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">부서</Label>
|
||||
<Input
|
||||
id="department"
|
||||
{...register("department")}
|
||||
/>
|
||||
<Input id="department" {...register("department")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -745,19 +688,13 @@ function UserCreatePage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="hanmac"
|
||||
className="mt-4 space-y-4"
|
||||
>
|
||||
<TabsContent value="hanmac" className="mt-4 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
소속별 직급/직무
|
||||
</p>
|
||||
<p className="text-sm font-medium">소속별 직급/직무</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
테넌트별 조직장 여부, 직무,
|
||||
직급을 입력합니다.
|
||||
테넌트별 조직장 여부, 직무, 직급을 입력합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -771,8 +708,7 @@ function UserCreatePage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{additionalAppointments.map(
|
||||
(appointment, index) => (
|
||||
{additionalAppointments.map((appointment, index) => (
|
||||
<div
|
||||
key={appointment.draftId}
|
||||
data-testid={`appointment-row-${index}`}
|
||||
@@ -788,44 +724,28 @@ function UserCreatePage() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setPickerTarget(
|
||||
{
|
||||
setPickerTarget({
|
||||
kind: "appointment",
|
||||
index,
|
||||
},
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
isResolvingTenant
|
||||
})
|
||||
}
|
||||
disabled={isResolvingTenant}
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
{appointment.tenantName ||
|
||||
"테넌트 선택"}
|
||||
{appointment.tenantName || "테넌트 선택"}
|
||||
</Button>
|
||||
{appointment.tenantSlug && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{
|
||||
appointment.tenantSlug
|
||||
}
|
||||
{appointment.tenantSlug}
|
||||
</span>
|
||||
)}
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<Checkbox
|
||||
checked={
|
||||
appointment.isOwner
|
||||
}
|
||||
onCheckedChange={(
|
||||
checked,
|
||||
) =>
|
||||
updateAppointment(
|
||||
index,
|
||||
{
|
||||
isOwner:
|
||||
checked ===
|
||||
true,
|
||||
},
|
||||
)
|
||||
checked={appointment.isOwner}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isOwner: checked === true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
조직장
|
||||
@@ -838,52 +758,30 @@ function UserCreatePage() {
|
||||
data-testid={`appointment-position-line-${index}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={`appointment-job-title-${index}`}
|
||||
>
|
||||
<Label htmlFor={`appointment-job-title-${index}`}>
|
||||
직무
|
||||
</Label>
|
||||
<Input
|
||||
id={`appointment-job-title-${index}`}
|
||||
value={
|
||||
appointment.jobTitle ??
|
||||
""
|
||||
}
|
||||
value={appointment.jobTitle ?? ""}
|
||||
onChange={(event) =>
|
||||
updateAppointment(
|
||||
index,
|
||||
{
|
||||
jobTitle:
|
||||
event
|
||||
.target
|
||||
.value,
|
||||
},
|
||||
)
|
||||
updateAppointment(index, {
|
||||
jobTitle: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor={`appointment-position-${index}`}
|
||||
>
|
||||
<Label htmlFor={`appointment-position-${index}`}>
|
||||
직급
|
||||
</Label>
|
||||
<Input
|
||||
id={`appointment-position-${index}`}
|
||||
value={
|
||||
appointment.position ??
|
||||
""
|
||||
}
|
||||
value={appointment.position ?? ""}
|
||||
onChange={(event) =>
|
||||
updateAppointment(
|
||||
index,
|
||||
{
|
||||
position:
|
||||
event
|
||||
.target
|
||||
.value,
|
||||
},
|
||||
)
|
||||
updateAppointment(index, {
|
||||
position: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -892,21 +790,15 @@ function UserCreatePage() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
removeAppointment(index)
|
||||
}
|
||||
onClick={() => removeAppointment(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t(
|
||||
"ui.common.delete",
|
||||
"삭제",
|
||||
)}
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -933,18 +825,11 @@ function UserCreatePage() {
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{userSchema.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label
|
||||
htmlFor={`metadata.${field.key}`}
|
||||
>
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={`metadata.${field.key}`}>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="ml-1 text-destructive">
|
||||
*
|
||||
</span>
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
)}
|
||||
{field.adminOnly && (
|
||||
<span className="ml-2 text-[10px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter">
|
||||
@@ -968,15 +853,12 @@ function UserCreatePage() {
|
||||
? "number"
|
||||
: field.type === "date"
|
||||
? "date"
|
||||
: field.type ===
|
||||
"boolean"
|
||||
: field.type === "boolean"
|
||||
? "checkbox"
|
||||
: "text"
|
||||
}
|
||||
className={
|
||||
field.type === "boolean"
|
||||
? "w-auto h-auto"
|
||||
: ""
|
||||
field.type === "boolean" ? "w-auto h-auto" : ""
|
||||
}
|
||||
{...registerMetadata(field)}
|
||||
/>
|
||||
@@ -984,9 +866,7 @@ function UserCreatePage() {
|
||||
<p className="text-xs text-destructive">
|
||||
{
|
||||
(
|
||||
errors.metadata[
|
||||
field.key
|
||||
] as {
|
||||
errors.metadata[field.key] as {
|
||||
message?: string;
|
||||
}
|
||||
)?.message
|
||||
@@ -1012,10 +892,7 @@ function UserCreatePage() {
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t(
|
||||
"ui.admin.users.create.submit",
|
||||
"사용자 생성",
|
||||
)}
|
||||
{t("ui.admin.users.create.submit", "사용자 생성")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1030,10 +907,7 @@ function UserCreatePage() {
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(
|
||||
"ui.admin.users.create.form.pick_tenant",
|
||||
"테넌트 선택",
|
||||
)}
|
||||
{t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
@@ -1043,10 +917,7 @@ function UserCreatePage() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<iframe
|
||||
title={t(
|
||||
"ui.admin.users.create.form.pick_tenant",
|
||||
"테넌트 선택",
|
||||
)}
|
||||
title={t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
|
||||
src={pickerUrl}
|
||||
className="h-[600px] w-full rounded-md border"
|
||||
/>
|
||||
|
||||
@@ -230,9 +230,7 @@ function TenantMetadataFields({
|
||||
className="text-xs font-semibold text-muted-foreground flex items-center gap-1"
|
||||
>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-destructive">*</span>
|
||||
)}
|
||||
{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
|
||||
@@ -240,10 +238,7 @@ function TenantMetadataFields({
|
||||
)}
|
||||
{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",
|
||||
)}
|
||||
{t("ui.admin.users.detail.form.is_login_id", "로그인 ID")}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
@@ -258,14 +253,8 @@ function TenantMetadataFields({
|
||||
? "checkbox"
|
||||
: "text"
|
||||
}
|
||||
className={
|
||||
field.type === "boolean"
|
||||
? "w-5 h-5"
|
||||
: "h-10 text-sm"
|
||||
}
|
||||
{...register(
|
||||
`metadata.${tenant.id}.${field.key}` as const,
|
||||
{
|
||||
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",
|
||||
@@ -274,17 +263,14 @@ function TenantMetadataFields({
|
||||
: false,
|
||||
pattern: field.validation
|
||||
? {
|
||||
value: new RegExp(
|
||||
field.validation,
|
||||
),
|
||||
value: new RegExp(field.validation),
|
||||
message: t(
|
||||
"msg.admin.users.detail.form.invalid_format",
|
||||
"형식이 올바르지 않습니다.",
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
)}
|
||||
})}
|
||||
/>
|
||||
{(
|
||||
errors.metadata as unknown as Record<
|
||||
@@ -324,8 +310,7 @@ function UserDetailPage() {
|
||||
const [passwordResetMode, setPasswordResetMode] =
|
||||
React.useState<PasswordResetMode>("generated");
|
||||
const [manualPassword, setManualPassword] = React.useState("");
|
||||
const [manualPasswordConfirm, setManualPasswordConfirm] =
|
||||
React.useState("");
|
||||
const [manualPasswordConfirm, setManualPasswordConfirm] = React.useState("");
|
||||
const [isManualPasswordVisible, setIsManualPasswordVisible] =
|
||||
React.useState(false);
|
||||
const [passwordResetError, setPasswordResetError] = React.useState<
|
||||
@@ -405,8 +390,7 @@ function UserDetailPage() {
|
||||
const watchedStatus = watch("status");
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (newPass: string) =>
|
||||
updateUser(userId, { password: newPass }),
|
||||
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
|
||||
onSuccess: (_, newPass) => {
|
||||
setGeneratedPassword(newPass);
|
||||
setPasswordResetError(null);
|
||||
@@ -420,10 +404,7 @@ function UserDetailPage() {
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
const message =
|
||||
err.response?.data?.error ||
|
||||
t(
|
||||
"msg.admin.users.detail.update_error",
|
||||
"수정에 실패했습니다.",
|
||||
);
|
||||
t("msg.admin.users.detail.update_error", "수정에 실패했습니다.");
|
||||
setPasswordResetError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
@@ -478,9 +459,7 @@ function UserDetailPage() {
|
||||
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
||||
return envTenantId.trim();
|
||||
}
|
||||
return (
|
||||
tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? ""
|
||||
);
|
||||
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
||||
}, [tenants]);
|
||||
const personalTenant = React.useMemo(
|
||||
() =>
|
||||
@@ -558,9 +537,7 @@ function UserDetailPage() {
|
||||
) => {
|
||||
setAdditionalAppointments((current) =>
|
||||
current.map((appointment, currentIndex) =>
|
||||
currentIndex === index
|
||||
? { ...appointment, ...patch }
|
||||
: appointment,
|
||||
currentIndex === index ? { ...appointment, ...patch } : appointment,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -625,8 +602,7 @@ function UserDetailPage() {
|
||||
tenantSlug:
|
||||
user.companyCode ||
|
||||
user.joinedTenants?.find(
|
||||
(t) =>
|
||||
t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||
)?.slug ||
|
||||
"",
|
||||
department: user.department || "",
|
||||
@@ -644,8 +620,7 @@ function UserDetailPage() {
|
||||
hanmacFamilyTenantId,
|
||||
);
|
||||
const resolvedUserType =
|
||||
metadata.userType === "personal" ||
|
||||
user.companyCode === "personal"
|
||||
metadata.userType === "personal" || user.companyCode === "personal"
|
||||
? "personal"
|
||||
: isUserHanmacFamily
|
||||
? "hanmac"
|
||||
@@ -657,22 +632,15 @@ function UserDetailPage() {
|
||||
...(user.tenant ? [user.tenant] : []),
|
||||
].filter(
|
||||
(tenant, index, allTenants) =>
|
||||
allTenants.findIndex((item) => item.id === tenant.id) ===
|
||||
index &&
|
||||
isHanmacFamilyTenant(
|
||||
tenant,
|
||||
tenants,
|
||||
hanmacFamilyTenantId,
|
||||
),
|
||||
allTenants.findIndex((item) => item.id === tenant.id) === index &&
|
||||
isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
|
||||
);
|
||||
setAdditionalAppointments(
|
||||
Array.isArray(rawAppointments)
|
||||
? (rawAppointments as UserAppointment[]).map(
|
||||
(appointment) => ({
|
||||
? (rawAppointments as UserAppointment[]).map((appointment) => ({
|
||||
...appointment,
|
||||
draftId: createDraftId(),
|
||||
}),
|
||||
)
|
||||
}))
|
||||
: isUserHanmacFamily
|
||||
? familyFallbackTenants.length > 0
|
||||
? familyFallbackTenants.map((tenant) => ({
|
||||
@@ -693,9 +661,7 @@ function UserDetailPage() {
|
||||
tenantId: fallbackAppointment.id,
|
||||
tenantName: fallbackAppointment.name,
|
||||
tenantSlug: fallbackAppointment.slug,
|
||||
isOwner:
|
||||
metadata.primaryTenantIsOwner ===
|
||||
true,
|
||||
isOwner: metadata.primaryTenantIsOwner === true,
|
||||
jobTitle: user.jobTitle,
|
||||
position: user.position,
|
||||
},
|
||||
@@ -726,10 +692,7 @@ function UserDetailPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.detail.delete_success",
|
||||
"사용자가 삭제되었습니다.",
|
||||
),
|
||||
t("msg.admin.users.detail.delete_success", "사용자가 삭제되었습니다."),
|
||||
);
|
||||
navigate("/users");
|
||||
},
|
||||
@@ -825,10 +788,7 @@ function UserDetailPage() {
|
||||
}, [user?.joinedTenants, user?.tenant]);
|
||||
const selectableRepresentativeTenants = React.useMemo(
|
||||
() =>
|
||||
filterNonHanmacFamilyTenants(
|
||||
userAffiliatedTenants,
|
||||
hanmacFamilyTenantId,
|
||||
),
|
||||
filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId),
|
||||
[userAffiliatedTenants, hanmacFamilyTenantId],
|
||||
);
|
||||
|
||||
@@ -844,10 +804,7 @@ function UserDetailPage() {
|
||||
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",
|
||||
"사용자를 찾을 수 없습니다.",
|
||||
)}
|
||||
{t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -874,11 +831,7 @@ function UserDetailPage() {
|
||||
{t("ui.admin.users.detail.back", "목록으로")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
size="sm"
|
||||
>
|
||||
<Button variant="destructive" onClick={handleDelete} size="sm">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.users.detail.delete", "사용자 삭제")}
|
||||
</Button>
|
||||
@@ -904,27 +857,15 @@ function UserDetailPage() {
|
||||
{user.tenant?.name ||
|
||||
user.companyCode ||
|
||||
user.joinedTenants?.find(
|
||||
(t) =>
|
||||
t.type === "COMPANY" ||
|
||||
t.type === "COMPANY_GROUP",
|
||||
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||
)?.name ||
|
||||
t(
|
||||
"ui.admin.users.detail.form.tenant_global",
|
||||
"시스템 전역",
|
||||
)}
|
||||
t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
user.status === "active"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
variant={user.status === "active" ? "default" : "secondary"}
|
||||
className="h-6 px-3"
|
||||
>
|
||||
{t(
|
||||
`ui.common.status.${user.status}`,
|
||||
user.status,
|
||||
)}
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-muted-foreground text-sm">
|
||||
@@ -934,10 +875,7 @@ function UserDetailPage() {
|
||||
</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"
|
||||
/>
|
||||
<Shield size={14} className="text-primary/70" />
|
||||
{user.phone}
|
||||
</div>
|
||||
)}
|
||||
@@ -946,8 +884,7 @@ function UserDetailPage() {
|
||||
</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}
|
||||
{t("ui.admin.users.detail.created_at", "가입일")}: {user.createdAt}
|
||||
</p>
|
||||
<p>
|
||||
{t("ui.admin.users.detail.updated_at", "최근 수정")}:{" "}
|
||||
@@ -974,20 +911,14 @@ function UserDetailPage() {
|
||||
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",
|
||||
"테넌트 프로필",
|
||||
)}
|
||||
{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",
|
||||
"보안 & 활동",
|
||||
)}
|
||||
{t("ui.admin.users.detail.tabs.security", "보안 & 활동")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -999,14 +930,8 @@ function UserDetailPage() {
|
||||
<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",
|
||||
"프로필 정보",
|
||||
)}
|
||||
<BadgeCheck size={18} className="text-primary" />
|
||||
{t("ui.admin.users.detail.edit_title", "프로필 정보")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
@@ -1023,10 +948,7 @@ function UserDetailPage() {
|
||||
htmlFor="email"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.email",
|
||||
"이메일",
|
||||
)}
|
||||
{t("ui.admin.users.detail.form.email", "이메일")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -1040,10 +962,7 @@ function UserDetailPage() {
|
||||
htmlFor="name"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.name",
|
||||
"이름",
|
||||
)}
|
||||
{t("ui.admin.users.detail.form.name", "이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
@@ -1064,10 +983,7 @@ function UserDetailPage() {
|
||||
htmlFor="phone"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.phone",
|
||||
"연락처",
|
||||
)}
|
||||
{t("ui.admin.users.detail.form.phone", "연락처")}
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
@@ -1083,24 +999,14 @@ function UserDetailPage() {
|
||||
htmlFor="status"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.status",
|
||||
"상태",
|
||||
)}
|
||||
{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"
|
||||
}
|
||||
checked={watchedStatus === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue(
|
||||
"status",
|
||||
checked
|
||||
? "active"
|
||||
: "inactive",
|
||||
)
|
||||
setValue("status", checked ? "active" : "inactive")
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -1158,23 +1064,15 @@ function UserDetailPage() {
|
||||
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
|
||||
profile?.role === "tenant_admin" &&
|
||||
selectableRepresentativeTenants.length <= 1
|
||||
}
|
||||
>
|
||||
{selectableRepresentativeTenants.map(
|
||||
(t) => (
|
||||
<option
|
||||
key={t.id}
|
||||
value={t.slug}
|
||||
>
|
||||
{t.name} (
|
||||
{t.slug})
|
||||
{selectableRepresentativeTenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</select>
|
||||
<BadgeCheck
|
||||
size={14}
|
||||
@@ -1218,19 +1116,13 @@ function UserDetailPage() {
|
||||
onClick={addAppointment}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t(
|
||||
"ui.common.add",
|
||||
"추가",
|
||||
)}
|
||||
{t("ui.common.add", "추가")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{additionalAppointments.map(
|
||||
(appointment, index) => (
|
||||
{additionalAppointments.map((appointment, index) => (
|
||||
<div
|
||||
key={
|
||||
appointment.draftId
|
||||
}
|
||||
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"
|
||||
>
|
||||
@@ -1249,16 +1141,12 @@ function UserDetailPage() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setPickerTarget(
|
||||
{
|
||||
setPickerTarget({
|
||||
kind: "appointment",
|
||||
index,
|
||||
},
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
isResolvingTenant
|
||||
})
|
||||
}
|
||||
disabled={isResolvingTenant}
|
||||
>
|
||||
<Building2 className="mr-2 h-4 w-4" />
|
||||
{appointment.tenantName ||
|
||||
@@ -1269,27 +1157,16 @@ function UserDetailPage() {
|
||||
</Button>
|
||||
{appointment.tenantSlug && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{
|
||||
appointment.tenantSlug
|
||||
}
|
||||
{appointment.tenantSlug}
|
||||
</span>
|
||||
)}
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<Checkbox
|
||||
checked={
|
||||
appointment.isOwner
|
||||
}
|
||||
onCheckedChange={(
|
||||
checked,
|
||||
) =>
|
||||
updateAppointment(
|
||||
index,
|
||||
{
|
||||
isOwner:
|
||||
checked ===
|
||||
true,
|
||||
},
|
||||
)
|
||||
checked={appointment.isOwner}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAppointment(index, {
|
||||
isOwner: checked === true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{t(
|
||||
@@ -1315,22 +1192,11 @@ function UserDetailPage() {
|
||||
</Label>
|
||||
<Input
|
||||
id={`detail-appointment-job-title-${index}`}
|
||||
value={
|
||||
appointment.jobTitle ??
|
||||
""
|
||||
}
|
||||
onChange={(
|
||||
event,
|
||||
) =>
|
||||
updateAppointment(
|
||||
index,
|
||||
{
|
||||
jobTitle:
|
||||
event
|
||||
.target
|
||||
.value,
|
||||
},
|
||||
)
|
||||
value={appointment.jobTitle ?? ""}
|
||||
onChange={(event) =>
|
||||
updateAppointment(index, {
|
||||
jobTitle: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -1346,22 +1212,11 @@ function UserDetailPage() {
|
||||
</Label>
|
||||
<Input
|
||||
id={`detail-appointment-position-${index}`}
|
||||
value={
|
||||
appointment.position ??
|
||||
""
|
||||
}
|
||||
onChange={(
|
||||
event,
|
||||
) =>
|
||||
updateAppointment(
|
||||
index,
|
||||
{
|
||||
position:
|
||||
event
|
||||
.target
|
||||
.value,
|
||||
},
|
||||
)
|
||||
value={appointment.position ?? ""}
|
||||
onChange={(event) =>
|
||||
updateAppointment(index, {
|
||||
position: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -1370,23 +1225,15 @@ function UserDetailPage() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
removeAppointment(
|
||||
index,
|
||||
)
|
||||
}
|
||||
onClick={() => removeAppointment(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t(
|
||||
"ui.common.delete",
|
||||
"삭제",
|
||||
)}
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1423,10 +1270,7 @@ function UserDetailPage() {
|
||||
htmlFor="position"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.position",
|
||||
"직급",
|
||||
)}
|
||||
{t("ui.admin.users.detail.form.position", "직급")}
|
||||
</Label>
|
||||
<Input
|
||||
id="position"
|
||||
@@ -1439,10 +1283,7 @@ function UserDetailPage() {
|
||||
htmlFor="jobTitle"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.job_title",
|
||||
"직무",
|
||||
)}
|
||||
{t("ui.admin.users.detail.form.job_title", "직무")}
|
||||
</Label>
|
||||
<Input
|
||||
id="jobTitle"
|
||||
@@ -1467,10 +1308,7 @@ function UserDetailPage() {
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<span className="text-base font-bold">
|
||||
{t(
|
||||
"ui.admin.users.detail.save",
|
||||
"저장하기",
|
||||
)}
|
||||
{t("ui.admin.users.detail.save", "저장하기")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1483,10 +1321,7 @@ function UserDetailPage() {
|
||||
<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"
|
||||
/>
|
||||
<Building2 size={18} className="text-primary" />
|
||||
{t(
|
||||
"ui.admin.users.detail.custom_fields.multi_title",
|
||||
"테넌트별 상세 프로필",
|
||||
@@ -1502,10 +1337,7 @@ function UserDetailPage() {
|
||||
<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"
|
||||
/>
|
||||
<Building2 size={48} className="mx-auto mb-4 opacity-20" />
|
||||
<p className="font-medium">
|
||||
{t(
|
||||
"msg.admin.users.detail.no_tenants",
|
||||
@@ -1519,8 +1351,7 @@ function UserDetailPage() {
|
||||
const tDetail = tenants.find(
|
||||
(tenant) => tenant.id === t.id,
|
||||
);
|
||||
const schema = (tDetail?.config
|
||||
?.userSchema ||
|
||||
const schema = (tDetail?.config?.userSchema ||
|
||||
[]) as UserSchemaField[];
|
||||
return (
|
||||
<TenantMetadataFields
|
||||
@@ -1568,10 +1399,7 @@ function UserDetailPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Key size={18} className="text-primary" />
|
||||
{t(
|
||||
"ui.admin.users.detail.password_title",
|
||||
"비밀번호 관리",
|
||||
)}
|
||||
{t("ui.admin.users.detail.password_title", "비밀번호 관리")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
@@ -1603,19 +1431,13 @@ function UserDetailPage() {
|
||||
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",
|
||||
"초기화 도구",
|
||||
)}
|
||||
{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"
|
||||
/>
|
||||
<Shield size={18} className="shrink-0" />
|
||||
<p className="leading-relaxed">
|
||||
{t(
|
||||
"msg.admin.users.detail.self_password_reset_blocked",
|
||||
@@ -1625,16 +1447,12 @@ function UserDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPasswordResetOpen &&
|
||||
!generatedPassword &&
|
||||
!isSelf && (
|
||||
{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,
|
||||
)
|
||||
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">
|
||||
@@ -1642,26 +1460,17 @@ function UserDetailPage() {
|
||||
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",
|
||||
"자동 생성",
|
||||
)}
|
||||
{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",
|
||||
"직접 입력",
|
||||
)}
|
||||
{t("ui.admin.users.detail.reset_manual", "직접 입력")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="auto"
|
||||
className="space-y-4"
|
||||
>
|
||||
<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",
|
||||
@@ -1671,9 +1480,7 @@ function UserDetailPage() {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setManualPassword(
|
||||
generateSecurePassword(),
|
||||
)
|
||||
setManualPassword(generateSecurePassword())
|
||||
}
|
||||
variant="secondary"
|
||||
className="w-full h-11 rounded-xl font-bold"
|
||||
@@ -1685,10 +1492,7 @@ function UserDetailPage() {
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="manual"
|
||||
className="space-y-5 pt-2"
|
||||
>
|
||||
<TabsContent value="manual" className="space-y-5 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-bold uppercase">
|
||||
{t(
|
||||
@@ -1699,18 +1503,11 @@ function UserDetailPage() {
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={
|
||||
isManualPasswordVisible
|
||||
? "text"
|
||||
: "password"
|
||||
}
|
||||
value={
|
||||
manualPassword
|
||||
isManualPasswordVisible ? "text" : "password"
|
||||
}
|
||||
value={manualPassword}
|
||||
onChange={(e) =>
|
||||
setManualPassword(
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
setManualPassword(e.target.value)
|
||||
}
|
||||
className="h-11 rounded-xl shadow-sm pr-12"
|
||||
/>
|
||||
@@ -1726,17 +1523,9 @@ function UserDetailPage() {
|
||||
}
|
||||
>
|
||||
{isManualPasswordVisible ? (
|
||||
<EyeOff
|
||||
size={
|
||||
18
|
||||
}
|
||||
/>
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye
|
||||
size={
|
||||
18
|
||||
}
|
||||
/>
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1750,14 +1539,9 @@ function UserDetailPage() {
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={
|
||||
manualPasswordConfirm
|
||||
}
|
||||
value={manualPasswordConfirm}
|
||||
onChange={(e) =>
|
||||
setManualPasswordConfirm(
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
setManualPasswordConfirm(e.target.value)
|
||||
}
|
||||
className="h-11 rounded-xl shadow-sm"
|
||||
/>
|
||||
@@ -1777,24 +1561,15 @@ function UserDetailPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={
|
||||
handleClosePasswordReset
|
||||
}
|
||||
onClick={handleClosePasswordReset}
|
||||
className="h-11 rounded-xl px-6 font-bold"
|
||||
>
|
||||
{t(
|
||||
"ui.common.cancel",
|
||||
"취소",
|
||||
)}
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={
|
||||
handleExecutePasswordReset
|
||||
}
|
||||
disabled={
|
||||
resetMutation.isPending
|
||||
}
|
||||
onClick={handleExecutePasswordReset}
|
||||
disabled={resetMutation.isPending}
|
||||
className="h-11 rounded-xl px-8 font-bold shadow-md"
|
||||
>
|
||||
{resetMutation.isPending && (
|
||||
@@ -1813,10 +1588,7 @@ function UserDetailPage() {
|
||||
{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"
|
||||
/>
|
||||
<BadgeCheck size={28} className="text-green-600" />
|
||||
{t(
|
||||
"ui.admin.users.detail.password_done",
|
||||
"성공적으로 초기화됨",
|
||||
@@ -1830,22 +1602,14 @@ function UserDetailPage() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
generatedPassword,
|
||||
);
|
||||
navigator.clipboard.writeText(generatedPassword);
|
||||
toast.success(
|
||||
t(
|
||||
"msg.common.copied",
|
||||
"복사되었습니다.",
|
||||
),
|
||||
t("msg.common.copied", "복사되었습니다."),
|
||||
);
|
||||
}}
|
||||
className="h-10 px-4 rounded-xl hover:bg-green-50 font-bold"
|
||||
>
|
||||
<Copy
|
||||
size={16}
|
||||
className="mr-2"
|
||||
/>
|
||||
<Copy size={16} className="mr-2" />
|
||||
{t("ui.common.copy", "복사")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1854,10 +1618,7 @@ function UserDetailPage() {
|
||||
type="button"
|
||||
onClick={handleClosePasswordReset}
|
||||
>
|
||||
{t(
|
||||
"ui.common.close",
|
||||
"안전하게 도구 닫기",
|
||||
)}
|
||||
{t("ui.common.close", "안전하게 도구 닫기")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1867,14 +1628,8 @@ function UserDetailPage() {
|
||||
<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",
|
||||
"서비스 이용 내역",
|
||||
)}
|
||||
<History size={18} className="text-primary" />
|
||||
{t("ui.admin.users.detail.history_title", "서비스 이용 내역")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
@@ -1886,18 +1641,11 @@ function UserDetailPage() {
|
||||
<CardContent className="p-8 pt-2">
|
||||
{rpHistoryQuery.isLoading ? (
|
||||
<div className="py-12 text-center text-muted-foreground animate-pulse">
|
||||
{t(
|
||||
"msg.common.loading",
|
||||
"불러오는 중...",
|
||||
)}
|
||||
{t("msg.common.loading", "불러오는 중...")}
|
||||
</div>
|
||||
) : !rpHistoryQuery.data ||
|
||||
rpHistoryQuery.data.length === 0 ? (
|
||||
) : !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"
|
||||
/>
|
||||
<History size={40} className="mx-auto mb-4 opacity-10" />
|
||||
<p className="text-sm font-medium">
|
||||
{t(
|
||||
"msg.admin.users.detail.no_history",
|
||||
@@ -1909,16 +1657,12 @@ function UserDetailPage() {
|
||||
<div className="grid gap-4">
|
||||
{rpHistoryQuery.data.map((item) => (
|
||||
<div
|
||||
key={
|
||||
item.client_id ||
|
||||
item.client_id
|
||||
}
|
||||
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}
|
||||
{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}
|
||||
@@ -1950,10 +1694,7 @@ function UserDetailPage() {
|
||||
<DialogContent className="max-w-[460px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(
|
||||
"ui.admin.users.detail.form.pick_tenant",
|
||||
"테넌트 선택",
|
||||
)}
|
||||
{t("ui.admin.users.detail.form.pick_tenant", "테넌트 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
@@ -1963,10 +1704,7 @@ function UserDetailPage() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<iframe
|
||||
title={t(
|
||||
"ui.admin.users.detail.form.pick_tenant",
|
||||
"테넌트 선택",
|
||||
)}
|
||||
title={t("ui.admin.users.detail.form.pick_tenant", "테넌트 선택")}
|
||||
src={pickerUrl}
|
||||
className="h-[600px] w-full rounded-md border"
|
||||
/>
|
||||
|
||||
@@ -159,10 +159,7 @@ function UserListPage() {
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t(
|
||||
"msg.admin.users.export_error",
|
||||
"사용자 내보내기에 실패했습니다.",
|
||||
),
|
||||
t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -400,10 +400,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
data-testid="user-import-tenant-resolution"
|
||||
>
|
||||
<div className="mb-2 font-medium">
|
||||
{t(
|
||||
"ui.admin.users.bulk.tenant_resolution",
|
||||
"테넌트 매핑",
|
||||
)}
|
||||
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{tenantPreviewRows.map((preview) => (
|
||||
@@ -487,7 +484,9 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
<td className="p-2">
|
||||
<input
|
||||
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
|
||||
value={hanmacEmailPreviews[index]?.finalEmail ?? u.email}
|
||||
value={
|
||||
hanmacEmailPreviews[index]?.finalEmail ?? u.email
|
||||
}
|
||||
onChange={(event) =>
|
||||
setPreviewData((prev) =>
|
||||
prev.map((item, itemIndex) =>
|
||||
|
||||
@@ -148,7 +148,9 @@ test.describe("Tenant Allowed Domains", () => {
|
||||
|
||||
await page.getByRole("button", { name: "저장" }).click();
|
||||
|
||||
await expect.poll(() => savedPayload).toMatchObject({
|
||||
await expect
|
||||
.poll(() => savedPayload)
|
||||
.toMatchObject({
|
||||
domains: ["samaneng.com"],
|
||||
forceDomainConflicts: ["samaneng.com"],
|
||||
});
|
||||
|
||||
@@ -288,9 +288,7 @@ test.describe("Tenants Management", () => {
|
||||
await page
|
||||
.getByTestId("tenant-import-match-select-3")
|
||||
.selectOption("__create__");
|
||||
await page
|
||||
.getByTestId("tenant-import-create-slug-3")
|
||||
.fill("child-created");
|
||||
await page.getByTestId("tenant-import-create-slug-3").fill("child-created");
|
||||
await page.getByTestId("tenant-import-confirm-btn").click();
|
||||
|
||||
await expect(page.getByTestId("tenant-import-result")).toContainText(
|
||||
|
||||
@@ -38,8 +38,9 @@ test.describe("Tenants CSV live E2E", () => {
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
|
||||
const headers = { ...route.request().headers() };
|
||||
delete headers.authorization;
|
||||
const { authorization: _authorization, ...headers } = route
|
||||
.request()
|
||||
.headers();
|
||||
headers["x-test-role"] = "super_admin";
|
||||
const response = await route.fetch({ url: liveUrl, headers });
|
||||
await route.fulfill({ response });
|
||||
|
||||
@@ -368,7 +368,9 @@ test.describe("User Management", () => {
|
||||
"외부 기업 회원",
|
||||
"개인 회원",
|
||||
]);
|
||||
await expect(page.getByRole("tab", { name: /외부 기업 회원/i })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /외부 기업 회원/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||
).toBeVisible();
|
||||
@@ -376,9 +378,7 @@ test.describe("User Management", () => {
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||
).toHaveAttribute("data-state", "active");
|
||||
await expect(
|
||||
page.getByLabel(/한맥 가족 구성원으로 등록/i),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
|
||||
|
||||
// Select Tenant first (important for schema fields to show up)
|
||||
await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
|
||||
@@ -527,9 +527,7 @@ test.describe("User Management", () => {
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||
).toHaveAttribute("data-state", "active");
|
||||
await expect(
|
||||
page.getByLabel(/한맥 가족 구성원으로 등록/i),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
|
||||
await expect(page.locator("select#role")).toHaveCount(0);
|
||||
await expect(page.locator("input#department")).toHaveCount(0);
|
||||
|
||||
@@ -722,9 +720,7 @@ test.describe("User Management", () => {
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||
).toHaveAttribute("data-state", "active");
|
||||
await expect(
|
||||
page.getByLabel(/한맥 가족 구성원으로 등록/i),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
|
||||
await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("detail-appointment-tenant-owner-line-0"),
|
||||
|
||||
@@ -177,9 +177,9 @@ test.describe("Users Bulk Upload", () => {
|
||||
),
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("user-import-tenant-resolution")).toContainText(
|
||||
/신규 생성|Create new/i,
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("user-import-tenant-resolution"),
|
||||
).toContainText(/신규 생성|Create new/i);
|
||||
await page.getByTestId("bulk-start-btn").click();
|
||||
|
||||
await expect(page.getByText("new@test.com")).toBeVisible();
|
||||
|
||||
@@ -39,8 +39,9 @@ test.describe("Users CSV live E2E", () => {
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
|
||||
const headers = { ...route.request().headers() };
|
||||
delete headers.authorization;
|
||||
const { authorization: _authorization, ...headers } = route
|
||||
.request()
|
||||
.headers();
|
||||
headers["x-test-role"] = "super_admin";
|
||||
const response = await route.fetch({ url: liveUrl, headers });
|
||||
await route.fulfill({ response });
|
||||
@@ -75,7 +76,9 @@ test.describe("Users CSV live E2E", () => {
|
||||
expect(path).toBeTruthy();
|
||||
|
||||
const csv = fs.readFileSync(path as string, "utf8");
|
||||
expect(csv).toContain("ID,Email,Name,Phone,Status,Tenant,Position,JobTitle,CreatedAt");
|
||||
expect(csv).toContain(
|
||||
"ID,Email,Name,Phone,Status,Tenant,Position,JobTitle,CreatedAt",
|
||||
);
|
||||
expect(csv).not.toContain("Role");
|
||||
expect(csv).not.toContain("Department");
|
||||
});
|
||||
|
||||
@@ -230,5 +230,4 @@ test.describe("User Schema Dynamic Form", () => {
|
||||
.first();
|
||||
await expect(errorMsg).toBeVisible();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user