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"
+ />
+
+
+
+
+
+
+
+ );
+};
+
+export default CreateUserModal;
diff --git a/frontend/src/components/modals/user/DeleteUserModal.jsx b/frontend/src/components/modals/user/DeleteUserModal.jsx
new file mode 100644
index 0000000..5763a0a
--- /dev/null
+++ b/frontend/src/components/modals/user/DeleteUserModal.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { Modal, Text, Button, Group, Stack } from '@mantine/core';
+
+const DeleteUserModal = ({ opened, onClose, onConfirm, user, loading }) => (
+
+
+
+ Are you sure you want to delete user "{user?.email}"? This action cannot
+ be undone and all associated data will be permanently deleted.
+
+
+
+
+
+
+
+);
+
+export default DeleteUserModal;
diff --git a/frontend/src/components/modals/user/EditUserModal.jsx b/frontend/src/components/modals/user/EditUserModal.jsx
new file mode 100644
index 0000000..0c84bd5
--- /dev/null
+++ b/frontend/src/components/modals/user/EditUserModal.jsx
@@ -0,0 +1,105 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Modal,
+ Stack,
+ TextInput,
+ Select,
+ Button,
+ Group,
+ PasswordInput,
+ Text,
+} from '@mantine/core';
+
+const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
+ const [formData, setFormData] = useState({
+ email: '',
+ displayName: '',
+ role: '',
+ password: '',
+ });
+
+ useEffect(() => {
+ if (user) {
+ setFormData({
+ email: user.email,
+ displayName: user.displayName || '',
+ role: user.role,
+ password: '',
+ });
+ }
+ }, [user]);
+
+ const handleSubmit = async () => {
+ const updateData = {
+ ...formData,
+ ...(formData.password ? { password: formData.password } : {}),
+ };
+
+ const result = await onEditUser(user.id, updateData);
+ if (result.success) {
+ setFormData({
+ email: '',
+ displayName: '',
+ role: '',
+ password: '',
+ });
+ onClose();
+ }
+ };
+
+ return (
+
+
+
+ setFormData({ ...formData, email: e.currentTarget.value })
+ }
+ placeholder="user@example.com"
+ />
+
+ setFormData({ ...formData, displayName: e.currentTarget.value })
+ }
+ placeholder="John Doe"
+ />
+
+
+ );
+};
+
+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
+
+ }
+ onClick={() => setCreateModalOpened(true)}
+ >
+ Create User
+
+
+
+
+
+
+ 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]}`;
+};