mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-06 16:04:23 +00:00
Merge pull request #14 from LordMathis/feat/user-auth
User authentication and account settings
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
handler := &handlers.Handler{
|
||||
DB: db,
|
||||
FS: fs,
|
||||
}
|
||||
|
||||
// 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))
|
||||
})
|
||||
|
||||
// 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)
|
||||
|
||||
// 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())
|
||||
|
||||
// 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("/", ListWorkspaces(db))
|
||||
r.Post("/", CreateWorkspace(db, fs))
|
||||
r.Get("/last", GetLastWorkspace(db))
|
||||
r.Put("/last", UpdateLastWorkspace(db))
|
||||
r.Get("/", handler.ListWorkspaces())
|
||||
r.Post("/", handler.CreateWorkspace())
|
||||
r.Get("/last", handler.GetLastWorkspaceName())
|
||||
r.Put("/last", handler.UpdateLastWorkspaceName())
|
||||
|
||||
r.Route("/{workspaceId}", func(r chi.Router) {
|
||||
r.Get("/", GetWorkspace(db))
|
||||
r.Put("/", UpdateWorkspace(db, fs))
|
||||
r.Delete("/", DeleteWorkspace(db))
|
||||
// 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("/", ListFiles(fs))
|
||||
r.Get("/last", GetLastOpenedFile(db))
|
||||
r.Put("/last", UpdateLastOpenedFile(db, fs))
|
||||
r.Get("/lookup", LookupFileByName(fs)) // Moved here
|
||||
|
||||
r.Post("/*", SaveFile(fs))
|
||||
r.Get("/*", GetFileContent(fs))
|
||||
r.Delete("/*", DeleteFile(fs))
|
||||
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", StageCommitAndPush(fs))
|
||||
r.Post("/pull", PullChanges(fs))
|
||||
})
|
||||
r.Post("/commit", handler.StageCommitAndPush())
|
||||
r.Post("/pull", handler.PullChanges())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
135
backend/internal/auth/jwt.go
Normal file
135
backend/internal/auth/jwt.go
Normal file
@@ -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)
|
||||
}
|
||||
128
backend/internal/auth/middleware.go
Normal file
128
backend/internal/auth/middleware.go
Normal file
@@ -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
|
||||
}
|
||||
140
backend/internal/auth/session.go
Normal file
140
backend/internal/auth/session.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
65
backend/internal/db/system_settings.go
Normal file
65
backend/internal/db/system_settings.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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,16 +114,33 @@ 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)
|
||||
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 {
|
||||
tx, err := db.Begin()
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
146
backend/internal/handlers/auth_handlers.go
Normal file
146
backend/internal/handlers/auth_handlers.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
30
backend/internal/handlers/handlers.go
Normal file
30
backend/internal/handlers/handlers.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
222
backend/internal/handlers/user_handlers.go
Normal file
222
backend/internal/handlers/user_handlers.go
Normal file
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
31
backend/internal/httpcontext/context.go
Normal file
31
backend/internal/httpcontext/context.go
Normal file
@@ -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))
|
||||
}
|
||||
53
backend/internal/middleware/context.go
Normal file
53
backend/internal/middleware/context.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
32
frontend/package-lock.json
generated
32
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <Layout />;
|
||||
function AuthenticatedContent() {
|
||||
const { user, loading, initialized } = useAuth();
|
||||
|
||||
if (!initialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceProvider>
|
||||
<ModalProvider>
|
||||
<Layout />
|
||||
</ModalProvider>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
@@ -20,11 +42,9 @@ function App() {
|
||||
<MantineProvider defaultColorScheme="light">
|
||||
<Notifications />
|
||||
<ModalsProvider>
|
||||
<WorkspaceProvider>
|
||||
<ModalProvider>
|
||||
<AppContent />
|
||||
</ModalProvider>
|
||||
</WorkspaceProvider>
|
||||
<AuthProvider>
|
||||
<AuthenticatedContent />
|
||||
</AuthProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</>
|
||||
|
||||
443
frontend/src/components/AccountSettings.jsx
Normal file
443
frontend/src/components/AccountSettings.jsx
Normal file
@@ -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 (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Confirm Password"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm">
|
||||
Please enter your password to confirm changing your email to: {email}
|
||||
</Text>
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
placeholder="Enter your current password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onConfirm(password);
|
||||
setPassword('');
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// Delete account confirmation modal
|
||||
const DeleteAccountModal = ({ opened, onClose, onConfirm }) => {
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Delete Account"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack>
|
||||
<Text c="red" fw={500}>
|
||||
Warning: This action cannot be undone
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
Please enter your password to confirm account deletion.
|
||||
</Text>
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
placeholder="Enter your current password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
onConfirm(password);
|
||||
setPassword('');
|
||||
}}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const AccordionControl = ({ children }) => (
|
||||
<Accordion.Control>
|
||||
<Title order={4}>{children}</Title>
|
||||
</Accordion.Control>
|
||||
);
|
||||
|
||||
const ProfileSettings = ({ settings, onInputChange }) => (
|
||||
<Box>
|
||||
<Stack spacing="md">
|
||||
<TextInput
|
||||
label="Display Name"
|
||||
value={settings.displayName || ''}
|
||||
onChange={(e) => onInputChange('displayName', e.currentTarget.value)}
|
||||
placeholder="Enter display name"
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
value={settings.email || ''}
|
||||
onChange={(e) => onInputChange('email', e.currentTarget.value)}
|
||||
placeholder="Enter email"
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Box>
|
||||
<Stack spacing="md">
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
value={settings.currentPassword || ''}
|
||||
onChange={(e) =>
|
||||
handlePasswordChange('currentPassword', e.currentTarget.value)
|
||||
}
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New Password"
|
||||
value={settings.newPassword || ''}
|
||||
onChange={(e) =>
|
||||
handlePasswordChange('newPassword', e.currentTarget.value)
|
||||
}
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Confirm New Password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) =>
|
||||
handlePasswordChange('confirmNewPassword', e.currentTarget.value)
|
||||
}
|
||||
placeholder="Confirm new password"
|
||||
error={error}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
Password must be at least 8 characters long. Leave password fields
|
||||
empty if you don't want to change it.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const DangerZone = ({ onDeleteClick }) => (
|
||||
<Box>
|
||||
<Button color="red" variant="light" onClick={onDeleteClick} fullWidth>
|
||||
Delete Account
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Title order={2}>Account Settings</Title>}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Stack spacing="xl">
|
||||
{state.hasUnsavedChanges && (
|
||||
<Badge color="yellow" variant="light">
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
defaultValue={['profile', 'security', 'danger']}
|
||||
multiple
|
||||
styles={(theme) => ({
|
||||
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],
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Accordion.Item value="profile">
|
||||
<AccordionControl>Profile</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<ProfileSettings
|
||||
settings={state.localSettings}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="security">
|
||||
<AccordionControl>Security</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<SecuritySettings
|
||||
settings={state.localSettings}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="danger">
|
||||
<AccordionControl>Danger Zone</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<DangerZone onDeleteClick={() => setDeleteModalOpened(true)} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={!state.hasUnsavedChanges}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<EmailPasswordModal
|
||||
opened={emailModalOpened}
|
||||
onClose={() => setEmailModalOpened(false)}
|
||||
onConfirm={handleEmailConfirm}
|
||||
email={state.localSettings.email}
|
||||
/>
|
||||
|
||||
<DeleteAccountModal
|
||||
opened={deleteModalOpened}
|
||||
onClose={() => setDeleteModalOpened(false)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSettings;
|
||||
@@ -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 = () => {
|
||||
</Text>
|
||||
<Group>
|
||||
<WorkspaceSwitcher />
|
||||
<Avatar src="https://via.placeholder.com/40" radius="xl" />
|
||||
<UserMenu />
|
||||
</Group>
|
||||
<Settings />
|
||||
</Group>
|
||||
|
||||
66
frontend/src/components/LoginPage.jsx
Normal file
66
frontend/src/components/LoginPage.jsx
Normal file
@@ -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 (
|
||||
<Container size={420} my={40}>
|
||||
<Title ta="center">Welcome to NovaMD</Title>
|
||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||
Please sign in to continue
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={loading}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -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(() => {
|
||||
|
||||
118
frontend/src/components/UserMenu.jsx
Normal file
118
frontend/src/components/UserMenu.jsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Popover
|
||||
width={200}
|
||||
position="bottom-end"
|
||||
withArrow
|
||||
shadow="md"
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Avatar
|
||||
radius="xl"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
>
|
||||
<IconUser size={24} />
|
||||
</Avatar>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm">
|
||||
{/* User Info Section */}
|
||||
<Group gap="sm">
|
||||
<Avatar radius="xl" size="md">
|
||||
<IconUser size={24} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{user.displayName || user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Menu Items */}
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
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],
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Group>
|
||||
<IconSettings size={16} />
|
||||
<Text size="sm">Account Settings</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<UnstyledButton
|
||||
onClick={handleLogout}
|
||||
px="sm"
|
||||
py="xs"
|
||||
color="red"
|
||||
style={(theme) => ({
|
||||
borderRadius: theme.radius.sm,
|
||||
'&:hover': {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[5]
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Group>
|
||||
<IconLogout size={16} color="red" />
|
||||
<Text size="sm" c="red">
|
||||
Logout
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<AccountSettings
|
||||
opened={accountSettingsOpened}
|
||||
onClose={() => setAccountSettingsOpened(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMenu;
|
||||
@@ -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 = () => {
|
||||
</Center>
|
||||
) : (
|
||||
workspaces.map((workspace) => {
|
||||
const isSelected = workspace.id === currentWorkspace?.id;
|
||||
const isSelected = workspace.name === currentWorkspace?.name;
|
||||
return (
|
||||
<Paper
|
||||
key={workspace.id}
|
||||
key={workspace.name}
|
||||
p="xs"
|
||||
withBorder
|
||||
style={{
|
||||
@@ -125,7 +125,7 @@ const WorkspaceSwitcher = () => {
|
||||
<UnstyledButton
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => {
|
||||
switchWorkspace(workspace.id);
|
||||
switchWorkspace(workspace.name);
|
||||
setPopoverOpened(false);
|
||||
}}
|
||||
>
|
||||
|
||||
120
frontend/src/contexts/AuthContext.jsx
Normal file
120
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (currentWorkspace) {
|
||||
initializeFile();
|
||||
}
|
||||
}, [currentWorkspace, loadLastOpenedFile, handleFileSelect]);
|
||||
|
||||
return { selectedFile, isNewFile, handleFileSelect };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
71
frontend/src/hooks/useProfileSettings.js
Normal file
71
frontend/src/hooks/useProfileSettings.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
97
frontend/src/services/authApi.js
Normal file
97
frontend/src/services/authApi.js
Normal file
@@ -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();
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user