diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index ef4ae14..9b37986 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,22 @@ 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()) + }) + // Workspace management + r.Route("/workspaces", func(r chi.Router) { + r.Get("/", handler.AdminListWorkspaces()) + }) + // System stats + r.Get("/stats", handler.AdminGetSystemStats()) }) // Workspace routes diff --git a/backend/internal/crypto/crypto.go b/backend/internal/crypto/crypto.go index 1b08245..76cf338 100644 --- a/backend/internal/crypto/crypto.go +++ b/backend/internal/crypto/crypto.go @@ -5,14 +5,13 @@ import ( "crypto/cipher" "crypto/rand" "encoding/base64" - "errors" "fmt" "io" ) var ( - ErrKeyRequired = errors.New("encryption key is required") - ErrInvalidKeySize = errors.New("encryption key must be 32 bytes (256 bits) when decoded") + ErrKeyRequired = fmt.Errorf("encryption key is required") + ErrInvalidKeySize = fmt.Errorf("encryption key must be 32 bytes (256 bits) when decoded") ) type Crypto struct { @@ -102,7 +101,7 @@ func (c *Crypto) Decrypt(ciphertext string) (string, error) { nonceSize := gcm.NonceSize() if len(data) < nonceSize { - return "", errors.New("ciphertext too short") + return "", fmt.Errorf("ciphertext too short") } nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] diff --git a/backend/internal/db/admin.go b/backend/internal/db/admin.go new file mode 100644 index 0000000..15c8a84 --- /dev/null +++ b/backend/internal/db/admin.go @@ -0,0 +1,68 @@ +// 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" + +// UserStats represents system-wide statistics +type UserStats struct { + TotalUsers int `json:"totalUsers"` + TotalWorkspaces int `json:"totalWorkspaces"` + ActiveUsers int `json:"activeUsers"` // Users with activity in last 30 days +} + +// 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() (*UserStats, error) { + stats := &UserStats{} + + // 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 + } + + 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..3c7e40f 100644 --- a/backend/internal/db/users.go +++ b/backend/internal/db/users.go @@ -5,10 +5,11 @@ import ( "novamd/internal/models" ) -func (db *DB) CreateUser(user *models.User) error { +// CreateUser inserts a new user record into the database +func (db *DB) CreateUser(user *models.User) (*models.User, error) { tx, err := db.Begin() if err != nil { - return err + return nil, err } defer tx.Rollback() @@ -17,15 +18,21 @@ func (db *DB) CreateUser(user *models.User) error { VALUES (?, ?, ?, ?)`, user.Email, user.DisplayName, user.PasswordHash, user.Role) if err != nil { - return err + return nil, err } userID, err := result.LastInsertId() if err != nil { - return err + return nil, err } user.ID = int(userID) + // Retrieve the created_at timestamp + err = tx.QueryRow("SELECT created_at FROM users WHERE id = ?", user.ID).Scan(&user.CreatedAt) + if err != nil { + return nil, err + } + // Create default workspace with default settings defaultWorkspace := &models.Workspace{ UserID: user.ID, @@ -36,24 +43,25 @@ func (db *DB) CreateUser(user *models.User) error { // Create workspace with settings err = db.createWorkspaceTx(tx, defaultWorkspace) if err != nil { - return err + return nil, err } // Update user's last workspace ID _, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID) if err != nil { - return err + return nil, err } err = tx.Commit() if err != nil { - return err + return nil, err } user.LastWorkspaceID = defaultWorkspace.ID - return nil + return user, 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 +86,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 +103,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 +121,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 +131,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 +154,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 +177,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..f2d1815 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) @@ -239,3 +249,43 @@ func (db *DB) GetLastOpenedFile(workspaceID int) (string, error) { } return filePath.String, nil } + +// GetAllWorkspaces retrieves all workspaces in the database +func (db *DB) GetAllWorkspaces() ([]*models.Workspace, error) { + rows, err := db.Query(` + SELECT + id, user_id, name, created_at, + theme, auto_save, + git_enabled, git_url, git_user, git_token, + git_auto_commit, git_commit_msg_template + FROM workspaces`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var workspaces []*models.Workspace + for rows.Next() { + workspace := &models.Workspace{} + var encryptedToken string + err := rows.Scan( + &workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt, + &workspace.Theme, &workspace.AutoSave, + &workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken, + &workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate, + ) + if err != nil { + return nil, err + } + + // Decrypt token + workspace.GitToken, err = db.decryptToken(encryptedToken) + if err != nil { + return nil, fmt.Errorf("failed to decrypt token: %w", err) + } + + workspaces = append(workspaces, workspace) + } + return workspaces, nil +} diff --git a/backend/internal/filesystem/files.go b/backend/internal/filesystem/files.go new file mode 100644 index 0000000..9b876d8 --- /dev/null +++ b/backend/internal/filesystem/files.go @@ -0,0 +1,218 @@ +// Package filesystem provides functionalities to interact with the file system, +// including listing files, finding files by name, getting file content, saving files, and deleting files. +package filesystem + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// FileNode represents a file or directory in the file system. +type FileNode struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Children []FileNode `json:"children,omitempty"` +} + +// ListFilesRecursively returns a list of all files in the workspace directory and its subdirectories. +func (fs *FileSystem) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + return fs.walkDirectory(workspacePath, "") +} + +func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + // Split entries into directories and files + var dirs, files []os.DirEntry + for _, entry := range entries { + if entry.IsDir() { + dirs = append(dirs, entry) + } else { + files = append(files, entry) + } + } + + // Sort directories and files separately + sort.Slice(dirs, func(i, j int) bool { + return strings.ToLower(dirs[i].Name()) < strings.ToLower(dirs[j].Name()) + }) + sort.Slice(files, func(i, j int) bool { + return strings.ToLower(files[i].Name()) < strings.ToLower(files[j].Name()) + }) + + // Create combined slice with directories first, then files + nodes := make([]FileNode, 0, len(entries)) + + // Add directories first + for _, entry := range dirs { + name := entry.Name() + path := filepath.Join(prefix, name) + fullPath := filepath.Join(dir, name) + + children, err := fs.walkDirectory(fullPath, path) + if err != nil { + return nil, err + } + + node := FileNode{ + ID: path, + Name: name, + Path: path, + Children: children, + } + nodes = append(nodes, node) + } + + // Then add files + for _, entry := range files { + name := entry.Name() + path := filepath.Join(prefix, name) + + node := FileNode{ + ID: path, + Name: name, + Path: path, + } + nodes = append(nodes, node) + } + + return nodes, nil +} + +// FindFileByName returns a list of file paths that match the given filename. +func (fs *FileSystem) FindFileByName(userID, workspaceID int, filename string) ([]string, error) { + var foundPaths []string + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + + err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + relPath, err := filepath.Rel(workspacePath, path) + if err != nil { + return err + } + if strings.EqualFold(info.Name(), filename) { + foundPaths = append(foundPaths, relPath) + } + } + return nil + }) + + if err != nil { + return nil, err + } + + if len(foundPaths) == 0 { + return nil, fmt.Errorf("file not found") + } + + return foundPaths, nil +} + +// GetFileContent returns the content of the file at the given path. +func (fs *FileSystem) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) { + fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) + if err != nil { + return nil, err + } + return os.ReadFile(fullPath) +} + +// SaveFile writes the content to the file at the given path. +func (fs *FileSystem) SaveFile(userID, workspaceID int, filePath string, content []byte) error { + fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) + if err != nil { + return err + } + + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + return os.WriteFile(fullPath, content, 0644) +} + +// DeleteFile deletes the file at the given path. +func (fs *FileSystem) DeleteFile(userID, workspaceID int, filePath string) error { + fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) + if err != nil { + return err + } + return os.Remove(fullPath) +} + +// FileCountStats holds statistics about files in a workspace +type FileCountStats struct { + TotalFiles int `json:"totalFiles"` + TotalSize int64 `json:"totalSize"` +} + +// GetFileStats returns the total number of files and related statistics in a workspace +// Parameters: +// - userID: the ID of the user who owns the workspace +// - workspaceID: the ID of the workspace to count files in +// Returns: +// - result: statistics about the files in the workspace +// - error: any error that occurred during counting +func (fs *FileSystem) GetFileStats(userID, workspaceID int) (*FileCountStats, error) { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + + // Check if workspace exists + if _, err := os.Stat(workspacePath); os.IsNotExist(err) { + return nil, fmt.Errorf("workspace directory does not exist") + } + + return fs.countFilesInPath(workspacePath) + +} + +func (fs *FileSystem) countFilesInPath(directoryPath string) (*FileCountStats, error) { + result := &FileCountStats{} + + err := filepath.WalkDir(directoryPath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip the .git directory + if d.IsDir() && d.Name() == ".git" { + return filepath.SkipDir + } + + // Only count regular files + if !d.IsDir() { + // Get relative path from workspace root + relPath, err := filepath.Rel(directoryPath, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + // Get file info for size + info, err := d.Info() + if err != nil { + return fmt.Errorf("failed to get file info for %s: %w", relPath, err) + } + + result.TotalFiles++ + result.TotalSize += info.Size() + } + return nil + }) + + if err != nil { + return nil, fmt.Errorf("error counting files: %w", err) + } + + return result, nil +} diff --git a/backend/internal/filesystem/filesystem.go b/backend/internal/filesystem/filesystem.go index 38e29c1..35f5637 100644 --- a/backend/internal/filesystem/filesystem.go +++ b/backend/internal/filesystem/filesystem.go @@ -1,27 +1,19 @@ package filesystem import ( - "errors" "fmt" "novamd/internal/gitutils" - "os" "path/filepath" - "sort" "strings" ) +// FileSystem represents the file system structure. type FileSystem struct { RootDir string GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo } -type FileNode struct { - ID string `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - Children []FileNode `json:"children,omitempty"` -} - +// New creates a new FileSystem instance. func New(rootDir string) *FileSystem { return &FileSystem{ RootDir: rootDir, @@ -29,30 +21,7 @@ func New(rootDir string) *FileSystem { } } -func (fs *FileSystem) GetWorkspacePath(userID, workspaceID int) string { - return filepath.Join(fs.RootDir, fmt.Sprintf("%d", userID), fmt.Sprintf("%d", workspaceID)) -} - -func (fs *FileSystem) InitializeUserWorkspace(userID, workspaceID int) error { - workspacePath := fs.GetWorkspacePath(userID, workspaceID) - err := os.MkdirAll(workspacePath, 0755) - if err != nil { - return fmt.Errorf("failed to create workspace directory: %w", err) - } - - return nil -} - -func (fs *FileSystem) DeleteUserWorkspace(userID, workspaceID int) error { - workspacePath := fs.GetWorkspacePath(userID, workspaceID) - err := os.RemoveAll(workspacePath) - if err != nil { - return fmt.Errorf("failed to delete workspace directory: %w", err) - } - - return nil -} - +// ValidatePath validates the given path and returns the cleaned path if it is valid. func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) { workspacePath := fs.GetWorkspacePath(userID, workspaceID) fullPath := filepath.Join(workspacePath, path) @@ -65,185 +34,7 @@ func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string return cleanPath, nil } -func (fs *FileSystem) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) { - workspacePath := fs.GetWorkspacePath(userID, workspaceID) - return fs.walkDirectory(workspacePath, "") -} - -func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return nil, err - } - - // Split entries into directories and files - var dirs, files []os.DirEntry - for _, entry := range entries { - if entry.IsDir() { - dirs = append(dirs, entry) - } else { - files = append(files, entry) - } - } - - // Sort directories and files separately - sort.Slice(dirs, func(i, j int) bool { - return strings.ToLower(dirs[i].Name()) < strings.ToLower(dirs[j].Name()) - }) - sort.Slice(files, func(i, j int) bool { - return strings.ToLower(files[i].Name()) < strings.ToLower(files[j].Name()) - }) - - // Create combined slice with directories first, then files - nodes := make([]FileNode, 0, len(entries)) - - // Add directories first - for _, entry := range dirs { - name := entry.Name() - path := filepath.Join(prefix, name) - fullPath := filepath.Join(dir, name) - - children, err := fs.walkDirectory(fullPath, path) - if err != nil { - return nil, err - } - - node := FileNode{ - ID: path, - Name: name, - Path: path, - Children: children, - } - nodes = append(nodes, node) - } - - // Then add files - for _, entry := range files { - name := entry.Name() - path := filepath.Join(prefix, name) - - node := FileNode{ - ID: path, - Name: name, - Path: path, - } - nodes = append(nodes, node) - } - - return nodes, nil -} - -func (fs *FileSystem) FindFileByName(userID, workspaceID int, filename string) ([]string, error) { - var foundPaths []string - workspacePath := fs.GetWorkspacePath(userID, workspaceID) - - err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() { - relPath, err := filepath.Rel(workspacePath, path) - if err != nil { - return err - } - if strings.EqualFold(info.Name(), filename) { - foundPaths = append(foundPaths, relPath) - } - } - return nil - }) - - if err != nil { - return nil, err - } - - if len(foundPaths) == 0 { - return nil, errors.New("file not found") - } - - return foundPaths, nil -} - -func (fs *FileSystem) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) { - fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) - if err != nil { - return nil, err - } - return os.ReadFile(fullPath) -} - -func (fs *FileSystem) SaveFile(userID, workspaceID int, filePath string, content []byte) error { - fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) - if err != nil { - return err - } - - dir := filepath.Dir(fullPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - - return os.WriteFile(fullPath, content, 0644) -} - -func (fs *FileSystem) DeleteFile(userID, workspaceID int, filePath string) error { - fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) - if err != nil { - return err - } - return os.Remove(fullPath) -} - -func (fs *FileSystem) CreateWorkspaceDirectory(userID, workspaceID int) error { - dir := fs.GetWorkspacePath(userID, workspaceID) - return os.MkdirAll(dir, 0755) -} - -func (fs *FileSystem) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken string) error { - workspacePath := fs.GetWorkspacePath(userID, workspaceID) - if _, ok := fs.GitRepos[userID]; !ok { - fs.GitRepos[userID] = make(map[int]*gitutils.GitRepo) - } - fs.GitRepos[userID][workspaceID] = gitutils.New(gitURL, gitUser, gitToken, workspacePath) - return fs.GitRepos[userID][workspaceID].EnsureRepo() -} - -func (fs *FileSystem) DisableGitRepo(userID, workspaceID int) { - if userRepos, ok := fs.GitRepos[userID]; ok { - delete(userRepos, workspaceID) - if len(userRepos) == 0 { - delete(fs.GitRepos, userID) - } - } -} - -func (fs *FileSystem) StageCommitAndPush(userID, workspaceID int, message string) error { - repo, ok := fs.getGitRepo(userID, workspaceID) - if !ok { - return errors.New("git settings not configured for this workspace") - } - - if err := repo.Commit(message); err != nil { - return err - } - - return repo.Push() -} - -func (fs *FileSystem) Pull(userID, workspaceID int) error { - repo, ok := fs.getGitRepo(userID, workspaceID) - if !ok { - return errors.New("git settings not configured for this workspace") - } - - return repo.Pull() -} - -func (fs *FileSystem) getGitRepo(userID, workspaceID int) (*gitutils.GitRepo, bool) { - userRepos, ok := fs.GitRepos[userID] - if !ok { - return nil, false - } - repo, ok := userRepos[workspaceID] - return repo, ok +// GetTotalFileStats returns the total file statistics for the file system. +func (fs *FileSystem) GetTotalFileStats() (*FileCountStats, error) { + return fs.countFilesInPath(fs.RootDir) } diff --git a/backend/internal/filesystem/git.go b/backend/internal/filesystem/git.go new file mode 100644 index 0000000..de83ad9 --- /dev/null +++ b/backend/internal/filesystem/git.go @@ -0,0 +1,60 @@ +package filesystem + +import ( + "fmt" + "novamd/internal/gitutils" +) + +// SetupGitRepo sets up a Git repository for the given user and workspace IDs. +func (fs *FileSystem) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken string) error { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + if _, ok := fs.GitRepos[userID]; !ok { + fs.GitRepos[userID] = make(map[int]*gitutils.GitRepo) + } + fs.GitRepos[userID][workspaceID] = gitutils.New(gitURL, gitUser, gitToken, workspacePath) + return fs.GitRepos[userID][workspaceID].EnsureRepo() +} + +// DisableGitRepo disables the Git repository for the given user and workspace IDs. +func (fs *FileSystem) DisableGitRepo(userID, workspaceID int) { + if userRepos, ok := fs.GitRepos[userID]; ok { + delete(userRepos, workspaceID) + if len(userRepos) == 0 { + delete(fs.GitRepos, userID) + } + } +} + +// StageCommitAndPush stages, commits, and pushes the changes to the Git repository. +func (fs *FileSystem) StageCommitAndPush(userID, workspaceID int, message string) error { + repo, ok := fs.getGitRepo(userID, workspaceID) + if !ok { + return fmt.Errorf("git settings not configured for this workspace") + } + + if err := repo.Commit(message); err != nil { + return err + } + + return repo.Push() +} + +// Pull pulls the changes from the remote Git repository. +func (fs *FileSystem) Pull(userID, workspaceID int) error { + repo, ok := fs.getGitRepo(userID, workspaceID) + if !ok { + return fmt.Errorf("git settings not configured for this workspace") + } + + return repo.Pull() +} + +// getGitRepo returns the Git repository for the given user and workspace IDs. +func (fs *FileSystem) getGitRepo(userID, workspaceID int) (*gitutils.GitRepo, bool) { + userRepos, ok := fs.GitRepos[userID] + if !ok { + return nil, false + } + repo, ok := userRepos[workspaceID] + return repo, ok +} diff --git a/backend/internal/filesystem/workspace.go b/backend/internal/filesystem/workspace.go new file mode 100644 index 0000000..122c816 --- /dev/null +++ b/backend/internal/filesystem/workspace.go @@ -0,0 +1,40 @@ +package filesystem + +import ( + "fmt" + "os" + "path/filepath" +) + +// GetWorkspacePath returns the path to the workspace directory for the given user and workspace IDs. +func (fs *FileSystem) GetWorkspacePath(userID, workspaceID int) string { + return filepath.Join(fs.RootDir, fmt.Sprintf("%d", userID), fmt.Sprintf("%d", workspaceID)) +} + +// InitializeUserWorkspace creates the workspace directory for the given user and workspace IDs. +func (fs *FileSystem) InitializeUserWorkspace(userID, workspaceID int) error { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + err := os.MkdirAll(workspacePath, 0755) + if err != nil { + return fmt.Errorf("failed to create workspace directory: %w", err) + } + + return nil +} + +// DeleteUserWorkspace deletes the workspace directory for the given user and workspace IDs. +func (fs *FileSystem) DeleteUserWorkspace(userID, workspaceID int) error { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + err := os.RemoveAll(workspacePath) + if err != nil { + return fmt.Errorf("failed to delete workspace directory: %w", err) + } + + return nil +} + +// CreateWorkspaceDirectory creates the workspace directory for the given user and workspace IDs. +func (fs *FileSystem) CreateWorkspaceDirectory(userID, workspaceID int) error { + dir := fs.GetWorkspacePath(userID, workspaceID) + return os.MkdirAll(dir, 0755) +} diff --git a/backend/internal/handlers/admin_handlers.go b/backend/internal/handlers/admin_handlers.go new file mode 100644 index 0000000..dc9ab0c --- /dev/null +++ b/backend/internal/handlers/admin_handlers.go @@ -0,0 +1,288 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "novamd/internal/db" + "novamd/internal/filesystem" + "novamd/internal/httpcontext" + "novamd/internal/models" + "strconv" + "time" + + "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, _ *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, + } + + insertedUser, err := h.DB.CreateUser(user) + if err != nil { + http.Error(w, "Failed to create user", http.StatusInternalServerError) + return + } + + // Initialize user workspace + if err := h.FS.InitializeUserWorkspace(insertedUser.ID, insertedUser.LastWorkspaceID); err != nil { + http.Error(w, "Failed to initialize user workspace", http.StatusInternalServerError) + return + } + + respondJSON(w, insertedUser) + } +} + +// 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.StatusNoContent) + } +} + +// WorkspaceStats holds workspace statistics +type WorkspaceStats struct { + UserID int `json:"userID"` + UserEmail string `json:"userEmail"` + WorkspaceID int `json:"workspaceID"` + WorkspaceName string `json:"workspaceName"` + WorkspaceCreatedAt time.Time `json:"workspaceCreatedAt"` + *filesystem.FileCountStats +} + +// AdminListWorkspaces returns a list of all workspaces and their stats +func (h *Handler) AdminListWorkspaces() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + workspaces, err := h.DB.GetAllWorkspaces() + if err != nil { + http.Error(w, "Failed to list workspaces", http.StatusInternalServerError) + return + } + + workspacesStats := make([]*WorkspaceStats, 0, len(workspaces)) + + for _, ws := range workspaces { + + workspaceData := &WorkspaceStats{} + + user, err := h.DB.GetUserByID(ws.UserID) + if err != nil { + http.Error(w, "Failed to get user", http.StatusInternalServerError) + return + } + + workspaceData.UserID = ws.UserID + workspaceData.UserEmail = user.Email + workspaceData.WorkspaceID = ws.ID + workspaceData.WorkspaceName = ws.Name + workspaceData.WorkspaceCreatedAt = ws.CreatedAt + + fileStats, err := h.FS.GetFileStats(ws.UserID, ws.ID) + if err != nil { + http.Error(w, "Failed to get file stats", http.StatusInternalServerError) + return + } + + workspaceData.FileCountStats = fileStats + + workspacesStats = append(workspacesStats, workspaceData) + } + + respondJSON(w, workspacesStats) + } +} + +// SystemStats holds system-wide statistics +type SystemStats struct { + *db.UserStats + *filesystem.FileCountStats +} + +// AdminGetSystemStats returns system-wide statistics for admins +func (h *Handler) AdminGetSystemStats() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + userStats, err := h.DB.GetSystemStats() + if err != nil { + http.Error(w, "Failed to get user stats", http.StatusInternalServerError) + return + } + + fileStats, err := h.FS.GetTotalFileStats() + if err != nil { + http.Error(w, "Failed to get file stats", http.StatusInternalServerError) + return + } + + stats := &SystemStats{ + UserStats: userStats, + FileCountStats: fileStats, + } + + respondJSON(w, stats) + } +} diff --git a/backend/internal/user/user.go b/backend/internal/user/user.go index dbfce37..fb83cc1 100644 --- a/backend/internal/user/user.go +++ b/backend/internal/user/user.go @@ -47,69 +47,18 @@ func (s *UserService) SetupAdminUser(adminEmail, adminPassword string) (*models. Role: models.RoleAdmin, } - err = s.DB.CreateUser(adminUser) + createdUser, err := s.DB.CreateUser(adminUser) if err != nil { return nil, fmt.Errorf("failed to create admin user: %w", err) } // Initialize workspace directory - err = s.FS.InitializeUserWorkspace(adminUser.ID, adminUser.LastWorkspaceID) + err = s.FS.InitializeUserWorkspace(createdUser.ID, createdUser.LastWorkspaceID) if err != nil { return nil, fmt.Errorf("failed to initialize admin workspace: %w", err) } - log.Printf("Created admin user with ID: %d and default workspace with ID: %d", adminUser.ID, adminUser.LastWorkspaceID) + log.Printf("Created admin user with ID: %d and default workspace with ID: %d", createdUser.ID, createdUser.LastWorkspaceID) return adminUser, nil } - -func (s *UserService) CreateUser(user *models.User) error { - err := s.DB.CreateUser(user) - if err != nil { - return fmt.Errorf("failed to create user: %w", err) - } - - err = s.FS.InitializeUserWorkspace(user.ID, user.LastWorkspaceID) - if err != nil { - return fmt.Errorf("failed to initialize user workspace: %w", err) - } - - return nil -} - -func (s *UserService) GetUserByID(id int) (*models.User, error) { - return s.DB.GetUserByID(id) -} - -func (s *UserService) GetUserByEmail(email string) (*models.User, error) { - return s.DB.GetUserByEmail(email) -} - -func (s *UserService) UpdateUser(user *models.User) error { - return s.DB.UpdateUser(user) -} - -func (s *UserService) DeleteUser(id int) error { - // First, get the user to check if they exist - user, err := s.DB.GetUserByID(id) - if err != nil { - return fmt.Errorf("failed to get user: %w", err) - } - - // Get user's workspaces - workspaces, err := s.DB.GetWorkspacesByUserID(id) - if err != nil { - return fmt.Errorf("failed to get user's workspaces: %w", err) - } - - // Delete workspace directories - for _, workspace := range workspaces { - err = s.FS.DeleteUserWorkspace(user.ID, workspace.ID) - if err != nil { - return fmt.Errorf("failed to delete workspace files: %w", err) - } - } - - // Delete user from database (this will cascade delete workspaces) - return s.DB.DeleteUser(id) -} diff --git a/frontend/src/components/modals/user/CreateUserModal.jsx b/frontend/src/components/modals/user/CreateUserModal.jsx new file mode 100644 index 0000000..23d5b5d --- /dev/null +++ b/frontend/src/components/modals/user/CreateUserModal.jsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import { + Modal, + Stack, + TextInput, + PasswordInput, + Select, + Button, + Group, +} from '@mantine/core'; + +const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [role, setRole] = useState('viewer'); + + const handleSubmit = async () => { + const result = await onCreateUser({ email, password, displayName, role }); + if (result.success) { + setEmail(''); + setPassword(''); + setDisplayName(''); + setRole('viewer'); + onClose(); + } + }; + + return ( + + + setEmail(e.currentTarget.value)} + placeholder="user@example.com" + /> + setDisplayName(e.currentTarget.value)} + placeholder="John Doe" + /> + setPassword(e.currentTarget.value)} + placeholder="Enter password" + /> + setFormData({ ...formData, role: value })} + data={[ + { value: 'admin', label: 'Admin' }, + { value: 'editor', label: 'Editor' }, + { value: 'viewer', label: 'Viewer' }, + ]} + /> + + setFormData({ ...formData, password: e.currentTarget.value }) + } + placeholder="Enter new password (leave empty to keep current)" + /> + + Leave password empty to keep the current password + + + + + + + + ); +}; + +export default EditUserModal; diff --git a/frontend/src/components/navigation/UserMenu.jsx b/frontend/src/components/navigation/UserMenu.jsx index c5d0e68..0002eb0 100644 --- a/frontend/src/components/navigation/UserMenu.jsx +++ b/frontend/src/components/navigation/UserMenu.jsx @@ -8,12 +8,19 @@ import { Text, Divider, } from '@mantine/core'; -import { IconUser, IconLogout, IconSettings } from '@tabler/icons-react'; +import { + IconUser, + IconUsers, + IconLogout, + IconSettings, +} from '@tabler/icons-react'; import { useAuth } from '../../contexts/AuthContext'; import AccountSettings from '../settings/account/AccountSettings'; +import AdminDashboard from '../settings/admin/AdminDashboard'; const UserMenu = () => { const [accountSettingsOpened, setAccountSettingsOpened] = useState(false); + const [adminDashboardOpened, setAdminDashboardOpened] = useState(false); const [opened, setOpened] = useState(false); const { user, logout } = useAuth(); @@ -81,6 +88,31 @@ const UserMenu = () => { + {user.role === 'admin' && ( + { + setAdminDashboardOpened(true); + setOpened(false); + }} + px="sm" + py="xs" + style={(theme) => ({ + borderRadius: theme.radius.sm, + '&:hover': { + backgroundColor: + theme.colorScheme === 'dark' + ? theme.colors.dark[5] + : theme.colors.gray[0], + }, + })} + > + + + Admin Dashboard + + + )} + { opened={accountSettingsOpened} onClose={() => setAccountSettingsOpened(false)} /> + + setAdminDashboardOpened(false)} + /> ); }; diff --git a/frontend/src/components/settings/admin/AdminDashboard.jsx b/frontend/src/components/settings/admin/AdminDashboard.jsx new file mode 100644 index 0000000..e095dc5 --- /dev/null +++ b/frontend/src/components/settings/admin/AdminDashboard.jsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import { Modal, Tabs } from '@mantine/core'; +import { IconUsers, IconFolders, IconChartBar } from '@tabler/icons-react'; +import { useAuth } from '../../../contexts/AuthContext'; +import AdminUsersTab from './AdminUsersTab'; +import AdminWorkspacesTab from './AdminWorkspacesTab'; +import AdminStatsTab from './AdminStatsTab'; + +const AdminDashboard = ({ opened, onClose }) => { + const { user: currentUser } = useAuth(); + const [activeTab, setActiveTab] = useState('users'); + + return ( + + + + }> + Users + + }> + Workspaces + + }> + Statistics + + + + + + + + + + + + + + + + + ); +}; + +export default AdminDashboard; diff --git a/frontend/src/components/settings/admin/AdminStatsTab.jsx b/frontend/src/components/settings/admin/AdminStatsTab.jsx new file mode 100644 index 0000000..3b7346f --- /dev/null +++ b/frontend/src/components/settings/admin/AdminStatsTab.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Table, Text, Box, LoadingOverlay, Alert } from '@mantine/core'; +import { IconAlertCircle } from '@tabler/icons-react'; +import { useAdminData } from '../../../hooks/useAdminData'; +import { formatBytes } from '../../../utils/formatBytes'; + +const AdminStatsTab = () => { + const { data: stats, loading, error } = useAdminData('stats'); + + if (loading) { + return ; + } + + if (error) { + return ( + } title="Error" color="red"> + {error} + + ); + } + + const statsRows = [ + { label: 'Total Users', value: stats.totalUsers }, + { label: 'Active Users', value: stats.activeUsers }, + { label: 'Total Workspaces', value: stats.totalWorkspaces }, + { label: 'Total Files', value: stats.totalFiles }, + { label: 'Total Storage Size', value: formatBytes(stats.totalSize) }, + ]; + + return ( + + + System Statistics + + + + + + Metric + Value + + + + {statsRows.map((row) => ( + + {row.label} + {row.value} + + ))} + +
+
+ ); +}; + +export default AdminStatsTab; diff --git a/frontend/src/components/settings/admin/AdminUsersTab.jsx b/frontend/src/components/settings/admin/AdminUsersTab.jsx new file mode 100644 index 0000000..f6d5b99 --- /dev/null +++ b/frontend/src/components/settings/admin/AdminUsersTab.jsx @@ -0,0 +1,162 @@ +import React, { useState } from 'react'; +import { + Table, + Button, + Group, + Text, + ActionIcon, + Box, + LoadingOverlay, + Alert, +} from '@mantine/core'; +import { + IconTrash, + IconEdit, + IconPlus, + IconAlertCircle, +} from '@tabler/icons-react'; +import { notifications } from '@mantine/notifications'; +import { useUserAdmin } from '../../../hooks/useUserAdmin'; +import CreateUserModal from '../../modals/user/CreateUserModal'; +import EditUserModal from '../../modals/user/EditUserModal'; +import DeleteUserModal from '../../modals/user/DeleteUserModal'; + +const AdminUsersTab = ({ currentUser }) => { + const { + users, + loading, + error, + create, + update, + delete: deleteUser, + } = useUserAdmin(); + + const [createModalOpened, setCreateModalOpened] = useState(false); + const [editModalData, setEditModalData] = useState(null); + const [deleteModalData, setDeleteModalData] = useState(null); + + const handleCreateUser = async (userData) => { + return await create(userData); + }; + + const handleEditUser = async (id, userData) => { + return await update(id, userData); + }; + + const handleDeleteClick = (user) => { + if (user.id === currentUser.id) { + notifications.show({ + title: 'Error', + message: 'You cannot delete your own account', + color: 'red', + }); + return; + } + setDeleteModalData(user); + }; + + const handleDeleteConfirm = async () => { + if (!deleteModalData) return; + const result = await deleteUser(deleteModalData.id); + if (result.success) { + setDeleteModalData(null); + } + }; + + const rows = users.map((user) => ( + + {user.email} + {user.displayName} + + {user.role} + + {new Date(user.createdAt).toLocaleDateString()} + + + setEditModalData(user)} + > + + + handleDeleteClick(user)} + disabled={user.id === currentUser.id} + > + + + + + + )); + + return ( + + + + {error && ( + } + title="Error" + color="red" + mb="md" + > + {error} + + )} + + + + User Management + + + + + + + + Email + Display Name + Role + Created At + Actions + + + {rows} +
+ + setCreateModalOpened(false)} + onCreateUser={handleCreateUser} + loading={loading} + /> + + setEditModalData(null)} + onEditUser={handleEditUser} + user={editModalData} + loading={loading} + /> + + setDeleteModalData(null)} + onConfirm={handleDeleteConfirm} + user={deleteModalData} + loading={loading} + /> +
+ ); +}; + +export default AdminUsersTab; diff --git a/frontend/src/components/settings/admin/AdminWorkspacesTab.jsx b/frontend/src/components/settings/admin/AdminWorkspacesTab.jsx new file mode 100644 index 0000000..5c2cf9d --- /dev/null +++ b/frontend/src/components/settings/admin/AdminWorkspacesTab.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { + Table, + Group, + Text, + ActionIcon, + Box, + LoadingOverlay, + Alert, +} from '@mantine/core'; +import { IconTrash, IconEdit, IconAlertCircle } from '@tabler/icons-react'; +import { useAdminData } from '../../../hooks/useAdminData'; +import { formatBytes } from '../../../utils/formatBytes'; + +const AdminWorkspacesTab = () => { + const { data: workspaces, loading, error } = useAdminData('workspaces'); + + const rows = workspaces.map((workspace) => ( + + {workspace.userEmail} + {workspace.workspaceName} + + {new Date(workspace.workspaceCreatedAt).toLocaleDateString()} + + {workspace.totalFiles} + {formatBytes(workspace.totalSize)} + + )); + + return ( + + + + {error && ( + } + title="Error" + color="red" + mb="md" + > + {error} + + )} + + + + Workspace Management + + + + + + + Owner + Name + Created At + Total Files + Total Size + + + {rows} +
+
+ ); +}; + +export default AdminWorkspacesTab; diff --git a/frontend/src/hooks/useAdminData.js b/frontend/src/hooks/useAdminData.js new file mode 100644 index 0000000..9669ad3 --- /dev/null +++ b/frontend/src/hooks/useAdminData.js @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react'; +import { notifications } from '@mantine/notifications'; +import { getUsers, getWorkspaces, getSystemStats } from '../services/adminApi'; + +// Hook for admin data fetching (stats and workspaces) +export const useAdminData = (type) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadData = async () => { + setLoading(true); + setError(null); + try { + let response; + switch (type) { + case 'stats': + response = await getSystemStats(); + break; + case 'workspaces': + response = await getWorkspaces(); + break; + case 'users': + response = await getUsers(); + break; + default: + throw new Error('Invalid data type'); + } + setData(response); + } catch (err) { + const message = err.response?.data?.error || err.message; + setError(message); + notifications.show({ + title: 'Error', + message: `Failed to load ${type}: ${message}`, + color: 'red', + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, [type]); + + return { data, loading, error, reload: loadData }; +}; diff --git a/frontend/src/hooks/useUserAdmin.js b/frontend/src/hooks/useUserAdmin.js new file mode 100644 index 0000000..f8e0a1d --- /dev/null +++ b/frontend/src/hooks/useUserAdmin.js @@ -0,0 +1,79 @@ +import { useAdminData } from './useAdminData'; +import { createUser, updateUser, deleteUser } from '../services/adminApi'; +import { notifications } from '@mantine/notifications'; + +export const useUserAdmin = () => { + const { data: users, loading, error, reload } = useAdminData('users'); + + const handleCreate = async (userData) => { + try { + await createUser(userData); + notifications.show({ + title: 'Success', + message: 'User created successfully', + color: 'green', + }); + reload(); + return { success: true }; + } catch (err) { + const message = err.response?.data?.error || err.message; + notifications.show({ + title: 'Error', + message: `Failed to create user: ${message}`, + color: 'red', + }); + return { success: false, error: message }; + } + }; + + const handleUpdate = async (userId, userData) => { + try { + await updateUser(userId, userData); + notifications.show({ + title: 'Success', + message: 'User updated successfully', + color: 'green', + }); + reload(); + return { success: true }; + } catch (err) { + const message = err.response?.data?.error || err.message; + notifications.show({ + title: 'Error', + message: `Failed to update user: ${message}`, + color: 'red', + }); + return { success: false, error: message }; + } + }; + + const handleDelete = async (userId) => { + try { + await deleteUser(userId); + notifications.show({ + title: 'Success', + message: 'User deleted successfully', + color: 'green', + }); + reload(); + return { success: true }; + } catch (err) { + const message = err.response?.data?.error || err.message; + notifications.show({ + title: 'Error', + message: `Failed to delete user: ${message}`, + color: 'red', + }); + return { success: false, error: message }; + } + }; + + return { + users, + loading, + error, + create: handleCreate, + update: handleUpdate, + delete: handleDelete, + }; +}; diff --git a/frontend/src/services/adminApi.js b/frontend/src/services/adminApi.js new file mode 100644 index 0000000..3c011d0 --- /dev/null +++ b/frontend/src/services/adminApi.js @@ -0,0 +1,49 @@ +import { apiCall } from './authApi'; +import { API_BASE_URL } from '../utils/constants'; + +const ADMIN_BASE_URL = `${API_BASE_URL}/admin`; + +// User Management +export const getUsers = async () => { + const response = await apiCall(`${ADMIN_BASE_URL}/users`); + return response.json(); +}; + +export const createUser = async (userData) => { + const response = await apiCall(`${ADMIN_BASE_URL}/users`, { + method: 'POST', + body: JSON.stringify(userData), + }); + return response.json(); +}; + +export const deleteUser = async (userId) => { + const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, { + method: 'DELETE', + }); + if (response.status === 204) { + return; + } else { + throw new Error('Failed to delete user with status: ', response.status); + } +}; + +export const updateUser = async (userId, userData) => { + const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, { + method: 'PUT', + body: JSON.stringify(userData), + }); + return response.json(); +}; + +// Workspace Management +export const getWorkspaces = async () => { + const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`); + return response.json(); +}; + +// System Statistics +export const getSystemStats = async () => { + const response = await apiCall(`${ADMIN_BASE_URL}/stats`); + return response.json(); +}; diff --git a/frontend/src/utils/formatBytes.js b/frontend/src/utils/formatBytes.js new file mode 100644 index 0000000..dde9f21 --- /dev/null +++ b/frontend/src/utils/formatBytes.js @@ -0,0 +1,10 @@ +export const formatBytes = (bytes) => { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + return `${size.toFixed(1)} ${units[unitIndex]}`; +};