Merge pull request #14 from LordMathis/feat/user-auth

User authentication and account settings
This commit is contained in:
2024-11-06 21:56:48 +01:00
committed by GitHub
45 changed files with 2353 additions and 357 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,48 +1,82 @@
package api
import (
"novamd/internal/auth"
"novamd/internal/db"
"novamd/internal/filesystem"
"novamd/internal/handlers"
"novamd/internal/middleware"
"github.com/go-chi/chi/v5"
)
func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) {
r.Route("/", func(r chi.Router) {
// User routes
r.Route("/users/{userId}", func(r chi.Router) {
r.Get("/", GetUser(db))
func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem, authMiddleware *auth.Middleware, sessionService *auth.SessionService) {
// Workspace routes
r.Route("/workspaces", func(r chi.Router) {
r.Get("/", ListWorkspaces(db))
r.Post("/", CreateWorkspace(db, fs))
r.Get("/last", GetLastWorkspace(db))
r.Put("/last", UpdateLastWorkspace(db))
handler := &handlers.Handler{
DB: db,
FS: fs,
}
r.Route("/{workspaceId}", func(r chi.Router) {
r.Get("/", GetWorkspace(db))
r.Put("/", UpdateWorkspace(db, fs))
r.Delete("/", DeleteWorkspace(db))
// Public routes (no authentication required)
r.Group(func(r chi.Router) {
r.Post("/auth/login", handler.Login(sessionService))
r.Post("/auth/refresh", handler.RefreshToken(sessionService))
})
// File routes
r.Route("/files", func(r chi.Router) {
r.Get("/", ListFiles(fs))
r.Get("/last", GetLastOpenedFile(db))
r.Put("/last", UpdateLastOpenedFile(db, fs))
r.Get("/lookup", LookupFileByName(fs)) // Moved here
// Protected routes (authentication required)
r.Group(func(r chi.Router) {
// Apply authentication middleware to all routes in this group
r.Use(authMiddleware.Authenticate)
r.Use(middleware.WithUserContext)
r.Post("/*", SaveFile(fs))
r.Get("/*", GetFileContent(fs))
r.Delete("/*", DeleteFile(fs))
// Auth routes
r.Post("/auth/logout", handler.Logout(sessionService))
r.Get("/auth/me", handler.GetCurrentUser())
})
// User profile routes
r.Put("/profile", handler.UpdateProfile())
r.Delete("/profile", handler.DeleteAccount())
// Git routes
r.Route("/git", func(r chi.Router) {
r.Post("/commit", StageCommitAndPush(fs))
r.Post("/pull", PullChanges(fs))
})
// Admin-only routes
r.Group(func(r chi.Router) {
r.Use(authMiddleware.RequireRole("admin"))
// r.Get("/admin/users", ListUsers(db))
// r.Post("/admin/users", CreateUser(db))
// r.Delete("/admin/users/{userId}", DeleteUser(db))
})
// Workspace routes
r.Route("/workspaces", func(r chi.Router) {
r.Get("/", handler.ListWorkspaces())
r.Post("/", handler.CreateWorkspace())
r.Get("/last", handler.GetLastWorkspaceName())
r.Put("/last", handler.UpdateLastWorkspaceName())
// Single workspace routes
r.Route("/{workspaceName}", func(r chi.Router) {
r.Use(middleware.WithWorkspaceContext(db))
r.Use(authMiddleware.RequireWorkspaceAccess)
r.Get("/", handler.GetWorkspace())
r.Put("/", handler.UpdateWorkspace())
r.Delete("/", handler.DeleteWorkspace())
// File routes
r.Route("/files", func(r chi.Router) {
r.Get("/", handler.ListFiles())
r.Get("/last", handler.GetLastOpenedFile())
r.Put("/last", handler.UpdateLastOpenedFile())
r.Get("/lookup", handler.LookupFileByName())
r.Post("/*", handler.SaveFile())
r.Get("/*", handler.GetFileContent())
r.Delete("/*", handler.DeleteFile())
})
// Git routes
r.Route("/git", func(r chi.Router) {
r.Post("/commit", handler.StageCommitAndPush())
r.Post("/pull", handler.PullChanges())
})
})
})

View File

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

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

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

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

View File

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

View File

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

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

View File

@@ -82,11 +82,11 @@ func (db *DB) GetUserByID(id int) (*models.User, error) {
user := &models.User{}
err := db.QueryRow(`
SELECT
id, email, display_name, role, created_at,
id, email, display_name, password_hash, role, created_at,
last_workspace_id
FROM users
WHERE id = ?`, id).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.CreatedAt,
Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt,
&user.LastWorkspaceID)
if err != nil {
return nil, err
@@ -114,15 +114,32 @@ func (db *DB) GetUserByEmail(email string) (*models.User, error) {
func (db *DB) UpdateUser(user *models.User) error {
_, err := db.Exec(`
UPDATE users
SET email = ?, display_name = ?, role = ?, last_workspace_id = ?
SET email = ?, display_name = ?, password_hash = ?, role = ?, last_workspace_id = ?
WHERE id = ?`,
user.Email, user.DisplayName, user.Role, user.LastWorkspaceID, user.ID)
user.Email, user.DisplayName, user.PasswordHash, user.Role, user.LastWorkspaceID, user.ID)
return err
}
func (db *DB) UpdateLastWorkspace(userID, workspaceID int) error {
_, err := db.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID)
return err
func (db *DB) UpdateLastWorkspace(userID int, workspaceName string) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var workspaceID int
err = tx.QueryRow("SELECT id FROM workspaces WHERE user_id = ? AND name = ?", userID, workspaceName).Scan(&workspaceID)
if err != nil {
return err
}
_, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID)
if err != nil {
return err
}
return tx.Commit()
}
func (db *DB) DeleteUser(id int) error {
@@ -147,8 +164,14 @@ func (db *DB) DeleteUser(id int) error {
return tx.Commit()
}
func (db *DB) GetLastWorkspaceID(userID int) (int, error) {
var workspaceID int
err := db.QueryRow("SELECT last_workspace_id FROM users WHERE id = ?", userID).Scan(&workspaceID)
return workspaceID, err
func (db *DB) GetLastWorkspaceName(userID int) (string, error) {
var workspaceName string
err := db.QueryRow(`
SELECT
w.name
FROM workspaces w
JOIN users u ON u.last_workspace_id = w.id
WHERE u.id = ?`, userID).
Scan(&workspaceName)
return workspaceName, err
}

View File

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

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

View File

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

View File

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

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

View File

@@ -1,4 +1,4 @@
package api
package handlers
import (
"net/http"

View 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"})
}
}

View File

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

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

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

View File

@@ -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",

View File

@@ -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",

View File

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

View 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;

View File

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

View 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;

View File

@@ -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(() => {

View 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;

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,9 @@ export const useFileNavigation = () => {
// Load last opened file when workspace changes
useEffect(() => {
const initializeFile = async () => {
setSelectedFile(DEFAULT_FILE.path);
setIsNewFile(true);
const lastFile = await loadLastOpenedFile();
if (lastFile) {
handleFileSelect(lastFile);
@@ -33,7 +36,9 @@ export const useFileNavigation = () => {
}
};
initializeFile();
if (currentWorkspace) {
initializeFile();
}
}, [currentWorkspace, loadLastOpenedFile, handleFileSelect]);
return { selectedFile, isNewFile, handleFileSelect };

View File

@@ -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',

View File

@@ -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',

View File

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

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

View File

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

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

View File

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

View File

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