forked from baron/baron-sso
i18n refresh and frontend fixes
This commit is contained in:
@@ -15,18 +15,30 @@ import {
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
createUser,
|
||||
fetchTenants,
|
||||
fetchTenant,
|
||||
type UserCreateRequest,
|
||||
type UserCreateResponse,
|
||||
createUser,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
label?: string;
|
||||
type?: "text" | "number" | "boolean";
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
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 [generatedPassword, setGeneratedPassword] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
||||
const [autoPassword, setAutoPassword] = React.useState(true);
|
||||
|
||||
@@ -41,7 +53,7 @@ function UserCreatePage() {
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<UserCreateRequest & { metadata: Record<string, any> }>({
|
||||
} = useForm<UserFormValues>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
@@ -57,13 +69,22 @@ function UserCreatePage() {
|
||||
const selectedCompanyCode = watch("companyCode");
|
||||
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
||||
|
||||
const selectedTenantId = selectedTenant?.id ?? "";
|
||||
|
||||
const { data: tenantDetail } = useQuery({
|
||||
queryKey: ["tenant", selectedTenant?.id],
|
||||
queryFn: () => fetchTenant(selectedTenant!.id),
|
||||
enabled: !!selectedTenant?.id,
|
||||
queryKey: ["tenant", selectedTenantId],
|
||||
queryFn: () => fetchTenant(selectedTenantId),
|
||||
enabled: selectedTenantId.length > 0,
|
||||
});
|
||||
|
||||
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
|
||||
const userSchema: UserSchemaField[] = Array.isArray(
|
||||
tenantDetail?.config?.userSchema,
|
||||
)
|
||||
? (tenantDetail?.config?.userSchema as UserSchemaField[])
|
||||
: [];
|
||||
|
||||
const registerMetadata = (key: string) =>
|
||||
register(`metadata.${key}` as `metadata.${string}`);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createUser,
|
||||
@@ -77,7 +98,10 @@ function UserCreatePage() {
|
||||
navigate("/users");
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
setError(err.response?.data?.error || "사용자 생성에 실패했습니다.");
|
||||
setError(
|
||||
err.response?.data?.error ||
|
||||
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,7 +116,12 @@ function UserCreatePage() {
|
||||
}
|
||||
|
||||
if (!data.password) {
|
||||
setError("비밀번호를 입력하거나 자동 생성을 사용해 주세요.");
|
||||
setError(
|
||||
t(
|
||||
"msg.admin.users.create.password_required",
|
||||
"비밀번호를 입력하거나 자동 생성을 사용해 주세요.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,17 +143,21 @@ function UserCreatePage() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<Link to="/users" className="hover:underline">
|
||||
Users
|
||||
{t("ui.admin.users.create.breadcrumb.section", "Users")}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">New</span>
|
||||
<span className="text-foreground">
|
||||
{t("ui.admin.users.create.breadcrumb.new", "New")}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">사용자 추가</h2>
|
||||
<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>
|
||||
@@ -132,9 +165,23 @@ function UserCreatePage() {
|
||||
{generatedPassword && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>초기 비밀번호 생성 완료</CardTitle>
|
||||
<CardTitle>
|
||||
{t(
|
||||
"ui.admin.users.create.password_generated.title",
|
||||
"초기 비밀번호 생성 완료",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{createdEmail ? `${createdEmail} 계정의 초기 비밀번호입니다.` : "초기 비밀번호가 생성되었습니다."}
|
||||
{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">
|
||||
@@ -142,11 +189,13 @@ function UserCreatePage() {
|
||||
<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")}>목록으로 이동</Button>
|
||||
<Button onClick={() => navigate("/users")}>
|
||||
{t("ui.admin.users.create.go_list", "목록으로 이동")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -154,8 +203,15 @@ function UserCreatePage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>계정 정보</CardTitle>
|
||||
<CardDescription>새로운 사용자를 시스템에 등록합니다.</CardDescription>
|
||||
<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">
|
||||
@@ -166,182 +222,200 @@ function UserCreatePage() {
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Label htmlFor="email">
|
||||
{t("ui.admin.users.create.form.email", "이메일")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="user@example.com"
|
||||
{...register("email", { required: "이메일은 필수입니다." })}
|
||||
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>
|
||||
<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">비밀번호</Label>
|
||||
<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="********"
|
||||
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">이름</Label>
|
||||
<Label htmlFor="name">
|
||||
{t("ui.admin.users.create.form.name", "이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="홍길동"
|
||||
{...register("name", { required: "이름은 필수입니다." })}
|
||||
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>
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">전화번호</Label>
|
||||
<Label htmlFor="phone">
|
||||
{t("ui.admin.users.create.form.phone", "전화번호")}
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="010-1234-5678"
|
||||
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="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="space-y-2">
|
||||
<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")}
|
||||
>
|
||||
<option value="">
|
||||
{t(
|
||||
"ui.admin.users.create.form.tenant_global",
|
||||
"시스템 전역 (소속 없음)",
|
||||
)}
|
||||
</option>
|
||||
|
||||
<Label htmlFor="companyCode">테넌트 (Tenant)</Label>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">
|
||||
{t("ui.admin.users.create.form.department", "부서")}
|
||||
</Label>
|
||||
|
||||
<select
|
||||
<Input
|
||||
id="department"
|
||||
placeholder={t(
|
||||
"ui.admin.users.create.form.department_placeholder",
|
||||
"개발팀",
|
||||
)}
|
||||
{...register("department")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
id="companyCode"
|
||||
{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>
|
||||
|
||||
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"
|
||||
<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}
|
||||
</Label>
|
||||
|
||||
{...register("companyCode")}
|
||||
<Input
|
||||
id={`metadata.${field.key}`}
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
{...registerMetadata(field.key)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
>
|
||||
|
||||
<option value="">시스템 전역 (소속 없음)</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">부서</Label>
|
||||
|
||||
<Input
|
||||
|
||||
id="department"
|
||||
|
||||
placeholder="개발팀"
|
||||
|
||||
{...register("department")}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{userSchema.length > 0 && (
|
||||
|
||||
<div className="border-t pt-4">
|
||||
|
||||
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
|
||||
|
||||
테넌트 확장 정보 (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}
|
||||
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
|
||||
id={`metadata.${field.key}`}
|
||||
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
|
||||
{...register(`metadata.${field.key}` as any)}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">역할 (Role)</Label>
|
||||
<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">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">
|
||||
{t("ui.common.role.user", "User")}
|
||||
</option>
|
||||
<option value="admin">
|
||||
{t("ui.common.role.admin", "Admin")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
시스템 접근 권한을 결정합니다.
|
||||
{t(
|
||||
"msg.admin.users.create.form.role_help",
|
||||
"시스템 접근 권한을 결정합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -351,14 +425,14 @@ function UserCreatePage() {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user