소스 검색

add admin create user endpoint and UI

Backend: POST /admin/users with email, password, role, name
Frontend: +Создать button with modal form on admin users page
neyrogovnarik 1 개월 전
부모
커밋
08877ff7e9
3개의 변경된 파일115개의 추가작업 그리고 1개의 파일을 삭제
  1. 1 0
      backend/cmd/api/main.go
  2. 48 0
      backend/internal/handlers/users.go
  3. 66 1
      frontend/src/app/admin/users/page.tsx

+ 1 - 0
backend/cmd/api/main.go

@@ -250,6 +250,7 @@ func main() {
 			}
 			r.Post("/places/{id}/moderate", placeHandler.Moderate)
 			r.Get("/admin/users", userHandler.AdminListUsers)
+			r.Post("/admin/users", userHandler.AdminCreateUser)
 			r.Patch("/admin/users/{id}", userHandler.AdminUpdateUser)
 			r.Patch("/admin/bookings/{id}/confirm", bookingHandler.Confirm)
 			r.Post("/admin/tags", tagHandler.CreateTag)

+ 48 - 0
backend/internal/handlers/users.go

@@ -7,6 +7,7 @@ import (
 	"strconv"
 
 	"github.com/go-chi/chi/v5"
+	"golang.org/x/crypto/bcrypt"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/middleware"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/repository"
@@ -89,6 +90,53 @@ func (h *UserHandler) UpdateMe(w http.ResponseWriter, r *http.Request) {
 
 // Admin endpoints
 
+type adminCreateUserRequest struct {
+	Email    string `json:"email" validate:"required,email,max=255"`
+	Password string `json:"password" validate:"required,min=8,max=72"`
+	Role     string `json:"role" validate:"required,user_role"`
+	Name     string `json:"name" validate:"omitempty,max=255"`
+}
+
+func (h *UserHandler) AdminCreateUser(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
+	var req adminCreateUserRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
+		return
+	}
+
+	if err := validator.Validate(req); err != nil {
+		writeValidationError(w, err)
+		return
+	}
+
+	existing, _ := h.userRepo.GetByEmail(r.Context(), req.Email)
+	if existing != nil {
+		writeError(w, http.StatusConflict, "email already exists", nil)
+		return
+	}
+
+	hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, "failed to hash password", err)
+		return
+	}
+
+	user := &models.User{
+		Email:        req.Email,
+		PasswordHash: string(hash),
+		Role:         req.Role,
+		Name:         &req.Name,
+	}
+
+	if err := h.userRepo.Create(r.Context(), user); err != nil {
+		writeError(w, http.StatusInternalServerError, "failed to create user", err)
+		return
+	}
+
+	writeJSON(w, http.StatusCreated, user)
+}
+
 type adminUpdateUserRequest struct {
 	Role   *string `json:"role" validate:"omitempty,user_role"`
 	Status *string `json:"status" validate:"omitempty,user_status"`

+ 66 - 1
frontend/src/app/admin/users/page.tsx

@@ -24,12 +24,68 @@ const statusLabels: Record<string, string> = {
  * пользователей по роли, изменять роль, блокировать/разблокировать.
  * Загружает до 100 пользователей через API /admin/users.
  */
+function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
+  const [email, setEmail] = useState('')
+  const [password, setPassword] = useState('')
+  const [role, setRole] = useState('customer')
+  const [name, setName] = useState('')
+  const [error, setError] = useState('')
+  const [saving, setSaving] = useState(false)
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setSaving(true)
+    setError('')
+    try {
+      await api.post('/admin/users', { email, password, role, name: name || undefined })
+      onCreated()
+    } catch (err: any) {
+      setError(err.message || 'Ошибка')
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
+      <div className="w-full max-w-sm rounded-xl bg-[#1e293b] p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
+        <h2 className="mb-4 text-lg font-bold text-white">Создать пользователя</h2>
+        {error && <p className="mb-3 text-sm text-red-400">{error}</p>}
+        <form onSubmit={handleSubmit} className="space-y-3">
+          <input type="text" placeholder="Имя" value={name} onChange={(e) => setName(e.target.value)}
+            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-sm text-white placeholder-white/40 outline-none focus:border-green-500" />
+          <input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required
+            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-sm text-white placeholder-white/40 outline-none focus:border-green-500" />
+          <input type="password" placeholder="Пароль" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8}
+            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-sm text-white placeholder-white/40 outline-none focus:border-green-500" />
+          <select value={role} onChange={(e) => setRole(e.target.value)}
+            className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-green-500"
+          >
+            {Object.entries(roleLabels).map(([k, v]) => (
+              <option key={k} value={k} className="bg-[#1e293b]">{v}</option>
+            ))}
+          </select>
+          <div className="flex items-center justify-end gap-3 pt-2">
+            <button type="button" onClick={onClose}
+              className="rounded-lg px-4 py-2 text-sm text-white/60 hover:text-white transition"
+            >Отмена</button>
+            <button type="submit" disabled={saving}
+              className="rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-500 transition disabled:opacity-50"
+            >{saving ? 'Создание...' : 'Создать'}</button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}
+
 export default function AdminUsersPage() {
   const { user: currentUser } = useAuth()
   const [users, setUsers] = useState<User[]>([])
   const [loading, setLoading] = useState(true)
   const [roleFilter, setRoleFilter] = useState('')
   const [msg, setMsg] = useState('')
+  const [showCreate, setShowCreate] = useState(false)
 
   const fetchUsers = useCallback(async () => {
     const params = new URLSearchParams()
@@ -61,7 +117,12 @@ export default function AdminUsersPage() {
 
   return (
     <div>
-      <h1 className="mb-6 text-2xl font-bold text-white">Управление пользователями</h1>
+      <div className="mb-6 flex items-center justify-between">
+        <h1 className="text-2xl font-bold text-white">Управление пользователями</h1>
+        <button onClick={() => setShowCreate(true)}
+          className="rounded-lg bg-green-600 px-4 py-2 text-sm text-white hover:bg-green-500 transition"
+        >+ Создать</button>
+      </div>
       {msg && <p className="mb-4 rounded-lg bg-blue-500/10 px-4 py-2 text-sm text-blue-400">{msg}</p>}
 
       <div className="mb-4 flex flex-wrap gap-2">
@@ -126,6 +187,10 @@ export default function AdminUsersPage() {
           </div>
         )
       })()}
+
+      {showCreate && (
+        <CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); setMsg('Пользователь создан'); fetchUsers() }} />
+      )}
     </div>
   )
 }