feat(ui): 🎉 More ui work
Some checks failed
CI / Test (1.23) (push) Failing after -22m35s
CI / Test (1.22) (push) Failing after -22m33s
CI / Build (push) Failing after -23m42s
CI / Lint (push) Failing after -23m17s

* 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:
Hein
2026-02-05 19:41:49 +02:00
parent f9773bd07f
commit 8b1eed6c42
32 changed files with 7293 additions and 38 deletions

View File

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