1
0
forked from baron/baron-sso

feat: implement user data CSV export with dynamic metadata columns

This commit is contained in:
2026-03-04 15:54:11 +09:00
parent c126634e16
commit 9720b77898
5 changed files with 125 additions and 2 deletions

View File

@@ -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";

View File

@@ -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() {
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button variant="outline" onClick={handleExport} className="gap-2">
<FileDown size={16} />
{t("ui.common.export", "내보내기")}
</Button>
<UserBulkUploadModal onSuccess={() => query.refetch()} />
<Dialog>
<DialogTrigger asChild>

View File

@@ -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<BulkUserResponse>(
"/v1/admin/users/bulk",

View File

@@ -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)

View File

@@ -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"`