1
0
forked from baron/baron-sso

Format adminfront code check targets

This commit is contained in:
2026-04-30 15:59:57 +09:00
parent 6c45eca3d3
commit 790be37930
17 changed files with 2531 additions and 2919 deletions

View File

@@ -24,6 +24,7 @@
"node_modules", "node_modules",
"tsconfig*.json", "tsconfig*.json",
"test-results", "test-results",
"test-results.nobody-backup",
"playwright-report" "playwright-report"
] ]
} }

View File

@@ -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( expect(
await screen.findByText( await screen.findByText(

View File

@@ -71,8 +71,7 @@ export function normalizeSchemaField(field: unknown): SchemaField {
type, type,
required: Boolean(source.required), required: Boolean(source.required),
adminOnly: Boolean(source.adminOnly), adminOnly: Boolean(source.adminOnly),
validation: validation: typeof source.validation === "string" ? source.validation : "",
typeof source.validation === "string" ? source.validation : "",
unsigned: Boolean(source.unsigned), unsigned: Boolean(source.unsigned),
isLoginId, isLoginId,
indexed: isLoginId || Boolean(source.indexed), indexed: isLoginId || Boolean(source.indexed),

View File

@@ -54,6 +54,9 @@ export function formatDomainConflictMessage(
const tenantName = const tenantName =
"tenant" in conflict "tenant" in conflict
? conflict.tenant.name ? conflict.tenant.name
: conflict.tenantName || conflict.tenantSlug || conflict.tenantId || "다른"; : conflict.tenantName ||
conflict.tenantSlug ||
conflict.tenantId ||
"다른";
return `${conflict.domain} 도메인은 ${tenantName} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`; return `${conflict.domain} 도메인은 ${tenantName} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
} }

View File

@@ -129,7 +129,10 @@ export function buildTenantImportPreview(
candidates[0] && candidates[0].score >= 0.95 candidates[0] && candidates[0].score >= 0.95
? candidates[0].tenantId ? candidates[0].tenantId
: "", : "",
defaultCreateSlug: suggestUniqueTenantSlug(row.slug || row.name, tenants), defaultCreateSlug: suggestUniqueTenantSlug(
row.slug || row.name,
tenants,
),
}; };
}) })
.sort((a, b) => { .sort((a, b) => {
@@ -148,10 +151,7 @@ export function serializeTenantImportCSV(
const sortedRows = [...previewRows].sort( const sortedRows = [...previewRows].sort(
(a, b) => a.row.rowNumber - b.row.rowNumber, (a, b) => a.row.rowNumber - b.row.rowNumber,
); );
const targetTenantIds = buildTargetTenantIds( const targetTenantIds = buildTargetTenantIds(sortedRows, selectedTenantIds);
sortedRows,
selectedTenantIds,
);
for (const preview of sortedRows) { for (const preview of sortedRows) {
const resolution = selectedTenantIds[preview.row.rowNumber] ?? ""; const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
@@ -241,7 +241,9 @@ function remapParentTenantId(
return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId; return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId;
} }
if (parentTenantSlug) { if (parentTenantSlug) {
return targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? ""; return (
targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? ""
);
} }
return ""; return "";
} }

View File

@@ -164,9 +164,7 @@ function UserCreatePage() {
if (typeof envTenantId === "string" && envTenantId.trim()) { if (typeof envTenantId === "string" && envTenantId.trim()) {
return envTenantId.trim(); return envTenantId.trim();
} }
return ( return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? ""
);
}, [tenants]); }, [tenants]);
const nonHanmacFamilyTenants = React.useMemo( const nonHanmacFamilyTenants = React.useMemo(
() => filterNonHanmacFamilyTenants(tenants, hanmacFamilyTenantId), () => filterNonHanmacFamilyTenants(tenants, hanmacFamilyTenantId),
@@ -291,9 +289,7 @@ function UserCreatePage() {
) => { ) => {
setAdditionalAppointments((current) => setAdditionalAppointments((current) =>
current.map((appointment, currentIndex) => current.map((appointment, currentIndex) =>
currentIndex === index currentIndex === index ? { ...appointment, ...patch } : appointment,
? { ...appointment, ...patch }
: appointment,
), ),
); );
}; };
@@ -339,10 +335,7 @@ function UserCreatePage() {
onError: (err: AxiosError<{ error?: string }>) => { onError: (err: AxiosError<{ error?: string }>) => {
setError( setError(
err.response?.data?.error || err.response?.data?.error ||
t( t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
"msg.admin.users.create.error",
"사용자 생성에 실패했습니다.",
),
); );
}, },
}); });
@@ -494,24 +487,15 @@ function UserCreatePage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3 rounded-md border border-dashed px-4 py-3"> <div className="flex flex-wrap items-center gap-3 rounded-md border border-dashed px-4 py-3">
<span className="font-mono text-sm"> <span className="font-mono text-sm">{generatedPassword}</span>
{generatedPassword} <Button size="sm" variant="outline" onClick={onCopyPassword}>
</span>
<Button
size="sm"
variant="outline"
onClick={onCopyPassword}
>
<ClipboardCopy className="mr-2 h-4 w-4" /> <ClipboardCopy className="mr-2 h-4 w-4" />
{t("ui.common.copy", "복사")} {t("ui.common.copy", "복사")}
</Button> </Button>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={() => navigate("/users")}> <Button onClick={() => navigate("/users")}>
{t( {t("ui.admin.users.create.go_list", "목록으로 이동")}
"ui.admin.users.create.go_list",
"목록으로 이동",
)}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -531,10 +515,7 @@ function UserCreatePage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
onSubmit={handleSubmit(onSubmit)}
className="space-y-6"
>
{error && ( {error && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive"> <div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error} {error}
@@ -543,10 +524,7 @@ function UserCreatePage() {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email"> <Label htmlFor="email">
{t( {t("ui.admin.users.create.form.email", "이메일")}
"ui.admin.users.create.form.email",
"이메일",
)}
</Label> </Label>
<Input <Input
id="email" id="email"
@@ -571,25 +549,15 @@ function UserCreatePage() {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="password"> <Label htmlFor="password">
{t( {t("ui.admin.users.create.form.password", "비밀번호")}
"ui.admin.users.create.form.password",
"비밀번호",
)}
</Label> </Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground"> <label className="flex items-center gap-2 text-xs text-muted-foreground">
<input <input
type="checkbox" type="checkbox"
checked={autoPassword} checked={autoPassword}
onChange={(event) => onChange={(event) => setAutoPassword(event.target.checked)}
setAutoPassword(
event.target.checked,
)
}
/> />
{t( {t("ui.admin.users.create.form.auto_password", "자동 생성")}
"ui.admin.users.create.form.auto_password",
"자동 생성",
)}
</label> </label>
</div> </div>
<Input <Input
@@ -618,10 +586,7 @@ function UserCreatePage() {
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name"> <Label htmlFor="name">
{t( {t("ui.admin.users.create.form.name", "이름")}
"ui.admin.users.create.form.name",
"이름",
)}
</Label> </Label>
<Input <Input
id="name" id="name"
@@ -645,10 +610,7 @@ function UserCreatePage() {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="phone"> <Label htmlFor="phone">
{t( {t("ui.admin.users.create.form.phone", "전화번호")}
"ui.admin.users.create.form.phone",
"전화번호",
)}
</Label> </Label>
<Input <Input
id="phone" id="phone"
@@ -661,10 +623,7 @@ function UserCreatePage() {
</div> </div>
</div> </div>
<Tabs <Tabs value={userType} onValueChange={handleUserTypeChange}>
value={userType}
onValueChange={handleUserTypeChange}
>
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground"> <TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
<TabsTrigger <TabsTrigger
value="hanmac" value="hanmac"
@@ -686,42 +645,26 @@ function UserCreatePage() {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent <TabsContent value="external" className="mt-4 space-y-4">
value="external"
className="mt-4 space-y-4"
>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="tenantSlug"> <Label htmlFor="tenantSlug"></Label>
</Label>
<select <select
id="tenantSlug" 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" 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")} {...register("tenantSlug")}
disabled={ disabled={profile?.role === "tenant_admin"}
profile?.role === "tenant_admin"
}
> >
{nonHanmacFamilyTenants.map( {nonHanmacFamilyTenants.map((tenant) => (
(tenant) => ( <option key={tenant.id} value={tenant.slug}>
<option {tenant.name} ({tenant.slug})
key={tenant.id}
value={tenant.slug}
>
{tenant.name} (
{tenant.slug})
</option> </option>
), ))}
)}
</select> </select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="department"></Label> <Label htmlFor="department"></Label>
<Input <Input id="department" {...register("department")} />
id="department"
{...register("department")}
/>
</div> </div>
</div> </div>
@@ -745,19 +688,13 @@ function UserCreatePage() {
</div> </div>
</TabsContent> </TabsContent>
<TabsContent <TabsContent value="hanmac" className="mt-4 space-y-4">
value="hanmac"
className="mt-4 space-y-4"
>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-sm font-medium"> <p className="text-sm font-medium"> /</p>
/
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
, , , , .
.
</p> </p>
</div> </div>
<Button <Button
@@ -771,8 +708,7 @@ function UserCreatePage() {
</Button> </Button>
</div> </div>
{additionalAppointments.map( {additionalAppointments.map((appointment, index) => (
(appointment, index) => (
<div <div
key={appointment.draftId} key={appointment.draftId}
data-testid={`appointment-row-${index}`} data-testid={`appointment-row-${index}`}
@@ -788,44 +724,28 @@ function UserCreatePage() {
type="button" type="button"
variant="outline" variant="outline"
onClick={() => onClick={() =>
setPickerTarget( setPickerTarget({
{
kind: "appointment", kind: "appointment",
index, index,
}, })
)
}
disabled={
isResolvingTenant
} }
disabled={isResolvingTenant}
> >
<Building2 className="mr-2 h-4 w-4" /> <Building2 className="mr-2 h-4 w-4" />
{appointment.tenantName || {appointment.tenantName || "테넌트 선택"}
"테넌트 선택"}
</Button> </Button>
{appointment.tenantSlug && ( {appointment.tenantSlug && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{ {appointment.tenantSlug}
appointment.tenantSlug
}
</span> </span>
)} )}
<label className="flex items-center gap-3 text-sm"> <label className="flex items-center gap-3 text-sm">
<Checkbox <Checkbox
checked={ checked={appointment.isOwner}
appointment.isOwner onCheckedChange={(checked) =>
} updateAppointment(index, {
onCheckedChange={( isOwner: checked === true,
checked, })
) =>
updateAppointment(
index,
{
isOwner:
checked ===
true,
},
)
} }
/> />
@@ -838,52 +758,30 @@ function UserCreatePage() {
data-testid={`appointment-position-line-${index}`} data-testid={`appointment-position-line-${index}`}
> >
<div className="space-y-2"> <div className="space-y-2">
<Label <Label htmlFor={`appointment-job-title-${index}`}>
htmlFor={`appointment-job-title-${index}`}
>
</Label> </Label>
<Input <Input
id={`appointment-job-title-${index}`} id={`appointment-job-title-${index}`}
value={ value={appointment.jobTitle ?? ""}
appointment.jobTitle ??
""
}
onChange={(event) => onChange={(event) =>
updateAppointment( updateAppointment(index, {
index, jobTitle: event.target.value,
{ })
jobTitle:
event
.target
.value,
},
)
} }
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label <Label htmlFor={`appointment-position-${index}`}>
htmlFor={`appointment-position-${index}`}
>
</Label> </Label>
<Input <Input
id={`appointment-position-${index}`} id={`appointment-position-${index}`}
value={ value={appointment.position ?? ""}
appointment.position ??
""
}
onChange={(event) => onChange={(event) =>
updateAppointment( updateAppointment(index, {
index, position: event.target.value,
{ })
position:
event
.target
.value,
},
)
} }
/> />
</div> </div>
@@ -892,21 +790,15 @@ function UserCreatePage() {
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onClick={() => removeAppointment(index)}
removeAppointment(index)
}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
<span className="sr-only"> <span className="sr-only">
{t( {t("ui.common.delete", "삭제")}
"ui.common.delete",
"삭제",
)}
</span> </span>
</Button> </Button>
</div> </div>
), ))}
)}
</div> </div>
</TabsContent> </TabsContent>
@@ -933,18 +825,11 @@ function UserCreatePage() {
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => ( {userSchema.map((field) => (
<div <div key={field.key} className="space-y-2">
key={field.key} <Label htmlFor={`metadata.${field.key}`}>
className="space-y-2"
>
<Label
htmlFor={`metadata.${field.key}`}
>
{field.label} {field.label}
{field.required && ( {field.required && (
<span className="ml-1 text-destructive"> <span className="ml-1 text-destructive">*</span>
*
</span>
)} )}
{field.adminOnly && ( {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"> <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" ? "number"
: field.type === "date" : field.type === "date"
? "date" ? "date"
: field.type === : field.type === "boolean"
"boolean"
? "checkbox" ? "checkbox"
: "text" : "text"
} }
className={ className={
field.type === "boolean" field.type === "boolean" ? "w-auto h-auto" : ""
? "w-auto h-auto"
: ""
} }
{...registerMetadata(field)} {...registerMetadata(field)}
/> />
@@ -984,9 +866,7 @@ function UserCreatePage() {
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
{ {
( (
errors.metadata[ errors.metadata[field.key] as {
field.key
] as {
message?: string; message?: string;
} }
)?.message )?.message
@@ -1012,10 +892,7 @@ function UserCreatePage() {
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
)} )}
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
{t( {t("ui.admin.users.create.submit", "사용자 생성")}
"ui.admin.users.create.submit",
"사용자 생성",
)}
</Button> </Button>
</div> </div>
</form> </form>
@@ -1030,10 +907,7 @@ function UserCreatePage() {
<DialogContent className="max-w-[460px] p-4"> <DialogContent className="max-w-[460px] p-4">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{t( {t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
"ui.admin.users.create.form.pick_tenant",
"테넌트 선택",
)}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{t( {t(
@@ -1043,10 +917,7 @@ function UserCreatePage() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<iframe <iframe
title={t( title={t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
"ui.admin.users.create.form.pick_tenant",
"테넌트 선택",
)}
src={pickerUrl} src={pickerUrl}
className="h-[600px] w-full rounded-md border" className="h-[600px] w-full rounded-md border"
/> />

View File

@@ -230,9 +230,7 @@ function TenantMetadataFields({
className="text-xs font-semibold text-muted-foreground flex items-center gap-1" className="text-xs font-semibold text-muted-foreground flex items-center gap-1"
> >
{field.label} {field.label}
{field.required && ( {field.required && <span className="text-destructive">*</span>}
<span className="text-destructive">*</span>
)}
{field.adminOnly && ( {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"> <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 Admin Only
@@ -240,10 +238,7 @@ function TenantMetadataFields({
)} )}
{field.isLoginId && ( {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"> <span className="ml-2 text-[9px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
{t( {t("ui.admin.users.detail.form.is_login_id", "로그인 ID")}
"ui.admin.users.detail.form.is_login_id",
"로그인 ID",
)}
</span> </span>
)} )}
</Label> </Label>
@@ -258,14 +253,8 @@ function TenantMetadataFields({
? "checkbox" ? "checkbox"
: "text" : "text"
} }
className={ className={field.type === "boolean" ? "w-5 h-5" : "h-10 text-sm"}
field.type === "boolean" {...register(`metadata.${tenant.id}.${field.key}` as const, {
? "w-5 h-5"
: "h-10 text-sm"
}
{...register(
`metadata.${tenant.id}.${field.key}` as const,
{
required: field.required required: field.required
? t( ? t(
"msg.admin.users.detail.form.field_required", "msg.admin.users.detail.form.field_required",
@@ -274,17 +263,14 @@ function TenantMetadataFields({
: false, : false,
pattern: field.validation pattern: field.validation
? { ? {
value: new RegExp( value: new RegExp(field.validation),
field.validation,
),
message: t( message: t(
"msg.admin.users.detail.form.invalid_format", "msg.admin.users.detail.form.invalid_format",
"형식이 올바르지 않습니다.", "형식이 올바르지 않습니다.",
), ),
} }
: undefined, : undefined,
}, })}
)}
/> />
{( {(
errors.metadata as unknown as Record< errors.metadata as unknown as Record<
@@ -324,8 +310,7 @@ function UserDetailPage() {
const [passwordResetMode, setPasswordResetMode] = const [passwordResetMode, setPasswordResetMode] =
React.useState<PasswordResetMode>("generated"); React.useState<PasswordResetMode>("generated");
const [manualPassword, setManualPassword] = React.useState(""); const [manualPassword, setManualPassword] = React.useState("");
const [manualPasswordConfirm, setManualPasswordConfirm] = const [manualPasswordConfirm, setManualPasswordConfirm] = React.useState("");
React.useState("");
const [isManualPasswordVisible, setIsManualPasswordVisible] = const [isManualPasswordVisible, setIsManualPasswordVisible] =
React.useState(false); React.useState(false);
const [passwordResetError, setPasswordResetError] = React.useState< const [passwordResetError, setPasswordResetError] = React.useState<
@@ -405,8 +390,7 @@ function UserDetailPage() {
const watchedStatus = watch("status"); const watchedStatus = watch("status");
const resetMutation = useMutation({ const resetMutation = useMutation({
mutationFn: (newPass: string) => mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
updateUser(userId, { password: newPass }),
onSuccess: (_, newPass) => { onSuccess: (_, newPass) => {
setGeneratedPassword(newPass); setGeneratedPassword(newPass);
setPasswordResetError(null); setPasswordResetError(null);
@@ -420,10 +404,7 @@ function UserDetailPage() {
onError: (err: AxiosError<{ error?: string }>) => { onError: (err: AxiosError<{ error?: string }>) => {
const message = const message =
err.response?.data?.error || err.response?.data?.error ||
t( t("msg.admin.users.detail.update_error", "수정에 실패했습니다.");
"msg.admin.users.detail.update_error",
"수정에 실패했습니다.",
);
setPasswordResetError(message); setPasswordResetError(message);
toast.error(message); toast.error(message);
}, },
@@ -478,9 +459,7 @@ function UserDetailPage() {
if (typeof envTenantId === "string" && envTenantId.trim()) { if (typeof envTenantId === "string" && envTenantId.trim()) {
return envTenantId.trim(); return envTenantId.trim();
} }
return ( return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? ""
);
}, [tenants]); }, [tenants]);
const personalTenant = React.useMemo( const personalTenant = React.useMemo(
() => () =>
@@ -558,9 +537,7 @@ function UserDetailPage() {
) => { ) => {
setAdditionalAppointments((current) => setAdditionalAppointments((current) =>
current.map((appointment, currentIndex) => current.map((appointment, currentIndex) =>
currentIndex === index currentIndex === index ? { ...appointment, ...patch } : appointment,
? { ...appointment, ...patch }
: appointment,
), ),
); );
}; };
@@ -625,8 +602,7 @@ function UserDetailPage() {
tenantSlug: tenantSlug:
user.companyCode || user.companyCode ||
user.joinedTenants?.find( user.joinedTenants?.find(
(t) => (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
t.type === "COMPANY" || t.type === "COMPANY_GROUP",
)?.slug || )?.slug ||
"", "",
department: user.department || "", department: user.department || "",
@@ -644,8 +620,7 @@ function UserDetailPage() {
hanmacFamilyTenantId, hanmacFamilyTenantId,
); );
const resolvedUserType = const resolvedUserType =
metadata.userType === "personal" || metadata.userType === "personal" || user.companyCode === "personal"
user.companyCode === "personal"
? "personal" ? "personal"
: isUserHanmacFamily : isUserHanmacFamily
? "hanmac" ? "hanmac"
@@ -657,22 +632,15 @@ function UserDetailPage() {
...(user.tenant ? [user.tenant] : []), ...(user.tenant ? [user.tenant] : []),
].filter( ].filter(
(tenant, index, allTenants) => (tenant, index, allTenants) =>
allTenants.findIndex((item) => item.id === tenant.id) === allTenants.findIndex((item) => item.id === tenant.id) === index &&
index && isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
isHanmacFamilyTenant(
tenant,
tenants,
hanmacFamilyTenantId,
),
); );
setAdditionalAppointments( setAdditionalAppointments(
Array.isArray(rawAppointments) Array.isArray(rawAppointments)
? (rawAppointments as UserAppointment[]).map( ? (rawAppointments as UserAppointment[]).map((appointment) => ({
(appointment) => ({
...appointment, ...appointment,
draftId: createDraftId(), draftId: createDraftId(),
}), }))
)
: isUserHanmacFamily : isUserHanmacFamily
? familyFallbackTenants.length > 0 ? familyFallbackTenants.length > 0
? familyFallbackTenants.map((tenant) => ({ ? familyFallbackTenants.map((tenant) => ({
@@ -693,9 +661,7 @@ function UserDetailPage() {
tenantId: fallbackAppointment.id, tenantId: fallbackAppointment.id,
tenantName: fallbackAppointment.name, tenantName: fallbackAppointment.name,
tenantSlug: fallbackAppointment.slug, tenantSlug: fallbackAppointment.slug,
isOwner: isOwner: metadata.primaryTenantIsOwner === true,
metadata.primaryTenantIsOwner ===
true,
jobTitle: user.jobTitle, jobTitle: user.jobTitle,
position: user.position, position: user.position,
}, },
@@ -726,10 +692,7 @@ function UserDetailPage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] }); queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success( toast.success(
t( t("msg.admin.users.detail.delete_success", "사용자가 삭제되었습니다."),
"msg.admin.users.detail.delete_success",
"사용자가 삭제되었습니다.",
),
); );
navigate("/users"); navigate("/users");
}, },
@@ -825,10 +788,7 @@ function UserDetailPage() {
}, [user?.joinedTenants, user?.tenant]); }, [user?.joinedTenants, user?.tenant]);
const selectableRepresentativeTenants = React.useMemo( const selectableRepresentativeTenants = React.useMemo(
() => () =>
filterNonHanmacFamilyTenants( filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId),
userAffiliatedTenants,
hanmacFamilyTenantId,
),
[userAffiliatedTenants, hanmacFamilyTenantId], [userAffiliatedTenants, hanmacFamilyTenantId],
); );
@@ -844,10 +804,7 @@ function UserDetailPage() {
return ( return (
<div className="rounded-md bg-destructive/15 p-6 text-center mt-6"> <div className="rounded-md bg-destructive/15 p-6 text-center mt-6">
<p className="text-destructive font-medium"> <p className="text-destructive font-medium">
{t( {t("msg.admin.users.detail.not_found", "사용자를 찾을 수 없습니다.")}
"msg.admin.users.detail.not_found",
"사용자를 찾을 수 없습니다.",
)}
</p> </p>
<Button <Button
variant="outline" variant="outline"
@@ -874,11 +831,7 @@ function UserDetailPage() {
{t("ui.admin.users.detail.back", "목록으로")} {t("ui.admin.users.detail.back", "목록으로")}
</Button> </Button>
{isAdmin && ( {isAdmin && (
<Button <Button variant="destructive" onClick={handleDelete} size="sm">
variant="destructive"
onClick={handleDelete}
size="sm"
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{t("ui.admin.users.detail.delete", "사용자 삭제")} {t("ui.admin.users.detail.delete", "사용자 삭제")}
</Button> </Button>
@@ -904,27 +857,15 @@ function UserDetailPage() {
{user.tenant?.name || {user.tenant?.name ||
user.companyCode || user.companyCode ||
user.joinedTenants?.find( user.joinedTenants?.find(
(t) => (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
t.type === "COMPANY" ||
t.type === "COMPANY_GROUP",
)?.name || )?.name ||
t( t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}
"ui.admin.users.detail.form.tenant_global",
"시스템 전역",
)}
</Badge> </Badge>
<Badge <Badge
variant={ variant={user.status === "active" ? "default" : "secondary"}
user.status === "active"
? "default"
: "secondary"
}
className="h-6 px-3" className="h-6 px-3"
> >
{t( {t(`ui.common.status.${user.status}`, user.status)}
`ui.common.status.${user.status}`,
user.status,
)}
</Badge> </Badge>
</div> </div>
<div className="flex flex-wrap items-center gap-4 text-muted-foreground text-sm"> <div className="flex flex-wrap items-center gap-4 text-muted-foreground text-sm">
@@ -934,10 +875,7 @@ function UserDetailPage() {
</div> </div>
{user.phone && ( {user.phone && (
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border"> <div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
<Shield <Shield size={14} className="text-primary/70" />
size={14}
className="text-primary/70"
/>
{user.phone} {user.phone}
</div> </div>
)} )}
@@ -946,8 +884,7 @@ function UserDetailPage() {
</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"> <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> <p>
{t("ui.admin.users.detail.created_at", "가입일")}:{" "} {t("ui.admin.users.detail.created_at", "가입일")}: {user.createdAt}
{user.createdAt}
</p> </p>
<p> <p>
{t("ui.admin.users.detail.updated_at", "최근 수정")}:{" "} {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" className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
> >
<Building2 size={16} className="mr-2" /> <Building2 size={16} className="mr-2" />
{t( {t("ui.admin.users.detail.tabs.tenants", "테넌트 프로필")}
"ui.admin.users.detail.tabs.tenants",
"테넌트 프로필",
)}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="security" value="security"
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm" className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
> >
<Shield size={16} className="mr-2" /> <Shield size={16} className="mr-2" />
{t( {t("ui.admin.users.detail.tabs.security", "보안 & 활동")}
"ui.admin.users.detail.tabs.security",
"보안 & 활동",
)}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -999,14 +930,8 @@ function UserDetailPage() {
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl overflow-hidden"> <Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl overflow-hidden">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<BadgeCheck <BadgeCheck size={18} className="text-primary" />
size={18} {t("ui.admin.users.detail.edit_title", "프로필 정보")}
className="text-primary"
/>
{t(
"ui.admin.users.detail.edit_title",
"프로필 정보",
)}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t( {t(
@@ -1023,10 +948,7 @@ function UserDetailPage() {
htmlFor="email" htmlFor="email"
className="text-xs font-bold uppercase text-muted-foreground" className="text-xs font-bold uppercase text-muted-foreground"
> >
{t( {t("ui.admin.users.detail.form.email", "이메일")}
"ui.admin.users.detail.form.email",
"이메일",
)}
</Label> </Label>
<Input <Input
id="email" id="email"
@@ -1040,10 +962,7 @@ function UserDetailPage() {
htmlFor="name" htmlFor="name"
className="text-xs font-bold uppercase text-muted-foreground" className="text-xs font-bold uppercase text-muted-foreground"
> >
{t( {t("ui.admin.users.detail.form.name", "이름")}
"ui.admin.users.detail.form.name",
"이름",
)}
</Label> </Label>
<Input <Input
id="name" id="name"
@@ -1064,10 +983,7 @@ function UserDetailPage() {
htmlFor="phone" htmlFor="phone"
className="text-xs font-bold uppercase text-muted-foreground" className="text-xs font-bold uppercase text-muted-foreground"
> >
{t( {t("ui.admin.users.detail.form.phone", "연락처")}
"ui.admin.users.detail.form.phone",
"연락처",
)}
</Label> </Label>
<Input <Input
id="phone" id="phone"
@@ -1083,24 +999,14 @@ function UserDetailPage() {
htmlFor="status" htmlFor="status"
className="text-xs font-bold uppercase text-muted-foreground" className="text-xs font-bold uppercase text-muted-foreground"
> >
{t( {t("ui.admin.users.detail.form.status", "상태")}
"ui.admin.users.detail.form.status",
"상태",
)}
</Label> </Label>
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3"> <div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
<Switch <Switch
id="status" id="status"
checked={ checked={watchedStatus === "active"}
watchedStatus === "active"
}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setValue( setValue("status", checked ? "active" : "inactive")
"status",
checked
? "active"
: "inactive",
)
} }
/> />
<span className="text-sm text-muted-foreground"> <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" 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")} {...register("tenantSlug")}
disabled={ disabled={
profile?.role === profile?.role === "tenant_admin" &&
"tenant_admin" && selectableRepresentativeTenants.length <= 1
selectableRepresentativeTenants.length <=
1
} }
> >
{selectableRepresentativeTenants.map( {selectableRepresentativeTenants.map((t) => (
(t) => ( <option key={t.id} value={t.slug}>
<option {t.name} ({t.slug})
key={t.id}
value={t.slug}
>
{t.name} (
{t.slug})
</option> </option>
), ))}
)}
</select> </select>
<BadgeCheck <BadgeCheck
size={14} size={14}
@@ -1218,19 +1116,13 @@ function UserDetailPage() {
onClick={addAppointment} onClick={addAppointment}
> >
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
{t( {t("ui.common.add", "추가")}
"ui.common.add",
"추가",
)}
</Button> </Button>
</div> </div>
{additionalAppointments.map( {additionalAppointments.map((appointment, index) => (
(appointment, index) => (
<div <div
key={ key={appointment.draftId}
appointment.draftId
}
data-testid={`detail-appointment-row-${index}`} 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" 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" type="button"
variant="outline" variant="outline"
onClick={() => onClick={() =>
setPickerTarget( setPickerTarget({
{
kind: "appointment", kind: "appointment",
index, index,
}, })
)
}
disabled={
isResolvingTenant
} }
disabled={isResolvingTenant}
> >
<Building2 className="mr-2 h-4 w-4" /> <Building2 className="mr-2 h-4 w-4" />
{appointment.tenantName || {appointment.tenantName ||
@@ -1269,27 +1157,16 @@ function UserDetailPage() {
</Button> </Button>
{appointment.tenantSlug && ( {appointment.tenantSlug && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{ {appointment.tenantSlug}
appointment.tenantSlug
}
</span> </span>
)} )}
<label className="flex items-center gap-3 text-sm"> <label className="flex items-center gap-3 text-sm">
<Checkbox <Checkbox
checked={ checked={appointment.isOwner}
appointment.isOwner onCheckedChange={(checked) =>
} updateAppointment(index, {
onCheckedChange={( isOwner: checked === true,
checked, })
) =>
updateAppointment(
index,
{
isOwner:
checked ===
true,
},
)
} }
/> />
{t( {t(
@@ -1315,22 +1192,11 @@ function UserDetailPage() {
</Label> </Label>
<Input <Input
id={`detail-appointment-job-title-${index}`} id={`detail-appointment-job-title-${index}`}
value={ value={appointment.jobTitle ?? ""}
appointment.jobTitle ?? onChange={(event) =>
"" updateAppointment(index, {
} jobTitle: event.target.value,
onChange={( })
event,
) =>
updateAppointment(
index,
{
jobTitle:
event
.target
.value,
},
)
} }
/> />
</div> </div>
@@ -1346,22 +1212,11 @@ function UserDetailPage() {
</Label> </Label>
<Input <Input
id={`detail-appointment-position-${index}`} id={`detail-appointment-position-${index}`}
value={ value={appointment.position ?? ""}
appointment.position ?? onChange={(event) =>
"" updateAppointment(index, {
} position: event.target.value,
onChange={( })
event,
) =>
updateAppointment(
index,
{
position:
event
.target
.value,
},
)
} }
/> />
</div> </div>
@@ -1370,23 +1225,15 @@ function UserDetailPage() {
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onClick={() => removeAppointment(index)}
removeAppointment(
index,
)
}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
<span className="sr-only"> <span className="sr-only">
{t( {t("ui.common.delete", "삭제")}
"ui.common.delete",
"삭제",
)}
</span> </span>
</Button> </Button>
</div> </div>
), ))}
)}
</div> </div>
</div> </div>
</div> </div>
@@ -1423,10 +1270,7 @@ function UserDetailPage() {
htmlFor="position" htmlFor="position"
className="text-xs font-bold uppercase text-muted-foreground" className="text-xs font-bold uppercase text-muted-foreground"
> >
{t( {t("ui.admin.users.detail.form.position", "직급")}
"ui.admin.users.detail.form.position",
"직급",
)}
</Label> </Label>
<Input <Input
id="position" id="position"
@@ -1439,10 +1283,7 @@ function UserDetailPage() {
htmlFor="jobTitle" htmlFor="jobTitle"
className="text-xs font-bold uppercase text-muted-foreground" className="text-xs font-bold uppercase text-muted-foreground"
> >
{t( {t("ui.admin.users.detail.form.job_title", "직무")}
"ui.admin.users.detail.form.job_title",
"직무",
)}
</Label> </Label>
<Input <Input
id="jobTitle" id="jobTitle"
@@ -1467,10 +1308,7 @@ function UserDetailPage() {
<Save className="mr-2 h-5 w-5" /> <Save className="mr-2 h-5 w-5" />
)} )}
<span className="text-base font-bold"> <span className="text-base font-bold">
{t( {t("ui.admin.users.detail.save", "저장하기")}
"ui.admin.users.detail.save",
"저장하기",
)}
</span> </span>
</Button> </Button>
</div> </div>
@@ -1483,10 +1321,7 @@ function UserDetailPage() {
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl"> <Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<Building2 <Building2 size={18} className="text-primary" />
size={18}
className="text-primary"
/>
{t( {t(
"ui.admin.users.detail.custom_fields.multi_title", "ui.admin.users.detail.custom_fields.multi_title",
"테넌트별 상세 프로필", "테넌트별 상세 프로필",
@@ -1502,10 +1337,7 @@ function UserDetailPage() {
<CardContent className="space-y-8 p-8"> <CardContent className="space-y-8 p-8">
{userAffiliatedTenants.length === 0 ? ( {userAffiliatedTenants.length === 0 ? (
<div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5"> <div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5">
<Building2 <Building2 size={48} className="mx-auto mb-4 opacity-20" />
size={48}
className="mx-auto mb-4 opacity-20"
/>
<p className="font-medium"> <p className="font-medium">
{t( {t(
"msg.admin.users.detail.no_tenants", "msg.admin.users.detail.no_tenants",
@@ -1519,8 +1351,7 @@ function UserDetailPage() {
const tDetail = tenants.find( const tDetail = tenants.find(
(tenant) => tenant.id === t.id, (tenant) => tenant.id === t.id,
); );
const schema = (tDetail?.config const schema = (tDetail?.config?.userSchema ||
?.userSchema ||
[]) as UserSchemaField[]; []) as UserSchemaField[];
return ( return (
<TenantMetadataFields <TenantMetadataFields
@@ -1568,10 +1399,7 @@ function UserDetailPage() {
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<Key size={18} className="text-primary" /> <Key size={18} className="text-primary" />
{t( {t("ui.admin.users.detail.password_title", "비밀번호 관리")}
"ui.admin.users.detail.password_title",
"비밀번호 관리",
)}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t( {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" 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" /> <RefreshCw className="mr-2 h-4 w-4" />
{t( {t("ui.admin.users.detail.reset_password", "초기화 도구")}
"ui.admin.users.detail.reset_password",
"초기화 도구",
)}
</Button> </Button>
</div> </div>
{isSelf && ( {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"> <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 <Shield size={18} className="shrink-0" />
size={18}
className="shrink-0"
/>
<p className="leading-relaxed"> <p className="leading-relaxed">
{t( {t(
"msg.admin.users.detail.self_password_reset_blocked", "msg.admin.users.detail.self_password_reset_blocked",
@@ -1625,16 +1447,12 @@ function UserDetailPage() {
</div> </div>
)} )}
{isPasswordResetOpen && {isPasswordResetOpen && !generatedPassword && !isSelf && (
!generatedPassword &&
!isSelf && (
<div className="mt-4 p-6 border rounded-2xl bg-card shadow-sm animate-in zoom-in-95 duration-200"> <div className="mt-4 p-6 border rounded-2xl bg-card shadow-sm animate-in zoom-in-95 duration-200">
<Tabs <Tabs
value={passwordResetMode} value={passwordResetMode}
onValueChange={(v) => onValueChange={(v) =>
setPasswordResetMode( setPasswordResetMode(v as PasswordResetMode)
v as PasswordResetMode,
)
} }
> >
<TabsList className="grid w-full grid-cols-2 mb-6 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg"> <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" 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" 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( {t("ui.admin.users.detail.reset_auto", "자동 생성")}
"ui.admin.users.detail.reset_auto",
"자동 생성",
)}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="manual" 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" 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( {t("ui.admin.users.detail.reset_manual", "직접 입력")}
"ui.admin.users.detail.reset_manual",
"직접 입력",
)}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent <TabsContent value="auto" className="space-y-4">
value="auto"
className="space-y-4"
>
<div className="bg-muted/50 p-4 rounded-xl text-xs text-muted-foreground leading-relaxed"> <div className="bg-muted/50 p-4 rounded-xl text-xs text-muted-foreground leading-relaxed">
{t( {t(
"msg.admin.users.detail.reset_auto_desc", "msg.admin.users.detail.reset_auto_desc",
@@ -1671,9 +1480,7 @@ function UserDetailPage() {
<Button <Button
type="button" type="button"
onClick={() => onClick={() =>
setManualPassword( setManualPassword(generateSecurePassword())
generateSecurePassword(),
)
} }
variant="secondary" variant="secondary"
className="w-full h-11 rounded-xl font-bold" className="w-full h-11 rounded-xl font-bold"
@@ -1685,10 +1492,7 @@ function UserDetailPage() {
</Button> </Button>
</TabsContent> </TabsContent>
<TabsContent <TabsContent value="manual" className="space-y-5 pt-2">
value="manual"
className="space-y-5 pt-2"
>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-bold uppercase"> <Label className="text-xs font-bold uppercase">
{t( {t(
@@ -1699,18 +1503,11 @@ function UserDetailPage() {
<div className="relative"> <div className="relative">
<Input <Input
type={ type={
isManualPasswordVisible isManualPasswordVisible ? "text" : "password"
? "text"
: "password"
}
value={
manualPassword
} }
value={manualPassword}
onChange={(e) => onChange={(e) =>
setManualPassword( setManualPassword(e.target.value)
e.target
.value,
)
} }
className="h-11 rounded-xl shadow-sm pr-12" className="h-11 rounded-xl shadow-sm pr-12"
/> />
@@ -1726,17 +1523,9 @@ function UserDetailPage() {
} }
> >
{isManualPasswordVisible ? ( {isManualPasswordVisible ? (
<EyeOff <EyeOff size={18} />
size={
18
}
/>
) : ( ) : (
<Eye <Eye size={18} />
size={
18
}
/>
)} )}
</Button> </Button>
</div> </div>
@@ -1750,14 +1539,9 @@ function UserDetailPage() {
</Label> </Label>
<Input <Input
type="password" type="password"
value={ value={manualPasswordConfirm}
manualPasswordConfirm
}
onChange={(e) => onChange={(e) =>
setManualPasswordConfirm( setManualPasswordConfirm(e.target.value)
e.target
.value,
)
} }
className="h-11 rounded-xl shadow-sm" className="h-11 rounded-xl shadow-sm"
/> />
@@ -1777,24 +1561,15 @@ function UserDetailPage() {
<Button <Button
variant="ghost" variant="ghost"
type="button" type="button"
onClick={ onClick={handleClosePasswordReset}
handleClosePasswordReset
}
className="h-11 rounded-xl px-6 font-bold" className="h-11 rounded-xl px-6 font-bold"
> >
{t( {t("ui.common.cancel", "취소")}
"ui.common.cancel",
"취소",
)}
</Button> </Button>
<Button <Button
type="button" type="button"
onClick={ onClick={handleExecutePasswordReset}
handleExecutePasswordReset disabled={resetMutation.isPending}
}
disabled={
resetMutation.isPending
}
className="h-11 rounded-xl px-8 font-bold shadow-md" className="h-11 rounded-xl px-8 font-bold shadow-md"
> >
{resetMutation.isPending && ( {resetMutation.isPending && (
@@ -1813,10 +1588,7 @@ function UserDetailPage() {
{generatedPassword && ( {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="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"> <div className="flex items-center gap-3 text-green-700 font-extrabold text-lg">
<BadgeCheck <BadgeCheck size={28} className="text-green-600" />
size={28}
className="text-green-600"
/>
{t( {t(
"ui.admin.users.detail.password_done", "ui.admin.users.detail.password_done",
"성공적으로 초기화됨", "성공적으로 초기화됨",
@@ -1830,22 +1602,14 @@ function UserDetailPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
navigator.clipboard.writeText( navigator.clipboard.writeText(generatedPassword);
generatedPassword,
);
toast.success( toast.success(
t( t("msg.common.copied", "복사되었습니다."),
"msg.common.copied",
"복사되었습니다.",
),
); );
}} }}
className="h-10 px-4 rounded-xl hover:bg-green-50 font-bold" className="h-10 px-4 rounded-xl hover:bg-green-50 font-bold"
> >
<Copy <Copy size={16} className="mr-2" />
size={16}
className="mr-2"
/>
{t("ui.common.copy", "복사")} {t("ui.common.copy", "복사")}
</Button> </Button>
</div> </div>
@@ -1854,10 +1618,7 @@ function UserDetailPage() {
type="button" type="button"
onClick={handleClosePasswordReset} onClick={handleClosePasswordReset}
> >
{t( {t("ui.common.close", "안전하게 도구 닫기")}
"ui.common.close",
"안전하게 도구 닫기",
)}
</Button> </Button>
</div> </div>
)} )}
@@ -1867,14 +1628,8 @@ function UserDetailPage() {
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl"> <Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<History <History size={18} className="text-primary" />
size={18} {t("ui.admin.users.detail.history_title", "서비스 이용 내역")}
className="text-primary"
/>
{t(
"ui.admin.users.detail.history_title",
"서비스 이용 내역",
)}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t( {t(
@@ -1886,18 +1641,11 @@ function UserDetailPage() {
<CardContent className="p-8 pt-2"> <CardContent className="p-8 pt-2">
{rpHistoryQuery.isLoading ? ( {rpHistoryQuery.isLoading ? (
<div className="py-12 text-center text-muted-foreground animate-pulse"> <div className="py-12 text-center text-muted-foreground animate-pulse">
{t( {t("msg.common.loading", "불러오는 중...")}
"msg.common.loading",
"불러오는 중...",
)}
</div> </div>
) : !rpHistoryQuery.data || ) : !rpHistoryQuery.data || rpHistoryQuery.data.length === 0 ? (
rpHistoryQuery.data.length === 0 ? (
<div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5"> <div className="py-16 text-center text-muted-foreground border-2 border-dashed rounded-2xl bg-muted/5">
<History <History size={40} className="mx-auto mb-4 opacity-10" />
size={40}
className="mx-auto mb-4 opacity-10"
/>
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{t( {t(
"msg.admin.users.detail.no_history", "msg.admin.users.detail.no_history",
@@ -1909,16 +1657,12 @@ function UserDetailPage() {
<div className="grid gap-4"> <div className="grid gap-4">
{rpHistoryQuery.data.map((item) => ( {rpHistoryQuery.data.map((item) => (
<div <div
key={ key={item.client_id || item.client_id}
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" 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"> <div className="flex flex-col gap-1.5">
<span className="font-extrabold text-base group-hover:text-primary transition-colors"> <span className="font-extrabold text-base group-hover:text-primary transition-colors">
{item.client_name || {item.client_name || item.client_id}
item.client_id}
</span> </span>
<span className="text-[11px] text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded w-fit"> <span className="text-[11px] text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded w-fit">
{item.client_id} {item.client_id}
@@ -1950,10 +1694,7 @@ function UserDetailPage() {
<DialogContent className="max-w-[460px] p-4"> <DialogContent className="max-w-[460px] p-4">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{t( {t("ui.admin.users.detail.form.pick_tenant", "테넌트 선택")}
"ui.admin.users.detail.form.pick_tenant",
"테넌트 선택",
)}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{t( {t(
@@ -1963,10 +1704,7 @@ function UserDetailPage() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<iframe <iframe
title={t( title={t("ui.admin.users.detail.form.pick_tenant", "테넌트 선택")}
"ui.admin.users.detail.form.pick_tenant",
"테넌트 선택",
)}
src={pickerUrl} src={pickerUrl}
className="h-[600px] w-full rounded-md border" className="h-[600px] w-full rounded-md border"
/> />

View File

@@ -159,10 +159,7 @@ function UserListPage() {
}, },
onError: () => { onError: () => {
toast.error( toast.error(
t( t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."),
"msg.admin.users.export_error",
"사용자 내보내기에 실패했습니다.",
),
); );
}, },
}); });

View File

@@ -400,10 +400,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
data-testid="user-import-tenant-resolution" data-testid="user-import-tenant-resolution"
> >
<div className="mb-2 font-medium"> <div className="mb-2 font-medium">
{t( {t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
"ui.admin.users.bulk.tenant_resolution",
"테넌트 매핑",
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{tenantPreviewRows.map((preview) => ( {tenantPreviewRows.map((preview) => (
@@ -487,7 +484,9 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
<td className="p-2"> <td className="p-2">
<input <input
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs" 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) => onChange={(event) =>
setPreviewData((prev) => setPreviewData((prev) =>
prev.map((item, itemIndex) => prev.map((item, itemIndex) =>

View File

@@ -148,7 +148,9 @@ test.describe("Tenant Allowed Domains", () => {
await page.getByRole("button", { name: "저장" }).click(); await page.getByRole("button", { name: "저장" }).click();
await expect.poll(() => savedPayload).toMatchObject({ await expect
.poll(() => savedPayload)
.toMatchObject({
domains: ["samaneng.com"], domains: ["samaneng.com"],
forceDomainConflicts: ["samaneng.com"], forceDomainConflicts: ["samaneng.com"],
}); });

View File

@@ -288,9 +288,7 @@ test.describe("Tenants Management", () => {
await page await page
.getByTestId("tenant-import-match-select-3") .getByTestId("tenant-import-match-select-3")
.selectOption("__create__"); .selectOption("__create__");
await page await page.getByTestId("tenant-import-create-slug-3").fill("child-created");
.getByTestId("tenant-import-create-slug-3")
.fill("child-created");
await page.getByTestId("tenant-import-confirm-btn").click(); await page.getByTestId("tenant-import-confirm-btn").click();
await expect(page.getByTestId("tenant-import-result")).toContainText( await expect(page.getByTestId("tenant-import-result")).toContainText(

View File

@@ -38,8 +38,9 @@ test.describe("Tenants CSV live E2E", () => {
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const requestUrl = new URL(route.request().url()); const requestUrl = new URL(route.request().url());
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`; const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
const headers = { ...route.request().headers() }; const { authorization: _authorization, ...headers } = route
delete headers.authorization; .request()
.headers();
headers["x-test-role"] = "super_admin"; headers["x-test-role"] = "super_admin";
const response = await route.fetch({ url: liveUrl, headers }); const response = await route.fetch({ url: liveUrl, headers });
await route.fulfill({ response }); await route.fulfill({ response });

View File

@@ -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( await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }), page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toBeVisible(); ).toBeVisible();
@@ -376,9 +378,7 @@ test.describe("User Management", () => {
await expect( await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }), page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toHaveAttribute("data-state", "active"); ).toHaveAttribute("data-state", "active");
await expect( await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
page.getByLabel(/한맥 가족 구성원으로 등록/i),
).toHaveCount(0);
// Select Tenant first (important for schema fields to show up) // Select Tenant first (important for schema fields to show up)
await page.getByRole("tab", { name: /외부 기업 회원/i }).click(); await page.getByRole("tab", { name: /외부 기업 회원/i }).click();
@@ -527,9 +527,7 @@ test.describe("User Management", () => {
await expect( await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }), page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toHaveAttribute("data-state", "active"); ).toHaveAttribute("data-state", "active");
await expect( await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
page.getByLabel(/한맥 가족 구성원으로 등록/i),
).toHaveCount(0);
await expect(page.locator("select#role")).toHaveCount(0); await expect(page.locator("select#role")).toHaveCount(0);
await expect(page.locator("input#department")).toHaveCount(0); await expect(page.locator("input#department")).toHaveCount(0);
@@ -722,9 +720,7 @@ test.describe("User Management", () => {
await expect( await expect(
page.getByRole("tab", { name: /한맥가족 구성원/i }), page.getByRole("tab", { name: /한맥가족 구성원/i }),
).toHaveAttribute("data-state", "active"); ).toHaveAttribute("data-state", "active");
await expect( await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
page.getByLabel(/한맥 가족 구성원으로 등록/i),
).toHaveCount(0);
await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible(); await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible();
await expect( await expect(
page.getByTestId("detail-appointment-tenant-owner-line-0"), page.getByTestId("detail-appointment-tenant-owner-line-0"),

View File

@@ -177,9 +177,9 @@ test.describe("Users Bulk Upload", () => {
), ),
}); });
await expect(page.getByTestId("user-import-tenant-resolution")).toContainText( await expect(
/신규 생성|Create new/i, page.getByTestId("user-import-tenant-resolution"),
); ).toContainText(/신규 생성|Create new/i);
await page.getByTestId("bulk-start-btn").click(); await page.getByTestId("bulk-start-btn").click();
await expect(page.getByText("new@test.com")).toBeVisible(); await expect(page.getByText("new@test.com")).toBeVisible();

View File

@@ -39,8 +39,9 @@ test.describe("Users CSV live E2E", () => {
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const requestUrl = new URL(route.request().url()); const requestUrl = new URL(route.request().url());
const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`; const liveUrl = `${baseURL}${requestUrl.pathname}${requestUrl.search}`;
const headers = { ...route.request().headers() }; const { authorization: _authorization, ...headers } = route
delete headers.authorization; .request()
.headers();
headers["x-test-role"] = "super_admin"; headers["x-test-role"] = "super_admin";
const response = await route.fetch({ url: liveUrl, headers }); const response = await route.fetch({ url: liveUrl, headers });
await route.fulfill({ response }); await route.fulfill({ response });
@@ -75,7 +76,9 @@ test.describe("Users CSV live E2E", () => {
expect(path).toBeTruthy(); expect(path).toBeTruthy();
const csv = fs.readFileSync(path as string, "utf8"); 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("Role");
expect(csv).not.toContain("Department"); expect(csv).not.toContain("Department");
}); });

View File

@@ -230,5 +230,4 @@ test.describe("User Schema Dynamic Form", () => {
.first(); .first();
await expect(errorMsg).toBeVisible(); await expect(errorMsg).toBeVisible();
}); });
}); });