diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index 31fc4a1a..00bd5ff2 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -17,7 +17,7 @@ import { UserPlus, Users, } from "lucide-react"; -import type React from "react"; +import * as React from "react"; import { useMemo, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 17473ff7..8468d18e 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -3,6 +3,7 @@ import type { AxiosError } from "axios"; import { ChevronLeft, ChevronRight, + FileDown, Pencil, Plus, RefreshCw, @@ -48,6 +49,7 @@ import { fetchTenant, fetchTenants, fetchUsers, + exportUsersCSVUrl, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { UserBulkUploadModal } from "./components/UserBulkUploadModal"; @@ -149,6 +151,11 @@ function UserListPage() { } }; + const handleExport = () => { + const url = exportUsersCSVUrl(search, selectedCompany); + window.open(url, "_blank"); + }; + const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response ?.data?.error; const fallbackError = @@ -252,6 +259,10 @@ function UserListPage() { {t("ui.common.refresh", "새로고침")} + query.refetch()} /> diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 376a005c..74d4c3f4 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -445,6 +445,19 @@ export async function createUser(payload: UserCreateRequest) { return data; } +export function exportUsersCSVUrl(search?: string, companyCode?: string) { + const params = new URLSearchParams(); + if (search) params.append("search", search); + if (companyCode) params.append("companyCode", companyCode); + + // Get mock role from storage if exists for dev environment + const mockRole = window.localStorage.getItem("X-Mock-Role"); + if (mockRole) params.append("x-test-role", mockRole); + + const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1"; + return `${baseUrl}/admin/users/export?${params.toString()}`; +} + export async function bulkCreateUsers(users: BulkUserItem[]) { const { data } = await apiClient.post( "/v1/admin/users/bulk", diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 82fb685d..ef9e72ca 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -642,7 +642,8 @@ func main() { relyingPartyHandler.Delete) // Admin User Management - admin.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin인 경우 해당 테넌트 사용자만 보이도록 Handler 수정 필요 + admin.Get("/users", requireAdmin, userHandler.ListUsers) + admin.Get("/users/export", requireAdmin, userHandler.ExportUsersCSV) admin.Post("/users", requireAdmin, userHandler.CreateUser) admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers) admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 3f84a18c..767f9e72 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -6,6 +6,7 @@ import ( "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "context" + "encoding/csv" "errors" "fmt" "log/slog" @@ -541,6 +542,103 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { }) } +func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error { + search := strings.TrimSpace(c.Query("search")) + companyCode := strings.TrimSpace(c.Query("companyCode")) + + var requesterRole string + var manageableSlugs []string + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { + requesterRole = profile.Role + if requesterRole == domain.RoleTenantAdmin { + for _, t := range profile.ManageableTenants { + manageableSlugs = append(manageableSlugs, strings.ToLower(t.Slug)) + } + if profile.CompanyCode != "" { + manageableSlugs = append(manageableSlugs, strings.ToLower(profile.CompanyCode)) + } + } + } + + // 1. Fetch Users using Repo for efficiency + users, _, err := h.UserRepo.List(c.Context(), 10000, 0, search, companyCode) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export") + } + + // 2. Filter by manageable tenants if tenant_admin + var filtered []domain.User + if requesterRole == domain.RoleTenantAdmin { + slugMap := make(map[string]bool) + for _, s := range manageableSlugs { + slugMap[s] = true + } + for _, u := range users { + if slugMap[strings.ToLower(u.CompanyCode)] { + filtered = append(filtered, u) + } + } + } else { + filtered = users + } + + // 3. Set CSV Headers + c.Set("Content-Type", "text/csv") + c.Set("Content-Disposition", "attachment; filename=users_export_"+time.Now().Format("20060102")+".csv") + + writer := csv.NewWriter(c) + defer writer.Flush() + + // Header row + header := []string{"ID", "Email", "Name", "Role", "Status", "Tenant", "Department", "Position", "JobTitle", "CreatedAt"} + + // Collect all possible metadata keys for dynamic columns + metaKeysMap := make(map[string]bool) + for _, u := range filtered { + for k := range u.Metadata { + metaKeysMap[k] = true + } + } + var metaKeys []string + for k := range metaKeysMap { + metaKeys = append(metaKeys, k) + header = append(header, "Meta:"+k) + } + + if err := writer.Write(header); err != nil { + return err + } + + // Data rows + for _, u := range filtered { + row := []string{ + u.ID, + u.Email, + u.Name, + u.Role, + u.Status, + u.CompanyCode, + u.Department, + u.Position, + u.JobTitle, + u.CreatedAt.Format(time.RFC3339), + } + // Append metadata values in order + for _, k := range metaKeys { + val := "" + if v, ok := u.Metadata[k]; ok { + val = fmt.Sprintf("%v", v) + } + row = append(row, val) + } + if err := writer.Write(row); err != nil { + return err + } + } + + return nil +} + func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { var req struct { UserIDs []string `json:"userIds"`