forked from baron/baron-sso
1204 lines
43 KiB
TypeScript
1204 lines
43 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import {
|
|
ArrowLeft,
|
|
Building2,
|
|
ClipboardCopy,
|
|
Loader2,
|
|
Plus,
|
|
Save,
|
|
ShieldAlert,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import * as React from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
|
import { Button } from "../../components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "../../components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "../../components/ui/dialog";
|
|
import { Input } from "../../components/ui/input";
|
|
import { Label } from "../../components/ui/label";
|
|
import { Switch } from "../../components/ui/switch";
|
|
import {
|
|
Tabs,
|
|
TabsContent,
|
|
TabsList,
|
|
TabsTrigger,
|
|
} from "../../components/ui/tabs";
|
|
import {
|
|
createUser,
|
|
fetchAllTenants,
|
|
fetchMe,
|
|
fetchTenant,
|
|
type TenantSummary,
|
|
type UserAppointment,
|
|
type UserCreateRequest,
|
|
type UserCreateResponse,
|
|
} from "../../lib/adminApi";
|
|
import { t } from "../../lib/i18n";
|
|
import {
|
|
canManageTenantScopedUsers,
|
|
isSuperAdminRole,
|
|
normalizeAdminRole,
|
|
} from "../../lib/roles";
|
|
import {
|
|
buildAuthenticatedOrgChartTenantPickerUrl,
|
|
filterNonHanmacFamilyTenants,
|
|
getTenantGradeOptions,
|
|
type OrgChartTenantSelection,
|
|
parseOrgChartTenantSelection,
|
|
} from "./orgChartPicker";
|
|
import { formatUserPolicyMessage } from "./userPolicyMessages";
|
|
import type { UserSchemaField } from "./userSchemaFields";
|
|
import { resolvePersonalTenant } from "./utils/personalTenant";
|
|
|
|
type UserFormValues = UserCreateRequest & {
|
|
metadata: Record<string, unknown> & {
|
|
sub_email?: string[];
|
|
};
|
|
};
|
|
type UserCategory = "hanmac" | "external" | "personal";
|
|
|
|
type PickerTarget = { kind: "appointment"; index: number };
|
|
|
|
type AppointmentDraft = UserAppointment & {
|
|
draftId: string;
|
|
};
|
|
|
|
type AdminFrontTestHooks = {
|
|
selectUserAppointmentTenant?: (
|
|
selection: OrgChartTenantSelection,
|
|
index?: number,
|
|
) => Promise<void>;
|
|
};
|
|
|
|
function createDraftId() {
|
|
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
|
|
}
|
|
|
|
async function resolveTenantSelection(
|
|
selection: OrgChartTenantSelection,
|
|
tenants: TenantSummary[],
|
|
) {
|
|
const cached = tenants.find((tenant) => tenant.id === selection.id);
|
|
if (cached) {
|
|
return {
|
|
id: cached.id,
|
|
name: cached.name,
|
|
slug: cached.slug,
|
|
};
|
|
}
|
|
|
|
const tenant = await fetchTenant(selection.id);
|
|
return {
|
|
id: tenant.id,
|
|
name: tenant.name,
|
|
slug: tenant.slug,
|
|
};
|
|
}
|
|
|
|
function createEmptyAppointment(): AppointmentDraft {
|
|
return {
|
|
draftId: createDraftId(),
|
|
tenantId: "",
|
|
tenantName: "",
|
|
tenantSlug: "",
|
|
isPrimary: false,
|
|
isOwner: false,
|
|
isAdmin: false,
|
|
isManager: false,
|
|
grade: "",
|
|
jobTitle: "",
|
|
position: "",
|
|
};
|
|
}
|
|
|
|
function UserCreatePage() {
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const queryClient = useQueryClient();
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [generatedPassword, setGeneratedPassword] = React.useState<
|
|
string | null
|
|
>(null);
|
|
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
|
const [autoPassword, setAutoPassword] = React.useState(true);
|
|
const [userCategory, setUserCategory] =
|
|
React.useState<UserCategory>("hanmac");
|
|
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
|
AppointmentDraft[]
|
|
>([]);
|
|
const [pickerTarget, setPickerTarget] = React.useState<PickerTarget | null>(
|
|
null,
|
|
);
|
|
const [isResolvingTenant, setIsResolvingTenant] = React.useState(false);
|
|
|
|
const [newSubEmail, setNewSubEmail] = React.useState("");
|
|
|
|
const { data: tenantsData } = useQuery({
|
|
queryKey: ["tenants", "all"],
|
|
queryFn: () => fetchAllTenants(),
|
|
});
|
|
const tenants = tenantsData?.items ?? [];
|
|
|
|
const { data: profile } = useQuery({
|
|
queryKey: ["me"],
|
|
queryFn: fetchMe,
|
|
});
|
|
const profileRole = normalizeAdminRole(profile?.role);
|
|
const canManageUsers =
|
|
canManageTenantScopedUsers(profile) ||
|
|
!!profile?.systemPermissions?.manage_users;
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
watch,
|
|
setValue,
|
|
formState: { errors },
|
|
} = useForm<UserFormValues>({
|
|
defaultValues: {
|
|
email: "",
|
|
password: "",
|
|
name: "",
|
|
phone: "",
|
|
tenantSlug: searchParams.get("tenantSlug") || "",
|
|
department: "",
|
|
grade: "",
|
|
position: "",
|
|
jobTitle: "",
|
|
role: "user",
|
|
metadata: {
|
|
sub_email: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const currentSubEmails = (watch("metadata.sub_email") as string[]) || [];
|
|
|
|
const handleAddSubEmail = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Enter" || e.key === "," || e.key === " ") {
|
|
e.preventDefault();
|
|
const value = newSubEmail.trim().replace(/,/g, "");
|
|
if (value?.includes("@") && !currentSubEmails.includes(value)) {
|
|
setValue("metadata.sub_email", [...currentSubEmails, value], {
|
|
shouldDirty: true,
|
|
});
|
|
setNewSubEmail("");
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRemoveSubEmail = (emailToRemove: string) => {
|
|
setValue(
|
|
"metadata.sub_email",
|
|
currentSubEmails.filter((e) => e !== emailToRemove),
|
|
{ shouldDirty: true },
|
|
);
|
|
};
|
|
|
|
// Lock company for non-super_admin
|
|
React.useEffect(() => {
|
|
if (profileRole !== "super_admin") {
|
|
const delegatedTenantSlug =
|
|
profile?.tenantSlug || profile?.manageableTenants?.[0]?.slug;
|
|
if (delegatedTenantSlug) {
|
|
setValue("tenantSlug", delegatedTenantSlug);
|
|
}
|
|
}
|
|
}, [profile, profileRole, setValue]);
|
|
|
|
const hanmacFamilyTenantId = React.useMemo(() => {
|
|
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
|
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
|
return envTenantId.trim();
|
|
}
|
|
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
|
}, [tenants]);
|
|
const nonHanmacFamilyTenants = React.useMemo(
|
|
() => filterNonHanmacFamilyTenants(tenants, hanmacFamilyTenantId),
|
|
[tenants, hanmacFamilyTenantId],
|
|
);
|
|
|
|
const selectedTenantSlug = watch("tenantSlug");
|
|
const personalTenant = React.useMemo(
|
|
() => resolvePersonalTenant(tenants),
|
|
[tenants],
|
|
);
|
|
const selectedTenant =
|
|
userCategory !== "external"
|
|
? undefined
|
|
: nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug);
|
|
|
|
const selectedTenantId = selectedTenant?.id ?? "";
|
|
|
|
const { data: tenantDetail } = useQuery({
|
|
queryKey: ["tenant", selectedTenantId],
|
|
queryFn: () => fetchTenant(selectedTenantId),
|
|
enabled: selectedTenantId.length > 0,
|
|
});
|
|
|
|
const userSchema: UserSchemaField[] = Array.isArray(
|
|
tenantDetail?.config?.userSchema,
|
|
)
|
|
? (tenantDetail?.config?.userSchema as UserSchemaField[])
|
|
: [];
|
|
|
|
const registerMetadata = (field: UserSchemaField) =>
|
|
register(`metadata.${field.key}` as `metadata.${string}`, {
|
|
required: field.required
|
|
? t(
|
|
"msg.admin.users.create.form.field_required",
|
|
"{{label}}은(는) 필수입니다.",
|
|
{
|
|
label: field.label || field.key,
|
|
},
|
|
)
|
|
: false,
|
|
pattern: field.validation
|
|
? {
|
|
value: new RegExp(field.validation),
|
|
message: t(
|
|
"msg.admin.users.create.form.field_invalid",
|
|
"{{label}} 형식이 올바르지 않습니다.",
|
|
{ label: field.label || field.key },
|
|
),
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
|
import.meta.env.ORGFRONT_URL,
|
|
{
|
|
tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined,
|
|
},
|
|
);
|
|
|
|
const applyTenantSelection = React.useCallback(
|
|
async (selection: OrgChartTenantSelection, target: PickerTarget) => {
|
|
setIsResolvingTenant(true);
|
|
try {
|
|
const tenant = await resolveTenantSelection(selection, tenants);
|
|
setAdditionalAppointments((current) =>
|
|
current.map((appointment, index) =>
|
|
index === target.index
|
|
? {
|
|
...appointment,
|
|
tenantId: tenant.id,
|
|
tenantName: tenant.name,
|
|
tenantSlug: tenant.slug,
|
|
}
|
|
: appointment,
|
|
),
|
|
);
|
|
setPickerTarget(null);
|
|
} catch (_) {
|
|
setError(
|
|
t(
|
|
"msg.admin.users.create.tenant_resolve_failed",
|
|
"선택한 테넌트 정보를 불러오지 못했습니다.",
|
|
),
|
|
);
|
|
} finally {
|
|
setIsResolvingTenant(false);
|
|
}
|
|
},
|
|
[tenants],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!pickerTarget) return;
|
|
|
|
const onMessage = (event: MessageEvent) => {
|
|
const selection = parseOrgChartTenantSelection(event.data);
|
|
if (!selection) return;
|
|
void applyTenantSelection(selection, pickerTarget);
|
|
};
|
|
|
|
window.addEventListener("message", onMessage);
|
|
return () => window.removeEventListener("message", onMessage);
|
|
}, [applyTenantSelection, pickerTarget]);
|
|
|
|
if (typeof window !== "undefined") {
|
|
const testWindow = window as Window &
|
|
typeof globalThis & {
|
|
__adminfrontTestHooks?: AdminFrontTestHooks;
|
|
};
|
|
const hooks = testWindow.__adminfrontTestHooks ?? {};
|
|
hooks.selectUserAppointmentTenant = async (selection, index = 0) => {
|
|
await applyTenantSelection(selection, {
|
|
kind: "appointment",
|
|
index,
|
|
});
|
|
};
|
|
testWindow.__adminfrontTestHooks = hooks;
|
|
}
|
|
|
|
const addAppointment = () => {
|
|
setAdditionalAppointments((current) => [
|
|
...current,
|
|
createEmptyAppointment(),
|
|
]);
|
|
};
|
|
|
|
const updateAppointment = (
|
|
index: number,
|
|
patch: Partial<UserAppointment>,
|
|
) => {
|
|
setAdditionalAppointments((current) =>
|
|
current.map((appointment, currentIndex) => {
|
|
if (currentIndex === index) {
|
|
return { ...appointment, ...patch };
|
|
}
|
|
if (patch.isPrimary === true) {
|
|
return { ...appointment, isPrimary: false };
|
|
}
|
|
return appointment;
|
|
}),
|
|
);
|
|
};
|
|
|
|
const removeAppointment = (index: number) => {
|
|
setAdditionalAppointments((current) =>
|
|
current.filter((_, currentIndex) => currentIndex !== index),
|
|
);
|
|
};
|
|
|
|
const handleUserCategoryChange = (value: string) => {
|
|
const nextCategory = value as UserCategory;
|
|
setUserCategory(nextCategory);
|
|
if (nextCategory !== "hanmac") {
|
|
setAdditionalAppointments([]);
|
|
}
|
|
};
|
|
|
|
const ensurePersonalTenant = async () => {
|
|
return personalTenant;
|
|
};
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: createUser,
|
|
onSuccess: (data: UserCreateResponse) => {
|
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
if (data.initialPassword) {
|
|
setGeneratedPassword(data.initialPassword);
|
|
setCreatedEmail(data.email);
|
|
return;
|
|
}
|
|
navigate("/users");
|
|
},
|
|
onError: (err: AxiosError<{ error?: string }>) => {
|
|
setError(
|
|
formatUserPolicyMessage(err.response?.data?.error) ||
|
|
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
|
|
);
|
|
},
|
|
});
|
|
|
|
const onSubmit = async (data: UserFormValues) => {
|
|
setError(null);
|
|
setGeneratedPassword(null);
|
|
setCreatedEmail(null);
|
|
|
|
const {
|
|
hanmacFamily: _hanmacFamily,
|
|
userType: _userType,
|
|
sub_email: rawSubEmail,
|
|
...formMetadata
|
|
} = data.metadata ?? {};
|
|
|
|
const sub_email = Array.isArray(rawSubEmail) ? rawSubEmail : [];
|
|
|
|
const metadata: Record<string, unknown> = {
|
|
...formMetadata,
|
|
...(sub_email.length > 0 ? { sub_email } : { sub_email: [] }),
|
|
};
|
|
|
|
const payload: UserCreateRequest = {
|
|
email: data.email,
|
|
password: data.password,
|
|
name: data.name,
|
|
phone: data.phone,
|
|
role: data.role,
|
|
metadata,
|
|
};
|
|
|
|
if (userCategory === "external") {
|
|
if (!data.tenantSlug) {
|
|
setError(
|
|
t(
|
|
"msg.admin.users.create.external_tenant_required",
|
|
"외부 사용자는 대표소속을 선택해 주세요.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
payload.tenantSlug = data.tenantSlug;
|
|
payload.department = data.department;
|
|
payload.grade = data.grade;
|
|
payload.position = data.position;
|
|
payload.jobTitle = data.jobTitle;
|
|
}
|
|
|
|
if (userCategory === "personal") {
|
|
try {
|
|
const tenant = await ensurePersonalTenant();
|
|
payload.tenantSlug = tenant.slug;
|
|
payload.metadata = {
|
|
...metadata,
|
|
personalTenantId: tenant.id,
|
|
};
|
|
} catch (_) {
|
|
setError(
|
|
t(
|
|
"msg.admin.users.create.personal_tenant_failed",
|
|
"Personal 테넌트를 준비하지 못했습니다.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (userCategory === "hanmac") {
|
|
const appointments = additionalAppointments
|
|
.filter((appointment) => appointment.tenantId)
|
|
.map((appointment) => ({
|
|
tenantId: appointment.tenantId,
|
|
tenantSlug: appointment.tenantSlug,
|
|
tenantName: appointment.tenantName,
|
|
isPrimary: appointment.isPrimary === true,
|
|
...(appointment.isOwner === true ? { isOwner: true } : {}),
|
|
...(appointment.isAdmin === true ? { isAdmin: true } : {}),
|
|
...(appointment.isManager === true ? { isManager: true } : {}),
|
|
grade: appointment.grade,
|
|
jobTitle: appointment.jobTitle,
|
|
position: appointment.position,
|
|
}));
|
|
|
|
if (appointments.length === 0) {
|
|
setError(
|
|
t(
|
|
"msg.admin.users.create.appointment_required",
|
|
"한맥 가족 구성원은 소속 테넌트를 하나 이상 선택해 주세요.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const primary = appointments.find((a) => a.isPrimary);
|
|
if (primary) {
|
|
metadata.primaryTenantId = primary.tenantId;
|
|
metadata.primaryTenantSlug = primary.tenantSlug;
|
|
metadata.primaryTenantName = primary.tenantName;
|
|
}
|
|
|
|
payload.additionalAppointments = appointments;
|
|
payload.metadata = {
|
|
...metadata,
|
|
additionalAppointments: appointments,
|
|
};
|
|
}
|
|
|
|
if (autoPassword) {
|
|
payload.password = "";
|
|
} else if (!data.password) {
|
|
setError(
|
|
t(
|
|
"msg.admin.users.create.password_required",
|
|
"비밀번호를 입력하거나 자동 생성을 사용해 주세요.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
mutation.mutate(payload);
|
|
};
|
|
|
|
const onCopyPassword = async () => {
|
|
if (!generatedPassword) return;
|
|
try {
|
|
await navigator.clipboard.writeText(generatedPassword);
|
|
} catch (_) {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
if (profile && !canManageUsers) {
|
|
return (
|
|
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
|
<ShieldAlert size={48} className="text-destructive" />
|
|
<h3 className="text-lg font-bold">
|
|
{t("msg.admin.common.forbidden", "이 작업을 수행할 권한이 없습니다.")}
|
|
</h3>
|
|
<Button onClick={() => navigate("/")}>
|
|
{t("ui.common.go_home", "홈으로 이동")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-3xl space-y-8">
|
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
|
<div className="space-y-2">
|
|
<h2 className="text-3xl font-semibold">
|
|
{t("ui.admin.users.create.title", "사용자 추가")}
|
|
</h2>
|
|
</div>
|
|
<Button variant="ghost" asChild>
|
|
<Link to="/users">
|
|
<ArrowLeft size={16} className="mr-2" />
|
|
{t("ui.admin.users.create.back", "목록으로 돌아가기")}
|
|
</Link>
|
|
</Button>
|
|
</header>
|
|
|
|
{generatedPassword && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>
|
|
{t(
|
|
"ui.admin.users.create.password_generated.title",
|
|
"초기 비밀번호 생성 완료",
|
|
)}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{createdEmail
|
|
? t(
|
|
"msg.admin.users.create.password_generated.with_email",
|
|
"{{email}} 계정의 초기 비밀번호입니다.",
|
|
{ email: createdEmail },
|
|
)
|
|
: t(
|
|
"msg.admin.users.create.password_generated.default",
|
|
"초기 비밀번호가 생성되었습니다.",
|
|
)}
|
|
</CardDescription>
|
|
</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}>
|
|
<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", "목록으로 이동")}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>
|
|
{t("ui.admin.users.create.account.title", "계정 정보")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.admin.users.create.account.subtitle",
|
|
"새로운 사용자를 시스템에 등록합니다.",
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
|
{error && (
|
|
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">
|
|
{t("ui.admin.users.create.form.email", "이메일")}
|
|
</Label>
|
|
<Input
|
|
id="email"
|
|
placeholder={t(
|
|
"ui.admin.users.create.form.email_placeholder",
|
|
"user@example.com",
|
|
)}
|
|
{...register("email", {
|
|
required: t(
|
|
"msg.admin.users.create.form.email_required",
|
|
"이메일은 필수입니다.",
|
|
),
|
|
})}
|
|
/>
|
|
{errors.email && (
|
|
<p className="text-xs text-destructive">
|
|
{errors.email.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="sub_email_input"
|
|
className="flex items-center gap-2"
|
|
>
|
|
{t("ui.admin.users.create.form.sub_email", "보조 이메일")}
|
|
</Label>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-wrap gap-2 mb-1">
|
|
{currentSubEmails.map((email) => (
|
|
<div
|
|
key={email}
|
|
className="inline-flex items-center gap-1 rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 bg-secondary text-secondary-foreground"
|
|
>
|
|
{email}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveSubEmail(email)}
|
|
className="text-muted-foreground hover:text-foreground ml-1 rounded-full p-0.5 hover:bg-muted transition-colors"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="relative">
|
|
<Input
|
|
id="sub_email_input"
|
|
value={newSubEmail}
|
|
onChange={(e) => setNewSubEmail(e.target.value)}
|
|
onKeyDown={handleAddSubEmail}
|
|
className="pr-20"
|
|
placeholder={t(
|
|
"ui.admin.users.create.form.sub_email_placeholder",
|
|
"추가할 이메일 입력 후 Enter",
|
|
)}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute right-1 top-1 h-8 text-xs font-bold"
|
|
data-testid="add-sub-email-btn"
|
|
onClick={() => {
|
|
const value = newSubEmail.trim().replace(/,/g, "");
|
|
if (
|
|
value?.includes("@") &&
|
|
!currentSubEmails.includes(value)
|
|
) {
|
|
setValue(
|
|
"metadata.sub_email",
|
|
[...currentSubEmails, value],
|
|
{ shouldDirty: true },
|
|
);
|
|
setNewSubEmail("");
|
|
}
|
|
}}
|
|
>
|
|
{t("ui.common.add", "추가")}
|
|
</Button>{" "}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground mt-1">
|
|
* 여러 개 입력 가능. 입력 후 엔터를 눌러 추가하세요.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="password">
|
|
{t("ui.admin.users.create.form.password", "비밀번호")}
|
|
</Label>
|
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<input
|
|
id="auto-password"
|
|
name="auto-password"
|
|
type="checkbox"
|
|
checked={autoPassword}
|
|
onChange={(event) => setAutoPassword(event.target.checked)}
|
|
/>
|
|
{t("ui.admin.users.create.form.auto_password", "자동 생성")}
|
|
</label>
|
|
</div>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
placeholder={t(
|
|
"ui.admin.users.create.form.password_placeholder",
|
|
"********",
|
|
)}
|
|
disabled={autoPassword}
|
|
{...register("password")}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{autoPassword
|
|
? t(
|
|
"msg.admin.users.create.form.password_auto_help",
|
|
"비워두면 시스템이 초기 비밀번호를 자동 생성합니다.",
|
|
)
|
|
: t(
|
|
"msg.admin.users.create.form.password_manual_help",
|
|
"초기 비밀번호를 직접 설정합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">
|
|
{t("ui.admin.users.create.form.name", "이름")}
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
placeholder={t(
|
|
"ui.admin.users.create.form.name_placeholder",
|
|
"홍길동",
|
|
)}
|
|
{...register("name", {
|
|
required: t(
|
|
"msg.admin.users.create.form.name_required",
|
|
"이름은 필수입니다.",
|
|
),
|
|
})}
|
|
/>
|
|
{errors.name && (
|
|
<p className="text-xs text-destructive">
|
|
{errors.name.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone">
|
|
{t("ui.admin.users.create.form.phone", "전화번호")}
|
|
</Label>
|
|
<Input
|
|
id="phone"
|
|
placeholder={t(
|
|
"ui.admin.users.create.form.phone_placeholder",
|
|
"010-1234-5678",
|
|
)}
|
|
{...register("phone")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="role">
|
|
{t("ui.admin.users.create.form.role", "역할")}
|
|
</Label>
|
|
<select
|
|
id="role"
|
|
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("role")}
|
|
disabled={!isSuperAdminRole(profile?.role)}
|
|
>
|
|
<option value="super_admin">
|
|
{t("ui.admin.role.super_admin", "시스템 관리자")}
|
|
</option>
|
|
<option value="user">
|
|
{t("ui.admin.role.user", "일반 사용자")}
|
|
</option>
|
|
</select>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(
|
|
"msg.admin.users.create.form.role_help",
|
|
"시스템 접근 권한을 결정합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<Tabs value={userCategory} onValueChange={handleUserCategoryChange}>
|
|
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
|
<TabsTrigger
|
|
value="hanmac"
|
|
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
|
>
|
|
한맥가족 구성원
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="external"
|
|
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
|
>
|
|
외부 기업 회원
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="personal"
|
|
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
|
>
|
|
개인 회원
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<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>
|
|
<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={profileRole !== "super_admin"}
|
|
>
|
|
{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")} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="grade">직급</Label>
|
|
<Input
|
|
id="grade"
|
|
placeholder="수석/책임/선임"
|
|
{...register("grade")}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="position">직책</Label>
|
|
<Input
|
|
id="position"
|
|
placeholder="팀장/센터장"
|
|
{...register("position")}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="jobTitle">직무</Label>
|
|
<Input
|
|
id="jobTitle"
|
|
placeholder="프론트엔드 개발"
|
|
{...register("jobTitle")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<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-xs text-muted-foreground">
|
|
테넌트별 조직장 여부, 직급, 직책, 직무를 입력합니다.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addAppointment}
|
|
data-testid="add-appointment-btn"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{t("ui.common.add", "추가")}
|
|
</Button>
|
|
</div>
|
|
|
|
{additionalAppointments.map((appointment, index) => (
|
|
<div
|
|
key={appointment.draftId}
|
|
data-testid={`appointment-row-${index}`}
|
|
className="grid gap-3 rounded-md border p-3 lg:grid-cols-[minmax(280px,1.2fr)_minmax(280px,1fr)_auto] lg:items-end"
|
|
>
|
|
<div
|
|
className="space-y-2"
|
|
data-testid={`appointment-tenant-owner-line-${index}`}
|
|
>
|
|
<Label>소속 테넌트</Label>
|
|
<div
|
|
className="flex items-center justify-between gap-3"
|
|
data-testid={`appointment-tenant-owner-controls-${index}`}
|
|
>
|
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="min-w-0 max-w-full"
|
|
onClick={() =>
|
|
setPickerTarget({
|
|
kind: "appointment",
|
|
index,
|
|
})
|
|
}
|
|
disabled={isResolvingTenant}
|
|
data-testid={`appointment-tenant-picker-${index}`}
|
|
>
|
|
<Building2 className="mr-2 h-4 w-4 shrink-0" />
|
|
<span className="pointer-events-none truncate">
|
|
{appointment.tenantName ||
|
|
t(
|
|
"ui.admin.users.create.form.pick_from_hanmac_family",
|
|
"한맥가족에서 선택",
|
|
)}
|
|
</span>
|
|
</Button>
|
|
{appointment.tenantSlug && (
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
{appointment.tenantSlug}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap">
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<Switch
|
|
checked={appointment.isPrimary === true}
|
|
onCheckedChange={(checked) =>
|
|
updateAppointment(index, {
|
|
isPrimary: checked === true,
|
|
})
|
|
}
|
|
aria-label={t(
|
|
"ui.admin.users.detail.form.appointment_owner",
|
|
"대표 조직",
|
|
)}
|
|
/>
|
|
{t(
|
|
"ui.admin.users.detail.form.appointment_owner",
|
|
"대표 조직",
|
|
)}
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<Switch
|
|
checked={appointment.isManager === true}
|
|
onCheckedChange={(checked) =>
|
|
updateAppointment(index, {
|
|
isManager: checked === true,
|
|
})
|
|
}
|
|
aria-label={t(
|
|
"ui.admin.users.detail.form.appointment_manager",
|
|
"조직장",
|
|
)}
|
|
/>
|
|
{t(
|
|
"ui.admin.users.detail.form.appointment_manager",
|
|
"조직장",
|
|
)}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="grid gap-3 sm:grid-cols-3"
|
|
data-testid={`appointment-position-line-${index}`}
|
|
>
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`appointment-grade-${index}`}>
|
|
직급
|
|
</Label>
|
|
<select
|
|
id={`appointment-grade-${index}`}
|
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
value={appointment.grade ?? ""}
|
|
onChange={(event) =>
|
|
updateAppointment(index, {
|
|
grade: event.target.value || "",
|
|
})
|
|
}
|
|
>
|
|
<option value="">없음</option>
|
|
{getTenantGradeOptions(appointment, tenants).map(
|
|
(grade) => (
|
|
<option key={grade} value={grade}>
|
|
{grade}
|
|
</option>
|
|
),
|
|
)}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`appointment-job-title-${index}`}>
|
|
직무
|
|
</Label>
|
|
<Input
|
|
id={`appointment-job-title-${index}`}
|
|
value={appointment.jobTitle ?? ""}
|
|
onChange={(event) =>
|
|
updateAppointment(index, {
|
|
jobTitle: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`appointment-position-${index}`}>
|
|
직책
|
|
</Label>
|
|
<Input
|
|
id={`appointment-position-${index}`}
|
|
value={appointment.position ?? ""}
|
|
onChange={(event) =>
|
|
updateAppointment(index, {
|
|
position: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeAppointment(index)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
<span className="sr-only">
|
|
{t("ui.common.delete", "삭제")}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="personal" className="mt-4">
|
|
<div
|
|
className="rounded-md border bg-muted/30 p-4 text-sm"
|
|
data-testid="personal-tenant-summary"
|
|
>
|
|
{personalTenant
|
|
? `Personal (${personalTenant.slug})`
|
|
: "Personal 테넌트로 생성됩니다."}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{userSchema.length > 0 && (
|
|
<div className="border-t pt-4">
|
|
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
|
|
{t(
|
|
"ui.admin.users.create.custom_fields.title",
|
|
"테넌트 확장 정보 (Custom Fields)",
|
|
)}
|
|
</h3>
|
|
|
|
<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}`}>
|
|
{field.label}
|
|
{field.required && (
|
|
<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">
|
|
Admin Only
|
|
</span>
|
|
)}
|
|
{field.isLoginId && (
|
|
<span className="ml-2 text-[10px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
|
|
{t(
|
|
"ui.admin.users.create.form.is_login_id",
|
|
"로그인 ID",
|
|
)}
|
|
</span>
|
|
)}
|
|
</Label>
|
|
|
|
<Input
|
|
id={`metadata.${field.key}`}
|
|
type={
|
|
field.type === "number"
|
|
? "number"
|
|
: field.type === "date"
|
|
? "date"
|
|
: field.type === "boolean"
|
|
? "checkbox"
|
|
: "text"
|
|
}
|
|
className={
|
|
field.type === "boolean" ? "w-auto h-auto" : ""
|
|
}
|
|
{...registerMetadata(field)}
|
|
/>
|
|
{errors.metadata?.[field.key] && (
|
|
<p className="text-xs text-destructive">
|
|
{
|
|
(
|
|
errors.metadata[field.key] as {
|
|
message?: string;
|
|
}
|
|
)?.message
|
|
}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => navigate("/users")}
|
|
>
|
|
{t("ui.common.cancel", "취소")}
|
|
</Button>
|
|
<Button type="submit" disabled={mutation.isPending}>
|
|
{mutation.isPending && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
<Save className="mr-2 h-4 w-4" />
|
|
{t("ui.admin.users.create.submit", "사용자 생성")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
<Dialog
|
|
open={pickerTarget !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setPickerTarget(null);
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-[460px] p-4">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t(
|
|
"msg.admin.users.create.form.picker_description",
|
|
"org-chart에서 테넌트를 선택하면 사용자 소속에 반영됩니다.",
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<iframe
|
|
title={t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
|
|
src={pickerUrl}
|
|
className="h-[600px] w-full rounded-md border"
|
|
data-testid="appointment-tenant-picker-frame"
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default UserCreatePage;
|