diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 6438190d..e1a31886 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -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() { {t("ui.common.refresh", "새로고침")} + query.refetch()} /> + + + + {t("ui.admin.users.bulk.title", "사용자 일괄 등록")} + + {t("msg.admin.users.bulk.description", "CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.")} + + + + {!results ? ( +
+
+ + + +
+ + {file && ( +
+
+ + {file.name} + ({(file.size / 1024).toFixed(1)} KB) +
+ {parsing ? ( +
+ + {t("msg.common.parsing", "파싱 중...")} +
+ ) : ( +
+ {t("msg.admin.users.bulk.parsed_count", "{{count}}명의 사용자가 감지되었습니다.", { count: previewData.length })} +
+ )} +
+ )} + + {previewData.length > 0 && ( + + + + + + + + + + + {previewData.slice(0, 10).map((u, i) => ( + + + + + + ))} + {previewData.length > 10 && ( + + + + )} + +
EmailNameTenant
{u.email}{u.name}{u.companyCode || "-"}
+ ... and {previewData.length - 10} more users +
+
+ )} +
+ ) : ( +
+
+
+
{successCount}
+
{t("ui.common.success", "성공")}
+
+
+
+
{failCount}
+
{t("ui.common.fail", "실패")}
+
+
+ + +
+ {results.map((r, i) => ( +
+ {r.success ? ( + + ) : ( + + )} +
+
{r.email}
+ {!r.success &&
{r.message}
} +
+
+ ))} +
+
+
+ )} + + + {!results ? ( + + ) : ( + + )} + + + + ); +} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 77a00a2e..f398bdd3 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -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; +}; + +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( + "/v1/admin/users/bulk", + { users }, + ); + return data; +} + export async function updateUser(userId: string, payload: UserUpdateRequest) { const { data } = await apiClient.put( `/v1/admin/users/${userId}`, diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e5c4a894..ec8a27aa 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 9d9cdeb8..582e0cda 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -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")