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