forked from baron/baron-sso
371 lines
12 KiB
TypeScript
371 lines
12 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 {
|
|
createUser,
|
|
fetchTenants,
|
|
type UserCreateRequest,
|
|
type UserCreateResponse,
|
|
} from "../../lib/adminApi";
|
|
|
|
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 {
|
|
register,
|
|
handleSubmit,
|
|
watch,
|
|
formState: { errors },
|
|
} = useForm<UserCreateRequest & { metadata: Record<string, any> }>({
|
|
defaultValues: {
|
|
email: "",
|
|
password: "",
|
|
name: "",
|
|
phone: "",
|
|
role: "user",
|
|
companyCode: "",
|
|
department: "",
|
|
metadata: {},
|
|
},
|
|
});
|
|
|
|
const selectedCompanyCode = watch("companyCode");
|
|
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
|
|
|
|
const { data: tenantDetail } = useQuery({
|
|
queryKey: ["tenant", selectedTenant?.id],
|
|
queryFn: () => fetchTenant(selectedTenant!.id),
|
|
enabled: !!selectedTenant?.id,
|
|
});
|
|
|
|
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
|
|
|
|
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 || "사용자 생성에 실패했습니다.");
|
|
},
|
|
});
|
|
|
|
const onSubmit = (data: UserCreateRequest) => {
|
|
setError(null);
|
|
setGeneratedPassword(null);
|
|
setCreatedEmail(null);
|
|
|
|
if (autoPassword) {
|
|
mutation.mutate({ ...data, password: "" });
|
|
return;
|
|
}
|
|
|
|
if (!data.password) {
|
|
setError("비밀번호를 입력하거나 자동 생성을 사용해 주세요.");
|
|
return;
|
|
}
|
|
|
|
mutation.mutate(data);
|
|
};
|
|
|
|
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">
|
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
|
<Link to="/users" className="hover:underline">
|
|
Users
|
|
</Link>
|
|
<span>/</span>
|
|
<span className="text-foreground">New</span>
|
|
</div>
|
|
<h2 className="text-3xl font-semibold">사용자 추가</h2>
|
|
</div>
|
|
<Button variant="ghost" asChild>
|
|
<Link to="/users">
|
|
<ArrowLeft size={16} className="mr-2" />
|
|
목록으로 돌아가기
|
|
</Link>
|
|
</Button>
|
|
</header>
|
|
|
|
{generatedPassword && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>초기 비밀번호 생성 완료</CardTitle>
|
|
<CardDescription>
|
|
{createdEmail ? `${createdEmail} 계정의 초기 비밀번호입니다.` : "초기 비밀번호가 생성되었습니다."}
|
|
</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" />
|
|
복사
|
|
</Button>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button onClick={() => navigate("/users")}>목록으로 이동</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>계정 정보</CardTitle>
|
|
<CardDescription>새로운 사용자를 시스템에 등록합니다.</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">이메일</Label>
|
|
<Input
|
|
id="email"
|
|
placeholder="user@example.com"
|
|
{...register("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">비밀번호</Label>
|
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoPassword}
|
|
onChange={(event) => setAutoPassword(event.target.checked)}
|
|
/>
|
|
자동 생성
|
|
</label>
|
|
</div>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
placeholder="********"
|
|
disabled={autoPassword}
|
|
{...register("password")}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{autoPassword
|
|
? "비워두면 시스템이 초기 비밀번호를 자동 생성합니다."
|
|
: "초기 비밀번호를 직접 설정합니다."}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">이름</Label>
|
|
<Input
|
|
id="name"
|
|
placeholder="홍길동"
|
|
{...register("name", { required: "이름은 필수입니다." })}
|
|
/>
|
|
{errors.name && (
|
|
<p className="text-xs text-destructive">{errors.name.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone">전화번호</Label>
|
|
<Input
|
|
id="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">테넌트 (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")}
|
|
|
|
>
|
|
|
|
<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="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>
|
|
</select>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
시스템 접근 권한을 결정합니다.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => navigate("/users")}
|
|
>
|
|
취소
|
|
</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" />
|
|
사용자 생성
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default UserCreatePage;
|