mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 23:44:22 +00:00
Merge pull request #16 from LordMathis/feat/admin-dashboard
Admin dashboard
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:]
|
||||||
|
|||||||
68
backend/internal/db/admin.go
Normal file
68
backend/internal/db/admin.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
218
backend/internal/filesystem/files.go
Normal file
218
backend/internal/filesystem/files.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
60
backend/internal/filesystem/git.go
Normal file
60
backend/internal/filesystem/git.go
Normal 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
|
||||||
|
}
|
||||||
40
backend/internal/filesystem/workspace.go
Normal file
40
backend/internal/filesystem/workspace.go
Normal 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)
|
||||||
|
}
|
||||||
288
backend/internal/handlers/admin_handlers.go
Normal file
288
backend/internal/handlers/admin_handlers.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
76
frontend/src/components/modals/user/CreateUserModal.jsx
Normal file
76
frontend/src/components/modals/user/CreateUserModal.jsx
Normal 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;
|
||||||
29
frontend/src/components/modals/user/DeleteUserModal.jsx
Normal file
29
frontend/src/components/modals/user/DeleteUserModal.jsx
Normal 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;
|
||||||
105
frontend/src/components/modals/user/EditUserModal.jsx
Normal file
105
frontend/src/components/modals/user/EditUserModal.jsx
Normal 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;
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
44
frontend/src/components/settings/admin/AdminDashboard.jsx
Normal file
44
frontend/src/components/settings/admin/AdminDashboard.jsx
Normal 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;
|
||||||
56
frontend/src/components/settings/admin/AdminStatsTab.jsx
Normal file
56
frontend/src/components/settings/admin/AdminStatsTab.jsx
Normal 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;
|
||||||
162
frontend/src/components/settings/admin/AdminUsersTab.jsx
Normal file
162
frontend/src/components/settings/admin/AdminUsersTab.jsx
Normal 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;
|
||||||
@@ -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;
|
||||||
48
frontend/src/hooks/useAdminData.js
Normal file
48
frontend/src/hooks/useAdminData.js
Normal 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 };
|
||||||
|
};
|
||||||
79
frontend/src/hooks/useUserAdmin.js
Normal file
79
frontend/src/hooks/useUserAdmin.js
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
49
frontend/src/services/adminApi.js
Normal file
49
frontend/src/services/adminApi.js
Normal 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();
|
||||||
|
};
|
||||||
10
frontend/src/utils/formatBytes.js
Normal file
10
frontend/src/utils/formatBytes.js
Normal 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]}`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user