diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4effa3d..671fc54 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -4,19 +4,20 @@ import ( "log" "net/http" "os" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "novamd/internal/api" + "novamd/internal/auth" "novamd/internal/config" "novamd/internal/db" "novamd/internal/filesystem" - "novamd/internal/user" + "novamd/internal/handlers" ) func main() { - // Load configuration cfg, err := config.Load() if err != nil { @@ -28,38 +29,56 @@ func main() { if err != nil { log.Fatal(err) } - defer func() { - if err := database.Close(); err != nil { - log.Printf("Error closing database: %v", err) + defer database.Close() + + // Get or generate JWT signing key + signingKey := cfg.JWTSigningKey + if signingKey == "" { + signingKey, err = database.EnsureJWTSecret() + if err != nil { + log.Fatal("Failed to ensure JWT secret:", err) } - }() + } // Initialize filesystem fs := filesystem.New(cfg.WorkDir) - // Initialize user service - userService := user.NewUserService(database, fs) - - // Create admin user - if _, err := userService.SetupAdminUser(cfg.AdminEmail, cfg.AdminPassword); err != nil { - log.Fatal(err) + // Initialize JWT service + jwtService, err := auth.NewJWTService(auth.JWTConfig{ + SigningKey: signingKey, + AccessTokenExpiry: 15 * time.Minute, + RefreshTokenExpiry: 7 * 24 * time.Hour, + }) + if err != nil { + log.Fatal("Failed to initialize JWT service:", err) } + // Initialize auth middleware + authMiddleware := auth.NewMiddleware(jwtService) + + // Initialize session service + sessionService := auth.NewSessionService(database.DB, jwtService) + // Set up router r := chi.NewRouter() + + // Middleware r.Use(middleware.Logger) r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Timeout(30 * time.Second)) - // API routes + // Set up routes r.Route("/api/v1", func(r chi.Router) { - api.SetupRoutes(r, database, fs) + api.SetupRoutes(r, database, fs, authMiddleware, sessionService) }) // Handle all other routes with static file server - r.Get("/*", api.NewStaticHandler(cfg.StaticPath).ServeHTTP) + r.Get("/*", handlers.NewStaticHandler(cfg.StaticPath).ServeHTTP) // Start server - port := os.Getenv("NOVAMD_PORT") + port := os.Getenv("PORT") if port == "" { port = "8080" } diff --git a/backend/go.mod b/backend/go.mod index 3cea35d..d3e5499 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,6 +6,8 @@ require ( github.com/go-chi/chi/v5 v5.1.0 github.com/go-git/go-git/v5 v5.12.0 github.com/go-playground/validator/v10 v10.22.1 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 github.com/mattn/go-sqlite3 v1.14.23 golang.org/x/crypto v0.21.0 ) diff --git a/backend/go.sum b/backend/go.sum index c5af50e..b2b586e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -44,10 +44,14 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= diff --git a/backend/internal/api/handler_utils.go b/backend/internal/api/handler_utils.go deleted file mode 100644 index 75c50cd..0000000 --- a/backend/internal/api/handler_utils.go +++ /dev/null @@ -1,37 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "net/http" - "strconv" - - "github.com/go-chi/chi/v5" -) - -func getUserID(r *http.Request) (int, error) { - userIDStr := chi.URLParam(r, "userId") - return strconv.Atoi(userIDStr) -} - -func getUserAndWorkspaceIDs(r *http.Request) (int, int, error) { - userID, err := getUserID(r) - if err != nil { - return 0, 0, errors.New("invalid userId") - } - - workspaceIDStr := chi.URLParam(r, "workspaceId") - workspaceID, err := strconv.Atoi(workspaceIDStr) - if err != nil { - return userID, 0, errors.New("invalid workspaceId") - } - - return userID, workspaceID, nil -} - -func respondJSON(w http.ResponseWriter, data interface{}) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(data); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } -} diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 3682ca3..ef4ae14 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -1,48 +1,82 @@ package api import ( + "novamd/internal/auth" "novamd/internal/db" "novamd/internal/filesystem" + "novamd/internal/handlers" + "novamd/internal/middleware" "github.com/go-chi/chi/v5" ) -func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) { - r.Route("/", func(r chi.Router) { - // User routes - r.Route("/users/{userId}", func(r chi.Router) { - r.Get("/", GetUser(db)) +func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem, authMiddleware *auth.Middleware, sessionService *auth.SessionService) { - // Workspace routes - r.Route("/workspaces", func(r chi.Router) { - r.Get("/", ListWorkspaces(db)) - r.Post("/", CreateWorkspace(db, fs)) - r.Get("/last", GetLastWorkspace(db)) - r.Put("/last", UpdateLastWorkspace(db)) + handler := &handlers.Handler{ + DB: db, + FS: fs, + } - r.Route("/{workspaceId}", func(r chi.Router) { - r.Get("/", GetWorkspace(db)) - r.Put("/", UpdateWorkspace(db, fs)) - r.Delete("/", DeleteWorkspace(db)) + // Public routes (no authentication required) + r.Group(func(r chi.Router) { + r.Post("/auth/login", handler.Login(sessionService)) + r.Post("/auth/refresh", handler.RefreshToken(sessionService)) + }) - // File routes - r.Route("/files", func(r chi.Router) { - r.Get("/", ListFiles(fs)) - r.Get("/last", GetLastOpenedFile(db)) - r.Put("/last", UpdateLastOpenedFile(db, fs)) - r.Get("/lookup", LookupFileByName(fs)) // Moved here + // Protected routes (authentication required) + r.Group(func(r chi.Router) { + // Apply authentication middleware to all routes in this group + r.Use(authMiddleware.Authenticate) + r.Use(middleware.WithUserContext) - r.Post("/*", SaveFile(fs)) - r.Get("/*", GetFileContent(fs)) - r.Delete("/*", DeleteFile(fs)) + // Auth routes + r.Post("/auth/logout", handler.Logout(sessionService)) + r.Get("/auth/me", handler.GetCurrentUser()) - }) + // User profile routes + r.Put("/profile", handler.UpdateProfile()) + r.Delete("/profile", handler.DeleteAccount()) - // Git routes - r.Route("/git", func(r chi.Router) { - r.Post("/commit", StageCommitAndPush(fs)) - r.Post("/pull", PullChanges(fs)) - }) + // Admin-only routes + r.Group(func(r chi.Router) { + r.Use(authMiddleware.RequireRole("admin")) + // r.Get("/admin/users", ListUsers(db)) + // r.Post("/admin/users", CreateUser(db)) + // r.Delete("/admin/users/{userId}", DeleteUser(db)) + }) + + // Workspace routes + r.Route("/workspaces", func(r chi.Router) { + r.Get("/", handler.ListWorkspaces()) + r.Post("/", handler.CreateWorkspace()) + r.Get("/last", handler.GetLastWorkspaceName()) + r.Put("/last", handler.UpdateLastWorkspaceName()) + + // Single workspace routes + r.Route("/{workspaceName}", func(r chi.Router) { + r.Use(middleware.WithWorkspaceContext(db)) + r.Use(authMiddleware.RequireWorkspaceAccess) + + r.Get("/", handler.GetWorkspace()) + r.Put("/", handler.UpdateWorkspace()) + r.Delete("/", handler.DeleteWorkspace()) + + // File routes + r.Route("/files", func(r chi.Router) { + r.Get("/", handler.ListFiles()) + r.Get("/last", handler.GetLastOpenedFile()) + r.Put("/last", handler.UpdateLastOpenedFile()) + r.Get("/lookup", handler.LookupFileByName()) + + r.Post("/*", handler.SaveFile()) + r.Get("/*", handler.GetFileContent()) + r.Delete("/*", handler.DeleteFile()) + }) + + // Git routes + r.Route("/git", func(r chi.Router) { + r.Post("/commit", handler.StageCommitAndPush()) + r.Post("/pull", handler.PullChanges()) }) }) }) diff --git a/backend/internal/api/user_handlers.go b/backend/internal/api/user_handlers.go deleted file mode 100644 index 28cd9fe..0000000 --- a/backend/internal/api/user_handlers.go +++ /dev/null @@ -1,25 +0,0 @@ -package api - -import ( - "net/http" - - "novamd/internal/db" -) - -func GetUser(db *db.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, err := getUserID(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - user, err := db.GetUserByID(userID) - if err != nil { - http.Error(w, "Failed to get user", http.StatusInternalServerError) - return - } - - respondJSON(w, user) - } -} diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..b1c0480 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -0,0 +1,135 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// TokenType represents the type of JWT token (access or refresh) +type TokenType string + +const ( + AccessToken TokenType = "access" // AccessToken - Short-lived token for API access + RefreshToken TokenType = "refresh" // RefreshToken - Long-lived token for obtaining new access tokens +) + +// Claims represents the custom claims we store in JWT tokens +type Claims struct { + jwt.RegisteredClaims // Embedded standard JWT claims + UserID int `json:"uid"` // User identifier + Role string `json:"role"` // User role (admin, editor, viewer) + Type TokenType `json:"type"` // Token type (access or refresh) +} + +// JWTConfig holds the configuration for the JWT service +type JWTConfig struct { + SigningKey string // Secret key used to sign tokens + AccessTokenExpiry time.Duration // How long access tokens are valid + RefreshTokenExpiry time.Duration // How long refresh tokens are valid +} + +// JWTService handles JWT token generation and validation +type JWTService struct { + config JWTConfig +} + +// NewJWTService creates a new JWT service with the provided configuration +// Returns an error if the signing key is missing +func NewJWTService(config JWTConfig) (*JWTService, error) { + if config.SigningKey == "" { + return nil, fmt.Errorf("signing key is required") + } + // Set default expiry times if not provided + if config.AccessTokenExpiry == 0 { + config.AccessTokenExpiry = 15 * time.Minute // Default to 15 minutes + } + if config.RefreshTokenExpiry == 0 { + config.RefreshTokenExpiry = 7 * 24 * time.Hour // Default to 7 days + } + return &JWTService{config: config}, nil +} + +// GenerateAccessToken creates a new access token for a user +// Parameters: +// - userID: the ID of the user +// - role: the role of the user +// Returns the signed token string or an error +func (s *JWTService) GenerateAccessToken(userID int, role string) (string, error) { + return s.generateToken(userID, role, AccessToken, s.config.AccessTokenExpiry) +} + +// GenerateRefreshToken creates a new refresh token for a user +// Parameters: +// - userID: the ID of the user +// - role: the role of the user +// Returns the signed token string or an error +func (s *JWTService) GenerateRefreshToken(userID int, role string) (string, error) { + return s.generateToken(userID, role, RefreshToken, s.config.RefreshTokenExpiry) +} + +// generateToken is an internal helper function that creates a new JWT token +// Parameters: +// - userID: the ID of the user +// - role: the role of the user +// - tokenType: the type of token (access or refresh) +// - expiry: how long the token should be valid +// Returns the signed token string or an error +func (s *JWTService) generateToken(userID int, role string, tokenType TokenType, expiry time.Duration) (string, error) { + now := time.Now() + claims := Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(expiry)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + UserID: userID, + Role: role, + Type: tokenType, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.config.SigningKey)) +} + +// ValidateToken validates and parses a JWT token +// Parameters: +// - tokenString: the token to validate +// Returns the token claims if valid, or an error if invalid +func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + // Validate the signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.config.SigningKey), nil + }) + + if err != nil { + return nil, fmt.Errorf("invalid token: %w", err) + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, fmt.Errorf("invalid token claims") +} + +// RefreshAccessToken creates a new access token using a refresh token +// Parameters: +// - refreshToken: the refresh token to use +// Returns a new access token if the refresh token is valid, or an error +func (s *JWTService) RefreshAccessToken(refreshToken string) (string, error) { + claims, err := s.ValidateToken(refreshToken) + if err != nil { + return "", fmt.Errorf("invalid refresh token: %w", err) + } + + if claims.Type != RefreshToken { + return "", fmt.Errorf("invalid token type: expected refresh token") + } + + return s.GenerateAccessToken(claims.UserID, claims.Role) +} diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go new file mode 100644 index 0000000..da2713d --- /dev/null +++ b/backend/internal/auth/middleware.go @@ -0,0 +1,128 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + "strings" + + "novamd/internal/httpcontext" +) + +type contextKey string + +const ( + UserContextKey contextKey = "user" +) + +// UserClaims represents the user information stored in the request context +type UserClaims struct { + UserID int + Role string +} + +// Middleware handles JWT authentication for protected routes +type Middleware struct { + jwtService *JWTService +} + +// NewMiddleware creates a new authentication middleware +func NewMiddleware(jwtService *JWTService) *Middleware { + return &Middleware{ + jwtService: jwtService, + } +} + +// Authenticate middleware validates JWT tokens and sets user information in context +func (m *Middleware) Authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extract token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Authorization header required", http.StatusUnauthorized) + return + } + + // Check Bearer token format + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + http.Error(w, "Invalid authorization format", http.StatusUnauthorized) + return + } + + // Validate token + claims, err := m.jwtService.ValidateToken(parts[1]) + if err != nil { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + // Check token type + if claims.Type != AccessToken { + http.Error(w, "Invalid token type", http.StatusUnauthorized) + return + } + + // Add user claims to request context + ctx := context.WithValue(r.Context(), UserContextKey, UserClaims{ + UserID: claims.UserID, + Role: claims.Role, + }) + + // Call the next handler with the updated context + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequireRole returns a middleware that ensures the user has the required role +func (m *Middleware) RequireRole(role string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := r.Context().Value(UserContextKey).(UserClaims) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if claims.Role != role && claims.Role != "admin" { + http.Error(w, "Insufficient permissions", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func (m *Middleware) RequireWorkspaceAccess(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get our handler context + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { + return + } + + // If no workspace in context, allow the request (might be a non-workspace endpoint) + if ctx.Workspace == nil { + next.ServeHTTP(w, r) + return + } + + // Check if user has access (either owner or admin) + if ctx.Workspace.UserID != ctx.UserID && ctx.UserRole != "admin" { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + next.ServeHTTP(w, r) + }) +} + +// GetUserFromContext retrieves user claims from the request context +func GetUserFromContext(ctx context.Context) (*UserClaims, error) { + claims, ok := ctx.Value(UserContextKey).(UserClaims) + if !ok { + return nil, fmt.Errorf("no user found in context") + } + return &claims, nil +} diff --git a/backend/internal/auth/session.go b/backend/internal/auth/session.go new file mode 100644 index 0000000..8168ccc --- /dev/null +++ b/backend/internal/auth/session.go @@ -0,0 +1,140 @@ +package auth + +import ( + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" +) + +// Session represents a user session in the database +type Session struct { + ID string // Unique session identifier + UserID int // ID of the user this session belongs to + RefreshToken string // The refresh token associated with this session + ExpiresAt time.Time // When this session expires + CreatedAt time.Time // When this session was created +} + +// SessionService manages user sessions in the database +type SessionService struct { + db *sql.DB // Database connection + jwtService *JWTService // JWT service for token operations +} + +// NewSessionService creates a new session service +// Parameters: +// - db: database connection +// - jwtService: JWT service for token operations +func NewSessionService(db *sql.DB, jwtService *JWTService) *SessionService { + return &SessionService{ + db: db, + jwtService: jwtService, + } +} + +// CreateSession creates a new user session +// Parameters: +// - userID: the ID of the user +// - role: the role of the user +// Returns: +// - session: the created session +// - accessToken: a new access token +// - error: any error that occurred +func (s *SessionService) CreateSession(userID int, role string) (*Session, string, error) { + // Generate both access and refresh tokens + accessToken, err := s.jwtService.GenerateAccessToken(userID, role) + if err != nil { + return nil, "", fmt.Errorf("failed to generate access token: %w", err) + } + + refreshToken, err := s.jwtService.GenerateRefreshToken(userID, role) + if err != nil { + return nil, "", fmt.Errorf("failed to generate refresh token: %w", err) + } + + // Validate the refresh token to get its expiry time + claims, err := s.jwtService.ValidateToken(refreshToken) + if err != nil { + return nil, "", fmt.Errorf("failed to validate refresh token: %w", err) + } + + // Create a new session record + session := &Session{ + ID: uuid.New().String(), + UserID: userID, + RefreshToken: refreshToken, + ExpiresAt: claims.ExpiresAt.Time, + CreatedAt: time.Now(), + } + + // Store the session in the database + _, err = s.db.Exec(` + INSERT INTO sessions (id, user_id, refresh_token, expires_at, created_at) + VALUES (?, ?, ?, ?, ?)`, + session.ID, session.UserID, session.RefreshToken, session.ExpiresAt, session.CreatedAt, + ) + if err != nil { + return nil, "", fmt.Errorf("failed to store session: %w", err) + } + + return session, accessToken, nil +} + +// RefreshSession creates a new access token using a refresh token +// Parameters: +// - refreshToken: the refresh token to use +// Returns: +// - string: a new access token +// - error: any error that occurred +func (s *SessionService) RefreshSession(refreshToken string) (string, error) { + // Validate the refresh token + claims, err := s.jwtService.ValidateToken(refreshToken) + if err != nil { + return "", fmt.Errorf("invalid refresh token: %w", err) + } + + // Check if the session exists and is not expired + var session Session + err = s.db.QueryRow(` + SELECT id, user_id, refresh_token, expires_at, created_at + FROM sessions + WHERE refresh_token = ? AND expires_at > ?`, + refreshToken, time.Now(), + ).Scan(&session.ID, &session.UserID, &session.RefreshToken, &session.ExpiresAt, &session.CreatedAt) + + if err == sql.ErrNoRows { + return "", fmt.Errorf("session not found or expired") + } + if err != nil { + return "", fmt.Errorf("failed to fetch session: %w", err) + } + + // Generate a new access token + return s.jwtService.GenerateAccessToken(claims.UserID, claims.Role) +} + +// InvalidateSession removes a session from the database +// Parameters: +// - sessionID: the ID of the session to invalidate +// Returns: +// - error: any error that occurred +func (s *SessionService) InvalidateSession(sessionID string) error { + _, err := s.db.Exec("DELETE FROM sessions WHERE id = ?", sessionID) + if err != nil { + return fmt.Errorf("failed to invalidate session: %w", err) + } + return nil +} + +// CleanExpiredSessions removes all expired sessions from the database +// Returns: +// - error: any error that occurred +func (s *SessionService) CleanExpiredSessions() error { + _, err := s.db.Exec("DELETE FROM sessions WHERE expires_at <= ?", time.Now()) + if err != nil { + return fmt.Errorf("failed to clean expired sessions: %w", err) + } + return nil +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 02257bf..b81e28e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { AdminEmail string AdminPassword string EncryptionKey string + JWTSigningKey string } func DefaultConfig() *Config { @@ -69,6 +70,7 @@ func Load() (*Config, error) { config.AdminEmail = os.Getenv("NOVAMD_ADMIN_EMAIL") config.AdminPassword = os.Getenv("NOVAMD_ADMIN_PASSWORD") config.EncryptionKey = os.Getenv("NOVAMD_ENCRYPTION_KEY") + config.JWTSigningKey = os.Getenv("NOVAMD_JWT_SIGNING_KEY") // Validate all settings if err := config.Validate(); err != nil { diff --git a/backend/internal/db/migrations.go b/backend/internal/db/migrations.go index 4b24d9c..ae5e4c9 100644 --- a/backend/internal/db/migrations.go +++ b/backend/internal/db/migrations.go @@ -45,6 +45,37 @@ var migrations = []Migration{ ); `, }, + { + Version: 2, + SQL: ` + -- Create sessions table for authentication + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + refresh_token TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + + -- Add indexes for performance + CREATE INDEX idx_sessions_user_id ON sessions(user_id); + CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + CREATE INDEX idx_sessions_refresh_token ON sessions(refresh_token); + + -- Add audit fields to workspaces + ALTER TABLE workspaces ADD COLUMN created_by INTEGER REFERENCES users(id); + ALTER TABLE workspaces ADD COLUMN updated_by INTEGER REFERENCES users(id); + ALTER TABLE workspaces ADD COLUMN updated_at TIMESTAMP; + + -- Create system_settings table for application settings + CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );`, + }, } func (db *DB) Migrate() error { diff --git a/backend/internal/db/system_settings.go b/backend/internal/db/system_settings.go new file mode 100644 index 0000000..0c8f75b --- /dev/null +++ b/backend/internal/db/system_settings.go @@ -0,0 +1,65 @@ +package db + +import ( + "crypto/rand" + "encoding/base64" + "fmt" +) + +const ( + JWTSecretKey = "jwt_secret" +) + +// EnsureJWTSecret makes sure a JWT signing secret exists in the database +// If no secret exists, it generates and stores a new one +func (db *DB) EnsureJWTSecret() (string, error) { + // First, try to get existing secret + secret, err := db.GetSystemSetting(JWTSecretKey) + if err == nil { + return secret, nil + } + + // Generate new secret if none exists + newSecret, err := generateRandomSecret(32) // 256 bits + if err != nil { + return "", fmt.Errorf("failed to generate JWT secret: %w", err) + } + + // Store the new secret + err = db.SetSystemSetting(JWTSecretKey, newSecret) + if err != nil { + return "", fmt.Errorf("failed to store JWT secret: %w", err) + } + + return newSecret, nil +} + +// GetSystemSetting retrieves a system setting by key +func (db *DB) GetSystemSetting(key string) (string, error) { + var value string + err := db.QueryRow("SELECT value FROM system_settings WHERE key = ?", key).Scan(&value) + if err != nil { + return "", err + } + return value, nil +} + +// SetSystemSetting stores or updates a system setting +func (db *DB) SetSystemSetting(key, value string) error { + _, err := db.Exec(` + INSERT INTO system_settings (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = ?`, + key, value, value) + return err +} + +// generateRandomSecret generates a cryptographically secure random string +func generateRandomSecret(bytes int) (string, error) { + b := make([]byte, bytes) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(b), nil +} diff --git a/backend/internal/db/users.go b/backend/internal/db/users.go index b2c3709..db350e7 100644 --- a/backend/internal/db/users.go +++ b/backend/internal/db/users.go @@ -82,11 +82,11 @@ func (db *DB) GetUserByID(id int) (*models.User, error) { user := &models.User{} err := db.QueryRow(` SELECT - id, email, display_name, role, created_at, + id, email, display_name, password_hash, role, created_at, last_workspace_id FROM users WHERE id = ?`, id). - Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.CreatedAt, + Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.LastWorkspaceID) if err != nil { return nil, err @@ -114,15 +114,32 @@ func (db *DB) GetUserByEmail(email string) (*models.User, error) { func (db *DB) UpdateUser(user *models.User) error { _, err := db.Exec(` UPDATE users - SET email = ?, display_name = ?, role = ?, last_workspace_id = ? + SET email = ?, display_name = ?, password_hash = ?, role = ?, last_workspace_id = ? WHERE id = ?`, - user.Email, user.DisplayName, user.Role, user.LastWorkspaceID, user.ID) + user.Email, user.DisplayName, user.PasswordHash, user.Role, user.LastWorkspaceID, user.ID) return err } -func (db *DB) UpdateLastWorkspace(userID, workspaceID int) error { - _, err := db.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID) - return err +func (db *DB) UpdateLastWorkspace(userID int, workspaceName string) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + var workspaceID int + + err = tx.QueryRow("SELECT id FROM workspaces WHERE user_id = ? AND name = ?", userID, workspaceName).Scan(&workspaceID) + if err != nil { + return err + } + + _, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID) + if err != nil { + return err + } + + return tx.Commit() } func (db *DB) DeleteUser(id int) error { @@ -147,8 +164,14 @@ func (db *DB) DeleteUser(id int) error { return tx.Commit() } -func (db *DB) GetLastWorkspaceID(userID int) (int, error) { - var workspaceID int - err := db.QueryRow("SELECT last_workspace_id FROM users WHERE id = ?", userID).Scan(&workspaceID) - return workspaceID, err +func (db *DB) GetLastWorkspaceName(userID int) (string, error) { + var workspaceName string + err := db.QueryRow(` + SELECT + w.name + FROM workspaces w + JOIN users u ON u.last_workspace_id = w.id + WHERE u.id = ?`, userID). + Scan(&workspaceName) + return workspaceName, err } diff --git a/backend/internal/db/workspaces.go b/backend/internal/db/workspaces.go index 15944ec..d339075 100644 --- a/backend/internal/db/workspaces.go +++ b/backend/internal/db/workspaces.go @@ -72,6 +72,38 @@ func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) { return workspace, nil } +func (db *DB) GetWorkspaceByName(userID int, workspaceName string) (*models.Workspace, error) { + workspace := &models.Workspace{} + var encryptedToken string + + err := db.QueryRow(` + 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 + WHERE user_id = ? AND name = ?`, + userID, workspaceName, + ).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) + } + + return workspace, nil +} + func (db *DB) UpdateWorkspace(workspace *models.Workspace) error { // Encrypt token before storing encryptedToken, err := db.encryptToken(workspace.GitToken) diff --git a/backend/internal/handlers/auth_handlers.go b/backend/internal/handlers/auth_handlers.go new file mode 100644 index 0000000..319b6d9 --- /dev/null +++ b/backend/internal/handlers/auth_handlers.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "novamd/internal/auth" + "novamd/internal/httpcontext" + "novamd/internal/models" + + "golang.org/x/crypto/bcrypt" +) + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type LoginResponse struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + User *models.User `json:"user"` + Session *auth.Session `json:"session"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refreshToken"` +} + +type RefreshResponse struct { + AccessToken string `json:"accessToken"` +} + +// Login handles user authentication and returns JWT tokens +func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + 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 == "" { + http.Error(w, "Email and password are required", http.StatusBadRequest) + return + } + + // Get user from database + user, err := h.DB.GetUserByEmail(req.Email) + if err != nil { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + // Verify password + err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) + if err != nil { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + // Create session and generate tokens + session, accessToken, err := authService.CreateSession(user.ID, string(user.Role)) + if err != nil { + http.Error(w, "Failed to create session", http.StatusInternalServerError) + return + } + + // Prepare response + response := LoginResponse{ + AccessToken: accessToken, + RefreshToken: session.RefreshToken, + User: user, + Session: session, + } + + respondJSON(w, response) + } +} + +// Logout invalidates the user's session +func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sessionID := r.Header.Get("X-Session-ID") + if sessionID == "" { + http.Error(w, "Session ID required", http.StatusBadRequest) + return + } + + err := authService.InvalidateSession(sessionID) + if err != nil { + http.Error(w, "Failed to logout", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + } +} + +// RefreshToken generates a new access token using a refresh token +func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.RefreshToken == "" { + http.Error(w, "Refresh token required", http.StatusBadRequest) + return + } + + // Generate new access token + accessToken, err := authService.RefreshSession(req.RefreshToken) + if err != nil { + http.Error(w, "Invalid refresh token", http.StatusUnauthorized) + return + } + + response := RefreshResponse{ + AccessToken: accessToken, + } + + respondJSON(w, response) + } +} + +// GetCurrentUser returns the currently authenticated user +func (h *Handler) GetCurrentUser() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { + return + } + + // Get user from database + user, err := h.DB.GetUserByID(ctx.UserID) + if err != nil { + http.Error(w, "User not found", http.StatusNotFound) + return + } + + respondJSON(w, user) + } +} diff --git a/backend/internal/api/file_handlers.go b/backend/internal/handlers/file_handlers.go similarity index 56% rename from backend/internal/api/file_handlers.go rename to backend/internal/handlers/file_handlers.go index dc98752..4af815a 100644 --- a/backend/internal/api/file_handlers.go +++ b/backend/internal/handlers/file_handlers.go @@ -1,25 +1,23 @@ -package api +package handlers import ( "encoding/json" "io" "net/http" - "novamd/internal/db" - "novamd/internal/filesystem" + "novamd/internal/httpcontext" "github.com/go-chi/chi/v5" ) -func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc { +func (h *Handler) ListFiles() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } - files, err := fs.ListFilesRecursively(userID, workspaceID) + files, err := h.FS.ListFilesRecursively(ctx.UserID, ctx.Workspace.ID) if err != nil { http.Error(w, "Failed to list files", http.StatusInternalServerError) return @@ -29,11 +27,10 @@ func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc { } } -func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc { +func (h *Handler) LookupFileByName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } @@ -43,7 +40,7 @@ func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc { return } - filePaths, err := fs.FindFileByName(userID, workspaceID, filename) + filePaths, err := h.FS.FindFileByName(ctx.UserID, ctx.Workspace.ID, filename) if err != nil { http.Error(w, "File not found", http.StatusNotFound) return @@ -53,16 +50,15 @@ func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc { } } -func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc { +func (h *Handler) GetFileContent() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } filePath := chi.URLParam(r, "*") - content, err := fs.GetFileContent(userID, workspaceID, filePath) + content, err := h.FS.GetFileContent(ctx.UserID, ctx.Workspace.ID, filePath) if err != nil { http.Error(w, "Failed to read file", http.StatusNotFound) return @@ -73,11 +69,10 @@ func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc { } } -func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc { +func (h *Handler) SaveFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } @@ -88,7 +83,7 @@ func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc { return } - err = fs.SaveFile(userID, workspaceID, filePath, content) + err = h.FS.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content) if err != nil { http.Error(w, "Failed to save file", http.StatusInternalServerError) return @@ -98,16 +93,15 @@ func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc { } } -func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc { +func (h *Handler) DeleteFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } filePath := chi.URLParam(r, "*") - err = fs.DeleteFile(userID, workspaceID, filePath) + err := h.FS.DeleteFile(ctx.UserID, ctx.Workspace.ID, filePath) if err != nil { http.Error(w, "Failed to delete file", http.StatusInternalServerError) return @@ -118,29 +112,32 @@ func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc { } } -func GetLastOpenedFile(db *db.DB) http.HandlerFunc { +func (h *Handler) GetLastOpenedFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - _, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } - filePath, err := db.GetLastOpenedFile(workspaceID) + filePath, err := h.DB.GetLastOpenedFile(ctx.Workspace.ID) if err != nil { http.Error(w, "Failed to get last opened file", http.StatusInternalServerError) return } + if _, err := h.FS.ValidatePath(ctx.UserID, ctx.Workspace.ID, filePath); err != nil { + http.Error(w, "Invalid file path", http.StatusBadRequest) + return + } + respondJSON(w, map[string]string{"lastOpenedFilePath": filePath}) } } -func UpdateLastOpenedFile(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { +func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } @@ -155,13 +152,13 @@ func UpdateLastOpenedFile(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc // Validate the file path exists in the workspace if requestBody.FilePath != "" { - if _, err := fs.ValidatePath(userID, workspaceID, requestBody.FilePath); err != nil { + if _, err := h.FS.ValidatePath(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath); err != nil { http.Error(w, "Invalid file path", http.StatusBadRequest) return } } - if err := db.UpdateLastOpenedFile(workspaceID, requestBody.FilePath); err != nil { + if err := h.DB.UpdateLastOpenedFile(ctx.Workspace.ID, requestBody.FilePath); err != nil { http.Error(w, "Failed to update last opened file", http.StatusInternalServerError) return } diff --git a/backend/internal/api/git_handlers.go b/backend/internal/handlers/git_handlers.go similarity index 64% rename from backend/internal/api/git_handlers.go rename to backend/internal/handlers/git_handlers.go index 7eaa146..61f7ba4 100644 --- a/backend/internal/api/git_handlers.go +++ b/backend/internal/handlers/git_handlers.go @@ -1,17 +1,16 @@ -package api +package handlers import ( "encoding/json" "net/http" - "novamd/internal/filesystem" + "novamd/internal/httpcontext" ) -func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { +func (h *Handler) StageCommitAndPush() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } @@ -29,7 +28,7 @@ func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { return } - err = fs.StageCommitAndPush(userID, workspaceID, requestBody.Message) + err := h.FS.StageCommitAndPush(ctx.UserID, ctx.Workspace.ID, requestBody.Message) if err != nil { http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError) return @@ -39,15 +38,14 @@ func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { } } -func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc { +func (h *Handler) PullChanges() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } - err = fs.Pull(userID, workspaceID) + err := h.FS.Pull(ctx.UserID, ctx.Workspace.ID) if err != nil { http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) return diff --git a/backend/internal/handlers/handlers.go b/backend/internal/handlers/handlers.go new file mode 100644 index 0000000..a9d4e75 --- /dev/null +++ b/backend/internal/handlers/handlers.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "novamd/internal/db" + "novamd/internal/filesystem" +) + +// Handler provides common functionality for all handlers +type Handler struct { + DB *db.DB + FS *filesystem.FileSystem +} + +// NewHandler creates a new handler with the given dependencies +func NewHandler(db *db.DB, fs *filesystem.FileSystem) *Handler { + return &Handler{ + DB: db, + FS: fs, + } +} + +// respondJSON is a helper to send JSON responses +func respondJSON(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} diff --git a/backend/internal/api/static_handler.go b/backend/internal/handlers/static_handler.go similarity index 99% rename from backend/internal/api/static_handler.go rename to backend/internal/handlers/static_handler.go index 971b35d..8dfb710 100644 --- a/backend/internal/api/static_handler.go +++ b/backend/internal/handlers/static_handler.go @@ -1,4 +1,4 @@ -package api +package handlers import ( "net/http" diff --git a/backend/internal/handlers/user_handlers.go b/backend/internal/handlers/user_handlers.go new file mode 100644 index 0000000..0a3148f --- /dev/null +++ b/backend/internal/handlers/user_handlers.go @@ -0,0 +1,222 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "novamd/internal/httpcontext" + + "golang.org/x/crypto/bcrypt" +) + +type UpdateProfileRequest struct { + DisplayName string `json:"displayName"` + Email string `json:"email"` + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + +type DeleteAccountRequest struct { + Password string `json:"password"` +} + +func (h *Handler) GetUser() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { + return + } + + user, err := h.DB.GetUserByID(ctx.UserID) + if err != nil { + http.Error(w, "Failed to get user", http.StatusInternalServerError) + return + } + + respondJSON(w, user) + } +} + +// UpdateProfile updates the current user's profile +func (h *Handler) UpdateProfile() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { + return + } + + var req UpdateProfileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get current user + user, err := h.DB.GetUserByID(ctx.UserID) + if err != nil { + http.Error(w, "User not found", http.StatusNotFound) + return + } + + // Start transaction for atomic updates + tx, err := h.DB.Begin() + if err != nil { + http.Error(w, "Failed to start transaction", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Handle password update if requested + if req.NewPassword != "" { + // Current password must be provided to change password + if req.CurrentPassword == "" { + http.Error(w, "Current password is required to change password", http.StatusBadRequest) + return + } + + // Verify current password + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil { + http.Error(w, "Current password is incorrect", http.StatusUnauthorized) + return + } + + // Validate new password + if len(req.NewPassword) < 8 { + http.Error(w, "New password must be at least 8 characters long", http.StatusBadRequest) + return + } + + // Hash new password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "Failed to process new password", http.StatusInternalServerError) + return + } + user.PasswordHash = string(hashedPassword) + } + + // Handle email update if requested + if req.Email != "" && req.Email != user.Email { + // Check if email change requires password verification + if req.CurrentPassword == "" { + http.Error(w, "Current password is required to change email", http.StatusBadRequest) + return + } + + // Verify current password if not already verified for password change + if req.NewPassword == "" { + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil { + http.Error(w, "Current password is incorrect", http.StatusUnauthorized) + return + } + } + + // Check if new email is already in use + existingUser, err := h.DB.GetUserByEmail(req.Email) + if err == nil && existingUser.ID != user.ID { + http.Error(w, "Email already in use", http.StatusConflict) + return + } + user.Email = req.Email + } + + // Update display name if provided (no password required) + if req.DisplayName != "" { + user.DisplayName = req.DisplayName + } + + // Update user in database + if err := h.DB.UpdateUser(user); err != nil { + http.Error(w, "Failed to update profile", http.StatusInternalServerError) + return + } + + if err := tx.Commit(); err != nil { + http.Error(w, "Failed to commit changes", http.StatusInternalServerError) + return + } + + // Return updated user data + respondJSON(w, user) + } +} + +// DeleteAccount handles user account deletion +func (h *Handler) DeleteAccount() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { + return + } + + var req DeleteAccountRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get current user + user, err := h.DB.GetUserByID(ctx.UserID) + if err != nil { + http.Error(w, "User not found", http.StatusNotFound) + return + } + + // Verify password + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + http.Error(w, "Password is incorrect", http.StatusUnauthorized) + return + } + + // Prevent admin from deleting their own account if they're the last admin + if user.Role == "admin" { + // Count number of admin users + adminCount := 0 + err := h.DB.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'admin'").Scan(&adminCount) + if err != nil { + http.Error(w, "Failed to verify admin status", http.StatusInternalServerError) + return + } + if adminCount <= 1 { + http.Error(w, "Cannot delete the last admin account", http.StatusForbidden) + return + } + } + + // Start transaction for consistent deletion + tx, err := h.DB.Begin() + if err != nil { + http.Error(w, "Failed to start transaction", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Get user's workspaces for cleanup + workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) + if err != nil { + http.Error(w, "Failed to get user workspaces", http.StatusInternalServerError) + return + } + + // Delete workspace directories + for _, workspace := range workspaces { + if err := h.FS.DeleteUserWorkspace(ctx.UserID, workspace.ID); err != nil { + http.Error(w, "Failed to delete workspace files", http.StatusInternalServerError) + return + } + } + + // Delete user from database (this will cascade delete workspaces and sessions) + if err := h.DB.DeleteUser(ctx.UserID); err != nil { + http.Error(w, "Failed to delete account", http.StatusInternalServerError) + return + } + + if err := tx.Commit(); err != nil { + http.Error(w, "Failed to commit transaction", http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]string{"message": "Account deleted successfully"}) + } +} diff --git a/backend/internal/api/workspace_handlers.go b/backend/internal/handlers/workspace_handlers.go similarity index 52% rename from backend/internal/api/workspace_handlers.go rename to backend/internal/handlers/workspace_handlers.go index 29415f5..bbb3c59 100644 --- a/backend/internal/api/workspace_handlers.go +++ b/backend/internal/handlers/workspace_handlers.go @@ -1,23 +1,22 @@ -package api +package handlers import ( "encoding/json" + "fmt" "net/http" - "novamd/internal/db" - "novamd/internal/filesystem" + "novamd/internal/httpcontext" "novamd/internal/models" ) -func ListWorkspaces(db *db.DB) http.HandlerFunc { +func (h *Handler) ListWorkspaces() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, err := getUserID(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } - workspaces, err := db.GetWorkspacesByUserID(userID) + workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) if err != nil { http.Error(w, "Failed to list workspaces", http.StatusInternalServerError) return @@ -27,11 +26,10 @@ func ListWorkspaces(db *db.DB) http.HandlerFunc { } } -func CreateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { +func (h *Handler) CreateWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, err := getUserID(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } @@ -41,13 +39,13 @@ func CreateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { return } - workspace.UserID = userID - if err := db.CreateWorkspace(&workspace); err != nil { + workspace.UserID = ctx.UserID + if err := h.DB.CreateWorkspace(&workspace); err != nil { http.Error(w, "Failed to create workspace", http.StatusInternalServerError) return } - if err := fs.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil { + if err := h.FS.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil { http.Error(w, "Failed to initialize workspace directory", http.StatusInternalServerError) return } @@ -56,34 +54,37 @@ func CreateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { } } -func GetWorkspace(db *db.DB) http.HandlerFunc { +func (h *Handler) GetWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } - workspace, err := db.GetWorkspaceByID(workspaceID) - if err != nil { - http.Error(w, "Workspace not found", http.StatusNotFound) - return - } - - if workspace.UserID != userID { - http.Error(w, "Unauthorized access to workspace", http.StatusForbidden) - return - } - - respondJSON(w, workspace) + respondJSON(w, ctx.Workspace) } } -func UpdateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { +func gitSettingsChanged(new, old *models.Workspace) bool { + // Check if Git was enabled/disabled + if new.GitEnabled != old.GitEnabled { + return true + } + + // If Git is enabled, check if any settings changed + if new.GitEnabled { + return new.GitURL != old.GitURL || + new.GitUser != old.GitUser || + new.GitToken != old.GitToken + } + + return false +} + +func (h *Handler) UpdateWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } @@ -94,8 +95,8 @@ func UpdateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { } // Set IDs from the request - workspace.ID = workspaceID - workspace.UserID = userID + workspace.ID = ctx.Workspace.ID + workspace.UserID = ctx.UserID // Validate the workspace if err := workspace.Validate(); err != nil { @@ -103,35 +104,26 @@ func UpdateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { return } - // Get current workspace for comparison - currentWorkspace, err := db.GetWorkspaceByID(workspaceID) - if err != nil { - http.Error(w, "Workspace not found", http.StatusNotFound) - return - } - - if currentWorkspace.UserID != userID { - http.Error(w, "Unauthorized access to workspace", http.StatusForbidden) - return - } - // Handle Git repository setup/teardown if Git settings changed - if workspace.GitEnabled != currentWorkspace.GitEnabled || - (workspace.GitEnabled && (workspace.GitURL != currentWorkspace.GitURL || - workspace.GitUser != currentWorkspace.GitUser || - workspace.GitToken != currentWorkspace.GitToken)) { + if gitSettingsChanged(&workspace, ctx.Workspace) { if workspace.GitEnabled { - err = fs.SetupGitRepo(userID, workspaceID, workspace.GitURL, workspace.GitUser, workspace.GitToken) - if err != nil { + if err := h.FS.SetupGitRepo( + ctx.UserID, + ctx.Workspace.ID, + workspace.GitURL, + workspace.GitUser, + workspace.GitToken, + ); err != nil { http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) return } + } else { - fs.DisableGitRepo(userID, workspaceID) + h.FS.DisableGitRepo(ctx.UserID, ctx.Workspace.ID) } } - if err := db.UpdateWorkspace(&workspace); err != nil { + if err := h.DB.UpdateWorkspace(&workspace); err != nil { http.Error(w, "Failed to update workspace", http.StatusInternalServerError) return } @@ -140,16 +132,15 @@ func UpdateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { } } -func DeleteWorkspace(db *db.DB) http.HandlerFunc { +func (h *Handler) DeleteWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, workspaceID, err := getUserAndWorkspaceIDs(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } // Check if this is the user's last workspace - workspaces, err := db.GetWorkspacesByUserID(userID) + workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) if err != nil { http.Error(w, "Failed to get workspaces", http.StatusInternalServerError) return @@ -161,16 +152,18 @@ func DeleteWorkspace(db *db.DB) http.HandlerFunc { } // Find another workspace to set as last + var nextWorkspaceName string var nextWorkspaceID int for _, ws := range workspaces { - if ws.ID != workspaceID { + if ws.ID != ctx.Workspace.ID { nextWorkspaceID = ws.ID + nextWorkspaceName = ws.Name break } } // Start transaction - tx, err := db.Begin() + tx, err := h.DB.Begin() if err != nil { http.Error(w, "Failed to start transaction", http.StatusInternalServerError) return @@ -178,14 +171,14 @@ func DeleteWorkspace(db *db.DB) http.HandlerFunc { defer tx.Rollback() // Update last workspace ID first - err = db.UpdateLastWorkspaceTx(tx, userID, nextWorkspaceID) + err = h.DB.UpdateLastWorkspaceTx(tx, ctx.UserID, nextWorkspaceID) if err != nil { http.Error(w, "Failed to update last workspace", http.StatusInternalServerError) return } // Delete the workspace - err = db.DeleteWorkspaceTx(tx, workspaceID) + err = h.DB.DeleteWorkspaceTx(tx, ctx.Workspace.ID) if err != nil { http.Error(w, "Failed to delete workspace", http.StatusInternalServerError) return @@ -198,46 +191,46 @@ func DeleteWorkspace(db *db.DB) http.HandlerFunc { } // Return the next workspace ID in the response so frontend knows where to redirect - respondJSON(w, map[string]int{"nextWorkspaceId": nextWorkspaceID}) + respondJSON(w, map[string]string{"nextWorkspaceName": nextWorkspaceName}) } } -func GetLastWorkspace(db *db.DB) http.HandlerFunc { +func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, err := getUserID(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } - workspaceID, err := db.GetLastWorkspaceID(userID) + workspaceName, err := h.DB.GetLastWorkspaceName(ctx.UserID) if err != nil { http.Error(w, "Failed to get last workspace", http.StatusInternalServerError) return } - respondJSON(w, map[string]int{"lastWorkspaceId": workspaceID}) + respondJSON(w, map[string]string{"lastWorkspaceName": workspaceName}) } } -func UpdateLastWorkspace(db *db.DB) http.HandlerFunc { +func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID, err := getUserID(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { return } var requestBody struct { - WorkspaceID int `json:"workspaceId"` + WorkspaceName string `json:"workspaceName"` } if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + fmt.Println(err) http.Error(w, "Invalid request body", http.StatusBadRequest) return } - if err := db.UpdateLastWorkspace(userID, requestBody.WorkspaceID); err != nil { + if err := h.DB.UpdateLastWorkspace(ctx.UserID, requestBody.WorkspaceName); err != nil { + fmt.Println(err) http.Error(w, "Failed to update last workspace", http.StatusInternalServerError) return } diff --git a/backend/internal/httpcontext/context.go b/backend/internal/httpcontext/context.go new file mode 100644 index 0000000..1e9b278 --- /dev/null +++ b/backend/internal/httpcontext/context.go @@ -0,0 +1,31 @@ +package httpcontext + +import ( + "context" + "net/http" + "novamd/internal/models" +) + +// HandlerContext holds the request-specific data available to all handlers +type HandlerContext struct { + UserID int + UserRole string + Workspace *models.Workspace +} + +type contextKey string + +const HandlerContextKey contextKey = "handlerContext" + +func GetRequestContext(w http.ResponseWriter, r *http.Request) (*HandlerContext, bool) { + ctx := r.Context().Value(HandlerContextKey) + if ctx == nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return nil, false + } + return ctx.(*HandlerContext), true +} + +func WithHandlerContext(r *http.Request, hctx *HandlerContext) *http.Request { + return r.WithContext(context.WithValue(r.Context(), HandlerContextKey, hctx)) +} diff --git a/backend/internal/middleware/context.go b/backend/internal/middleware/context.go new file mode 100644 index 0000000..95a72d4 --- /dev/null +++ b/backend/internal/middleware/context.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "net/http" + "novamd/internal/auth" + "novamd/internal/db" + "novamd/internal/httpcontext" + + "github.com/go-chi/chi/v5" +) + +// User ID and User Role context +func WithUserContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, err := auth.GetUserFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + hctx := &httpcontext.HandlerContext{ + UserID: claims.UserID, + UserRole: claims.Role, + } + + r = httpcontext.WithHandlerContext(r, hctx) + next.ServeHTTP(w, r) + }) +} + +// Workspace context +func WithWorkspaceContext(db *db.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, ok := httpcontext.GetRequestContext(w, r) + if !ok { + return + } + + workspaceName := chi.URLParam(r, "workspaceName") + workspace, err := db.GetWorkspaceByName(ctx.UserID, workspaceName) + if err != nil { + http.Error(w, "Workspace not found", http.StatusNotFound) + return + } + + // Update existing context with workspace + ctx.Workspace = workspace + r = httpcontext.WithHandlerContext(r, ctx) + next.ServeHTTP(w, r) + }) + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d42e69c..a16cf85 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,7 @@ "rehype-mathjax": "^6.0.0", "rehype-prism": "^2.3.3", "rehype-react": "^8.0.0", + "remark": "^15.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.1", @@ -4975,6 +4976,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", + "integrity": "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-math": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", @@ -5024,6 +5041,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4c2b615..5f7b4d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,7 @@ "rehype-mathjax": "^6.0.0", "rehype-prism": "^2.3.3", "rehype-react": "^8.0.0", + "remark": "^15.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2ba651a..75040d1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,14 +3,36 @@ import { MantineProvider, ColorSchemeScript } from '@mantine/core'; import { Notifications } from '@mantine/notifications'; import { ModalsProvider } from '@mantine/modals'; import Layout from './components/Layout'; +import LoginPage from './components/LoginPage'; import { WorkspaceProvider } from './contexts/WorkspaceContext'; import { ModalProvider } from './contexts/ModalContext'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; import './App.scss'; -function AppContent() { - return ; +function AuthenticatedContent() { + const { user, loading, initialized } = useAuth(); + + if (!initialized) { + return null; + } + + if (loading) { + return
Loading...
; + } + + if (!user) { + return ; + } + + return ( + + + + + + ); } function App() { @@ -20,11 +42,9 @@ function App() { - - - - - + + + diff --git a/frontend/src/components/AccountSettings.jsx b/frontend/src/components/AccountSettings.jsx new file mode 100644 index 0000000..c4ad495 --- /dev/null +++ b/frontend/src/components/AccountSettings.jsx @@ -0,0 +1,443 @@ +import React, { useState, useReducer, useRef } from 'react'; +import { + Modal, + Badge, + Button, + Group, + Title, + Stack, + Accordion, + TextInput, + PasswordInput, + Box, + Text, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { useAuth } from '../contexts/AuthContext'; +import { useProfileSettings } from '../hooks/useProfileSettings'; + +// Reducer for managing settings state +const initialState = { + localSettings: {}, + initialSettings: {}, + hasUnsavedChanges: false, +}; + +function settingsReducer(state, action) { + switch (action.type) { + case 'INIT_SETTINGS': + return { + ...state, + localSettings: action.payload, + initialSettings: action.payload, + hasUnsavedChanges: false, + }; + case 'UPDATE_LOCAL_SETTINGS': + const newLocalSettings = { ...state.localSettings, ...action.payload }; + const hasChanges = + JSON.stringify(newLocalSettings) !== + JSON.stringify(state.initialSettings); + return { + ...state, + localSettings: newLocalSettings, + hasUnsavedChanges: hasChanges, + }; + case 'MARK_SAVED': + return { + ...state, + initialSettings: state.localSettings, + hasUnsavedChanges: false, + }; + default: + return state; + } +} + +// Password confirmation modal for email changes +const EmailPasswordModal = ({ opened, onClose, onConfirm, email }) => { + const [password, setPassword] = useState(''); + + return ( + + + + Please enter your password to confirm changing your email to: {email} + + setPassword(e.currentTarget.value)} + required + /> + + + + + + + ); +}; + +// Delete account confirmation modal +const DeleteAccountModal = ({ opened, onClose, onConfirm }) => { + const [password, setPassword] = useState(''); + + return ( + + + + Warning: This action cannot be undone + + + Please enter your password to confirm account deletion. + + setPassword(e.currentTarget.value)} + required + /> + + + + + + + ); +}; + +const AccordionControl = ({ children }) => ( + + {children} + +); + +const ProfileSettings = ({ settings, onInputChange }) => ( + + + onInputChange('displayName', e.currentTarget.value)} + placeholder="Enter display name" + /> + onInputChange('email', e.currentTarget.value)} + placeholder="Enter email" + /> + + +); + +const SecuritySettings = ({ settings, onInputChange }) => { + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + + const handlePasswordChange = (field, value) => { + if (field === 'confirmNewPassword') { + setConfirmPassword(value); + // Check if passwords match when either password field changes + if (value !== settings.newPassword) { + setError('Passwords do not match'); + } else { + setError(''); + } + } else { + onInputChange(field, value); + // Check if passwords match when either password field changes + if (field === 'newPassword' && value !== confirmPassword) { + setError('Passwords do not match'); + } else if (value === confirmPassword) { + setError(''); + } + } + }; + + return ( + + + + handlePasswordChange('currentPassword', e.currentTarget.value) + } + placeholder="Enter current password" + /> + + handlePasswordChange('newPassword', e.currentTarget.value) + } + placeholder="Enter new password" + /> + + handlePasswordChange('confirmNewPassword', e.currentTarget.value) + } + placeholder="Confirm new password" + error={error} + /> + + Password must be at least 8 characters long. Leave password fields + empty if you don't want to change it. + + + + ); +}; + +const DangerZone = ({ onDeleteClick }) => ( + + + +); + +const AccountSettings = ({ opened, onClose }) => { + const { user, logout, refreshUser } = useAuth(); + const { loading, updateProfile, deleteAccount } = useProfileSettings(); + const [state, dispatch] = useReducer(settingsReducer, initialState); + const isInitialMount = useRef(true); + const [deleteModalOpened, setDeleteModalOpened] = useState(false); + const [emailModalOpened, setEmailModalOpened] = useState(false); + + // Initialize settings on mount + React.useEffect(() => { + if (isInitialMount.current && user) { + isInitialMount.current = false; + const settings = { + displayName: user.displayName, + email: user.email, + currentPassword: '', + newPassword: '', + }; + dispatch({ type: 'INIT_SETTINGS', payload: settings }); + } + }, [user]); + + const handleInputChange = (key, value) => { + dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } }); + }; + + const handleSubmit = async () => { + const updates = {}; + const needsPasswordConfirmation = + state.localSettings.email !== state.initialSettings.email; + + // Add display name if changed + if (state.localSettings.displayName !== state.initialSettings.displayName) { + updates.displayName = state.localSettings.displayName; + } + + // Handle password change + if (state.localSettings.newPassword) { + if (!state.localSettings.currentPassword) { + notifications.show({ + title: 'Error', + message: 'Current password is required to change password', + color: 'red', + }); + return; + } + updates.newPassword = state.localSettings.newPassword; + updates.currentPassword = state.localSettings.currentPassword; + } + + // If we're only changing display name or have password already provided, proceed directly + if (!needsPasswordConfirmation || state.localSettings.currentPassword) { + if (needsPasswordConfirmation) { + updates.email = state.localSettings.email; + // If we don't have a password change, we still need to include the current password for email change + if (!updates.currentPassword) { + updates.currentPassword = state.localSettings.currentPassword; + } + } + + const result = await updateProfile(updates); + if (result.success) { + await refreshUser(); + dispatch({ type: 'MARK_SAVED' }); + onClose(); + } + } else { + // Only show the email confirmation modal if we don't already have the password + setEmailModalOpened(true); + } + }; + + const handleEmailConfirm = async (password) => { + const updates = { + ...state.localSettings, + currentPassword: password, + }; + // Remove any undefined/empty values + Object.keys(updates).forEach((key) => { + if (updates[key] === undefined || updates[key] === '') { + delete updates[key]; + } + }); + // Remove keys that haven't changed + if (updates.displayName === state.initialSettings.displayName) { + delete updates.displayName; + } + if (updates.email === state.initialSettings.email) { + delete updates.email; + } + + const result = await updateProfile(updates); + if (result.success) { + await refreshUser(); + dispatch({ type: 'MARK_SAVED' }); + setEmailModalOpened(false); + onClose(); + } + }; + + const handleDelete = async (password) => { + const result = await deleteAccount(password); + if (result.success) { + setDeleteModalOpened(false); + onClose(); + logout(); + } + }; + + return ( + <> + Account Settings} + centered + size="lg" + > + + {state.hasUnsavedChanges && ( + + Unsaved Changes + + )} + + ({ + control: { + paddingTop: theme.spacing.md, + paddingBottom: theme.spacing.md, + }, + item: { + borderBottom: `1px solid ${ + theme.colorScheme === 'dark' + ? theme.colors.dark[4] + : theme.colors.gray[3] + }`, + '&[data-active]': { + backgroundColor: + theme.colorScheme === 'dark' + ? theme.colors.dark[7] + : theme.colors.gray[0], + }, + }, + })} + > + + Profile + + + + + + + Security + + + + + + + Danger Zone + + setDeleteModalOpened(true)} /> + + + + + + + + + + + + setEmailModalOpened(false)} + onConfirm={handleEmailConfirm} + email={state.localSettings.email} + /> + + setDeleteModalOpened(false)} + onConfirm={handleDelete} + /> + + ); +}; + +export default AccountSettings; diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index b9f894a..be18fd8 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -1,5 +1,6 @@ import React from 'react'; -import { Group, Text, Avatar } from '@mantine/core'; +import { Group, Text } from '@mantine/core'; +import UserMenu from './UserMenu'; import WorkspaceSwitcher from './WorkspaceSwitcher'; import Settings from './Settings'; @@ -11,7 +12,7 @@ const Header = () => { - + diff --git a/frontend/src/components/LoginPage.jsx b/frontend/src/components/LoginPage.jsx new file mode 100644 index 0000000..c5c55f1 --- /dev/null +++ b/frontend/src/components/LoginPage.jsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import { + TextInput, + PasswordInput, + Paper, + Title, + Container, + Button, + Text, + Stack, +} from '@mantine/core'; +import { useAuth } from '../contexts/AuthContext'; + +const LoginPage = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + try { + await login(email, password); + } finally { + setLoading(false); + } + }; + + return ( + + Welcome to NovaMD + + Please sign in to continue + + + +
+ + setEmail(event.currentTarget.value)} + /> + + setPassword(event.currentTarget.value)} + /> + + + +
+
+
+ ); +}; + +export default LoginPage; diff --git a/frontend/src/components/MarkdownPreview.jsx b/frontend/src/components/MarkdownPreview.jsx index a86ae1e..90a8402 100644 --- a/frontend/src/components/MarkdownPreview.jsx +++ b/frontend/src/components/MarkdownPreview.jsx @@ -21,15 +21,10 @@ const MarkdownPreview = ({ content, handleFileSelect }) => { if (href.startsWith(`${baseUrl}/internal/`)) { // For existing files, extract the path and directly select it - const [filePath, heading] = decodeURIComponent( + const [filePath] = decodeURIComponent( href.replace(`${baseUrl}/internal/`, '') ).split('#'); handleFileSelect(filePath); - - // TODO: Handle heading navigation if needed - if (heading) { - console.debug('Heading navigation not implemented:', heading); - } } else if (href.startsWith(`${baseUrl}/notfound/`)) { // For non-existent files, show a notification const fileName = decodeURIComponent( @@ -47,7 +42,7 @@ const MarkdownPreview = ({ content, handleFileSelect }) => { () => unified() .use(remarkParse) - .use(remarkWikiLinks, currentWorkspace?.id) + .use(remarkWikiLinks, currentWorkspace?.name) .use(remarkMath) .use(remarkRehype) .use(rehypeMathjax) @@ -90,7 +85,7 @@ const MarkdownPreview = ({ content, handleFileSelect }) => { }, }, }), - [baseUrl, handleFileSelect, currentWorkspace?.id] + [baseUrl, handleFileSelect, currentWorkspace?.name] ); useEffect(() => { diff --git a/frontend/src/components/UserMenu.jsx b/frontend/src/components/UserMenu.jsx new file mode 100644 index 0000000..6d50b44 --- /dev/null +++ b/frontend/src/components/UserMenu.jsx @@ -0,0 +1,118 @@ +import React, { useState } from 'react'; +import { + Avatar, + Popover, + Stack, + UnstyledButton, + Group, + Text, + Divider, +} from '@mantine/core'; +import { IconUser, IconLogout, IconSettings } from '@tabler/icons-react'; +import { useAuth } from '../contexts/AuthContext'; +import AccountSettings from './AccountSettings'; + +const UserMenu = () => { + const [accountSettingsOpened, setAccountSettingsOpened] = useState(false); + const [opened, setOpened] = useState(false); + const { user, logout } = useAuth(); + + const handleLogout = () => { + logout(); + }; + + return ( + <> + + + setOpened((o) => !o)} + > + + + + + + + {/* User Info Section */} + + + + +
+ + {user.displayName || user.email} + +
+
+ + + + {/* Menu Items */} + { + setAccountSettingsOpened(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], + }, + })} + > + + + Account Settings + + + + ({ + borderRadius: theme.radius.sm, + '&:hover': { + backgroundColor: + theme.colorScheme === 'dark' + ? theme.colors.dark[5] + : theme.colors.gray[0], + }, + })} + > + + + + Logout + + + +
+
+
+ + setAccountSettingsOpened(false)} + /> + + ); +}; + +export default UserMenu; diff --git a/frontend/src/components/WorkspaceSwitcher.jsx b/frontend/src/components/WorkspaceSwitcher.jsx index e9b79a0..e29f24d 100644 --- a/frontend/src/components/WorkspaceSwitcher.jsx +++ b/frontend/src/components/WorkspaceSwitcher.jsx @@ -47,7 +47,7 @@ const WorkspaceSwitcher = () => { const handleWorkspaceCreated = async (newWorkspace) => { await loadWorkspaces(); - switchWorkspace(newWorkspace.id); + switchWorkspace(newWorkspace.name); }; return ( @@ -102,10 +102,10 @@ const WorkspaceSwitcher = () => { ) : ( workspaces.map((workspace) => { - const isSelected = workspace.id === currentWorkspace?.id; + const isSelected = workspace.name === currentWorkspace?.name; return ( { { - switchWorkspace(workspace.id); + switchWorkspace(workspace.name); setPopoverOpened(false); }} > diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..a93d0c5 --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,120 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, +} from 'react'; +import { notifications } from '@mantine/notifications'; +import * as authApi from '../services/authApi'; + +const AuthContext = createContext(null); + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [initialized, setInitialized] = useState(false); + + // Load user data on mount + useEffect(() => { + const initializeAuth = async () => { + try { + const storedToken = localStorage.getItem('accessToken'); + if (storedToken) { + authApi.setAuthToken(storedToken); + const userData = await authApi.getCurrentUser(); + setUser(userData); + } + } catch (error) { + console.error('Failed to initialize auth:', error); + localStorage.removeItem('accessToken'); + authApi.clearAuthToken(); + } finally { + setLoading(false); + setInitialized(true); + } + }; + + initializeAuth(); + }, []); + + const login = useCallback(async (email, password) => { + try { + const { accessToken, user: userData } = await authApi.login( + email, + password + ); + localStorage.setItem('accessToken', accessToken); + authApi.setAuthToken(accessToken); + setUser(userData); + notifications.show({ + title: 'Success', + message: 'Logged in successfully', + color: 'green', + }); + return true; + } catch (error) { + console.error('Login failed:', error); + notifications.show({ + title: 'Error', + message: error.message || 'Login failed', + color: 'red', + }); + return false; + } + }, []); + + const logout = useCallback(async () => { + try { + await authApi.logout(); + } catch (error) { + console.error('Logout failed:', error); + } finally { + localStorage.removeItem('accessToken'); + authApi.clearAuthToken(); + setUser(null); + } + }, []); + + const refreshToken = useCallback(async () => { + try { + const { accessToken } = await authApi.refreshToken(); + localStorage.setItem('accessToken', accessToken); + authApi.setAuthToken(accessToken); + return true; + } catch (error) { + console.error('Token refresh failed:', error); + await logout(); + return false; + } + }, [logout]); + + const refreshUser = useCallback(async () => { + try { + const userData = await authApi.getCurrentUser(); + setUser(userData); + } catch (error) { + console.error('Failed to refresh user data:', error); + } + }, []); + + const value = { + user, + loading, + initialized, + login, + logout, + refreshToken, + refreshUser, + }; + + return {children}; +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/frontend/src/contexts/WorkspaceContext.jsx b/frontend/src/contexts/WorkspaceContext.jsx index f7804e2..beddf4f 100644 --- a/frontend/src/contexts/WorkspaceContext.jsx +++ b/frontend/src/contexts/WorkspaceContext.jsx @@ -8,10 +8,10 @@ import React, { import { useMantineColorScheme } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { - fetchLastWorkspaceId, + fetchLastWorkspaceName, getWorkspace, updateWorkspace, - updateLastWorkspace, + updateLastWorkspaceName, deleteWorkspace, listWorkspaces, } from '../services/api'; @@ -41,9 +41,9 @@ export const WorkspaceProvider = ({ children }) => { } }, []); - const loadWorkspaceData = useCallback(async (workspaceId) => { + const loadWorkspaceData = useCallback(async (workspaceName) => { try { - const workspace = await getWorkspace(workspaceId); + const workspace = await getWorkspace(workspaceName); setCurrentWorkspace(workspace); setColorScheme(workspace.theme); } catch (error) { @@ -61,8 +61,8 @@ export const WorkspaceProvider = ({ children }) => { const allWorkspaces = await listWorkspaces(); if (allWorkspaces.length > 0) { const firstWorkspace = allWorkspaces[0]; - await updateLastWorkspace(firstWorkspace.id); - await loadWorkspaceData(firstWorkspace.id); + await updateLastWorkspaceName(firstWorkspace.name); + await loadWorkspaceData(firstWorkspace.name); } } catch (error) { console.error('Failed to load first available workspace:', error); @@ -77,9 +77,9 @@ export const WorkspaceProvider = ({ children }) => { useEffect(() => { const initializeWorkspace = async () => { try { - const { lastWorkspaceId } = await fetchLastWorkspaceId(); - if (lastWorkspaceId) { - await loadWorkspaceData(lastWorkspaceId); + const { lastWorkspaceName } = await fetchLastWorkspaceName(); + if (lastWorkspaceName) { + await loadWorkspaceData(lastWorkspaceName); } else { await loadFirstAvailableWorkspace(); } @@ -95,11 +95,11 @@ export const WorkspaceProvider = ({ children }) => { initializeWorkspace(); }, []); - const switchWorkspace = useCallback(async (workspaceId) => { + const switchWorkspace = useCallback(async (workspaceName) => { try { setLoading(true); - await updateLastWorkspace(workspaceId); - await loadWorkspaceData(workspaceId); + await updateLastWorkspaceName(workspaceName); + await loadWorkspaceData(workspaceName); await loadWorkspaces(); } catch (error) { console.error('Failed to switch workspace:', error); @@ -129,10 +129,10 @@ export const WorkspaceProvider = ({ children }) => { } // Delete workspace and get the next workspace ID - const response = await deleteWorkspace(currentWorkspace.id); + const response = await deleteWorkspace(currentWorkspace.name); // Load the new workspace data - await loadWorkspaceData(response.nextWorkspaceId); + await loadWorkspaceData(response.nextWorkspaceName); notifications.show({ title: 'Success', @@ -162,7 +162,7 @@ export const WorkspaceProvider = ({ children }) => { }; const response = await updateWorkspace( - currentWorkspace.id, + currentWorkspace.name, updatedWorkspace ); setCurrentWorkspace(response); diff --git a/frontend/src/hooks/useFileContent.js b/frontend/src/hooks/useFileContent.js index cb304d0..21b8776 100644 --- a/frontend/src/hooks/useFileContent.js +++ b/frontend/src/hooks/useFileContent.js @@ -19,7 +19,7 @@ export const useFileContent = (selectedFile) => { if (filePath === DEFAULT_FILE.path) { newContent = DEFAULT_FILE.content; } else if (!isImageFile(filePath)) { - newContent = await fetchFileContent(currentWorkspace.id, filePath); + newContent = await fetchFileContent(currentWorkspace.name, filePath); } else { newContent = ''; // Set empty content for image files } diff --git a/frontend/src/hooks/useFileList.js b/frontend/src/hooks/useFileList.js index ec8ea1f..4557f1f 100644 --- a/frontend/src/hooks/useFileList.js +++ b/frontend/src/hooks/useFileList.js @@ -10,7 +10,7 @@ export const useFileList = () => { if (!currentWorkspace || workspaceLoading) return; try { - const fileList = await fetchFileList(currentWorkspace.id); + const fileList = await fetchFileList(currentWorkspace.name); if (Array.isArray(fileList)) { setFiles(fileList); } else { diff --git a/frontend/src/hooks/useFileNavigation.js b/frontend/src/hooks/useFileNavigation.js index e98eb95..ed4ff76 100644 --- a/frontend/src/hooks/useFileNavigation.js +++ b/frontend/src/hooks/useFileNavigation.js @@ -25,6 +25,9 @@ export const useFileNavigation = () => { // Load last opened file when workspace changes useEffect(() => { const initializeFile = async () => { + setSelectedFile(DEFAULT_FILE.path); + setIsNewFile(true); + const lastFile = await loadLastOpenedFile(); if (lastFile) { handleFileSelect(lastFile); @@ -33,7 +36,9 @@ export const useFileNavigation = () => { } }; - initializeFile(); + if (currentWorkspace) { + initializeFile(); + } }, [currentWorkspace, loadLastOpenedFile, handleFileSelect]); return { selectedFile, isNewFile, handleFileSelect }; diff --git a/frontend/src/hooks/useFileOperations.js b/frontend/src/hooks/useFileOperations.js index 0110755..6df5dae 100644 --- a/frontend/src/hooks/useFileOperations.js +++ b/frontend/src/hooks/useFileOperations.js @@ -29,7 +29,7 @@ export const useFileOperations = () => { if (!currentWorkspace) return false; try { - await saveFileContent(currentWorkspace.id, filePath, content); + await saveFileContent(currentWorkspace.name, filePath, content); notifications.show({ title: 'Success', message: 'File saved successfully', @@ -55,7 +55,7 @@ export const useFileOperations = () => { if (!currentWorkspace) return false; try { - await deleteFile(currentWorkspace.id, filePath); + await deleteFile(currentWorkspace.name, filePath); notifications.show({ title: 'Success', message: 'File deleted successfully', @@ -81,7 +81,7 @@ export const useFileOperations = () => { if (!currentWorkspace) return false; try { - await saveFileContent(currentWorkspace.id, fileName, initialContent); + await saveFileContent(currentWorkspace.name, fileName, initialContent); notifications.show({ title: 'Success', message: 'File created successfully', diff --git a/frontend/src/hooks/useGitOperations.js b/frontend/src/hooks/useGitOperations.js index 40e1669..9a3e5b3 100644 --- a/frontend/src/hooks/useGitOperations.js +++ b/frontend/src/hooks/useGitOperations.js @@ -10,7 +10,7 @@ export const useGitOperations = () => { if (!currentWorkspace || !settings.gitEnabled) return false; try { - await pullChanges(currentWorkspace.id); + await pullChanges(currentWorkspace.name); notifications.show({ title: 'Success', message: 'Successfully pulled latest changes', @@ -33,7 +33,7 @@ export const useGitOperations = () => { if (!currentWorkspace || !settings.gitEnabled) return false; try { - await commitAndPush(currentWorkspace.id, message); + await commitAndPush(currentWorkspace.name, message); notifications.show({ title: 'Success', message: 'Successfully committed and pushed changes', diff --git a/frontend/src/hooks/useLastOpenedFile.js b/frontend/src/hooks/useLastOpenedFile.js index 1844fd6..d1b8bef 100644 --- a/frontend/src/hooks/useLastOpenedFile.js +++ b/frontend/src/hooks/useLastOpenedFile.js @@ -9,7 +9,7 @@ export const useLastOpenedFile = () => { if (!currentWorkspace) return null; try { - const response = await getLastOpenedFile(currentWorkspace.id); + const response = await getLastOpenedFile(currentWorkspace.name); return response.lastOpenedFilePath || null; } catch (error) { console.error('Failed to load last opened file:', error); @@ -22,7 +22,7 @@ export const useLastOpenedFile = () => { if (!currentWorkspace) return; try { - await updateLastOpenedFile(currentWorkspace.id, filePath); + await updateLastOpenedFile(currentWorkspace.name, filePath); } catch (error) { console.error('Failed to save last opened file:', error); } diff --git a/frontend/src/hooks/useProfileSettings.js b/frontend/src/hooks/useProfileSettings.js new file mode 100644 index 0000000..381e784 --- /dev/null +++ b/frontend/src/hooks/useProfileSettings.js @@ -0,0 +1,71 @@ +import { useState, useCallback } from 'react'; +import { notifications } from '@mantine/notifications'; +import { updateProfile, deleteProfile } from '../services/api'; + +export function useProfileSettings() { + const [loading, setLoading] = useState(false); + + const handleProfileUpdate = useCallback(async (updates) => { + setLoading(true); + try { + const updatedUser = await updateProfile(updates); + + notifications.show({ + title: 'Success', + message: 'Profile updated successfully', + color: 'green', + }); + + return { success: true, user: updatedUser }; + } catch (error) { + let errorMessage = 'Failed to update profile'; + + if (error.message.includes('password')) { + errorMessage = 'Current password is incorrect'; + } else if (error.message.includes('email')) { + errorMessage = 'Email is already in use'; + } + + notifications.show({ + title: 'Error', + message: errorMessage, + color: 'red', + }); + + return { success: false, error: error.message }; + } finally { + setLoading(false); + } + }, []); + + const handleAccountDeletion = useCallback(async (password) => { + setLoading(true); + try { + await deleteProfile(password); + + notifications.show({ + title: 'Success', + message: 'Account deleted successfully', + color: 'green', + }); + + return { success: true }; + } catch (error) { + notifications.show({ + title: 'Error', + message: error.message || 'Failed to delete account', + color: 'red', + }); + + return { success: false, error: error.message }; + } finally { + setLoading(false); + } + }, []); + + return { + loading, + updateProfile: handleProfileUpdate, + deleteAccount: handleAccountDeletion, + }; +} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 6fbed6c..dc8c1df 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1,43 +1,44 @@ -const API_BASE_URL = window.API_BASE_URL; +import { API_BASE_URL } from '../utils/constants'; +import { apiCall } from './authApi'; -const apiCall = async (url, options = {}) => { - try { - const response = await fetch(url, options); - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error( - errorData?.message || `HTTP error! status: ${response.status}` - ); - } - return response; - } catch (error) { - console.error(`API call failed: ${error.message}`); - throw error; - } -}; - -export const fetchLastWorkspaceId = async () => { - const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`); +export const updateProfile = async (updates) => { + const response = await apiCall(`${API_BASE_URL}/profile`, { + method: 'PUT', + body: JSON.stringify(updates), + }); return response.json(); }; -export const fetchFileList = async (workspaceId) => { +export const deleteProfile = async (password) => { + const response = await apiCall(`${API_BASE_URL}/profile`, { + method: 'DELETE', + body: JSON.stringify({ password }), + }); + return response.json(); +}; + +export const fetchLastWorkspaceName = async () => { + const response = await apiCall(`${API_BASE_URL}/workspaces/last`); + return response.json(); +}; + +export const fetchFileList = async (workspaceName) => { const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files` + `${API_BASE_URL}/workspaces/${workspaceName}/files` ); return response.json(); }; -export const fetchFileContent = async (workspaceId, filePath) => { +export const fetchFileContent = async (workspaceName, filePath) => { const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}` + `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}` ); return response.text(); }; -export const saveFileContent = async (workspaceId, filePath, content) => { +export const saveFileContent = async (workspaceName, filePath, content) => { const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`, + `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`, { method: 'POST', headers: { @@ -49,9 +50,9 @@ export const saveFileContent = async (workspaceId, filePath, content) => { return response.text(); }; -export const deleteFile = async (workspaceId, filePath) => { +export const deleteFile = async (workspaceName, filePath) => { const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`, + `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`, { method: 'DELETE', } @@ -59,17 +60,15 @@ export const deleteFile = async (workspaceId, filePath) => { return response.text(); }; -export const getWorkspace = async (workspaceId) => { - const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}` - ); +export const getWorkspace = async (workspaceName) => { + const response = await apiCall(`${API_BASE_URL}/workspaces/${workspaceName}`); return response.json(); }; // Combined function to update workspace data including settings -export const updateWorkspace = async (workspaceId, workspaceData) => { +export const updateWorkspace = async (workspaceName, workspaceData) => { const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}`, + `${API_BASE_URL}/workspaces/${workspaceName}`, { method: 'PUT', headers: { @@ -81,9 +80,9 @@ export const updateWorkspace = async (workspaceId, workspaceData) => { return response.json(); }; -export const pullChanges = async (workspaceId) => { +export const pullChanges = async (workspaceName) => { const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}/git/pull`, + `${API_BASE_URL}/workspaces/${workspaceName}/git/pull`, { method: 'POST', } @@ -91,9 +90,9 @@ export const pullChanges = async (workspaceId) => { return response.json(); }; -export const commitAndPush = async (workspaceId, message) => { +export const commitAndPush = async (workspaceName, message) => { const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}/git/commit`, + `${API_BASE_URL}/workspaces/${workspaceName}/git/commit`, { method: 'POST', headers: { @@ -105,13 +104,13 @@ export const commitAndPush = async (workspaceId, message) => { return response.json(); }; -export const getFileUrl = (workspaceId, filePath) => { - return `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`; +export const getFileUrl = (workspaceName, filePath) => { + return `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`; }; -export const lookupFileByName = async (workspaceId, filename) => { +export const lookupFileByName = async (workspaceName, filename) => { const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/lookup?filename=${encodeURIComponent( + `${API_BASE_URL}/workspaces/${workspaceName}/files/lookup?filename=${encodeURIComponent( filename )}` ); @@ -119,9 +118,9 @@ export const lookupFileByName = async (workspaceId, filename) => { return data.paths; }; -export const updateLastOpenedFile = async (workspaceId, filePath) => { +export const updateLastOpenedFile = async (workspaceName, filePath) => { const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/last`, + `${API_BASE_URL}/workspaces/${workspaceName}/files/last`, { method: 'PUT', headers: { @@ -133,20 +132,20 @@ export const updateLastOpenedFile = async (workspaceId, filePath) => { return response.json(); }; -export const getLastOpenedFile = async (workspaceId) => { +export const getLastOpenedFile = async (workspaceName) => { const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/last` + `${API_BASE_URL}/workspaces/${workspaceName}/files/last` ); return response.json(); }; export const listWorkspaces = async () => { - const response = await apiCall(`${API_BASE_URL}/users/1/workspaces`); + const response = await apiCall(`${API_BASE_URL}/workspaces`); return response.json(); }; export const createWorkspace = async (name) => { - const response = await apiCall(`${API_BASE_URL}/users/1/workspaces`, { + const response = await apiCall(`${API_BASE_URL}/workspaces`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -156,9 +155,9 @@ export const createWorkspace = async (name) => { return response.json(); }; -export const deleteWorkspace = async (workspaceId) => { +export const deleteWorkspace = async (workspaceName) => { const response = await apiCall( - `${API_BASE_URL}/users/1/workspaces/${workspaceId}`, + `${API_BASE_URL}/workspaces/${workspaceName}`, { method: 'DELETE', } @@ -166,13 +165,13 @@ export const deleteWorkspace = async (workspaceId) => { return response.json(); }; -export const updateLastWorkspace = async (workspaceId) => { - const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`, { +export const updateLastWorkspaceName = async (workspaceName) => { + const response = await apiCall(`${API_BASE_URL}/workspaces/last`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ workspaceId }), + body: JSON.stringify({ workspaceName }), }); return response.json(); }; diff --git a/frontend/src/services/authApi.js b/frontend/src/services/authApi.js new file mode 100644 index 0000000..9b1ae31 --- /dev/null +++ b/frontend/src/services/authApi.js @@ -0,0 +1,97 @@ +import { API_BASE_URL } from '../utils/constants'; + +let authToken = null; + +export const setAuthToken = (token) => { + authToken = token; +}; + +export const clearAuthToken = () => { + authToken = null; +}; + +export const getAuthHeaders = () => { + const headers = { + 'Content-Type': 'application/json', + }; + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + return headers; +}; + +// Update the existing apiCall function to include auth headers +export const apiCall = async (url, options = {}) => { + try { + const headers = { + ...getAuthHeaders(), + ...options.headers, + }; + + const response = await fetch(url, { + ...options, + headers, + }); + + // Handle 401 responses + if (response.status === 401) { + const isRefreshEndpoint = url.endsWith('/auth/refresh'); + if (!isRefreshEndpoint) { + // Attempt token refresh and retry the request + const refreshSuccess = await refreshToken(); + if (refreshSuccess) { + // Retry the original request with the new token + return apiCall(url, options); + } + } + throw new Error('Authentication failed'); + } + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error( + errorData?.message || `HTTP error! status: ${response.status}` + ); + } + + return response; + } catch (error) { + console.error(`API call failed: ${error.message}`); + throw error; + } +}; + +// Authentication endpoints +export const login = async (email, password) => { + const response = await apiCall(`${API_BASE_URL}/auth/login`, { + method: 'POST', + body: JSON.stringify({ email, password }), + }); + return response.json(); +}; + +export const logout = async () => { + const sessionId = localStorage.getItem('sessionId'); + await apiCall(`${API_BASE_URL}/auth/logout`, { + method: 'POST', + headers: { + 'X-Session-ID': sessionId, + }, + }); +}; + +export const refreshToken = async () => { + const refreshToken = localStorage.getItem('refreshToken'); + const response = await apiCall(`${API_BASE_URL}/auth/refresh`, { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }); + return response.json(); +}; + +export const getCurrentUser = async () => { + const response = await apiCall(`${API_BASE_URL}/auth/me`); + return response.json(); +}; diff --git a/frontend/src/utils/remarkWikiLinks.js b/frontend/src/utils/remarkWikiLinks.js index d271d28..15d66aa 100644 --- a/frontend/src/utils/remarkWikiLinks.js +++ b/frontend/src/utils/remarkWikiLinks.js @@ -27,10 +27,10 @@ function createFileLink(filePath, displayText, heading, baseUrl) { }; } -function createImageNode(workspaceId, filePath, displayText) { +function createImageNode(workspaceName, filePath, displayText) { return { type: 'image', - url: getFileUrl(workspaceId, filePath), + url: getFileUrl(workspaceName, filePath), alt: displayText, title: displayText, }; @@ -43,9 +43,9 @@ function addMarkdownExtension(fileName) { return `${fileName}.md`; } -export function remarkWikiLinks(workspaceId) { +export function remarkWikiLinks(workspaceName) { return async function transformer(tree) { - if (!workspaceId) { + if (!workspaceName) { console.warn('No workspace ID provided to remarkWikiLinks plugin'); return; } @@ -113,13 +113,13 @@ export function remarkWikiLinks(workspaceId) { ? match.fileName : addMarkdownExtension(match.fileName); - const paths = await lookupFileByName(workspaceId, lookupFileName); + const paths = await lookupFileByName(workspaceName, lookupFileName); if (paths && paths.length > 0) { const filePath = paths[0]; if (match.isImage) { newNodes.push( - createImageNode(workspaceId, filePath, match.displayText) + createImageNode(workspaceName, filePath, match.displayText) ); } else { newNodes.push( diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 5ce297c..85e6a8d 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -52,11 +52,16 @@ export default defineConfig(({ mode }) => ({ // Markdown processing markdown: [ - 'react-markdown', 'react-syntax-highlighter', - 'rehype-katex', + 'rehype-mathjax', + 'rehype-prism', + 'rehype-react', + 'remark', 'remark-math', - 'katex', + 'remark-parse', + 'remark-rehype', + 'unified', + 'unist-util-visit', ], // Icons and utilities