Ver código fonte

feat: bootstrap setup — первый superadmin через /admin если нет ни одного

neyrogovnarik 1 mês atrás
pai
commit
9a1678f6f3

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

@@ -93,6 +93,7 @@ func main() {
 	tagHandler := handlers.NewTagHandler(tagRepo, featureRepo)
 	wsHub := handlers.NewWSHub(cfg.AllowedOrigins, cfg.AppEnv == "production", authSvc)
 	go wsHub.Run()
+	setupHandler := handlers.NewSetupHandler(userRepo, authSvc, cfg.AppEnv)
 	uploadHandler, err := handlers.NewUploadHandler(cfg.S3Endpoint, cfg.S3PublicEndpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, false)
 	if err != nil {
 		logger.ErrorContext(ctx, "upload handler init failed", log.WithError(err))
@@ -171,6 +172,10 @@ func main() {
 			w.Write([]byte(`{"status":"ok"}`))
 		})
 
+		// Admin setup (first superadmin creation)
+		r.Get("/admin/setup-status", setupHandler.Status)
+		r.Post("/admin/setup", setupHandler.Setup)
+
 		// Public auth - strict rate limiting
 		r.Group(func(r chi.Router) {
 			if authLimiter != nil {

+ 3 - 27
backend/internal/handlers/auth.go

@@ -5,7 +5,6 @@ import (
 	"encoding/json"
 	"errors"
 	"net/http"
-	"time"
 
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/middleware"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/services"
@@ -63,7 +62,7 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	h.setRefreshTokenCookie(w, refreshToken)
+	setRefreshTokenCookie(w, refreshToken, h.isProd)
 	writeJSON(w, http.StatusCreated, result)
 }
 
@@ -102,7 +101,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	h.setRefreshTokenCookie(w, refreshToken)
+	setRefreshTokenCookie(w, refreshToken, h.isProd)
 	writeJSON(w, http.StatusOK, result)
 }
 
@@ -125,7 +124,7 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	h.setRefreshTokenCookie(w, newRefreshToken)
+	setRefreshTokenCookie(w, newRefreshToken, h.isProd)
 	writeJSON(w, http.StatusOK, result)
 }
 
@@ -158,29 +157,6 @@ func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, user)
 }
 
-const refreshTokenCookieName = "refresh_token"
-
-func (h *AuthHandler) setRefreshTokenCookie(w http.ResponseWriter, token string) {
-	cookie := &http.Cookie{
-		Name:     refreshTokenCookieName,
-		Value:    token,
-		Path:     "/",
-		HttpOnly: true,
-		Secure:   h.isProd,
-		SameSite: http.SameSiteLaxMode,
-		MaxAge:   int((30 * 24 * time.Hour).Seconds()),
-	}
-	http.SetCookie(w, cookie)
-}
-
-func getRefreshTokenFromCookie(r *http.Request) string {
-	cookie, err := r.Cookie(refreshTokenCookieName)
-	if err != nil {
-		return ""
-	}
-	return cookie.Value
-}
-
 func (h *AuthHandler) clearRefreshTokenCookie(w http.ResponseWriter) {
 	sameSite := http.SameSiteLaxMode
 	if h.isProd {

+ 27 - 0
backend/internal/handlers/helpers.go

@@ -3,3 +3,30 @@
 // Upload (S3 presigned URLs) и WebSocket (посетители на карте).
 // Используется валидация через validator и middleware для авторизации/ролей.
 package handlers
+
+import (
+	"net/http"
+	"time"
+)
+
+const refreshTokenCookieName = "refresh_token"
+
+func setRefreshTokenCookie(w http.ResponseWriter, token string, isProd bool) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     refreshTokenCookieName,
+		Value:    token,
+		Path:     "/",
+		HttpOnly: true,
+		Secure:   isProd,
+		SameSite: http.SameSiteLaxMode,
+		MaxAge:   int((30 * 24 * time.Hour).Seconds()),
+	})
+}
+
+func getRefreshTokenFromCookie(r *http.Request) string {
+	cookie, err := r.Cookie(refreshTokenCookieName)
+	if err != nil {
+		return ""
+	}
+	return cookie.Value
+}

+ 82 - 0
backend/internal/handlers/setup.go

@@ -0,0 +1,82 @@
+package handlers
+
+import (
+	"encoding/json"
+	"errors"
+	"net/http"
+
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/repository"
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/services"
+	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/validator"
+)
+
+type SetupHandler struct {
+	userRepo *repository.UserRepo
+	authSvc  *services.AuthService
+	isProd   bool
+}
+
+func NewSetupHandler(userRepo *repository.UserRepo, authSvc *services.AuthService, appEnv string) *SetupHandler {
+	return &SetupHandler{
+		userRepo: userRepo,
+		authSvc:  authSvc,
+		isProd:   appEnv == "production",
+	}
+}
+
+func (h *SetupHandler) Status(w http.ResponseWriter, r *http.Request) {
+	hasSuperadmin, err := h.userRepo.HasSuperadmin(r.Context())
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, "failed to check setup status", err)
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]bool{"has_superadmin": hasSuperadmin})
+}
+
+type setupRequest struct {
+	Email    string `json:"email" validate:"required,email,max=255"`
+	Password string `json:"password" validate:"required,min=8,max=72"`
+	Name     string `json:"name" validate:"omitempty,max=255"`
+}
+
+func (h *SetupHandler) Setup(w http.ResponseWriter, r *http.Request) {
+	hasSuperadmin, err := h.userRepo.HasSuperadmin(r.Context())
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, "failed to check setup status", err)
+		return
+	}
+	if hasSuperadmin {
+		writeError(w, http.StatusConflict, "superadmin already exists", nil)
+		return
+	}
+
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
+	var req setupRequest
+	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
+	}
+
+	result, refreshToken, err := h.authSvc.Register(r.Context(), services.RegisterInput{
+		Email:    req.Email,
+		Password: req.Password,
+		Role:     "superadmin",
+		Name:     req.Name,
+	})
+	if err != nil {
+		if errors.Is(err, services.ErrEmailExists) {
+			writeError(w, http.StatusConflict, "email already exists", err)
+			return
+		}
+		writeError(w, http.StatusInternalServerError, "setup failed", err)
+		return
+	}
+
+	setRefreshTokenCookie(w, refreshToken, h.isProd)
+	writeJSON(w, http.StatusCreated, result)
+}

+ 11 - 0
backend/internal/repository/users.go

@@ -65,6 +65,17 @@ func (r *UserRepo) UpdateStatus(ctx context.Context, id, status string) error {
 	return err
 }
 
+func (r *UserRepo) HasSuperadmin(ctx context.Context) (bool, error) {
+	var count int
+	err := r.pool.QueryRow(ctx,
+		`SELECT COUNT(*) FROM users WHERE role = 'superadmin' AND deleted_at IS NULL`,
+	).Scan(&count)
+	if err != nil {
+		return false, fmt.Errorf("has superadmin: %w", err)
+	}
+	return count > 0, nil
+}
+
 func (r *UserRepo) List(ctx context.Context, filter models.UserFilter) ([]*models.User, error) {
 	query := `SELECT id, email, password_hash, role, status, name, avatar_url, phone, bio, country,
 	                  created_at, updated_at, deleted_at

+ 82 - 5
frontend/src/app/admin/layout.tsx

@@ -2,23 +2,100 @@
 
 import Link from 'next/link'
 import { usePathname, useRouter } from 'next/navigation'
-import { useEffect } from 'react'
+import { useState, useEffect } from 'react'
 import { useAuth } from '@/hooks/useAuth'
+import { api, setAccessToken } from '@/lib/api'
 
 const ALLOWED_ROLES = ['moderator', 'superadmin']
 
+function SetupForm({ onDone }: { onDone: () => void }) {
+  const [email, setEmail] = useState('')
+  const [password, setPassword] = useState('')
+  const [name, setName] = useState('')
+  const [error, setError] = useState('')
+  const [loading, setLoading] = useState(false)
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setError('')
+    setLoading(true)
+    try {
+      const res = await api.post<{ user: any; access_token: string }>('/admin/setup', { email, password, name })
+      setAccessToken(res.access_token)
+      onDone()
+    } catch (err: any) {
+      setError(err.message || 'Ошибка создания администратора')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <div className="flex min-h-screen items-center justify-center bg-[#0f172a]">
+      <form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border border-white/10 bg-white/5 p-6">
+        <h1 className="mb-6 text-xl font-bold text-white">Создание первого администратора</h1>
+        <p className="mb-4 text-sm text-white/40">В системе ещё нет суперадминистратора. Создайте первую учётную запись.</p>
+
+        {error && <p className="mb-4 rounded bg-red-500/10 px-3 py-2 text-sm text-red-400">{error}</p>}
+
+        <div className="space-y-4">
+          <div>
+            <label className="mb-1 block text-sm text-white/60">Email</label>
+            <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
+              className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-green-500" />
+          </div>
+          <div>
+            <label className="mb-1 block text-sm text-white/60">Имя (необязательно)</label>
+            <input type="text" value={name} onChange={(e) => setName(e.target.value)}
+              className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-green-500" />
+          </div>
+          <div>
+            <label className="mb-1 block text-sm text-white/60">Пароль</label>
+            <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8}
+              className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-green-500" />
+          </div>
+          <button type="submit" disabled={loading}
+            className="w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-500 transition disabled:opacity-50"
+          >
+            {loading ? 'Создание...' : 'Создать администратора'}
+          </button>
+        </div>
+      </form>
+    </div>
+  )
+}
+
 export default function AdminLayout({ children }: { children: React.ReactNode }) {
   const pathname = usePathname()
   const router = useRouter()
-  const { user, isLoading } = useAuth()
+  const { user, isLoading: authLoading } = useAuth()
+  const [setupStatus, setSetupStatus] = useState<'loading' | 'needed' | 'done'>('loading')
 
   useEffect(() => {
-    if (!isLoading && (!user || !ALLOWED_ROLES.includes(user.role))) {
+    api.get<{ has_superadmin: boolean }>('/admin/setup-status')
+      .then((res) => setSetupStatus(res.has_superadmin ? 'done' : 'needed'))
+      .catch(() => setSetupStatus('done'))
+  }, [])
+
+  useEffect(() => {
+    if (setupStatus === 'done' && !authLoading && (!user || !ALLOWED_ROLES.includes(user.role))) {
       router.replace('/')
     }
-  }, [user, isLoading, router])
+  }, [setupStatus, user, authLoading, router])
+
+  if (setupStatus === 'loading') {
+    return (
+      <div className="flex min-h-screen items-center justify-center bg-[#0f172a]">
+        <p className="text-white/40">Загрузка...</p>
+      </div>
+    )
+  }
+
+  if (setupStatus === 'needed') {
+    return <SetupForm onDone={() => setSetupStatus('done')} />
+  }
 
-  if (isLoading || !user || !ALLOWED_ROLES.includes(user.role)) {
+  if (authLoading || !user || !ALLOWED_ROLES.includes(user.role)) {
     return (
       <div className="flex min-h-screen items-center justify-center bg-[#0f172a]">
         <p className="text-white/40">Загрузка...</p>