1
0
forked from baron/baron-sso

users 정보 페이지 구현

This commit is contained in:
2026-01-29 14:47:20 +09:00
parent df03771121
commit ee4c07f66d
12 changed files with 1139 additions and 14 deletions

View File

@@ -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: <DashboardPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "tenants/:id", element: <TenantDetailPage /> },

View File

@@ -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 },

View File

@@ -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<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<UserCreateRequest>({
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 (
<div className="max-w-3xl space-y-8">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
Users
</Link>
<span>/</span>
<span className="text-foreground">New</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
</div>
<Button variant="ghost" asChild>
<Link to="/users">
<ArrowLeft size={16} className="mr-2" />
</Link>
</Button>
</header>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
placeholder="user@example.com"
{...register("email", { required: "이메일은 필수입니다." })}
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
placeholder="********"
{...register("password", {
required: "비밀번호는 필수입니다.",
minLength: { value: 6, message: "6자 이상 입력해주세요." },
})}
/>
<p className="text-xs text-muted-foreground">
.
</p>
{errors.password && (
<p className="text-xs text-destructive">{errors.password.message}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
placeholder="홍길동"
{...register("name", { required: "이름은 필수입니다." })}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Input
id="phone"
placeholder="010-1234-5678"
{...register("phone")}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> </Label>
<Input
id="companyCode"
placeholder="HMAC"
{...register("companyCode")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<div className="relative">
<select
id="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => navigate("/users")}
>
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
export default UserCreatePage;

View File

@@ -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<string | null>(null);
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
const { data: user, isLoading, isError } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id!),
enabled: !!id,
});
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<UserUpdateRequest>({
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 <div className="p-8 text-center">Loading...</div>;
}
if (isError || !user) {
return (
<div className="p-8 text-center text-destructive">
.
</div>
);
}
return (
<div className="max-w-3xl space-y-8">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
Users
</Link>
<span>/</span>
<span className="text-foreground">{user.name}</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
</div>
<Button variant="ghost" asChild>
<Link to="/users">
<ArrowLeft size={16} className="mr-2" />
</Link>
</Button>
</header>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
{user.email} .
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
)}
{successMsg && (
<div className="rounded-md bg-green-500/15 p-3 text-sm text-green-500">
{successMsg}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
placeholder="홍길동"
{...register("name", { required: "이름은 필수입니다." })}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Input
id="phone"
placeholder="010-1234-5678"
{...register("phone")}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="status"></Label>
<div className="relative">
<select
id="status"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("status")}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="blocked">Blocked</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<div className="relative">
<select
id="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> </Label>
<Input
id="companyCode"
placeholder="HMAC"
{...register("companyCode")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground"> </h3>
<div className="space-y-2">
<Label htmlFor="password"> </Label>
<Input
id="password"
type="password"
placeholder="변경할 경우에만 입력"
{...register("password")}
/>
<p className="text-xs text-muted-foreground">
. .
</p>
</div>
</div>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => navigate("/users")}
>
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
export default UserDetailPage;

View File

@@ -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 (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Users</span>
<span>/</span>
<span className="text-foreground">List</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
. (Local DB)
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
</Button>
<Button asChild>
<Link to="/users/new">
<Plus size={16} />
</Link>
</Button>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Registry</CardTitle>
<CardDescription>
{total} .
</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="이름 또는 이메일 검색..."
className="pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<Button variant="secondary" onClick={handleSearch}>
</Button>
</div>
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg ?? fallbackError}
</div>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME / EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>COMPANY / DEPT</TableHead>
<TableHead>CREATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
...
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
.
</TableCell>
</TableRow>
)}
{items.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<User size={16} />
</div>
<div className="flex flex-col">
<span className="font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{user.role}</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active" ? "default" : "secondary"
}
>
{user.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span>{user.companyCode || "-"}</span>
<span className="text-xs text-muted-foreground">
{user.department || "-"}
</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
>
<Pencil size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={16} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || query.isFetching}
>
<ChevronLeft size={16} />
Previous
</Button>
<div className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || query.isFetching}
>
Next
<ChevronRight size={16} />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default UserListPage;

View File

@@ -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<UserListResponse>("/v1/admin/users", {
params: { limit, offset, search },
});
return data;
}
export async function fetchUser(userId: string) {
const { data } = await apiClient.get<UserSummary>(
`/v1/admin/users/${userId}`,
);
return data;
}
export async function createUser(payload: UserCreateRequest) {
const { data } = await apiClient.post<UserSummary>(
"/v1/admin/users",
payload,
);
return data;
}
export async function updateUser(userId: string, payload: UserUpdateRequest) {
const { data } = await apiClient.put<UserSummary>(
`/v1/admin/users/${userId}`,
payload,
);
return data;
}
export async function deleteUser(userId: string) {
await apiClient.delete(`/v1/admin/users/${userId}`);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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