forked from baron/baron-sso
users 정보 페이지 구현
This commit is contained in:
@@ -12,6 +12,9 @@ import RoleListPage from "../features/roles/RoleListPage";
|
|||||||
import TenantCreatePage from "../features/tenants/TenantCreatePage";
|
import TenantCreatePage from "../features/tenants/TenantCreatePage";
|
||||||
import TenantDetailPage from "../features/tenants/TenantDetailPage";
|
import TenantDetailPage from "../features/tenants/TenantDetailPage";
|
||||||
import TenantListPage from "../features/tenants/TenantListPage";
|
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(
|
export const router = createBrowserRouter(
|
||||||
[
|
[
|
||||||
@@ -23,6 +26,9 @@ export const router = createBrowserRouter(
|
|||||||
{ path: "dashboard", element: <DashboardPage /> },
|
{ path: "dashboard", element: <DashboardPage /> },
|
||||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||||
{ path: "auth", element: <AuthPage /> },
|
{ path: "auth", element: <AuthPage /> },
|
||||||
|
{ path: "users", element: <UserListPage /> },
|
||||||
|
{ path: "users/new", element: <UserCreatePage /> },
|
||||||
|
{ path: "users/:id", element: <UserDetailPage /> },
|
||||||
{ path: "tenants", element: <TenantListPage /> },
|
{ path: "tenants", element: <TenantListPage /> },
|
||||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||||
{ path: "tenants/:id", element: <TenantDetailPage /> },
|
{ path: "tenants/:id", element: <TenantDetailPage /> },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Sun,
|
Sun,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
@@ -17,6 +18,7 @@ const navItems = [
|
|||||||
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
{ label: "Overview", to: "/", icon: LayoutDashboard },
|
||||||
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
|
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
|
||||||
{ label: "Tenants", to: "/tenants", icon: Building2 },
|
{ label: "Tenants", to: "/tenants", icon: Building2 },
|
||||||
|
{ label: "Users", to: "/users", icon: Users },
|
||||||
{ label: "Roles & RBAC", to: "/roles", icon: Shield },
|
{ label: "Roles & RBAC", to: "/roles", icon: Shield },
|
||||||
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
|
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
|
||||||
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
||||||
|
|||||||
206
adminfront/src/features/users/UserCreatePage.tsx
Normal file
206
adminfront/src/features/users/UserCreatePage.tsx
Normal 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;
|
||||||
251
adminfront/src/features/users/UserDetailPage.tsx
Normal file
251
adminfront/src/features/users/UserDetailPage.tsx
Normal 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;
|
||||||
270
adminfront/src/features/users/UserListPage.tsx
Normal file
270
adminfront/src/features/users/UserListPage.tsx
Normal 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;
|
||||||
@@ -154,3 +154,78 @@ export async function fetchRoles(limit = 50, offset = 0) {
|
|||||||
export async function deleteRole(roleId: string) {
|
export async function deleteRole(roleId: string) {
|
||||||
await apiClient.delete(`/v1/admin/roles/${roleId}`);
|
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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,13 +104,17 @@ func main() {
|
|||||||
// ClickHouse
|
// ClickHouse
|
||||||
chHost := getEnv("CLICKHOUSE_HOST", "localhost")
|
chHost := getEnv("CLICKHOUSE_HOST", "localhost")
|
||||||
chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000"))
|
chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000"))
|
||||||
chUser := getEnv("CLICKHOUSE_USER", "default")
|
chUser := getEnv("CLICKHOUSE_USER", "baron")
|
||||||
chPass := getEnv("CLICKHOUSE_PASSWORD", "")
|
chPass := getEnv("CLICKHOUSE_PASSWORD", "password")
|
||||||
chDB := getEnv("CLICKHOUSE_DB", "default")
|
chDB := getEnv("CLICKHOUSE_DB", "baron_sso")
|
||||||
|
|
||||||
auditRepo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB)
|
var auditRepo domain.AuditRepository
|
||||||
if err != nil {
|
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)
|
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)
|
// PostgreSQL (Meta Store)
|
||||||
@@ -138,11 +142,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("❌ Failed to connect to PostgreSQL", "error", err)
|
slog.Error("❌ Failed to connect to PostgreSQL", "error", err)
|
||||||
// For local dev without Postgres, we might want to continue or panic.
|
os.Exit(1)
|
||||||
// But bootstrap requires DB.
|
|
||||||
if getEnv("APP_ENV", "dev") == "production" {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
slog.Info("✅ Connected to PostgreSQL")
|
slog.Info("✅ Connected to PostgreSQL")
|
||||||
|
|
||||||
@@ -164,6 +164,7 @@ func main() {
|
|||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
devHandler := handler.NewDevHandler()
|
devHandler := handler.NewDevHandler()
|
||||||
tenantHandler := handler.NewTenantHandler(db)
|
tenantHandler := handler.NewTenantHandler(db)
|
||||||
|
userHandler := handler.NewUserHandler(db)
|
||||||
|
|
||||||
// 3. Initialize Fiber
|
// 3. Initialize Fiber
|
||||||
appEnv := getEnv("APP_ENV", "dev")
|
appEnv := getEnv("APP_ENV", "dev")
|
||||||
@@ -244,7 +245,9 @@ func main() {
|
|||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
app.Use(recover.New())
|
app.Use(recover.New(recover.Config{
|
||||||
|
EnableStackTrace: true,
|
||||||
|
}))
|
||||||
app.Use(cors.New(cors.Config{
|
app.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: "*", // Adjust in production
|
AllowOrigins: "*", // Adjust in production
|
||||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||||
@@ -389,6 +392,13 @@ func main() {
|
|||||||
admin.Put("/tenants/:id", tenantHandler.UpdateTenant)
|
admin.Put("/tenants/:id", tenantHandler.UpdateTenant)
|
||||||
admin.Delete("/tenants/:id", tenantHandler.DeleteTenant)
|
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 관리)
|
// 개발자 포털 라우트 (RP/Consent 관리)
|
||||||
dev := api.Group("/dev")
|
dev := api.Group("/dev")
|
||||||
dev.Get("/clients", devHandler.ListClients)
|
dev.Get("/clients", devHandler.ListClients)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func seedAdminUser(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var user domain.User
|
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) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
slog.Info("[Bootstrap] Creating initial admin user", "email", adminEmail)
|
slog.Info("[Bootstrap] Creating initial admin user", "email", adminEmail)
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
|
|||||||
req.EventID = ensureRequestID(c)
|
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 {
|
if err := h.repo.Create(&req); err != nil {
|
||||||
// Log internal error but don't expose details
|
// Log internal error but don't expose details
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
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)
|
logs, err := h.repo.FindPage(c.Context(), limit+1, cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
|||||||
267
backend/internal/handler/user_handler.go
Normal file
267
backend/internal/handler/user_handler.go
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"reflect"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
@@ -17,6 +18,14 @@ type AuditRequiredConfig struct {
|
|||||||
CommandMethods map[string]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 {
|
func RequireAudit(config AuditRequiredConfig) fiber.Handler {
|
||||||
commandMethods := config.CommandMethods
|
commandMethods := config.CommandMethods
|
||||||
if len(commandMethods) == 0 {
|
if len(commandMethods) == 0 {
|
||||||
@@ -40,8 +49,10 @@ func RequireAudit(config AuditRequiredConfig) fiber.Handler {
|
|||||||
if _, excluded := excludePaths[c.Path()]; excluded {
|
if _, excluded := excludePaths[c.Path()]; excluded {
|
||||||
return c.Next()
|
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()
|
start := time.Now()
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ type ClickHouseRepository struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewClickHouseRepository(host string, port int, user, password, db string) (*ClickHouseRepository, error) {
|
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{
|
conn, err := clickhouse.Open(&clickhouse.Options{
|
||||||
Addr: []string{fmt.Sprintf("%s:%d", host, port)},
|
Addr: []string{fmt.Sprintf("%s:%d", host, port)},
|
||||||
Auth: clickhouse.Auth{
|
Auth: clickhouse.Auth{
|
||||||
|
|||||||
Reference in New Issue
Block a user