Merge pull request #16 from LordMathis/feat/admin-dashboard

Admin dashboard
This commit is contained in:
2024-11-10 15:16:42 +01:00
committed by GitHub
26 changed files with 1545 additions and 287 deletions

View File

@@ -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 package api
import ( import (
@@ -10,6 +11,7 @@ import (
"github.com/go-chi/chi/v5" "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) { func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem, authMiddleware *auth.Middleware, sessionService *auth.SessionService) {
handler := &handlers.Handler{ handler := &handlers.Handler{
@@ -38,11 +40,22 @@ func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem, authMiddlew
r.Delete("/profile", handler.DeleteAccount()) r.Delete("/profile", handler.DeleteAccount())
// Admin-only routes // Admin-only routes
r.Group(func(r chi.Router) { r.Route("/admin", func(r chi.Router) {
r.Use(authMiddleware.RequireRole("admin")) r.Use(authMiddleware.RequireRole("admin"))
// r.Get("/admin/users", ListUsers(db)) // User management
// r.Post("/admin/users", CreateUser(db)) r.Route("/users", func(r chi.Router) {
// r.Delete("/admin/users/{userId}", DeleteUser(db)) 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 // Workspace routes

View File

@@ -5,14 +5,13 @@ import (
"crypto/cipher" "crypto/cipher"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"io" "io"
) )
var ( var (
ErrKeyRequired = errors.New("encryption key is required") ErrKeyRequired = fmt.Errorf("encryption key is required")
ErrInvalidKeySize = errors.New("encryption key must be 32 bytes (256 bits) when decoded") ErrInvalidKeySize = fmt.Errorf("encryption key must be 32 bytes (256 bits) when decoded")
) )
type Crypto struct { type Crypto struct {
@@ -102,7 +101,7 @@ func (c *Crypto) Decrypt(ciphertext string) (string, error) {
nonceSize := gcm.NonceSize() nonceSize := gcm.NonceSize()
if len(data) < nonceSize { if len(data) < nonceSize {
return "", errors.New("ciphertext too short") return "", fmt.Errorf("ciphertext too short")
} }
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]

View File

@@ -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
}

View File

@@ -6,14 +6,16 @@ import (
"novamd/internal/crypto" "novamd/internal/crypto"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3" // SQLite driver
) )
// DB represents the database connection
type DB struct { type DB struct {
*sql.DB *sql.DB
crypto *crypto.Crypto crypto *crypto.Crypto
} }
// Init initializes the database connection
func Init(dbPath string, encryptionKey string) (*DB, error) { func Init(dbPath string, encryptionKey string) (*DB, error) {
db, err := sql.Open("sqlite3", dbPath) db, err := sql.Open("sqlite3", dbPath)
if err != nil { if err != nil {
@@ -42,6 +44,7 @@ func Init(dbPath string, encryptionKey string) (*DB, error) {
return database, nil return database, nil
} }
// Close closes the database connection
func (db *DB) Close() error { func (db *DB) Close() error {
return db.DB.Close() return db.DB.Close()
} }

View File

@@ -5,6 +5,7 @@ import (
"log" "log"
) )
// Migration represents a database migration
type Migration struct { type Migration struct {
Version int Version int
SQL string SQL string
@@ -78,6 +79,7 @@ var migrations = []Migration{
}, },
} }
// Migrate applies all database migrations
func (db *DB) Migrate() error { func (db *DB) Migrate() error {
// Create migrations table if it doesn't exist // Create migrations table if it doesn't exist
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS migrations ( _, err := db.Exec(`CREATE TABLE IF NOT EXISTS migrations (

View File

@@ -7,6 +7,7 @@ import (
) )
const ( const (
// JWTSecretKey is the key for the JWT secret in the system settings
JWTSecretKey = "jwt_secret" JWTSecretKey = "jwt_secret"
) )

View File

@@ -5,10 +5,11 @@ import (
"novamd/internal/models" "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() tx, err := db.Begin()
if err != nil { if err != nil {
return err return nil, err
} }
defer tx.Rollback() defer tx.Rollback()
@@ -17,15 +18,21 @@ func (db *DB) CreateUser(user *models.User) error {
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?)`,
user.Email, user.DisplayName, user.PasswordHash, user.Role) user.Email, user.DisplayName, user.PasswordHash, user.Role)
if err != nil { if err != nil {
return err return nil, err
} }
userID, err := result.LastInsertId() userID, err := result.LastInsertId()
if err != nil { if err != nil {
return err return nil, err
} }
user.ID = int(userID) 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 // Create default workspace with default settings
defaultWorkspace := &models.Workspace{ defaultWorkspace := &models.Workspace{
UserID: user.ID, UserID: user.ID,
@@ -36,24 +43,25 @@ func (db *DB) CreateUser(user *models.User) error {
// Create workspace with settings // Create workspace with settings
err = db.createWorkspaceTx(tx, defaultWorkspace) err = db.createWorkspaceTx(tx, defaultWorkspace)
if err != nil { if err != nil {
return err return nil, err
} }
// Update user's last workspace ID // Update user's last workspace ID
_, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID) _, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID)
if err != nil { if err != nil {
return err return nil, err
} }
err = tx.Commit() err = tx.Commit()
if err != nil { if err != nil {
return err return nil, err
} }
user.LastWorkspaceID = defaultWorkspace.ID 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 { func (db *DB) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error {
result, err := tx.Exec(` result, err := tx.Exec(`
INSERT INTO workspaces ( INSERT INTO workspaces (
@@ -78,6 +86,7 @@ func (db *DB) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error {
return nil return nil
} }
// GetUserByID retrieves a user by ID
func (db *DB) GetUserByID(id int) (*models.User, error) { func (db *DB) GetUserByID(id int) (*models.User, error) {
user := &models.User{} user := &models.User{}
err := db.QueryRow(` err := db.QueryRow(`
@@ -94,6 +103,7 @@ func (db *DB) GetUserByID(id int) (*models.User, error) {
return user, nil return user, nil
} }
// GetUserByEmail retrieves a user by email
func (db *DB) GetUserByEmail(email string) (*models.User, error) { func (db *DB) GetUserByEmail(email string) (*models.User, error) {
user := &models.User{} user := &models.User{}
err := db.QueryRow(` err := db.QueryRow(`
@@ -111,6 +121,7 @@ func (db *DB) GetUserByEmail(email string) (*models.User, error) {
return user, nil return user, nil
} }
// UpdateUser updates a user's information
func (db *DB) UpdateUser(user *models.User) error { func (db *DB) UpdateUser(user *models.User) error {
_, err := db.Exec(` _, err := db.Exec(`
UPDATE users UPDATE users
@@ -120,6 +131,7 @@ func (db *DB) UpdateUser(user *models.User) error {
return err return err
} }
// UpdateLastWorkspace updates the last workspace the user accessed
func (db *DB) UpdateLastWorkspace(userID int, workspaceName string) error { func (db *DB) UpdateLastWorkspace(userID int, workspaceName string) error {
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
@@ -142,6 +154,7 @@ func (db *DB) UpdateLastWorkspace(userID int, workspaceName string) error {
return tx.Commit() return tx.Commit()
} }
// DeleteUser deletes a user and all their workspaces
func (db *DB) DeleteUser(id int) error { func (db *DB) DeleteUser(id int) error {
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
@@ -164,6 +177,7 @@ func (db *DB) DeleteUser(id int) error {
return tx.Commit() return tx.Commit()
} }
// GetLastWorkspaceName returns the name of the last workspace the user accessed
func (db *DB) GetLastWorkspaceName(userID int) (string, error) { func (db *DB) GetLastWorkspaceName(userID int) (string, error) {
var workspaceName string var workspaceName string
err := db.QueryRow(` err := db.QueryRow(`

View File

@@ -6,6 +6,7 @@ import (
"novamd/internal/models" "novamd/internal/models"
) )
// CreateWorkspace inserts a new workspace record into the database
func (db *DB) CreateWorkspace(workspace *models.Workspace) error { func (db *DB) CreateWorkspace(workspace *models.Workspace) error {
// Set default settings if not provided // Set default settings if not provided
if workspace.Theme == "" { if workspace.Theme == "" {
@@ -40,6 +41,7 @@ func (db *DB) CreateWorkspace(workspace *models.Workspace) error {
return nil return nil
} }
// GetWorkspaceByID retrieves a workspace by its ID
func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) { func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) {
workspace := &models.Workspace{} workspace := &models.Workspace{}
var encryptedToken string var encryptedToken string
@@ -72,6 +74,7 @@ func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) {
return workspace, nil return workspace, nil
} }
// GetWorkspaceByName retrieves a workspace by its name and user ID
func (db *DB) GetWorkspaceByName(userID int, workspaceName string) (*models.Workspace, error) { func (db *DB) GetWorkspaceByName(userID int, workspaceName string) (*models.Workspace, error) {
workspace := &models.Workspace{} workspace := &models.Workspace{}
var encryptedToken string var encryptedToken string
@@ -104,6 +107,7 @@ func (db *DB) GetWorkspaceByName(userID int, workspaceName string) (*models.Work
return workspace, nil return workspace, nil
} }
// UpdateWorkspace updates a workspace record in the database
func (db *DB) UpdateWorkspace(workspace *models.Workspace) error { func (db *DB) UpdateWorkspace(workspace *models.Workspace) error {
// Encrypt token before storing // Encrypt token before storing
encryptedToken, err := db.encryptToken(workspace.GitToken) encryptedToken, err := db.encryptToken(workspace.GitToken)
@@ -139,6 +143,7 @@ func (db *DB) UpdateWorkspace(workspace *models.Workspace) error {
return err return err
} }
// GetWorkspacesByUserID retrieves all workspaces for a user
func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) { func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) {
rows, err := db.Query(` rows, err := db.Query(`
SELECT SELECT
@@ -208,26 +213,31 @@ func (db *DB) UpdateWorkspaceSettings(workspace *models.Workspace) error {
return err return err
} }
// DeleteWorkspace removes a workspace record from the database
func (db *DB) DeleteWorkspace(id int) error { func (db *DB) DeleteWorkspace(id int) error {
_, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id) _, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id)
return err return err
} }
// DeleteWorkspaceTx removes a workspace record from the database within a transaction
func (db *DB) DeleteWorkspaceTx(tx *sql.Tx, id int) error { func (db *DB) DeleteWorkspaceTx(tx *sql.Tx, id int) error {
_, err := tx.Exec("DELETE FROM workspaces WHERE id = ?", id) _, err := tx.Exec("DELETE FROM workspaces WHERE id = ?", id)
return err 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 { func (db *DB) UpdateLastWorkspaceTx(tx *sql.Tx, userID, workspaceID int) error {
_, err := tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID) _, err := tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID)
return err return err
} }
// UpdateLastOpenedFile updates the last opened file path for a workspace
func (db *DB) UpdateLastOpenedFile(workspaceID int, filePath string) error { func (db *DB) UpdateLastOpenedFile(workspaceID int, filePath string) error {
_, err := db.Exec("UPDATE workspaces SET last_opened_file_path = ? WHERE id = ?", filePath, workspaceID) _, err := db.Exec("UPDATE workspaces SET last_opened_file_path = ? WHERE id = ?", filePath, workspaceID)
return err return err
} }
// GetLastOpenedFile retrieves the last opened file path for a workspace
func (db *DB) GetLastOpenedFile(workspaceID int) (string, error) { func (db *DB) GetLastOpenedFile(workspaceID int) (string, error) {
var filePath sql.NullString var filePath sql.NullString
err := db.QueryRow("SELECT last_opened_file_path FROM workspaces WHERE id = ?", workspaceID).Scan(&filePath) 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 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
}

View File

@@ -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
}

View File

@@ -1,27 +1,19 @@
package filesystem package filesystem
import ( import (
"errors"
"fmt" "fmt"
"novamd/internal/gitutils" "novamd/internal/gitutils"
"os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
) )
// FileSystem represents the file system structure.
type FileSystem struct { type FileSystem struct {
RootDir string RootDir string
GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo
} }
type FileNode struct { // New creates a new FileSystem instance.
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Children []FileNode `json:"children,omitempty"`
}
func New(rootDir string) *FileSystem { func New(rootDir string) *FileSystem {
return &FileSystem{ return &FileSystem{
RootDir: rootDir, RootDir: rootDir,
@@ -29,30 +21,7 @@ func New(rootDir string) *FileSystem {
} }
} }
func (fs *FileSystem) GetWorkspacePath(userID, workspaceID int) string { // ValidatePath validates the given path and returns the cleaned path if it is valid.
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
}
func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) { func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) {
workspacePath := fs.GetWorkspacePath(userID, workspaceID) workspacePath := fs.GetWorkspacePath(userID, workspaceID)
fullPath := filepath.Join(workspacePath, path) fullPath := filepath.Join(workspacePath, path)
@@ -65,185 +34,7 @@ func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string
return cleanPath, nil return cleanPath, nil
} }
func (fs *FileSystem) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) { // GetTotalFileStats returns the total file statistics for the file system.
workspacePath := fs.GetWorkspacePath(userID, workspaceID) func (fs *FileSystem) GetTotalFileStats() (*FileCountStats, error) {
return fs.walkDirectory(workspacePath, "") return fs.countFilesInPath(fs.RootDir)
}
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
} }

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -47,69 +47,18 @@ func (s *UserService) SetupAdminUser(adminEmail, adminPassword string) (*models.
Role: models.RoleAdmin, Role: models.RoleAdmin,
} }
err = s.DB.CreateUser(adminUser) createdUser, err := s.DB.CreateUser(adminUser)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create admin user: %w", err) return nil, fmt.Errorf("failed to create admin user: %w", err)
} }
// Initialize workspace directory // Initialize workspace directory
err = s.FS.InitializeUserWorkspace(adminUser.ID, adminUser.LastWorkspaceID) err = s.FS.InitializeUserWorkspace(createdUser.ID, createdUser.LastWorkspaceID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to initialize admin workspace: %w", err) 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 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)
}

View File

@@ -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 (
<Modal opened={opened} onClose={onClose} title="Create New User" centered>
<Stack>
<TextInput
label="Email"
required
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
placeholder="user@example.com"
/>
<TextInput
label="Display Name"
value={displayName}
onChange={(e) => setDisplayName(e.currentTarget.value)}
placeholder="John Doe"
/>
<PasswordInput
label="Password"
required
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
placeholder="Enter password"
/>
<Select
label="Role"
required
value={role}
onChange={setRole}
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
]}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loading}>
Create User
</Button>
</Group>
</Stack>
</Modal>
);
};
export default CreateUserModal;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
const DeleteUserModal = ({ opened, onClose, onConfirm, user, loading }) => (
<Modal
opened={opened}
onClose={onClose}
title="Delete User"
centered
size="sm"
>
<Stack>
<Text>
Are you sure you want to delete user "{user?.email}"? This action cannot
be undone and all associated data will be permanently deleted.
</Text>
<Group justify="flex-end" mt="xl">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button color="red" onClick={onConfirm} loading={loading}>
Delete User
</Button>
</Group>
</Stack>
</Modal>
);
export default DeleteUserModal;

View File

@@ -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 (
<Modal opened={opened} onClose={onClose} title="Edit User" centered>
<Stack>
<TextInput
label="Email"
required
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.currentTarget.value })
}
placeholder="user@example.com"
/>
<TextInput
label="Display Name"
value={formData.displayName}
onChange={(e) =>
setFormData({ ...formData, displayName: e.currentTarget.value })
}
placeholder="John Doe"
/>
<Select
label="Role"
required
value={formData.role}
onChange={(value) => setFormData({ ...formData, role: value })}
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
]}
/>
<PasswordInput
label="New Password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.currentTarget.value })
}
placeholder="Enter new password (leave empty to keep current)"
/>
<Text size="xs" c="dimmed">
Leave password empty to keep the current password
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loading}>
Save Changes
</Button>
</Group>
</Stack>
</Modal>
);
};
export default EditUserModal;

View File

@@ -8,12 +8,19 @@ import {
Text, Text,
Divider, Divider,
} from '@mantine/core'; } 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 { useAuth } from '../../contexts/AuthContext';
import AccountSettings from '../settings/account/AccountSettings'; import AccountSettings from '../settings/account/AccountSettings';
import AdminDashboard from '../settings/admin/AdminDashboard';
const UserMenu = () => { const UserMenu = () => {
const [accountSettingsOpened, setAccountSettingsOpened] = useState(false); const [accountSettingsOpened, setAccountSettingsOpened] = useState(false);
const [adminDashboardOpened, setAdminDashboardOpened] = useState(false);
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@@ -81,6 +88,31 @@ const UserMenu = () => {
</Group> </Group>
</UnstyledButton> </UnstyledButton>
{user.role === 'admin' && (
<UnstyledButton
onClick={() => {
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],
},
})}
>
<Group>
<IconUsers size={16} />
<Text size="sm">Admin Dashboard</Text>
</Group>
</UnstyledButton>
)}
<UnstyledButton <UnstyledButton
onClick={handleLogout} onClick={handleLogout}
px="sm" px="sm"
@@ -111,6 +143,11 @@ const UserMenu = () => {
opened={accountSettingsOpened} opened={accountSettingsOpened}
onClose={() => setAccountSettingsOpened(false)} onClose={() => setAccountSettingsOpened(false)}
/> />
<AdminDashboard
opened={adminDashboardOpened}
onClose={() => setAdminDashboardOpened(false)}
/>
</> </>
); );
}; };

View File

@@ -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 (
<Modal opened={opened} onClose={onClose} size="xl" title="Admin Dashboard">
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
Users
</Tabs.Tab>
<Tabs.Tab value="workspaces" leftSection={<IconFolders size={16} />}>
Workspaces
</Tabs.Tab>
<Tabs.Tab value="stats" leftSection={<IconChartBar size={16} />}>
Statistics
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="users" pt="md">
<AdminUsersTab currentUser={currentUser} />
</Tabs.Panel>
<Tabs.Panel value="workspaces" pt="md">
<AdminWorkspacesTab />
</Tabs.Panel>
<Tabs.Panel value="stats" pt="md">
<AdminStatsTab />
</Tabs.Panel>
</Tabs>
</Modal>
);
};
export default AdminDashboard;

View File

@@ -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 <LoadingOverlay visible={true} />;
}
if (error) {
return (
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
{error}
</Alert>
);
}
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 (
<Box pos="relative">
<Text size="xl" fw={700} mb="md">
System Statistics
</Text>
<Table striped highlightOnHover withBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Metric</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{statsRows.map((row) => (
<Table.Tr key={row.label}>
<Table.Td>{row.label}</Table.Td>
<Table.Td>{row.value}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
);
};
export default AdminStatsTab;

View File

@@ -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) => (
<Table.Tr key={user.id}>
<Table.Td>{user.email}</Table.Td>
<Table.Td>{user.displayName}</Table.Td>
<Table.Td>
<Text transform="capitalize">{user.role}</Text>
</Table.Td>
<Table.Td>{new Date(user.createdAt).toLocaleDateString()}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
<ActionIcon
variant="subtle"
color="blue"
onClick={() => setEditModalData(user)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDeleteClick(user)}
disabled={user.id === currentUser.id}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Box pos="relative">
<LoadingOverlay visible={loading} />
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
title="Error"
color="red"
mb="md"
>
{error}
</Alert>
)}
<Group justify="space-between" mb="md">
<Text size="xl" fw={700}>
User Management
</Text>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => setCreateModalOpened(true)}
>
Create User
</Button>
</Group>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Email</Table.Th>
<Table.Th>Display Name</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th style={{ width: 100 }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
<CreateUserModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onCreateUser={handleCreateUser}
loading={loading}
/>
<EditUserModal
opened={!!editModalData}
onClose={() => setEditModalData(null)}
onEditUser={handleEditUser}
user={editModalData}
loading={loading}
/>
<DeleteUserModal
opened={!!deleteModalData}
onClose={() => setDeleteModalData(null)}
onConfirm={handleDeleteConfirm}
user={deleteModalData}
loading={loading}
/>
</Box>
);
};
export default AdminUsersTab;

View File

@@ -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) => (
<Table.Tr key={workspace.id}>
<Table.Td>{workspace.userEmail}</Table.Td>
<Table.Td>{workspace.workspaceName}</Table.Td>
<Table.Td>
{new Date(workspace.workspaceCreatedAt).toLocaleDateString()}
</Table.Td>
<Table.Td>{workspace.totalFiles}</Table.Td>
<Table.Td>{formatBytes(workspace.totalSize)}</Table.Td>
</Table.Tr>
));
return (
<Box pos="relative">
<LoadingOverlay visible={loading} />
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
title="Error"
color="red"
mb="md"
>
{error}
</Alert>
)}
<Group justify="space-between" mb="md">
<Text size="xl" fw={700}>
Workspace Management
</Text>
</Group>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Owner</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Total Files</Table.Th>
<Table.Th>Total Size</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Box>
);
};
export default AdminWorkspacesTab;

View File

@@ -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 };
};

View File

@@ -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,
};
};

View File

@@ -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();
};

View File

@@ -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]}`;
};