forked from baron/baron-sso
541 lines
18 KiB
TypeScript
541 lines
18 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import { ArrowLeft, ClipboardCopy, Loader2, Save } from "lucide-react";
|
|
import * as React from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { Button } from "../../components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "../../components/ui/card";
|
|
import { Input } from "../../components/ui/input";
|
|
import { Label } from "../../components/ui/label";
|
|
import {
|
|
type UserCreateRequest,
|
|
type UserCreateResponse,
|
|
createUser,
|
|
fetchTenant,
|
|
fetchTenants,
|
|
fetchMe,
|
|
} from "../../lib/adminApi";
|
|
import { t } from "../../lib/i18n";
|
|
|
|
type UserSchemaField = {
|
|
key: string;
|
|
label?: string;
|
|
type?: "text" | "number" | "boolean" | "date";
|
|
required?: boolean;
|
|
adminOnly?: boolean;
|
|
validation?: string;
|
|
};
|
|
|
|
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
|
|
|
function UserCreatePage() {
|
|
const navigate = useNavigate();
|
|
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 { data: tenantsData } = useQuery({
|
|
queryKey: ["tenants", { limit: 100 }],
|
|
queryFn: () => fetchTenants(100, 0),
|
|
});
|
|
const tenants = tenantsData?.items ?? [];
|
|
|
|
const { data: profile } = useQuery({
|
|
queryKey: ["me"],
|
|
queryFn: fetchMe,
|
|
});
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
watch,
|
|
setValue,
|
|
formState: { errors },
|
|
} = useForm<UserFormValues>({
|
|
defaultValues: {
|
|
email: "",
|
|
password: "",
|
|
name: "",
|
|
phone: "",
|
|
role: "user",
|
|
companyCode: "",
|
|
department: "",
|
|
position: "",
|
|
jobTitle: "",
|
|
metadata: {},
|
|
},
|
|
});
|
|
|
|
// Lock company for tenant_admin
|
|
React.useEffect(() => {
|
|
const p = profile as any;
|
|
if (p?.role === "tenant_admin" && p.companyCode) {
|
|
setValue("companyCode", p.companyCode);
|
|
}
|
|
}, [profile, setValue]);
|
|
|
|
const selectedCompanyCode = watch("companyCode");
|
|
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
|
|
|
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 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(
|
|
err.response?.data?.error ||
|
|
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
|
|
);
|
|
},
|
|
});
|
|
|
|
const onSubmit = (data: UserFormValues) => {
|
|
setError(null);
|
|
setGeneratedPassword(null);
|
|
setCreatedEmail(null);
|
|
|
|
const payload = { ...data };
|
|
|
|
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
|
|
}
|
|
};
|
|
|
|
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">
|
|
<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
|
|
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="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="companyCode">
|
|
{t("ui.admin.users.create.form.tenant", "테넌트 (Tenant)")}
|
|
</Label>
|
|
|
|
<div className="relative">
|
|
<select
|
|
id="companyCode"
|
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
{...register("companyCode")}
|
|
disabled={(profile as any)?.role === "tenant_admin"}
|
|
>
|
|
<option value="">
|
|
{t(
|
|
"ui.admin.users.create.form.tenant_global",
|
|
"시스템 전역 (소속 없음)",
|
|
)}
|
|
</option>
|
|
|
|
{tenants.map((t) => (
|
|
<option key={t.id} value={t.slug}>
|
|
{t.name} ({t.slug})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="department">
|
|
{t("ui.admin.users.create.form.department", "부서")}
|
|
</Label>
|
|
|
|
<Input
|
|
id="department"
|
|
placeholder={t(
|
|
"ui.admin.users.create.form.department_placeholder",
|
|
"개발팀",
|
|
)}
|
|
{...register("department")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="position">
|
|
{t("ui.admin.users.create.form.position", "직급")}
|
|
</Label>
|
|
|
|
<Input
|
|
id="position"
|
|
placeholder={t(
|
|
"ui.admin.users.create.form.position_placeholder",
|
|
"수석/책임/선임",
|
|
)}
|
|
{...register("position")}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="jobTitle">
|
|
{t("ui.admin.users.create.form.job_title", "직무")}
|
|
</Label>
|
|
|
|
<Input
|
|
id="jobTitle"
|
|
placeholder={t(
|
|
"ui.admin.users.create.form.job_title_placeholder",
|
|
"프론트엔드 개발",
|
|
)}
|
|
{...register("jobTitle")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{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>
|
|
)}
|
|
</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="space-y-2">
|
|
<Label htmlFor="role">
|
|
{t("ui.admin.users.create.form.role", "역할 (Role)")}
|
|
</Label>
|
|
<div className="relative">
|
|
<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 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
{...register("role")}
|
|
>
|
|
<option value="user">
|
|
{t("ui.admin.role.user", "TENANT MEMBER")}
|
|
</option>
|
|
<option value="tenant_admin">
|
|
{t("ui.admin.role.tenant_admin", "TENANT ADMIN")}
|
|
</option>
|
|
<option value="rp_admin">
|
|
{t("ui.admin.role.rp_admin", "RP ADMIN")}
|
|
</option>
|
|
<option value="super_admin">
|
|
{t("ui.admin.role.super_admin", "SUPER ADMIN")}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(
|
|
"msg.admin.users.create.form.role_help",
|
|
"시스템 접근 권한을 결정합니다.",
|
|
)}
|
|
</p>
|
|
</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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default UserCreatePage;
|