forked from baron/baron-sso
feat: implement CSV bulk user upload functionality
This commit is contained in:
@@ -32,6 +32,7 @@ import {
|
||||
} from "../../components/ui/table";
|
||||
import { deleteUser, fetchUsers } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
||||
|
||||
function UserListPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -130,6 +131,7 @@ function UserListPage() {
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<UserBulkUploadModal onSuccess={() => query.refetch()} />
|
||||
<Button asChild>
|
||||
<Link to="/users/new">
|
||||
<Plus size={16} />
|
||||
|
||||
258
adminfront/src/features/users/components/UserBulkUploadModal.tsx
Normal file
258
adminfront/src/features/users/components/UserBulkUploadModal.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertCircle, CheckCircle2, Download, FileText, Loader2, Upload } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||
import { bulkCreateUsers, type BulkUserItem, type BulkUserResult } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
interface UserBulkUploadModalProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [parsing, setParsing] = React.useState(false);
|
||||
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
|
||||
const [results, setResults] = React.useState<BulkUserResult[] | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: bulkCreateUsers,
|
||||
onSuccess: (data) => {
|
||||
setResults(data.results);
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
parseCSV(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const parseCSV = (file: File) => {
|
||||
setParsing(true);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
const lines = text.split(/\r?\n/);
|
||||
if (lines.length < 2) {
|
||||
setParsing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const headers = lines[0].split(",").map(h => h.trim().toLowerCase());
|
||||
const data: BulkUserItem[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (!lines[i].trim()) continue;
|
||||
|
||||
// Simple CSV split (doesn't handle commas in quotes, but enough for basic template)
|
||||
const values = lines[i].split(",").map(v => v.trim());
|
||||
const item: any = { metadata: {} };
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
const value = values[index];
|
||||
if (!value) return;
|
||||
|
||||
if (["email", "name", "phone", "role", "companycode", "department"].includes(header)) {
|
||||
const key = header === "companycode" ? "companyCode" : header;
|
||||
item[key] = value;
|
||||
} else {
|
||||
item.metadata[header] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (item.email && item.name) {
|
||||
data.push(item as BulkUserItem);
|
||||
}
|
||||
}
|
||||
|
||||
setPreviewData(data);
|
||||
setParsing(false);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (previewData.length > 0) {
|
||||
mutation.mutate(previewData);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const headers = "email,name,phone,role,companyCode,department,employee_id";
|
||||
const example = "user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
|
||||
const blob = new Blob([`${headers}
|
||||
${example}`], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "user_bulk_template.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setPreviewData([]);
|
||||
setResults(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const successCount = results?.filter(r => r.success).length ?? 0;
|
||||
const failCount = results ? results.length - successCount : 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(val) => { setOpen(val); if (!val) reset(); }}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Upload size={16} />
|
||||
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("msg.admin.users.bulk.description", "CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!results ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button variant="ghost" size="sm" onClick={downloadTemplate} className="gap-2">
|
||||
<Download size={14} />
|
||||
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button onClick={() => fileInputRef.current?.click()} variant="secondary">
|
||||
{file ? t("ui.common.change_file", "파일 변경") : t("ui.common.select_file", "파일 선택")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="rounded-lg border p-4 bg-muted/20">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FileText className="text-primary" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span>
|
||||
</div>
|
||||
{parsing ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t("msg.common.parsing", "파싱 중...")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("msg.admin.users.bulk.parsed_count", "{{count}}명의 사용자가 감지되었습니다.", { count: previewData.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewData.length > 0 && (
|
||||
<ScrollArea className="h-[200px] rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
<th className="p-2 text-left">Email</th>
|
||||
<th className="p-2 text-left">Name</th>
|
||||
<th className="p-2 text-left">Tenant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewData.slice(0, 10).map((u, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="p-2">{u.email}</td>
|
||||
<td className="p-2">{u.name}</td>
|
||||
<td className="p-2">{u.companyCode || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{previewData.length > 10 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="p-2 text-center text-muted-foreground italic">
|
||||
... and {previewData.length - 10} more users
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{successCount}</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">{t("ui.common.success", "성공")}</div>
|
||||
</div>
|
||||
<div className="w-px h-10 bg-border" />
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-2xl font-bold text-destructive">{failCount}</div>
|
||||
<div className="text-xs text-muted-foreground uppercase">{t("ui.common.fail", "실패")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[250px] rounded-md border">
|
||||
<div className="p-2 space-y-2">
|
||||
{results.map((r, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-2 rounded border bg-card text-sm">
|
||||
{r.success ? (
|
||||
<CheckCircle2 size={16} className="text-green-500 mt-0.5" />
|
||||
) : (
|
||||
<AlertCircle size={16} className="text-destructive mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{r.email}</div>
|
||||
{!r.success && <div className="text-xs text-destructive">{r.message}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{!results ? (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={previewData.length === 0 || mutation.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{mutation.isPending && <Loader2 size={16} className="mr-2 animate-spin" />}
|
||||
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setOpen(false)} className="w-full sm:w-auto">
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -397,6 +397,27 @@ export type UserUpdateRequest = {
|
||||
jobTitle?: string;
|
||||
};
|
||||
|
||||
export type BulkUserItem = {
|
||||
email: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
companyCode?: string;
|
||||
department?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type BulkUserResult = {
|
||||
email: string;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type BulkUserResponse = {
|
||||
results: BulkUserResult[];
|
||||
};
|
||||
|
||||
export async function fetchUsers(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
@@ -424,6 +445,14 @@ export async function createUser(payload: UserCreateRequest) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function bulkCreateUsers(users: BulkUserItem[]) {
|
||||
const { data } = await apiClient.post<BulkUserResponse>(
|
||||
"/v1/admin/users/bulk",
|
||||
{ users },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
||||
const { data } = await apiClient.put<UserSummary>(
|
||||
`/v1/admin/users/${userId}`,
|
||||
|
||||
@@ -644,6 +644,7 @@ func main() {
|
||||
// Admin User Management
|
||||
admin.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin인 경우 해당 테넌트 사용자만 보이도록 Handler 수정 필요
|
||||
admin.Post("/users", requireAdmin, userHandler.CreateUser)
|
||||
admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers)
|
||||
admin.Get("/users/:id", requireAdmin, userHandler.GetUser)
|
||||
admin.Put("/users/:id", requireAdmin, userHandler.UpdateUser)
|
||||
admin.Delete("/users/:id", requireAdmin, userHandler.DeleteUser)
|
||||
|
||||
@@ -363,6 +363,153 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusCreated).JSON(response)
|
||||
}
|
||||
|
||||
type bulkUserItem struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
CompanyCode string `json:"companyCode"`
|
||||
Department string `json:"department"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
type bulkUserResult struct {
|
||||
Email string `json:"email"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
}
|
||||
|
||||
func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
if h.OryProvider == nil || h.KratosAdmin == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Users []bulkUserItem `json:"users"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
if len(req.Users) == 0 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "no users provided")
|
||||
}
|
||||
|
||||
policy, err := h.OryProvider.GetPasswordPolicy()
|
||||
if err != nil || policy == nil {
|
||||
policy = &domain.PasswordPolicy{
|
||||
MinLength: 12, Number: true, NonAlphanumeric: true,
|
||||
}
|
||||
}
|
||||
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
results := make([]bulkUserResult, 0, len(req.Users))
|
||||
|
||||
// Pre-fetch tenant schemas to avoid redundant DB calls
|
||||
tenantSchemas := make(map[string][]interface{})
|
||||
|
||||
for _, item := range req.Users {
|
||||
email := strings.TrimSpace(item.Email)
|
||||
if email == "" {
|
||||
results = append(results, bulkUserResult{Email: "unknown", Success: false, Message: "email is required"})
|
||||
continue
|
||||
}
|
||||
|
||||
// Role-based access check
|
||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||
if item.CompanyCode != requester.CompanyCode {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve Schema
|
||||
var schema []interface{}
|
||||
if item.CompanyCode != "" {
|
||||
if s, ok := tenantSchemas[item.CompanyCode]; ok {
|
||||
schema = s
|
||||
} else if h.TenantService != nil {
|
||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), item.CompanyCode)
|
||||
if err == nil && tenant != nil {
|
||||
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
|
||||
tenantSchemas[item.CompanyCode] = s
|
||||
schema = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validation
|
||||
if schema != nil {
|
||||
if err := h.validateMetadata(item.Metadata, schema, true); err != nil {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "validation failed: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
password, _ := utils.GeneratePasswordWithPolicy(policy)
|
||||
role := item.Role
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
attributes := map[string]interface{}{
|
||||
"department": item.Department,
|
||||
"affiliationType": "internal",
|
||||
"companyCode": item.CompanyCode,
|
||||
"grade": role,
|
||||
"role": role,
|
||||
}
|
||||
|
||||
// Resolve TenantID
|
||||
var tenantID string
|
||||
if item.CompanyCode != "" && h.TenantService != nil {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), item.CompanyCode); err == nil && tenant != nil {
|
||||
tenantID = tenant.ID
|
||||
attributes["tenant_id"] = tenantID
|
||||
}
|
||||
}
|
||||
|
||||
// Merge metadata
|
||||
for k, v := range item.Metadata {
|
||||
if _, exists := attributes[k]; !exists {
|
||||
attributes[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
|
||||
Email: email,
|
||||
Name: item.Name,
|
||||
PhoneNumber: normalizePhoneNumber(item.Phone),
|
||||
Attributes: attributes,
|
||||
}, password)
|
||||
|
||||
if err != nil {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
// Sync to local DB
|
||||
if h.UserRepo != nil {
|
||||
identity, _ := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
||||
if identity != nil {
|
||||
localUser := h.mapToLocalUser(*identity)
|
||||
_ = h.UserRepo.Update(context.Background(), localUser)
|
||||
if h.KetoOutboxRepo != nil {
|
||||
h.syncKetoRole(context.Background(), localUser.ID, role, "", "", localUser.TenantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, bulkUserResult{Email: email, Success: true, UserID: identityID})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if h.KratosAdmin == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
|
||||
|
||||
Reference in New Issue
Block a user