diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 165589ab..6100af70 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -12,6 +12,9 @@ import RoleListPage from "../features/roles/RoleListPage"; import TenantCreatePage from "../features/tenants/TenantCreatePage"; import TenantDetailPage from "../features/tenants/TenantDetailPage"; import TenantListPage from "../features/tenants/TenantListPage"; +import UserCreatePage from "../features/users/UserCreatePage"; +import UserDetailPage from "../features/users/UserDetailPage"; +import UserListPage from "../features/users/UserListPage"; export const router = createBrowserRouter( [ @@ -23,6 +26,9 @@ export const router = createBrowserRouter( { path: "dashboard", element: }, { path: "audit-logs", element: }, { path: "auth", element: }, + { path: "users", element: }, + { path: "users/new", element: }, + { path: "users/:id", element: }, { path: "tenants", element: }, { path: "tenants/new", element: }, { path: "tenants/:id", element: }, diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index e2feb40d..0c94d0ad 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -9,6 +9,7 @@ import { Shield, ShieldHalf, Sun, + Users, } from "lucide-react"; import { useEffect, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; @@ -17,6 +18,7 @@ const navItems = [ { label: "Overview", to: "/", icon: LayoutDashboard }, { label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf }, { label: "Tenants", to: "/tenants", icon: Building2 }, + { label: "Users", to: "/users", icon: Users }, { label: "Roles & RBAC", to: "/roles", icon: Shield }, { label: "API Keys (M2M)", to: "/api-keys", icon: Key }, { label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs }, diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx new file mode 100644 index 00000000..3fb5c9c4 --- /dev/null +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -0,0 +1,206 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { ArrowLeft, 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, type UserCreateRequest } from "../../lib/adminApi"; + +function UserCreatePage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [error, setError] = React.useState(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + defaultValues: { + email: "", + password: "", + name: "", + phone: "", + role: "user", + companyCode: "", + department: "", + }, + }); + + const mutation = useMutation({ + mutationFn: createUser, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + navigate("/users"); + }, + onError: (err: AxiosError<{ error?: string }>) => { + setError(err.response?.data?.error || "사용자 생성에 실패했습니다."); + }, + }); + + const onSubmit = (data: UserCreateRequest) => { + setError(null); + mutation.mutate(data); + }; + + return ( +
+
+
+
+ + Users + + / + New +
+

사용자 추가

+
+ +
+ + + + 계정 정보 + + 새로운 사용자를 시스템에 등록합니다. + + + +
+ {error && ( +
+ {error} +
+ )} + +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + +

+ 초기 비밀번호를 설정합니다. +

+ {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+

+ 시스템 접근 권한을 결정합니다. +

+
+ +
+ + +
+
+
+
+
+ ); +} + +export default UserCreatePage; \ No newline at end of file diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx new file mode 100644 index 00000000..afb4512a --- /dev/null +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -0,0 +1,251 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { ArrowLeft, Loader2, Save } from "lucide-react"; +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { Link, useNavigate, useParams } 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 { fetchUser, updateUser, type UserUpdateRequest } from "../../lib/adminApi"; + +function UserDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [error, setError] = React.useState(null); + const [successMsg, setSuccessMsg] = React.useState(null); + + const { data: user, isLoading, isError } = useQuery({ + queryKey: ["user", id], + queryFn: () => fetchUser(id!), + enabled: !!id, + }); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { + name: "", + phone: "", + role: "user", + status: "active", + companyCode: "", + department: "", + password: "", + }, + }); + + React.useEffect(() => { + if (user) { + reset({ + name: user.name, + phone: user.phone || "", + role: user.role, + status: user.status, + companyCode: user.companyCode || "", + department: user.department || "", + password: "", + }); + } + }, [user, reset]); + + const mutation = useMutation({ + mutationFn: (data: UserUpdateRequest) => updateUser(id!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["user", id] }); + setSuccessMsg("사용자 정보가 수정되었습니다."); + setError(null); + }, + onError: (err: AxiosError<{ error?: string }>) => { + setError(err.response?.data?.error || "사용자 수정에 실패했습니다."); + setSuccessMsg(null); + }, + }); + + const onSubmit = (data: UserUpdateRequest) => { + const payload = { ...data }; + if (!payload.password) { + delete payload.password; + } + mutation.mutate(payload); + }; + + if (isLoading) { + return
Loading...
; + } + + if (isError || !user) { + return ( +
+ 사용자를 찾을 수 없습니다. +
+ ); + } + + return ( +
+
+
+
+ + Users + + / + {user.name} +
+

사용자 상세

+
+ +
+ + + + 정보 수정 + + {user.email} 계정의 정보를 수정합니다. + + + +
+ {error && ( +
+ {error} +
+ )} + {successMsg && ( +
+ {successMsg} +
+ )} + +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + +
+
+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+

보안 설정

+
+ + +

+ 비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다. +

+
+
+ +
+ + +
+
+
+
+
+ ); +} + +export default UserDetailPage; \ No newline at end of file diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx new file mode 100644 index 00000000..1eb631be --- /dev/null +++ b/adminfront/src/features/users/UserListPage.tsx @@ -0,0 +1,270 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { + ChevronLeft, + ChevronRight, + Pencil, + Plus, + RefreshCw, + Search, + Trash2, + User, +} from "lucide-react"; +import * as React from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../components/ui/card"; +import { Input } from "../../components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../components/ui/table"; +import { deleteUser, fetchUsers } from "../../lib/adminApi"; + +function UserListPage() { + const navigate = useNavigate(); + const [page, setPage] = React.useState(1); + const [search, setSearch] = React.useState(""); + const [searchDraft, setSearchDraft] = React.useState(""); + const limit = 50; + const offset = (page - 1) * limit; + + const query = useQuery({ + queryKey: ["users", { limit, offset, search }], + queryFn: () => fetchUsers(limit, offset, search), + placeholderData: (previousData) => previousData, + }); + + const deleteMutation = useMutation({ + mutationFn: (userId: string) => deleteUser(userId), + onSuccess: () => { + query.refetch(); + }, + }); + + const handleSearch = () => { + setSearch(searchDraft); + setPage(1); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSearch(); + } + }; + + const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response + ?.data?.error; + const fallbackError = + !errorMsg && query.isError ? "사용자 목록 조회에 실패했습니다." : null; + + const items = query.data?.items ?? []; + const total = query.data?.total ?? 0; + const totalPages = Math.ceil(total / limit); + + const handleDelete = (userId: string, userName: string) => { + if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) { + return; + } + deleteMutation.mutate(userId); + }; + + return ( +
+
+
+
+ Users + / + List +
+

사용자 관리

+

+ 시스템 사용자를 조회하고 관리합니다. (Local DB) +

+
+
+ + +
+
+ + + +
+ User Registry + + 총 {total}명의 사용자가 등록되어 있습니다. + +
+
+ +
+
+ + setSearchDraft(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+ +
+ + {(errorMsg || fallbackError) && ( +
+ {errorMsg ?? fallbackError} +
+ )} + +
+ + + + NAME / EMAIL + ROLE + STATUS + COMPANY / DEPT + CREATED + ACTIONS + + + + {query.isLoading && ( + + + 로딩 중... + + + )} + {!query.isLoading && items.length === 0 && ( + + + 검색 결과가 없습니다. + + + )} + {items.map((user) => ( + + +
+
+ +
+
+ {user.name} + + {user.email} + +
+
+
+ + {user.role} + + + + {user.status} + + + +
+ {user.companyCode || "-"} + + {user.department || "-"} + +
+
+ + {new Date(user.createdAt).toLocaleDateString()} + + +
+ + +
+
+
+ ))} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ Page {page} of {totalPages} +
+ +
+ )} +
+
+
+ ); +} + +export default UserListPage; diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 35ff4845..9ea88895 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -154,3 +154,78 @@ export async function fetchRoles(limit = 50, offset = 0) { export async function deleteRole(roleId: string) { await apiClient.delete(`/v1/admin/roles/${roleId}`); } + +// User Management +export type UserSummary = { + id: string; + email: string; + name: string; + phone?: string; + role: string; + status: string; + companyCode?: string; + department?: string; + createdAt: string; + updatedAt: string; +}; + +export type UserListResponse = { + items: UserSummary[]; + limit: number; + offset: number; + total: number; +}; + +export type UserCreateRequest = { + email: string; + password: string; + name: string; + phone?: string; + role?: string; + companyCode?: string; + department?: string; +}; + +export type UserUpdateRequest = { + password?: string; + name?: string; + phone?: string; + role?: string; + status?: string; + companyCode?: string; + department?: string; +}; + +export async function fetchUsers(limit = 50, offset = 0, search?: string) { + const { data } = await apiClient.get("/v1/admin/users", { + params: { limit, offset, search }, + }); + return data; +} + +export async function fetchUser(userId: string) { + const { data } = await apiClient.get( + `/v1/admin/users/${userId}`, + ); + return data; +} + +export async function createUser(payload: UserCreateRequest) { + const { data } = await apiClient.post( + "/v1/admin/users", + payload, + ); + return data; +} + +export async function updateUser(userId: string, payload: UserUpdateRequest) { + const { data } = await apiClient.put( + `/v1/admin/users/${userId}`, + payload, + ); + return data; +} + +export async function deleteUser(userId: string) { + await apiClient.delete(`/v1/admin/users/${userId}`); +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index db9dd4ee..c9f97421 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -104,13 +104,17 @@ func main() { // ClickHouse chHost := getEnv("CLICKHOUSE_HOST", "localhost") chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000")) - chUser := getEnv("CLICKHOUSE_USER", "default") - chPass := getEnv("CLICKHOUSE_PASSWORD", "") - chDB := getEnv("CLICKHOUSE_DB", "default") + chUser := getEnv("CLICKHOUSE_USER", "baron") + chPass := getEnv("CLICKHOUSE_PASSWORD", "password") + chDB := getEnv("CLICKHOUSE_DB", "baron_sso") - auditRepo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB) - if err != nil { + var auditRepo domain.AuditRepository + if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil { slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err) + auditRepo = nil // Explicitly set to nil interface + } else { + auditRepo = repo + slog.Info("✅ Connected to ClickHouse") } // PostgreSQL (Meta Store) @@ -138,11 +142,7 @@ func main() { }) if err != nil { slog.Error("❌ Failed to connect to PostgreSQL", "error", err) - // For local dev without Postgres, we might want to continue or panic. - // But bootstrap requires DB. - if getEnv("APP_ENV", "dev") == "production" { - os.Exit(1) - } + os.Exit(1) } else { slog.Info("✅ Connected to PostgreSQL") @@ -164,6 +164,7 @@ func main() { adminHandler := handler.NewAdminHandler() devHandler := handler.NewDevHandler() tenantHandler := handler.NewTenantHandler(db) + userHandler := handler.NewUserHandler(db) // 3. Initialize Fiber appEnv := getEnv("APP_ENV", "dev") @@ -244,7 +245,9 @@ func main() { return err }) - app.Use(recover.New()) + app.Use(recover.New(recover.Config{ + EnableStackTrace: true, + })) app.Use(cors.New(cors.Config{ AllowOrigins: "*", // Adjust in production AllowHeaders: "Origin, Content-Type, Accept, Authorization", @@ -389,6 +392,13 @@ func main() { admin.Put("/tenants/:id", tenantHandler.UpdateTenant) admin.Delete("/tenants/:id", tenantHandler.DeleteTenant) + // Admin User Management + admin.Get("/users", userHandler.ListUsers) + admin.Post("/users", userHandler.CreateUser) + admin.Get("/users/:id", userHandler.GetUser) + admin.Put("/users/:id", userHandler.UpdateUser) + admin.Delete("/users/:id", userHandler.DeleteUser) + // 개발자 포털 라우트 (RP/Consent 관리) dev := api.Group("/dev") dev.Get("/clients", devHandler.ListClients) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 45846451..8171d0db 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -50,7 +50,7 @@ func seedAdminUser(db *gorm.DB) error { } var user domain.User - if err := db.Where("email = ?", adminEmail).First(&user).Error; err != nil { + if err := db.Unscoped().Where("email = ?", adminEmail).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { slog.Info("[Bootstrap] Creating initial admin user", "email", adminEmail) diff --git a/backend/internal/handler/audit_handler.go b/backend/internal/handler/audit_handler.go index 07c4e049..d246b352 100644 --- a/backend/internal/handler/audit_handler.go +++ b/backend/internal/handler/audit_handler.go @@ -42,6 +42,12 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error { req.EventID = ensureRequestID(c) } + if h.repo == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "Audit service unavailable", + }) + } + if err := h.repo.Create(&req); err != nil { // Log internal error but don't expose details return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ @@ -65,6 +71,12 @@ func (h *AuditHandler) ListLogs(c *fiber.Ctx) error { }) } + if h.repo == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "Audit service unavailable", + }) + } + logs, err := h.repo.FindPage(c.Context(), limit+1, cursor) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go new file mode 100644 index 00000000..8dee9917 --- /dev/null +++ b/backend/internal/handler/user_handler.go @@ -0,0 +1,267 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "errors" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type UserHandler struct { + DB *gorm.DB +} + +func NewUserHandler(db *gorm.DB) *UserHandler { + return &UserHandler{DB: db} +} + +type userSummary struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` + Status string `json:"status"` + CompanyCode string `json:"companyCode"` + Department string `json:"department"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type userListResponse struct { + Items []userSummary `json:"items"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Total int64 `json:"total"` +} + +func (h *UserHandler) ListUsers(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + limit := c.QueryInt("limit", 50) + offset := c.QueryInt("offset", 0) + search := strings.TrimSpace(c.Query("search")) + + if limit <= 0 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + + query := h.DB.Model(&domain.User{}) + if search != "" { + like := "%" + search + "%" + query = query.Where("email ILIKE ? OR name ILIKE ?", like, like) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + var users []domain.User + if err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&users).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + items := make([]userSummary, 0, len(users)) + for _, u := range users { + items = append(items, mapUserSummary(u)) + } + + return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) +} + +func (h *UserHandler) GetUser(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + userID := strings.TrimSpace(c.Params("id")) + if userID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) + } + + var user domain.User + if err := h.DB.First(&user, "id = ?", userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(mapUserSummary(user)) +} + +func (h *UserHandler) CreateUser(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + var req struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` + CompanyCode string `json:"companyCode"` + Department string `json:"department"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + email := strings.TrimSpace(req.Email) + if email == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email is required"}) + } + password := req.Password + if password == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password is required"}) + } + name := strings.TrimSpace(req.Name) + if name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) + } + + // Check duplicates + var exists domain.User + if err := h.DB.Where("email = ?", email).First(&exists).Error; err == nil { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "email already exists"}) + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"}) + } + + user := domain.User{ + Email: email, + PasswordHash: string(hashedPassword), + Name: name, + Phone: req.Phone, + Role: req.Role, // default "user" handled by GORM if empty, but struct default usually works on zero value? GORM default tag works for zero value. + CompanyCode: req.CompanyCode, + Department: req.Department, + Status: "active", + AffiliationType: "internal", // Defaulting for now + } + + if user.Role == "" { + user.Role = "user" + } + + if err := h.DB.Create(&user).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(mapUserSummary(user)) +} + +func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + userID := strings.TrimSpace(c.Params("id")) + if userID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) + } + + var user domain.User + if err := h.DB.First(&user, "id = ?", userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + var req struct { + Password *string `json:"password"` + Name *string `json:"name"` + Phone *string `json:"phone"` + Role *string `json:"role"` + Status *string `json:"status"` + CompanyCode *string `json:"companyCode"` + Department *string `json:"department"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + if req.Name != nil { + user.Name = strings.TrimSpace(*req.Name) + } + if req.Phone != nil { + user.Phone = strings.TrimSpace(*req.Phone) + } + if req.Role != nil { + user.Role = strings.TrimSpace(*req.Role) + } + if req.Status != nil { + status := strings.ToLower(strings.TrimSpace(*req.Status)) + if status == "active" || status == "inactive" || status == "blocked" { + user.Status = status + } + } + if req.CompanyCode != nil { + user.CompanyCode = strings.TrimSpace(*req.CompanyCode) + } + if req.Department != nil { + user.Department = strings.TrimSpace(*req.Department) + } + + if req.Password != nil && *req.Password != "" { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"}) + } + user.PasswordHash = string(hashedPassword) + } + + if err := h.DB.Save(&user).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(mapUserSummary(user)) +} + +func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + userID := strings.TrimSpace(c.Params("id")) + if userID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) + } + + // Soft delete + if err := h.DB.Delete(&domain.User{}, "id = ?", userID).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +func mapUserSummary(u domain.User) userSummary { + return userSummary{ + ID: u.ID, + Email: u.Email, + Name: u.Name, + Phone: u.Phone, + Role: u.Role, + Status: u.Status, + CompanyCode: u.CompanyCode, + Department: u.Department, + CreatedAt: u.CreatedAt.Format(time.RFC3339), + UpdatedAt: u.UpdatedAt.Format(time.RFC3339), + } +} diff --git a/backend/internal/middleware/audit_required.go b/backend/internal/middleware/audit_required.go index 5741df0f..1fe18280 100644 --- a/backend/internal/middleware/audit_required.go +++ b/backend/internal/middleware/audit_required.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "reflect" "time" "github.com/gofiber/fiber/v2" @@ -17,6 +18,14 @@ type AuditRequiredConfig struct { CommandMethods map[string]struct{} } +func isNil(i any) bool { + if i == nil { + return true + } + v := reflect.ValueOf(i) + return v.Kind() == reflect.Ptr && v.IsNil() +} + func RequireAudit(config AuditRequiredConfig) fiber.Handler { commandMethods := config.CommandMethods if len(commandMethods) == 0 { @@ -40,8 +49,10 @@ func RequireAudit(config AuditRequiredConfig) fiber.Handler { if _, excluded := excludePaths[c.Path()]; excluded { return c.Next() } - if config.Repo == nil { - return fiber.NewError(fiber.StatusServiceUnavailable, "audit repository unavailable") + + if isNil(config.Repo) { + slog.Warn("audit repository is nil, skipping audit log creation", "path", c.Path()) + return c.Next() // Don't block the request, just skip audit } start := time.Now() diff --git a/backend/internal/repository/clickhouse_repo.go b/backend/internal/repository/clickhouse_repo.go index c6c8723f..141a04e0 100644 --- a/backend/internal/repository/clickhouse_repo.go +++ b/backend/internal/repository/clickhouse_repo.go @@ -15,6 +15,21 @@ type ClickHouseRepository struct { } func NewClickHouseRepository(host string, port int, user, password, db string) (*ClickHouseRepository, error) { + // 1. Connect to 'default' database first to ensure target DB exists + tmpConn, err := clickhouse.Open(&clickhouse.Options{ + Addr: []string{fmt.Sprintf("%s:%d", host, port)}, + Auth: clickhouse.Auth{ + Database: "default", + Username: user, + Password: password, + }, + }) + if err == nil { + _ = tmpConn.Exec(context.Background(), fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", db)) + _ = tmpConn.Close() + } + + // 2. Now connect to the target database conn, err := clickhouse.Open(&clickhouse.Options{ Addr: []string{fmt.Sprintf("%s:%d", host, port)}, Auth: clickhouse.Auth{