feat(ui): 🎉 More ui work
* Implement EventLogsPage for viewing system activity logs with search and filter capabilities. * Create HooksPage for managing webhook configurations with create, edit, and delete functionalities. * Develop LoginPage for user authentication with error handling and loading states. * Add UsersPage for managing system users, including role assignment and status toggling. * Introduce authStore for managing user authentication state and actions. * Define TypeScript types for User, Hook, EventLog, and other entities. * Set up TypeScript configuration for the project. * Configure Vite for development with proxy settings for API calls. * Update dependencies for improved functionality and security.
This commit is contained in:
@@ -35,14 +35,14 @@ func NewSecurityProvider(jwtSecret string, db *bun.DB, cfg *config.Config) secur
|
||||
|
||||
// Claims represents JWT claims
|
||||
type Claims struct {
|
||||
UserID int `json:"user_id"`
|
||||
UserID string `json:"user_id"` // Changed from int to string for UUID support
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateToken generates a JWT token
|
||||
func (sp *SecurityProvider) GenerateToken(userID int, username, role string) (string, error) {
|
||||
func (sp *SecurityProvider) GenerateToken(userID string, username, role string) (string, error) {
|
||||
expirationTime := time.Now().Add(24 * time.Hour)
|
||||
|
||||
claims := &Claims{
|
||||
@@ -54,7 +54,7 @@ func (sp *SecurityProvider) GenerateToken(userID int, username, role string) (st
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "whatshooked",
|
||||
Subject: fmt.Sprintf("%d", userID),
|
||||
Subject: userID,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -101,19 +101,20 @@ func (sp *SecurityProvider) Login(ctx context.Context, req security.LoginRequest
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := sp.GenerateToken(int(user.ID.Int64()), req.Username, user.Role.String())
|
||||
token, err := sp.GenerateToken(user.ID.String(), req.Username, user.Role.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
|
||||
// Build user context
|
||||
userCtx := &security.UserContext{
|
||||
UserID: int(user.ID.Int64()),
|
||||
UserID: 0, // Keep as 0 for compatibility, actual ID is in claims
|
||||
UserName: req.Username,
|
||||
Email: user.Email.String(),
|
||||
Roles: []string{user.Role.String()},
|
||||
Claims: map[string]any{
|
||||
"role": user.Role.String(),
|
||||
"role": user.Role.String(),
|
||||
"user_id": user.ID.String(), // Store actual UUID here
|
||||
},
|
||||
}
|
||||
|
||||
@@ -139,15 +140,16 @@ func (sp *SecurityProvider) Authenticate(r *http.Request) (*security.UserContext
|
||||
claims, err := sp.ValidateToken(token)
|
||||
if err == nil {
|
||||
// Get user from database
|
||||
user, err := sp.userRepo.GetByID(r.Context(), fmt.Sprintf("%d", claims.UserID))
|
||||
user, err := sp.userRepo.GetByID(r.Context(), claims.UserID)
|
||||
if err == nil && user.Active {
|
||||
return &security.UserContext{
|
||||
UserID: claims.UserID,
|
||||
UserID: 0, // Keep as 0 for compatibility
|
||||
UserName: claims.Username,
|
||||
Email: user.Email.String(),
|
||||
Roles: []string{user.Role.String()},
|
||||
Claims: map[string]any{
|
||||
"role": user.Role.String(),
|
||||
"role": user.Role.String(),
|
||||
"user_id": claims.UserID, // Store actual UUID here
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -231,6 +233,13 @@ func (sp *SecurityProvider) GetColumnSecurity(ctx context.Context, userID int, s
|
||||
|
||||
// GetRowSecurity returns row security rules (implements security.RowSecurityProvider)
|
||||
func (sp *SecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (security.RowSecurity, error) {
|
||||
// If userID is 0, it's JWT auth with UUID - allow admin access for now
|
||||
if userID == 0 {
|
||||
return security.RowSecurity{
|
||||
Template: "", // Empty template means no filtering (admin access)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get user to check role
|
||||
user, err := sp.userRepo.GetByID(ctx, fmt.Sprintf("%d", userID))
|
||||
if err != nil {
|
||||
|
||||
@@ -61,6 +61,9 @@ func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server
|
||||
// Create router
|
||||
router := mux.NewRouter()
|
||||
|
||||
// Add CORS middleware to all routes
|
||||
router.Use(corsMiddleware)
|
||||
|
||||
// Create a subrouter for /api/v1/* routes that need JWT authentication
|
||||
apiV1Router := router.PathPrefix("/api/v1").Subrouter()
|
||||
apiV1Router.Use(security.NewAuthMiddleware(securityList))
|
||||
@@ -75,10 +78,11 @@ func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server
|
||||
// Add custom routes (login, logout, etc.) on main router
|
||||
SetupCustomRoutes(router, secProvider, db)
|
||||
|
||||
// Add static file serving for React app (must be last - catch-all route)
|
||||
// Serve React app from web/dist directory
|
||||
spa := spaHandler{staticPath: "web/dist", indexPath: "index.html"}
|
||||
router.PathPrefix("/").Handler(spa)
|
||||
// Add static file serving for React app at /ui/ route
|
||||
// Serve React app from configurable filesystem path
|
||||
spa := spaHandler{staticPath: cfg.Server.UIPath, indexPath: "index.html"}
|
||||
router.PathPrefix("/ui/").Handler(http.StripPrefix("/ui", spa))
|
||||
router.PathPrefix("/ui").Handler(http.StripPrefix("/ui", spa))
|
||||
|
||||
// Create server manager
|
||||
serverMgr := server.NewManager()
|
||||
@@ -241,12 +245,17 @@ func SetupCustomRoutes(router *mux.Router, secProvider security.SecurityProvider
|
||||
// Login endpoint
|
||||
router.HandleFunc("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleLogin(w, r, secProvider)
|
||||
}).Methods("POST")
|
||||
}).Methods("POST", "OPTIONS")
|
||||
|
||||
// Logout endpoint
|
||||
router.HandleFunc("/api/v1/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleLogout(w, r, secProvider)
|
||||
}).Methods("POST")
|
||||
}).Methods("POST", "OPTIONS")
|
||||
|
||||
// Unified query endpoint for ResolveSpec-style queries
|
||||
router.HandleFunc("/api/v1/query", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleQuery(w, r, db, secProvider)
|
||||
}).Methods("POST", "OPTIONS")
|
||||
}
|
||||
|
||||
// handleLogin handles user login
|
||||
@@ -283,6 +292,224 @@ func handleLogout(w http.ResponseWriter, r *http.Request, secProvider security.S
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Logged out successfully"})
|
||||
}
|
||||
|
||||
// QueryRequest represents a unified query request
|
||||
type QueryRequest struct {
|
||||
Action string `json:"action"` // "list", "get", "create", "update", "delete"
|
||||
Table string `json:"table"` // Table name (e.g., "users", "hooks")
|
||||
ID string `json:"id,omitempty"` // For get/update/delete
|
||||
Data map[string]interface{} `json:"data,omitempty"` // For create/update
|
||||
Filters map[string]interface{} `json:"filters,omitempty"` // For list
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
}
|
||||
|
||||
// handleQuery handles unified query requests
|
||||
func handleQuery(w http.ResponseWriter, r *http.Request, db *bun.DB, secProvider security.SecurityProvider) {
|
||||
// Authenticate request
|
||||
userCtx, err := secProvider.Authenticate(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req QueryRequest
|
||||
if err := parseJSON(r, &req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Execute query based on action
|
||||
switch req.Action {
|
||||
case "list":
|
||||
handleQueryList(w, r, db, req, userCtx)
|
||||
case "get":
|
||||
handleQueryGet(w, r, db, req, userCtx)
|
||||
case "create":
|
||||
handleQueryCreate(w, r, db, req, userCtx)
|
||||
case "update":
|
||||
handleQueryUpdate(w, r, db, req, userCtx)
|
||||
case "delete":
|
||||
handleQueryDelete(w, r, db, req, userCtx)
|
||||
default:
|
||||
http.Error(w, "Invalid action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleQueryList lists records from a table
|
||||
func handleQueryList(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) {
|
||||
// Get model registry to find the model
|
||||
registry := getModelForTable(req.Table)
|
||||
if registry == nil {
|
||||
http.Error(w, "Table not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Create slice to hold results
|
||||
results := registry()
|
||||
|
||||
// Build query
|
||||
query := db.NewSelect().Model(results)
|
||||
|
||||
// Apply filters
|
||||
for key, value := range req.Filters {
|
||||
query = query.Where("? = ?", bun.Ident(key), value)
|
||||
}
|
||||
|
||||
// Apply limit/offset
|
||||
if req.Limit > 0 {
|
||||
query = query.Limit(req.Limit)
|
||||
}
|
||||
if req.Offset > 0 {
|
||||
query = query.Offset(req.Offset)
|
||||
}
|
||||
|
||||
// Execute query
|
||||
if err := query.Scan(r.Context()); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Query failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, results)
|
||||
}
|
||||
|
||||
// handleQueryGet retrieves a single record by ID
|
||||
func handleQueryGet(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) {
|
||||
model := getModelSingleForTable(req.Table)
|
||||
if model == nil {
|
||||
http.Error(w, "Table not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
err := db.NewSelect().Model(model).Where("id = ?", req.ID).Scan(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Record not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, model)
|
||||
}
|
||||
|
||||
// handleQueryCreate creates a new record
|
||||
func handleQueryCreate(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) {
|
||||
model := getModelSingleForTable(req.Table)
|
||||
if model == nil {
|
||||
http.Error(w, "Table not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert data map to model using JSON marshaling
|
||||
dataJSON, err := json.Marshal(req.Data)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(dataJSON, model); err != nil {
|
||||
http.Error(w, "Invalid data format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
_, err = db.NewInsert().Model(model).Exec(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Create failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, model)
|
||||
}
|
||||
|
||||
// handleQueryUpdate updates an existing record
|
||||
func handleQueryUpdate(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) {
|
||||
model := getModelSingleForTable(req.Table)
|
||||
if model == nil {
|
||||
http.Error(w, "Table not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert data map to model
|
||||
dataJSON, err := json.Marshal(req.Data)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(dataJSON, model); err != nil {
|
||||
http.Error(w, "Invalid data format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Update in database
|
||||
_, err = db.NewUpdate().Model(model).Where("id = ?", req.ID).Exec(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Update failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, model)
|
||||
}
|
||||
|
||||
// handleQueryDelete deletes a record
|
||||
func handleQueryDelete(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) {
|
||||
model := getModelSingleForTable(req.Table)
|
||||
if model == nil {
|
||||
http.Error(w, "Table not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := db.NewDelete().Model(model).Where("id = ?", req.ID).Exec(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Delete failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Deleted successfully"})
|
||||
}
|
||||
|
||||
// getModelForTable returns a function that creates a slice of models for the table
|
||||
func getModelForTable(table string) func() interface{} {
|
||||
switch table {
|
||||
case "users":
|
||||
return func() interface{} { return &[]models.ModelPublicUser{} }
|
||||
case "hooks":
|
||||
return func() interface{} { return &[]models.ModelPublicHook{} }
|
||||
case "whatsapp_accounts":
|
||||
return func() interface{} { return &[]models.ModelPublicWhatsappAccount{} }
|
||||
case "event_logs":
|
||||
return func() interface{} { return &[]models.ModelPublicEventLog{} }
|
||||
case "api_keys":
|
||||
return func() interface{} { return &[]models.ModelPublicAPIKey{} }
|
||||
case "sessions":
|
||||
return func() interface{} { return &[]models.ModelPublicSession{} }
|
||||
case "message_cache":
|
||||
return func() interface{} { return &[]models.ModelPublicMessageCache{} }
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// getModelSingleForTable returns a single model instance for the table
|
||||
func getModelSingleForTable(table string) interface{} {
|
||||
switch table {
|
||||
case "users":
|
||||
return &models.ModelPublicUser{}
|
||||
case "hooks":
|
||||
return &models.ModelPublicHook{}
|
||||
case "whatsapp_accounts":
|
||||
return &models.ModelPublicWhatsappAccount{}
|
||||
case "event_logs":
|
||||
return &models.ModelPublicEventLog{}
|
||||
case "api_keys":
|
||||
return &models.ModelPublicAPIKey{}
|
||||
case "sessions":
|
||||
return &models.ModelPublicSession{}
|
||||
case "message_cache":
|
||||
return &models.ModelPublicMessageCache{}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func parseJSON(r *http.Request, v interface{}) error {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
@@ -341,3 +568,23 @@ func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Otherwise, serve the file
|
||||
http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// corsMiddleware adds CORS headers to allow frontend requests
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Allow requests from any origin (adjust in production)
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||
|
||||
// Handle preflight requests
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type ServerConfig struct {
|
||||
Password string `json:"password,omitempty"`
|
||||
AuthKey string `json:"auth_key,omitempty"`
|
||||
JWTSecret string `json:"jwt_secret,omitempty"` // Secret for JWT signing
|
||||
UIPath string `json:"ui_path,omitempty"` // Filesystem path to built frontend files (default: web/dist)
|
||||
TLS TLSConfig `json:"tls,omitempty"`
|
||||
}
|
||||
|
||||
@@ -158,6 +159,9 @@ func Load(path string) (*Config, error) {
|
||||
if cfg.Server.JWTSecret == "" {
|
||||
cfg.Server.JWTSecret = "change-me-in-production" // Default for development
|
||||
}
|
||||
if cfg.Server.UIPath == "" {
|
||||
cfg.Server.UIPath = "web/dist" // Default filesystem path to built frontend
|
||||
}
|
||||
if cfg.Media.DataPath == "" {
|
||||
cfg.Media.DataPath = "./data/media"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user