From 0480c165ae2b7a1e26719fd3ef8e82a082edb368 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 7 Nov 2024 21:32:09 +0100 Subject: [PATCH] Implement admin api handlers --- backend/internal/api/routes.go | 17 +- backend/internal/db/admin.go | 80 ++++++++ backend/internal/db/db.go | 5 +- backend/internal/db/migrations.go | 2 + backend/internal/db/system_settings.go | 1 + backend/internal/db/users.go | 8 + backend/internal/db/workspaces.go | 10 + backend/internal/handlers/admin_handlers.go | 215 ++++++++++++++++++++ 8 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 backend/internal/db/admin.go create mode 100644 backend/internal/handlers/admin_handlers.go diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index ef4ae14..ddb350c 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -1,3 +1,4 @@ +// Package api contains the API routes for the application. It sets up the routes for the public and protected endpoints, as well as the admin-only routes. package api import ( @@ -10,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" ) +// SetupRoutes configures the API routes func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem, authMiddleware *auth.Middleware, sessionService *auth.SessionService) { handler := &handlers.Handler{ @@ -38,11 +40,18 @@ func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem, authMiddlew r.Delete("/profile", handler.DeleteAccount()) // Admin-only routes - r.Group(func(r chi.Router) { + r.Route("/admin", func(r chi.Router) { r.Use(authMiddleware.RequireRole("admin")) - // r.Get("/admin/users", ListUsers(db)) - // r.Post("/admin/users", CreateUser(db)) - // r.Delete("/admin/users/{userId}", DeleteUser(db)) + // User management + r.Route("/users", func(r chi.Router) { + r.Get("/", handler.AdminListUsers()) + r.Post("/", handler.AdminCreateUser()) + r.Get("/{userId}", handler.AdminGetUser()) + r.Put("/{userId}", handler.AdminUpdateUser()) + r.Delete("/{userId}", handler.AdminDeleteUser()) + }) + // System stats + r.Get("/stats", handler.AdminGetSystemStats()) }) // Workspace routes diff --git a/backend/internal/db/admin.go b/backend/internal/db/admin.go new file mode 100644 index 0000000..3b8432d --- /dev/null +++ b/backend/internal/db/admin.go @@ -0,0 +1,80 @@ +// Package db provides the database access layer for the application. It contains methods for interacting with the database, such as creating, updating, and deleting records. +package db + +import "novamd/internal/models" + +// SystemStats represents system-wide statistics +type SystemStats struct { + TotalUsers int `json:"totalUsers"` + TotalWorkspaces int `json:"totalWorkspaces"` + ActiveUsers int `json:"activeUsers"` // Users with activity in last 30 days + StorageUsed int `json:"storageUsed"` // Total storage used in bytes + TotalFiles int `json:"totalFiles"` // Total number of files across all workspaces +} + +// GetAllUsers returns a list of all users in the system +func (db *DB) GetAllUsers() ([]*models.User, error) { + rows, err := db.Query(` + SELECT + id, email, display_name, role, created_at, + last_workspace_id + FROM users + ORDER BY id ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []*models.User + for rows.Next() { + user := &models.User{} + err := rows.Scan( + &user.ID, &user.Email, &user.DisplayName, &user.Role, + &user.CreatedAt, &user.LastWorkspaceID, + ) + if err != nil { + return nil, err + } + users = append(users, user) + } + return users, nil +} + +// GetSystemStats returns system-wide statistics +func (db *DB) GetSystemStats() (*SystemStats, error) { + stats := &SystemStats{} + + // Get total users + err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&stats.TotalUsers) + if err != nil { + return nil, err + } + + // Get total workspaces + err = db.QueryRow("SELECT COUNT(*) FROM workspaces").Scan(&stats.TotalWorkspaces) + if err != nil { + return nil, err + } + + // Get active users (users with activity in last 30 days) + err = db.QueryRow(` + SELECT COUNT(DISTINCT user_id) + FROM sessions + WHERE created_at > datetime('now', '-30 days')`). + Scan(&stats.ActiveUsers) + if err != nil { + return nil, err + } + + // Get total files and storage used + // Note: This assumes you're tracking file sizes in your filesystem + err = db.QueryRow(` + SELECT COUNT(*), COALESCE(SUM(size), 0) + FROM files`). + Scan(&stats.TotalFiles, &stats.StorageUsed) + if err != nil { + return nil, err + } + + return stats, nil +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index d176700..8ca51c3 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -6,14 +6,16 @@ import ( "novamd/internal/crypto" - _ "github.com/mattn/go-sqlite3" + _ "github.com/mattn/go-sqlite3" // SQLite driver ) +// DB represents the database connection type DB struct { *sql.DB crypto *crypto.Crypto } +// Init initializes the database connection func Init(dbPath string, encryptionKey string) (*DB, error) { db, err := sql.Open("sqlite3", dbPath) if err != nil { @@ -42,6 +44,7 @@ func Init(dbPath string, encryptionKey string) (*DB, error) { return database, nil } +// Close closes the database connection func (db *DB) Close() error { return db.DB.Close() } diff --git a/backend/internal/db/migrations.go b/backend/internal/db/migrations.go index ae5e4c9..84560f5 100644 --- a/backend/internal/db/migrations.go +++ b/backend/internal/db/migrations.go @@ -5,6 +5,7 @@ import ( "log" ) +// Migration represents a database migration type Migration struct { Version int SQL string @@ -78,6 +79,7 @@ var migrations = []Migration{ }, } +// Migrate applies all database migrations func (db *DB) Migrate() error { // Create migrations table if it doesn't exist _, err := db.Exec(`CREATE TABLE IF NOT EXISTS migrations ( diff --git a/backend/internal/db/system_settings.go b/backend/internal/db/system_settings.go index 0c8f75b..76fdfa5 100644 --- a/backend/internal/db/system_settings.go +++ b/backend/internal/db/system_settings.go @@ -7,6 +7,7 @@ import ( ) const ( + // JWTSecretKey is the key for the JWT secret in the system settings JWTSecretKey = "jwt_secret" ) diff --git a/backend/internal/db/users.go b/backend/internal/db/users.go index db350e7..9ce15ab 100644 --- a/backend/internal/db/users.go +++ b/backend/internal/db/users.go @@ -5,6 +5,7 @@ import ( "novamd/internal/models" ) +// CreateUser inserts a new user record into the database func (db *DB) CreateUser(user *models.User) error { tx, err := db.Begin() if err != nil { @@ -54,6 +55,7 @@ func (db *DB) CreateUser(user *models.User) error { return nil } +// Helper function to create a workspace in a transaction func (db *DB) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error { result, err := tx.Exec(` INSERT INTO workspaces ( @@ -78,6 +80,7 @@ func (db *DB) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error { return nil } +// GetUserByID retrieves a user by ID func (db *DB) GetUserByID(id int) (*models.User, error) { user := &models.User{} err := db.QueryRow(` @@ -94,6 +97,7 @@ func (db *DB) GetUserByID(id int) (*models.User, error) { return user, nil } +// GetUserByEmail retrieves a user by email func (db *DB) GetUserByEmail(email string) (*models.User, error) { user := &models.User{} err := db.QueryRow(` @@ -111,6 +115,7 @@ func (db *DB) GetUserByEmail(email string) (*models.User, error) { return user, nil } +// UpdateUser updates a user's information func (db *DB) UpdateUser(user *models.User) error { _, err := db.Exec(` UPDATE users @@ -120,6 +125,7 @@ func (db *DB) UpdateUser(user *models.User) error { return err } +// UpdateLastWorkspace updates the last workspace the user accessed func (db *DB) UpdateLastWorkspace(userID int, workspaceName string) error { tx, err := db.Begin() if err != nil { @@ -142,6 +148,7 @@ func (db *DB) UpdateLastWorkspace(userID int, workspaceName string) error { return tx.Commit() } +// DeleteUser deletes a user and all their workspaces func (db *DB) DeleteUser(id int) error { tx, err := db.Begin() if err != nil { @@ -164,6 +171,7 @@ func (db *DB) DeleteUser(id int) error { return tx.Commit() } +// GetLastWorkspaceName returns the name of the last workspace the user accessed func (db *DB) GetLastWorkspaceName(userID int) (string, error) { var workspaceName string err := db.QueryRow(` diff --git a/backend/internal/db/workspaces.go b/backend/internal/db/workspaces.go index d339075..e3f07d9 100644 --- a/backend/internal/db/workspaces.go +++ b/backend/internal/db/workspaces.go @@ -6,6 +6,7 @@ import ( "novamd/internal/models" ) +// CreateWorkspace inserts a new workspace record into the database func (db *DB) CreateWorkspace(workspace *models.Workspace) error { // Set default settings if not provided if workspace.Theme == "" { @@ -40,6 +41,7 @@ func (db *DB) CreateWorkspace(workspace *models.Workspace) error { return nil } +// GetWorkspaceByID retrieves a workspace by its ID func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) { workspace := &models.Workspace{} var encryptedToken string @@ -72,6 +74,7 @@ func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) { return workspace, nil } +// GetWorkspaceByName retrieves a workspace by its name and user ID func (db *DB) GetWorkspaceByName(userID int, workspaceName string) (*models.Workspace, error) { workspace := &models.Workspace{} var encryptedToken string @@ -104,6 +107,7 @@ func (db *DB) GetWorkspaceByName(userID int, workspaceName string) (*models.Work return workspace, nil } +// UpdateWorkspace updates a workspace record in the database func (db *DB) UpdateWorkspace(workspace *models.Workspace) error { // Encrypt token before storing encryptedToken, err := db.encryptToken(workspace.GitToken) @@ -139,6 +143,7 @@ func (db *DB) UpdateWorkspace(workspace *models.Workspace) error { return err } +// GetWorkspacesByUserID retrieves all workspaces for a user func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) { rows, err := db.Query(` SELECT @@ -208,26 +213,31 @@ func (db *DB) UpdateWorkspaceSettings(workspace *models.Workspace) error { return err } +// DeleteWorkspace removes a workspace record from the database func (db *DB) DeleteWorkspace(id int) error { _, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id) return err } +// DeleteWorkspaceTx removes a workspace record from the database within a transaction func (db *DB) DeleteWorkspaceTx(tx *sql.Tx, id int) error { _, err := tx.Exec("DELETE FROM workspaces WHERE id = ?", id) return err } +// UpdateLastWorkspaceTx sets the last workspace for a user in with a transaction func (db *DB) UpdateLastWorkspaceTx(tx *sql.Tx, userID, workspaceID int) error { _, err := tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID) return err } +// UpdateLastOpenedFile updates the last opened file path for a workspace func (db *DB) UpdateLastOpenedFile(workspaceID int, filePath string) error { _, err := db.Exec("UPDATE workspaces SET last_opened_file_path = ? WHERE id = ?", filePath, workspaceID) return err } +// GetLastOpenedFile retrieves the last opened file path for a workspace func (db *DB) GetLastOpenedFile(workspaceID int) (string, error) { var filePath sql.NullString err := db.QueryRow("SELECT last_opened_file_path FROM workspaces WHERE id = ?", workspaceID).Scan(&filePath) diff --git a/backend/internal/handlers/admin_handlers.go b/backend/internal/handlers/admin_handlers.go new file mode 100644 index 0000000..6b3c4a9 --- /dev/null +++ b/backend/internal/handlers/admin_handlers.go @@ -0,0 +1,215 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "novamd/internal/httpcontext" + "novamd/internal/models" + "strconv" + + "github.com/go-chi/chi/v5" + "golang.org/x/crypto/bcrypt" +) + +type CreateUserRequest struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + Role models.UserRole `json:"role"` +} + +type UpdateUserRequest struct { + Email string `json:"email,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Password string `json:"password,omitempty"` + Role models.UserRole `json:"role,omitempty"` +} + +// AdminListUsers returns a list of all users +func (h *Handler) AdminListUsers() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + users, err := h.DB.GetAllUsers() + if err != nil { + http.Error(w, "Failed to list users", http.StatusInternalServerError) + return + } + + respondJSON(w, users) + } +} + +// AdminCreateUser creates a new user +func (h *Handler) AdminCreateUser() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req CreateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate request + if req.Email == "" || req.Password == "" || req.Role == "" { + http.Error(w, "Email, password, and role are required", http.StatusBadRequest) + return + } + + // Check if email already exists + existingUser, err := h.DB.GetUserByEmail(req.Email) + if err == nil && existingUser != nil { + http.Error(w, "Email already exists", http.StatusConflict) + return + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "Failed to hash password", http.StatusInternalServerError) + return + } + + // Create user + user := &models.User{ + Email: req.Email, + DisplayName: req.DisplayName, + PasswordHash: string(hashedPassword), + Role: req.Role, + } + + if err := h.DB.CreateUser(user); err != nil { + http.Error(w, "Failed to create user", http.StatusInternalServerError) + return + } + + // Initialize user workspace + if err := h.FS.InitializeUserWorkspace(user.ID, user.LastWorkspaceID); err != nil { + http.Error(w, "Failed to initialize user workspace", http.StatusInternalServerError) + return + } + + respondJSON(w, user) + } +} + +// AdminGetUser gets a specific user by ID +func (h *Handler) AdminGetUser() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := strconv.Atoi(chi.URLParam(r, "userId")) + if err != nil { + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + + user, err := h.DB.GetUserByID(userID) + if err != nil { + http.Error(w, "User not found", http.StatusNotFound) + return + } + + respondJSON(w, user) + } +} + +// AdminUpdateUser updates a specific user +func (h *Handler) AdminUpdateUser() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := strconv.Atoi(chi.URLParam(r, "userId")) + if err != nil { + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + + // Get existing user + user, err := h.DB.GetUserByID(userID) + if err != nil { + http.Error(w, "User not found", http.StatusNotFound) + return + } + + var req UpdateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Update fields if provided + if req.Email != "" { + user.Email = req.Email + } + if req.DisplayName != "" { + user.DisplayName = req.DisplayName + } + if req.Role != "" { + user.Role = req.Role + } + if req.Password != "" { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "Failed to hash password", http.StatusInternalServerError) + return + } + user.PasswordHash = string(hashedPassword) + } + + if err := h.DB.UpdateUser(user); err != nil { + http.Error(w, "Failed to update user", http.StatusInternalServerError) + return + } + + respondJSON(w, user) + } +} + +// AdminDeleteUser deletes a specific user +func (h *Handler) AdminDeleteUser() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { + return + } + + userID, err := strconv.Atoi(chi.URLParam(r, "userId")) + if err != nil { + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + + // Prevent admin from deleting themselves + if userID == ctx.UserID { + http.Error(w, "Cannot delete your own account", http.StatusBadRequest) + return + } + + // Get user before deletion to check role + user, err := h.DB.GetUserByID(userID) + if err != nil { + http.Error(w, "User not found", http.StatusNotFound) + return + } + + // Prevent deletion of other admin users + if user.Role == models.RoleAdmin && ctx.UserID != userID { + http.Error(w, "Cannot delete other admin users", http.StatusForbidden) + return + } + + if err := h.DB.DeleteUser(userID); err != nil { + http.Error(w, "Failed to delete user", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + } +} + +// AdminGetSystemStats returns system-wide statistics for admins +func (h *Handler) AdminGetSystemStats() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + stats, err := h.DB.GetSystemStats() + if err != nil { + http.Error(w, "Failed to get system stats", http.StatusInternalServerError) + return + } + + respondJSON(w, stats) + } +}