+ Admin Dashboard +
+Verwaltung und Statistiken
+0
+Registrierte Nutzer
+0
+Eigene Stunden
+0
+Eingetragene Klausuren
+Nutzerverwaltung
+| + Name | ++ Status | ++ Registriert am | ++ Aktionen | +
|---|
diff --git a/backend/admin-service/handlers.go b/backend/admin-service/handlers.go new file mode 100644 index 0000000..f1aedf1 --- /dev/null +++ b/backend/admin-service/handlers.go @@ -0,0 +1,395 @@ +package adminservice + +import ( + "fmt" + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/nora-nak/backend/models" + "github.com/nora-nak/backend/utils" + "gorm.io/gorm" +) + +type AdminHandler struct { + DB *gorm.DB +} + +func NewAdminHandler(db *gorm.DB) *AdminHandler { + return &AdminHandler{DB: db} +} + +// GetDashboardStats returns statistics for the admin dashboard +// GET /v1/admin/stats +func (h *AdminHandler) GetDashboardStats(c *fiber.Ctx) error { + var userCount int64 + var customHourCount int64 + var examCount int64 + + if err := h.DB.Model(&models.User{}).Count(&userCount).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "detail": "Failed to count users", + }) + } + + if err := h.DB.Model(&models.CustomHour{}).Count(&customHourCount).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "detail": "Failed to count custom hours", + }) + } + + if err := h.DB.Model(&models.Exam{}).Count(&examCount).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "detail": "Failed to count exams", + }) + } + + return c.JSON(fiber.Map{ + "user_count": userCount, + "custom_hour_count": customHourCount, + "exam_count": examCount, + }) +} + +// GetUsers returns a list of users with pagination and search +// GET /v1/admin/users +func (h *AdminHandler) GetUsers(c *fiber.Ctx) error { + page, _ := strconv.Atoi(c.Query("page", "1")) + limit, _ := strconv.Atoi(c.Query("limit", "20")) + search := c.Query("search", "") + + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + offset := (page - 1) * limit + + var users []models.User + var total int64 + + query := h.DB.Model(&models.User{}) + + if search != "" { + searchTerm := "%" + search + "%" + query = query.Where("first_name ILIKE ? OR last_name ILIKE ? OR mail ILIKE ?", searchTerm, searchTerm, searchTerm) + } + + if err := query.Count(&total).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "detail": "Failed to count users", + }) + } + + if err := query.Select("id, mail, created_at, verified, is_admin, uuid, first_name, last_name, initials, zenturien_id").Order("created_at DESC").Offset(offset).Limit(limit).Find(&users).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "detail": "Failed to fetch users", + }) + } + + return c.JSON(fiber.Map{ + "users": users, + "meta": fiber.Map{ + "total": total, + "page": page, + "limit": limit, + "pages": (total + int64(limit) - 1) / int64(limit), + }, + }) +} + +// verifyAdminPassword checks if the provided password matches the current admin user's password +func (h *AdminHandler) verifyAdminPassword(c *fiber.Ctx, password string) error { + // Get current user from context (set by AuthMiddleware) + user, ok := c.Locals("user").(*models.User) + if !ok || user == nil { + return fmt.Errorf("user not authenticated") + } + + if !user.IsAdmin { + return fmt.Errorf("user is not an admin") + } + + if password == "" { + return fmt.Errorf("password required") + } + + // Verify password + if !utils.CheckPasswordHash(password, user.PasswordHash) { + return fmt.Errorf("invalid password") + } + + return nil +} + +// PromoteToAdmin promotes a user to admin +// PUT /v1/admin/users/:id/promote +func (h *AdminHandler) PromoteToAdmin(c *fiber.Ctx) error { + var input struct { + AdminPassword string `json:"admin_password"` + } + + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "Invalid request body", + }) + } + + // Verify admin password + if err := h.verifyAdminPassword(c, input.AdminPassword); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "detail": "Authentication failed: " + err.Error(), + }) + } + + id, err := strconv.Atoi(c.Params("id")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "Invalid user ID", + }) + } + + var user models.User + if err := h.DB.First(&user, id).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "detail": "User not found", + }) + } + + if user.IsAdmin { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "User is already an admin", + }) + } + + user.IsAdmin = true + if err := h.DB.Save(&user).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "detail": "Failed to promote user", + }) + } + + return c.JSON(fiber.Map{ + "message": fmt.Sprintf("User %s %s promoted to admin", user.FirstName, user.LastName), + "user": user, + }) +} + +// DeleteUser deletes a user +// DELETE /v1/admin/users/:id +func (h *AdminHandler) DeleteUser(c *fiber.Ctx) error { + var input struct { + AdminPassword string `json:"admin_password"` + } + + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "Invalid request body", + }) + } + + // Verify admin password + if err := h.verifyAdminPassword(c, input.AdminPassword); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "detail": "Authentication failed: " + err.Error(), + }) + } + + id, err := strconv.Atoi(c.Params("id")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "Invalid user ID", + }) + } + + if err := h.DB.Delete(&models.User{}, id).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "detail": "Failed to delete user", + }) + } + + return c.JSON(fiber.Map{ + "message": "User deleted successfully", + }) +} + +// VerifyUser toggles user verification status +// PUT /v1/admin/users/:id/verify +func (h *AdminHandler) VerifyUser(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "Invalid user ID", + }) + } + + var input struct { + Verified bool `json:"verified"` + AdminPassword string `json:"admin_password"` + } + + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "Invalid request body", + }) + } + + // Verify admin password + if err := h.verifyAdminPassword(c, input.AdminPassword); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "detail": "Authentication failed: " + err.Error(), + }) + } + + var user models.User + if err := h.DB.First(&user, id).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "detail": "User not found", + }) + } + + user.Verified = input.Verified + if err := h.DB.Save(&user).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "detail": "Failed to update user verification", + }) + } + + return c.JSON(fiber.Map{ + "message": "User verification updated", + "user": user, + }) +} + +// UpdateUser updates user details +// PUT /v1/admin/users/:id +func (h *AdminHandler) UpdateUser(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "Invalid user ID", + }) + } + + var input struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Mail string `json:"mail"` + Initials string `json:"initials"` + AdminPassword string `json:"admin_password"` + } + + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "Invalid request body", + }) + } + + // Verify admin password + if err := h.verifyAdminPassword(c, input.AdminPassword); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "detail": "Authentication failed: " + err.Error(), + }) + } + + // Validate email domain + if len(input.Mail) < 18 || input.Mail[len(input.Mail)-17:] != "@nordakademie.de" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "Email must end with @nordakademie.de", + }) + } + + var user models.User + if err := h.DB.First(&user, id).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "detail": "User not found", + }) + } + + user.FirstName = input.FirstName + user.LastName = input.LastName + user.Mail = input.Mail + user.Initials = input.Initials + + if err := h.DB.Save(&user).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "detail": "Failed to update user", + }) + } + + return c.JSON(fiber.Map{ + "message": "User updated successfully", + "user": user, + }) +} + +// ResetUserPassword resets a user's password +// POST /v1/admin/users/:id/reset-password +func (h *AdminHandler) ResetUserPassword(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "Invalid user ID", + }) + } + + var input struct { + NewPassword string `json:"new_password"` + AdminPassword string `json:"admin_password"` + } + + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "Invalid request body", + }) + } + + // Verify admin password + if err := h.verifyAdminPassword(c, input.AdminPassword); err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "detail": "Authentication failed: " + err.Error(), + }) + } + + if len(input.NewPassword) < 8 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "detail": "Password must be at least 8 characters long", + }) + } + + var user models.User + if err := h.DB.First(&user, id).Error; err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "detail": "User not found", + }) + } + + // Hash new password + hashedPassword, err := utils.HashPassword(input.NewPassword) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "detail": "Failed to hash password", + }) + } + + user.PasswordHash = hashedPassword + if err := h.DB.Save(&user).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "detail": "Failed to update password", + }) + } + + // Invalidate all sessions for this user + if err := h.DB.Where("user_id = ?", user.ID).Delete(&models.Session{}).Error; err != nil { + // Log error but don't fail the request as password is already reset + fmt.Printf("Failed to invalidate sessions for user %d: %v\n", user.ID, err) + } + + return c.JSON(fiber.Map{ + "message": "Password reset successfully", + }) +} diff --git a/backend/admin-service/routes.go b/backend/admin-service/routes.go new file mode 100644 index 0000000..ce503a5 --- /dev/null +++ b/backend/admin-service/routes.go @@ -0,0 +1,19 @@ +package adminservice + +import ( + "github.com/gofiber/fiber/v2" + "github.com/nora-nak/backend/middleware" + "gorm.io/gorm" +) + +func SetupRoutes(app fiber.Router, db *gorm.DB) { + adminHandler := NewAdminHandler(db) + admin := app.Group("/v1/admin", middleware.AuthMiddleware, middleware.AdminMiddleware) + admin.Get("/stats", adminHandler.GetDashboardStats) + admin.Get("/users", adminHandler.GetUsers) + admin.Put("/users/:id/promote", adminHandler.PromoteToAdmin) + admin.Delete("/users/:id", adminHandler.DeleteUser) + admin.Put("/users/:id/verify", adminHandler.VerifyUser) + admin.Put("/users/:id", adminHandler.UpdateUser) + admin.Post("/users/:id/reset-password", adminHandler.ResetUserPassword) +} diff --git a/backend/handlers/timetable.go b/backend/handlers/timetable.go index 7d9f8c7..c7c122a 100644 --- a/backend/handlers/timetable.go +++ b/backend/handlers/timetable.go @@ -18,8 +18,8 @@ type UserResponse struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` SubscriptionUUID *string `json:"subscription_uuid,omitempty"` - Zenturie *string `json:"zenturie,omitempty"` - Year *string `json:"year,omitempty"` + Zenturie *string `json:"zenturie"` + Year *string `json:"year"` } // ZenturieResponse represents zenturie information diff --git a/backend/main.go b/backend/main.go index 2ff5c72..b824b51 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "io" "log" "os" @@ -12,12 +13,16 @@ import ( "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/google/uuid" "github.com/joho/godotenv" + adminservice "github.com/nora-nak/backend/admin-service" "github.com/nora-nak/backend/config" "github.com/nora-nak/backend/handlers" "github.com/nora-nak/backend/middleware" + "github.com/nora-nak/backend/models" "github.com/nora-nak/backend/services" + "github.com/nora-nak/backend/utils" ) func main() { @@ -45,6 +50,11 @@ func main() { log.Fatal("Failed to run migrations:", err) } + // Seed initial admin user + if err := seedAdmin(); err != nil { + log.Printf("WARNING: Failed to seed admin user: %v", err) + } + // Start scheduler (run immediately on startup) if err := services.StartScheduler(true); err != nil { log.Printf("WARNING: Failed to start scheduler: %v", err) @@ -94,6 +104,9 @@ func main() { // Protected routes (requires authentication) setupProtectedRoutes(app) + // Admin routes + adminservice.SetupRoutes(app, config.DB) + // Start server port := config.AppConfig.ServerPort log.Printf("Server starting on port %s", port) @@ -249,3 +262,61 @@ func setupLogging() error { return nil } + +// seedAdmin creates the initial admin user if it doesn't exist +func seedAdmin() error { + adminEmail := os.Getenv("ADMIN_EMAIL") + if adminEmail == "" { + adminEmail = "nora.team@nordakademie.de" + } + + var user models.User + result := config.DB.Where("mail = ?", adminEmail).First(&user) + + if result.Error == nil { + // User exists, ensure is_admin is true + if !user.IsAdmin { + user.IsAdmin = true + if err := config.DB.Save(&user).Error; err != nil { + return fmt.Errorf("failed to update admin user: %w", err) + } + log.Println("Existing admin user updated with admin privileges") + } + return nil + } + + // User does not exist, create it + log.Println("Creating initial admin user...") + + adminPassword := os.Getenv("ADMIN_PASSWORD") + if adminPassword == "" { + adminPassword = "Start123!" + log.Println("WARNING: ADMIN_PASSWORD not set, using default password") + } + + passwordHash, err := utils.HashPassword(adminPassword) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + subscriptionUUID := uuid.New().String() + + newUser := models.User{ + Mail: adminEmail, + PasswordHash: passwordHash, + Verified: true, + IsAdmin: true, + FirstName: "NORA", + LastName: "Admin", + Initials: "NA", + UUID: uuid.New(), + SubscriptionUUID: &subscriptionUUID, + } + + if err := config.DB.Create(&newUser).Error; err != nil { + return fmt.Errorf("failed to create admin user: %w", err) + } + + log.Println("Initial admin user created successfully") + return nil +} diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index bbe6a4a..223e7b1 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -56,6 +56,23 @@ func AuthMiddleware(c *fiber.Ctx) error { return c.Next() } +func AdminMiddleware(c *fiber.Ctx) error { + user, ok := c.Locals("user").(*models.User) + if !ok || user == nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "detail": "User not authenticated", + }) + } + + if !user.IsAdmin { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "detail": "Admin access required", + }) + } + + return c.Next() +} + // GetCurrentUser retrieves the current user from context func GetCurrentUser(c *fiber.Ctx) *models.User { user, ok := c.Locals("user").(*models.User) diff --git a/backend/models/models.go b/backend/models/models.go index af548a2..afe693b 100644 --- a/backend/models/models.go +++ b/backend/models/models.go @@ -23,6 +23,7 @@ type User struct { PasswordHash string `gorm:"not null" json:"-"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` Verified bool `gorm:"default:false" json:"verified"` + IsAdmin bool `gorm:"default:false" json:"is_admin"` UUID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid()" json:"uuid"` VerificationCode *string `gorm:"size:6;index" json:"verification_code,omitempty"` // 6-digit verification code VerificationExpiry *time.Time `gorm:"index" json:"verification_expiry,omitempty"` // Expiry for email verification code diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..dec0a97 --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,260 @@ + + + +
+ + +Verwaltung und Statistiken
+Registrierte Nutzer
+Eigene Stunden
+Eingetragene Klausuren
+| + Name | ++ Status | ++ Registriert am | ++ Aktionen | +
|---|
Bitte gib dein Passwort ein, um diese Aktion zu bestätigen.
+ +