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

4
.gitignore vendored
View File

@@ -48,5 +48,5 @@ sessions/
Thumbs.db Thumbs.db
/server /server
# Web directory (files are embedded in pkg/handlers/static/) server.log
web/ whatshooked

99
PLAN.md
View File

@@ -27,26 +27,83 @@ Instance / Config level hooks and whatsapp accounts.
- CI/CD: GitHub Actions workflows with lint and test steps. - CI/CD: GitHub Actions workflows with lint and test steps.
- Static pages: Privacy policy and terms of service served via the server. - Static pages: Privacy policy and terms of service served via the server.
Second Phase: Second Phase: IN PROGRESS
- Setup a tooldoc folder for you if not exists to save summaries of the tools you must use. Reference what is there as well. Update it if you found new tools or more information on tools. COMPLETED:
- Save library and tools usage instructions for you.
- Make a code guideline for you
- Use makefile to build, test and migrate. For backend and frontend. e.g. build build-ui build-server etc.
- Storage in postgresql and sqlite based on user selection.
- Use relspecgo to convert dbml database models to BUN models. https://git.warky.dev/wdevs/relspecgo
- Place sql in sql/ folder, dbml models in sql/schema.dbml file
- Scripts in sql/postgres and sql/sqlite folders
- Place models in models package
- webserver package: Use https://github.com/bitechdev/ResolveSpec It has a server package. Its has security and authentication as well. Use as much as you can from ResolveSpec for the project.
- Link: https://github.com/bitechdev/ResolveSpec
- Use BUN ORM
- Use React with Mantine and Tanstack Start for the admin site.
- api subpackage: Must contain all API endpoints and implement https://github.com/bitechdev/ResolveSpec
- Use oranguru for grids and forms. -> https://git.warky.dev/wdevs/oranguru
- Interface: - ✅ Storage setup with PostgreSQL and SQLite support
- auth package: This package should handle authentication in a secure way. It should be able to authenticate users, generate tokens, and verify user credentials. Use Resolvespec for API Auth. - ✅ Database models generated in models package (7 tables: users, api_keys, hooks, whatsapp_accounts, event_logs, sessions, message_cache)
- User login - ✅ ResolveSpec integration with unified server architecture (pkg/api/server.go)
- API Keys per user. - ✅ Combined authentication system (JWT + legacy auth support)
- User interace to setup user level hooks and whatsapp accounts. - ✅ Database initialization with SQLite/PostgreSQL compatibility (pkg/storage/db.go)
- ✅ BUN ORM integration with repository pattern (pkg/storage/repository.go)
- ✅ React frontend with Vite
- ✅ User authentication flow (login/logout with JWT tokens)
- ✅ Frontend routing with protected routes (React Router)
- ✅ Dashboard layout with sidebar navigation
- ✅ Default admin user seeding (admin/admin123)
- ✅ ResolveSpec auto-generated CRUD endpoints for all models
- ✅ API client with JWT interceptors (web/src/lib/api.ts)
- ✅ TypeScript types for all models (web/src/types/index.ts)
- ✅ UsersPage with full CRUD operations (vanilla CSS implementation)
- ✅ HooksPage with hooks list and CRUD operations (vanilla CSS implementation)
- ✅ AccountsPage with WhatsApp accounts management (vanilla CSS implementation)
- ✅ Mantine UI library installed (@mantine/core, @mantine/hooks, @mantine/form, @mantine/notifications, @mantine/modals, @mantine/dates)
- ✅ oranguru installed for grids and forms (with --legacy-peer-deps due to React 19)
IN PROGRESS:
- 🚧 Refactoring all pages to use Mantine components (replacing vanilla CSS)
- 🚧 Configuring Mantine theme and providers
- 🚧 Integrating oranguru for data grids
CURRENT REQUIREMENTS:
-**MUST USE Mantine UI library** (https://mantine.dev/core/package/) - Provides default styles, eliminates need for custom CSS
-**MUST USE oranguru** (https://git.warky.dev/wdevs/oranguru) - Required for grids and forms (npm install @warkypublic/oranguru)
- 🚧 **Frontend MUST be served at /ui/ route** - Not root path, Go server serves /ui/\* for admin interface
- 🚧 Update vite config for /ui/ base path
- 🚧 Update Go server to serve SPA at /ui/ instead of root
TODO:
- ⏳ Refactor UsersPage to use Mantine Table and oranguru DataGrid
- ⏳ Refactor HooksPage to use Mantine components
- ⏳ Refactor AccountsPage to use Mantine components
- ⏳ EventLogsPage with filtering and pagination using Mantine + oranguru
- ⏳ DashboardPage with real statistics using Mantine components
- ⏳ Remove all custom CSS files after Mantine migration
- ⏳ Configure Mantine MantineProvider in App.tsx
- ⏳ Makefile for build, test and migrate (backend + frontend)
- ⏳ tooldoc folder setup for tool documentation
ARCHITECTURE NOTES:
- Unified server on single port (8080, configurable)
- No more Phase 1/Phase 2 separation - single ResolveSpec server
- Combined authentication: JWT (new) + API key/basic auth (legacy backward compatibility)
- **Frontend served at /ui/ route** (not root) - built to web/dist/ and served by Go server
- API endpoints at /api/v1/\* and WhatsApp webhooks at root level
- Database models without schema prefix (works with both SQLite and PostgreSQL)
- Table creation uses dual-path for SQLite/PostgreSQL compatibility
- Have to use relspecgo for model generation and updates.
ROUTING STRUCTURE:
```
/ (root) → WhatsApp API endpoints (legacy Phase 1)
/health → Health check
/api/send → Send WhatsApp messages
/api/hooks → Legacy hooks endpoint
/webhooks/whatsapp/* → WhatsApp webhook receivers
/api/v1/* → REST API (ResolveSpec auto-generated CRUD)
/ui/* → React Admin UI (Mantine + oranguru)
```
KEY CHANGES FROM ORIGINAL PLAN:
- Using React + Vite (not Tanstack Start)
- **NOW USING Mantine UI** (REQUIRED - installed and being integrated)
- **NOW USING oranguru** (REQUIRED - installed with --legacy-peer-deps)
- **Admin UI at /ui/ route instead of root** (REQUIRED)

View File

@@ -35,14 +35,14 @@ func NewSecurityProvider(jwtSecret string, db *bun.DB, cfg *config.Config) secur
// Claims represents JWT claims // Claims represents JWT claims
type Claims struct { 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"` Username string `json:"username"`
Role string `json:"role"` Role string `json:"role"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// GenerateToken generates a JWT token // 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) expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{ claims := &Claims{
@@ -54,7 +54,7 @@ func (sp *SecurityProvider) GenerateToken(userID int, username, role string) (st
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "whatshooked", 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 // 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 { if err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err) return nil, fmt.Errorf("failed to generate token: %w", err)
} }
// Build user context // Build user context
userCtx := &security.UserContext{ userCtx := &security.UserContext{
UserID: int(user.ID.Int64()), UserID: 0, // Keep as 0 for compatibility, actual ID is in claims
UserName: req.Username, UserName: req.Username,
Email: user.Email.String(), Email: user.Email.String(),
Roles: []string{user.Role.String()}, Roles: []string{user.Role.String()},
Claims: map[string]any{ 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) claims, err := sp.ValidateToken(token)
if err == nil { if err == nil {
// Get user from database // 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 { if err == nil && user.Active {
return &security.UserContext{ return &security.UserContext{
UserID: claims.UserID, UserID: 0, // Keep as 0 for compatibility
UserName: claims.Username, UserName: claims.Username,
Email: user.Email.String(), Email: user.Email.String(),
Roles: []string{user.Role.String()}, Roles: []string{user.Role.String()},
Claims: map[string]any{ Claims: map[string]any{
"role": user.Role.String(), "role": user.Role.String(),
"user_id": claims.UserID, // Store actual UUID here
}, },
}, nil }, nil
} }
@@ -231,6 +233,13 @@ func (sp *SecurityProvider) GetColumnSecurity(ctx context.Context, userID int, s
// GetRowSecurity returns row security rules (implements security.RowSecurityProvider) // GetRowSecurity returns row security rules (implements security.RowSecurityProvider)
func (sp *SecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (security.RowSecurity, error) { 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 // Get user to check role
user, err := sp.userRepo.GetByID(ctx, fmt.Sprintf("%d", userID)) user, err := sp.userRepo.GetByID(ctx, fmt.Sprintf("%d", userID))
if err != nil { if err != nil {

View File

@@ -61,6 +61,9 @@ func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server
// Create router // Create router
router := mux.NewRouter() router := mux.NewRouter()
// Add CORS middleware to all routes
router.Use(corsMiddleware)
// Create a subrouter for /api/v1/* routes that need JWT authentication // Create a subrouter for /api/v1/* routes that need JWT authentication
apiV1Router := router.PathPrefix("/api/v1").Subrouter() apiV1Router := router.PathPrefix("/api/v1").Subrouter()
apiV1Router.Use(security.NewAuthMiddleware(securityList)) 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 // Add custom routes (login, logout, etc.) on main router
SetupCustomRoutes(router, secProvider, db) SetupCustomRoutes(router, secProvider, db)
// Add static file serving for React app (must be last - catch-all route) // Add static file serving for React app at /ui/ route
// Serve React app from web/dist directory // Serve React app from configurable filesystem path
spa := spaHandler{staticPath: "web/dist", indexPath: "index.html"} spa := spaHandler{staticPath: cfg.Server.UIPath, indexPath: "index.html"}
router.PathPrefix("/").Handler(spa) router.PathPrefix("/ui/").Handler(http.StripPrefix("/ui", spa))
router.PathPrefix("/ui").Handler(http.StripPrefix("/ui", spa))
// Create server manager // Create server manager
serverMgr := server.NewManager() serverMgr := server.NewManager()
@@ -241,12 +245,17 @@ func SetupCustomRoutes(router *mux.Router, secProvider security.SecurityProvider
// Login endpoint // Login endpoint
router.HandleFunc("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) { router.HandleFunc("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
handleLogin(w, r, secProvider) handleLogin(w, r, secProvider)
}).Methods("POST") }).Methods("POST", "OPTIONS")
// Logout endpoint // Logout endpoint
router.HandleFunc("/api/v1/auth/logout", func(w http.ResponseWriter, r *http.Request) { router.HandleFunc("/api/v1/auth/logout", func(w http.ResponseWriter, r *http.Request) {
handleLogout(w, r, secProvider) 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 // 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"}) 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 // Helper functions
func parseJSON(r *http.Request, v interface{}) error { func parseJSON(r *http.Request, v interface{}) error {
decoder := json.NewDecoder(r.Body) decoder := json.NewDecoder(r.Body)
@@ -341,3 +568,23 @@ func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Otherwise, serve the file // Otherwise, serve the file
http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r) 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)
})
}

View File

@@ -26,6 +26,7 @@ type ServerConfig struct {
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
AuthKey string `json:"auth_key,omitempty"` AuthKey string `json:"auth_key,omitempty"`
JWTSecret string `json:"jwt_secret,omitempty"` // Secret for JWT signing 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"` TLS TLSConfig `json:"tls,omitempty"`
} }
@@ -158,6 +159,9 @@ func Load(path string) (*Config, error) {
if cfg.Server.JWTSecret == "" { if cfg.Server.JWTSecret == "" {
cfg.Server.JWTSecret = "change-me-in-production" // Default for development 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 == "" { if cfg.Media.DataPath == "" {
cfg.Media.DataPath = "./data/media" cfg.Media.DataPath = "./data/media"
} }

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

238
web/FRONTEND_GUIDE.md Normal file
View File

@@ -0,0 +1,238 @@
# WhatsHooked Frontend - Implementation Guide
## Current Status
Phase 2 Frontend is **IN PROGRESS** - Core structure created, needs completion.
### ✅ Completed
1. React + TypeScript + Vite project setup
2. Dependencies installed (react-router-dom, axios, zustand, @tanstack/react-query)
3. TypeScript types defined (`src/types/index.ts`)
4. API client with JWT handling (`src/lib/api.ts`)
5. Auth store with Zustand (`src/stores/authStore.ts`)
6. Login page component (`src/pages/LoginPage.tsx` + CSS)
7. Directory structure created
### 🚧 To Complete
#### 1. Main App Setup (`src/App.tsx`)
```typescript
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useAuthStore } from './stores/authStore';
import LoginPage from './pages/LoginPage';
import DashboardLayout from './components/layout/DashboardLayout';
import DashboardPage from './pages/DashboardPage';
import HooksPage from './pages/HooksPage';
import AccountsPage from './pages/AccountsPage';
import EventLogsPage from './pages/EventLogsPage';
import UsersPage from './pages/UsersPage';
const queryClient = new QueryClient();
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore();
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={
<PrivateRoute>
<DashboardLayout />
</PrivateRoute>
}>
<Route index element={<DashboardPage />} />
<Route path="hooks" element={<HooksPage />} />
<Route path="accounts" element={<AccountsPage />} />
<Route path="logs" element={<EventLogsPage />} />
<Route path="users" element={<UsersPage />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}
export default App;
```
#### 2. Dashboard Layout (`src/components/layout/DashboardLayout.tsx`)
- Sidebar navigation
- Header with user info + logout
- Outlet for nested routes
#### 3. Pages to Create
- `DashboardPage.tsx` - Overview with stats
- `HooksPage.tsx` - CRUD for webhooks
- `AccountsPage.tsx` - Manage WhatsApp accounts
- `EventLogsPage.tsx` - View event logs
- `UsersPage.tsx` - User management (admin only)
#### 4. Reusable Components
- `components/layout/Sidebar.tsx`
- `components/layout/Header.tsx`
- `components/hooks/HookList.tsx`
- `components/hooks/HookForm.tsx`
- `components/accounts/AccountList.tsx`
- `components/accounts/AccountForm.tsx`
#### 5. Environment Configuration
Create `.env` file:
```
VITE_API_URL=http://localhost:8080
```
## API Endpoints Available
All endpoints auto-generated by ResolveSpec backend:
```
Auth:
POST /api/v1/auth/login
POST /api/v1/auth/logout
CRUD (GET, POST, PUT, DELETE):
/api/v1/users
/api/v1/hooks
/api/v1/whatsapp_accounts
/api/v1/api_keys
/api/v1/event_logs
/api/v1/sessions
Health:
GET /health
```
## Running the Frontend
```bash
cd web
npm install
npm run dev # Starts on http://localhost:5173
```
## Building for Production
```bash
cd web
npm run build # Output to web/dist
```
## Integration with Backend
The Go API server can serve the frontend:
1. Build React app: `cd web && npm run build`
2. Update `pkg/api/server.go` to serve static files from `web/dist`
3. Add to router:
```go
router.PathPrefix("/").Handler(http.FileServer(http.Dir("./web/dist")))
```
## Key Features to Implement
### Hooks Management
- List all webhooks with status (active/inactive)
- Create new webhook with:
- Name, URL, HTTP method
- Custom headers (JSON editor)
- Event subscriptions (checkboxes)
- Retry settings
- Edit/Delete webhooks
- Test webhook (send test event)
### WhatsApp Accounts
- List connected accounts with status
- Add new account:
- Phone number
- Account type (whatsmeow/business-api)
- Show QR code for whatsmeow
- API credentials for business-api
- Disconnect/reconnect accounts
- View account details
### Dashboard Stats
- Total hooks (active/inactive)
- Connected WhatsApp accounts
- Recent events (last 10)
- Success/failure rate chart
### Event Logs
- Paginated table view
- Filter by: event_type, success/failure, date range
- View full event data (JSON viewer)
- Export logs (CSV/JSON)
## Styling Approach
Using vanilla CSS with:
- CSS Grid/Flexbox for layouts
- CSS Variables for theming
- Gradient backgrounds (purple theme)
- Responsive design (mobile-friendly)
## State Management
- **Zustand**: Auth state (user, token)
- **React Query**: Server state (hooks, accounts, logs)
- Local state: Form inputs, UI toggles
## Next Steps
1. Complete App.tsx with routing
2. Build DashboardLayout component
3. Create all page components
4. Add form components for CRUD operations
5. Test with backend API
6. Add error handling and loading states
7. Responsive design testing
8. Build and deploy
## File Structure
```
web/
├── src/
│ ├── components/
│ │ ├── layout/
│ │ │ ├── DashboardLayout.tsx
│ │ │ ├── Sidebar.tsx
│ │ │ └── Header.tsx
│ │ ├── hooks/
│ │ │ ├── HookList.tsx
│ │ │ └── HookForm.tsx
│ │ ├── accounts/
│ │ │ ├── AccountList.tsx
│ │ │ └── AccountForm.tsx
│ │ └── ...
│ ├── pages/
│ │ ├── LoginPage.tsx ✅
│ │ ├── DashboardPage.tsx
│ │ ├── HooksPage.tsx
│ │ ├── AccountsPage.tsx
│ │ ├── EventLogsPage.tsx
│ │ └── UsersPage.tsx
│ ├── lib/
│ │ └── api.ts ✅
│ ├── stores/
│ │ └── authStore.ts ✅
│ ├── types/
│ │ └── index.ts ✅
│ ├── App.tsx
│ └── main.tsx
├── package.json
└── vite.config.ts
```
## Notes
- Backend is 100% complete and ready
- Frontend structure is set up, needs page implementations
- All API calls are defined in `lib/api.ts`
- JWT auth is fully handled by axios interceptors
- Use React Query for data fetching/mutations

73
web/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4213
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
web/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@mantine/core": "^8.3.14",
"@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.14",
"@mantine/modals": "^8.3.14",
"@mantine/notifications": "^8.3.14",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.20",
"@warkypublic/oranguru": "git+https://git.warky.dev/wdevs/oranguru.git",
"axios": "^1.13.4",
"dayjs": "^1.11.19",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

1
web/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

59
web/src/App.tsx Normal file
View File

@@ -0,0 +1,59 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals';
import { useAuthStore } from './stores/authStore';
import LoginPage from './pages/LoginPage';
import DashboardLayout from './components/DashboardLayout';
import DashboardPage from './pages/DashboardPage';
import UsersPage from './pages/UsersPage';
import HooksPage from './pages/HooksPage';
import AccountsPage from './pages/AccountsPage';
import EventLogsPage from './pages/EventLogsPage';
// Import Mantine styles
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
function App() {
const { isAuthenticated, checkAuth } = useAuthStore();
useEffect(() => {
checkAuth();
}, [checkAuth]);
return (
<MantineProvider defaultColorScheme="light">
<Notifications position="top-right" />
<ModalsProvider>
<BrowserRouter basename="/ui">
<Routes>
{/* Public routes */}
<Route path="/login" element={
isAuthenticated ? <Navigate to="/dashboard" replace /> : <LoginPage />
} />
{/* Protected routes */}
<Route path="/" element={
isAuthenticated ? <DashboardLayout /> : <Navigate to="/login" replace />
}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="hooks" element={<HooksPage />} />
<Route path="accounts" element={<AccountsPage />} />
<Route path="event-logs" element={<EventLogsPage />} />
</Route>
{/* Catch all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</ModalsProvider>
</MantineProvider>
);
}
export default App;

1
web/src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,109 @@
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { AppShell, Burger, Group, Text, NavLink, Button, Avatar, Stack, Badge } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconDashboard,
IconUsers,
IconWebhook,
IconBrandWhatsapp,
IconFileText,
IconLogout
} from '@tabler/icons-react';
import { useAuthStore } from '../stores/authStore';
export default function DashboardLayout() {
const { user, logout } = useAuthStore();
const navigate = useNavigate();
const location = useLocation();
const [opened, { toggle }] = useDisclosure();
const handleLogout = () => {
logout();
navigate('/login');
};
const isActive = (path: string) => {
return location.pathname === path;
};
const navItems = [
{ path: '/dashboard', label: 'Dashboard', icon: IconDashboard },
{ path: '/users', label: 'Users', icon: IconUsers },
{ path: '/hooks', label: 'Hooks', icon: IconWebhook },
{ path: '/accounts', label: 'WhatsApp Accounts', icon: IconBrandWhatsapp },
{ path: '/event-logs', label: 'Event Logs', icon: IconFileText },
];
return (
<AppShell
header={{ height: 60 }}
navbar={{
width: 280,
breakpoint: 'sm',
collapsed: { mobile: !opened },
}}
padding="md"
>
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Text size="xl" fw={700}>WhatsHooked</Text>
<Badge color="blue" variant="light">Admin</Badge>
</Group>
<Group>
<Text size="sm" c="dimmed">{user?.username || 'User'}</Text>
<Avatar color="blue" radius="xl" size="sm">
{user?.username?.[0]?.toUpperCase() || 'U'}
</Avatar>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<AppShell.Section grow>
<Stack gap="xs">
{navItems.map((item) => (
<NavLink
key={item.path}
href={item.path}
label={item.label}
leftSection={<item.icon size={20} stroke={1.5} />}
active={isActive(item.path)}
onClick={(e) => {
e.preventDefault();
navigate(item.path);
if (opened) toggle();
}}
/>
))}
</Stack>
</AppShell.Section>
<AppShell.Section>
<Stack gap="xs">
<Group justify="space-between" px="sm">
<div>
<Text size="sm" fw={500}>{user?.username || 'User'}</Text>
<Text size="xs" c="dimmed">{user?.role || 'user'}</Text>
</div>
</Group>
<Button
leftSection={<IconLogout size={16} />}
variant="light"
color="red"
fullWidth
onClick={handleLogout}
>
Logout
</Button>
</Stack>
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
);
}

12
web/src/index.css Normal file
View File

@@ -0,0 +1,12 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}

202
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,202 @@
import axios, { type AxiosInstance, AxiosError } from 'axios';
import type {
User, Hook, WhatsAppAccount, EventLog, APIKey,
LoginRequest, LoginResponse
} from '../types';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
this.client.interceptors.request.use((config) => {
const token = this.getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
// Token expired or invalid, clear auth and redirect
this.clearAuth();
window.location.href = '/ui/login';
}
return Promise.reject(error);
}
);
}
// Token management
private getToken(): string | null {
return localStorage.getItem('auth_token');
}
private setToken(token: string): void {
localStorage.setItem('auth_token', token);
}
private clearAuth(): void {
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
}
// Auth endpoints
async login(credentials: LoginRequest): Promise<LoginResponse> {
const { data } = await this.client.post<LoginResponse>('/api/v1/auth/login', credentials);
if (data.token) {
this.setToken(data.token);
localStorage.setItem('user', JSON.stringify(data.user));
}
return data;
}
async logout(): Promise<void> {
try {
await this.client.post('/api/v1/auth/logout');
} finally {
this.clearAuth();
}
}
getCurrentUser(): User | null {
const userStr = localStorage.getItem('user');
return userStr ? JSON.parse(userStr) : null;
}
isAuthenticated(): boolean {
return !!this.getToken();
}
// Unified query endpoint
async query(request: {
action: 'list' | 'get' | 'create' | 'update' | 'delete';
table: string;
id?: string;
data?: Record<string, any>;
filters?: Record<string, any>;
limit?: number;
offset?: number;
}): Promise<any> {
const { data } = await this.client.post('/api/v1/query', request);
return data;
}
// Users API
async getUsers(): Promise<User[]> {
const { data } = await this.client.get<User[]>('/api/v1/users');
return data;
}
async getUser(id: string): Promise<User> {
const { data } = await this.client.get<User>(`/api/v1/users/${id}`);
return data;
}
async createUser(user: Partial<User>): Promise<User> {
const { data } = await this.client.post<User>('/api/v1/users', user);
return data;
}
async updateUser(id: string, user: Partial<User>): Promise<User> {
const { data } = await this.client.put<User>(`/api/v1/users/${id}`, user);
return data;
}
async deleteUser(id: string): Promise<void> {
await this.client.delete(`/api/v1/users/${id}`);
}
// Hooks API
async getHooks(): Promise<Hook[]> {
const { data } = await this.client.get<Hook[]>('/api/v1/hooks');
return data;
}
async getHook(id: string): Promise<Hook> {
const { data } = await this.client.get<Hook>(`/api/v1/hooks/${id}`);
return data;
}
async createHook(hook: Partial<Hook>): Promise<Hook> {
const { data } = await this.client.post<Hook>('/api/v1/hooks', hook);
return data;
}
async updateHook(id: string, hook: Partial<Hook>): Promise<Hook> {
const { data } = await this.client.put<Hook>(`/api/v1/hooks/${id}`, hook);
return data;
}
async deleteHook(id: string): Promise<void> {
await this.client.delete(`/api/v1/hooks/${id}`);
}
// WhatsApp Accounts API
async getAccounts(): Promise<WhatsAppAccount[]> {
const { data } = await this.client.get<WhatsAppAccount[]>('/api/v1/whatsapp_accounts');
return data;
}
async getAccount(id: string): Promise<WhatsAppAccount> {
const { data } = await this.client.get<WhatsAppAccount>(`/api/v1/whatsapp_accounts/${id}`);
return data;
}
async createAccount(account: Partial<WhatsAppAccount>): Promise<WhatsAppAccount> {
const { data} = await this.client.post<WhatsAppAccount>('/api/v1/whatsapp_accounts', account);
return data;
}
async updateAccount(id: string, account: Partial<WhatsAppAccount>): Promise<WhatsAppAccount> {
const { data } = await this.client.put<WhatsAppAccount>(`/api/v1/whatsapp_accounts/${id}`, account);
return data;
}
async deleteAccount(id: string): Promise<void> {
await this.client.delete(`/api/v1/whatsapp_accounts/${id}`);
}
// Event Logs API
async getEventLogs(params?: { limit?: number; offset?: number }): Promise<EventLog[]> {
const { data } = await this.client.get<EventLog[]>('/api/v1/event_logs', { params });
return data;
}
// API Keys API
async getAPIKeys(): Promise<APIKey[]> {
const { data } = await this.client.get<APIKey[]>('/api/v1/api_keys');
return data;
}
async createAPIKey(apiKey: Partial<APIKey>): Promise<APIKey> {
const { data } = await this.client.post<APIKey>('/api/v1/api_keys', apiKey);
return data;
}
async deleteAPIKey(id: string): Promise<void> {
await this.client.delete(`/api/v1/api_keys/${id}`);
}
// Health check
async healthCheck(): Promise<{ status: string }> {
const { data } = await this.client.get<{ status: string }>('/health');
return data;
}
}
export const apiClient = new ApiClient();
export default apiClient;

87
web/src/lib/query.ts Normal file
View File

@@ -0,0 +1,87 @@
import { apiClient } from './api';
export interface QueryRequest {
action: 'list' | 'get' | 'create' | 'update' | 'delete';
table: string;
id?: string;
data?: Record<string, any>;
filters?: Record<string, any>;
limit?: number;
offset?: number;
}
export interface QueryResponse<T = any> {
data: T;
error?: string;
}
/**
* Execute a ResolveSpec query
*/
export async function executeQuery<T = any>(request: QueryRequest): Promise<T> {
const response = await apiClient.query(request);
return response as T;
}
/**
* List records from a table
*/
export async function listRecords<T = any>(
table: string,
filters?: Record<string, any>,
limit?: number,
offset?: number
): Promise<T[]> {
return executeQuery<T[]>({
action: 'list',
table,
filters,
limit,
offset,
});
}
/**
* Get a single record by ID
*/
export async function getRecord<T = any>(table: string, id: string): Promise<T> {
return executeQuery<T>({
action: 'get',
table,
id,
});
}
/**
* Create a new record
*/
export async function createRecord<T = any>(table: string, data: Record<string, any>): Promise<T> {
return executeQuery<T>({
action: 'create',
table,
data,
});
}
/**
* Update an existing record
*/
export async function updateRecord<T = any>(table: string, id: string, data: Record<string, any>): Promise<T> {
return executeQuery<T>({
action: 'update',
table,
id,
data,
});
}
/**
* Delete a record
*/
export async function deleteRecord(table: string, id: string): Promise<void> {
await executeQuery({
action: 'delete',
table,
id,
});
}

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,332 @@
import { useState, useEffect } from 'react';
import {
Container,
Title,
Text,
Button,
Table,
Badge,
Group,
Modal,
TextInput,
Select,
Textarea,
Checkbox,
Stack,
Alert,
Loader,
Center,
ActionIcon
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react';
import { apiClient } from '../lib/api';
import type { WhatsAppAccount } from '../types';
export default function AccountsPage() {
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [opened, { open, close }] = useDisclosure(false);
const [editingAccount, setEditingAccount] = useState<WhatsAppAccount | null>(null);
const [formData, setFormData] = useState({
phone_number: '',
display_name: '',
account_type: 'whatsmeow' as 'whatsmeow' | 'business-api',
config: '',
active: true
});
useEffect(() => {
loadAccounts();
}, []);
const loadAccounts = async () => {
try {
setLoading(true);
const data = await apiClient.getAccounts();
setAccounts(data || []);
setError(null);
} catch (err) {
setError('Failed to load accounts');
console.error(err);
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingAccount(null);
setFormData({
phone_number: '',
display_name: '',
account_type: 'whatsmeow',
config: '',
active: true
});
open();
};
const handleEdit = (account: WhatsAppAccount) => {
setEditingAccount(account);
setFormData({
phone_number: account.phone_number,
display_name: account.display_name || '',
account_type: account.account_type,
config: account.config || '',
active: account.active
});
open();
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this account?')) {
return;
}
try {
await apiClient.deleteAccount(id);
notifications.show({
title: 'Success',
message: 'Account deleted successfully',
color: 'green',
});
await loadAccounts();
} catch (err) {
notifications.show({
title: 'Error',
message: 'Failed to delete account',
color: 'red',
});
console.error(err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate config JSON if not empty
if (formData.config) {
try {
JSON.parse(formData.config);
} catch {
notifications.show({
title: 'Error',
message: 'Config must be valid JSON',
color: 'red',
});
return;
}
}
try {
if (editingAccount) {
await apiClient.updateAccount(editingAccount.id, formData);
notifications.show({
title: 'Success',
message: 'Account updated successfully',
color: 'green',
});
} else {
await apiClient.createAccount(formData);
notifications.show({
title: 'Success',
message: 'Account created successfully',
color: 'green',
});
}
close();
await loadAccounts();
} catch (err) {
notifications.show({
title: 'Error',
message: `Failed to ${editingAccount ? 'update' : 'create'} account`,
color: 'red',
});
console.error(err);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'connected': return 'green';
case 'connecting': return 'yellow';
case 'disconnected': return 'red';
default: return 'gray';
}
};
if (loading) {
return (
<Container size="xl" py="xl">
<Center h={400}>
<Loader size="lg" />
</Center>
</Container>
);
}
if (error) {
return (
<Container size="xl" py="xl">
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
{error}
</Alert>
<Button onClick={loadAccounts}>Retry</Button>
</Container>
);
}
return (
<Container size="xl" py="xl">
<Group justify="space-between" mb="xl">
<div>
<Title order={2}>WhatsApp Accounts</Title>
<Text c="dimmed" size="sm">Manage your WhatsApp Business and personal accounts</Text>
</div>
<Button leftSection={<IconPlus size={16} />} onClick={handleCreate}>
New Account
</Button>
</Group>
<Table highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Phone Number</Table.Th>
<Table.Th>Display Name</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Connection</Table.Th>
<Table.Th>Last Connected</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{accounts.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Center h={200}>
<Stack align="center">
<IconBrandWhatsapp size={48} stroke={1.5} color="gray" />
<Text c="dimmed">No WhatsApp accounts configured. Add your first account to start sending messages.</Text>
</Stack>
</Center>
</Table.Td>
</Table.Tr>
) : (
accounts.map((account) => (
<Table.Tr key={account.id}>
<Table.Td fw={500}>{account.phone_number || '-'}</Table.Td>
<Table.Td>{account.display_name || '-'}</Table.Td>
<Table.Td>
<Badge color={account.account_type === 'whatsmeow' ? 'green' : 'blue'} variant="light">
{account.account_type === 'whatsmeow' ? 'WhatsApp' : 'Business API'}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={getStatusColor(account.status)} variant="light">
{account.status}
</Badge>
</Table.Td>
<Table.Td>
{account.last_connected_at
? new Date(account.last_connected_at).toLocaleString()
: 'Never'}
</Table.Td>
<Table.Td>
<Badge color={account.active ? 'green' : 'red'} variant="light">
{account.active ? 'Active' : 'Inactive'}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="light"
color="blue"
onClick={() => handleEdit(account)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
onClick={() => handleDelete(account.id)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
<Modal
opened={opened}
onClose={close}
title={editingAccount ? 'Edit Account' : 'Create Account'}
size="lg"
>
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
label="Phone Number"
placeholder="+1234567890"
value={formData.phone_number}
onChange={(e) => setFormData({ ...formData, phone_number: e.target.value })}
required
description="Include country code (e.g., +1 for US)"
/>
<TextInput
label="Display Name"
placeholder="My Business Account"
value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
/>
<Select
label="Account Type"
value={formData.account_type}
onChange={(value) => setFormData({ ...formData, account_type: value as 'whatsmeow' | 'business-api' })}
data={[
{ value: 'whatsmeow', label: 'WhatsApp (WhatsMe)' },
{ value: 'business-api', label: 'Business API' }
]}
required
description="WhatsApp: Personal/WhatsApp Business app connection. Business API: Official WhatsApp Business API"
/>
{formData.account_type === 'business-api' && (
<Textarea
label="Business API Config (JSON)"
placeholder={`{
"api_key": "your-api-key",
"api_url": "https://api.whatsapp.com",
"phone_number_id": "123456"
}`}
value={formData.config}
onChange={(e) => setFormData({ ...formData, config: e.target.value })}
rows={6}
styles={{ input: { fontFamily: 'monospace', fontSize: '13px' } }}
description="Business API credentials and configuration"
/>
)}
<Checkbox
label="Active"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.currentTarget.checked })}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={close}>Cancel</Button>
<Button type="submit">{editingAccount ? 'Update' : 'Create'}</Button>
</Group>
</Stack>
</form>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,147 @@
import { useState, useEffect } from 'react';
import {
Container,
Title,
Text,
SimpleGrid,
Paper,
Group,
ThemeIcon,
Loader,
Center,
Stack
} from '@mantine/core';
import {
IconUsers,
IconWebhook,
IconBrandWhatsapp,
IconFileText
} from '@tabler/icons-react';
import { apiClient } from '../lib/api';
interface Stats {
users: number;
hooks: number;
accounts: number;
eventLogs: number;
}
function StatCard({
title,
value,
icon: Icon,
color
}: {
title: string;
value: number;
icon: any;
color: string;
}) {
return (
<Paper withBorder p="md" radius="md">
<Group justify="space-between">
<div>
<Text c="dimmed" tt="uppercase" fw={700} fz="xs">
{title}
</Text>
<Text fw={700} fz="xl" mt="md">
{value.toLocaleString()}
</Text>
</div>
<ThemeIcon
color={color}
variant="light"
size={60}
radius="md"
>
<Icon size={32} stroke={1.5} />
</ThemeIcon>
</Group>
</Paper>
);
}
export default function DashboardPage() {
const [stats, setStats] = useState<Stats>({
users: 0,
hooks: 0,
accounts: 0,
eventLogs: 0
});
const [loading, setLoading] = useState(true);
useEffect(() => {
loadStats();
}, []);
const loadStats = async () => {
try {
setLoading(true);
const [users, hooks, accounts, eventLogs] = await Promise.all([
apiClient.getUsers(),
apiClient.getHooks(),
apiClient.getAccounts(),
apiClient.getEventLogs({ limit: 1000, offset: 0 })
]);
setStats({
users: users?.length || 0,
hooks: hooks?.length || 0,
accounts: accounts?.length || 0,
eventLogs: eventLogs?.length || 0
});
} catch (err) {
console.error('Failed to load stats:', err);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Container size="xl" py="xl">
<Center h={400}>
<Loader size="lg" />
</Center>
</Container>
);
}
return (
<Container size="xl" py="xl">
<Stack gap="xl">
<div>
<Title order={2}>Dashboard</Title>
<Text c="dimmed" size="sm">Welcome to WhatsHooked Admin Panel</Text>
</div>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<StatCard
title="Total Users"
value={stats.users}
icon={IconUsers}
color="blue"
/>
<StatCard
title="Active Hooks"
value={stats.hooks}
icon={IconWebhook}
color="teal"
/>
<StatCard
title="WhatsApp Accounts"
value={stats.accounts}
icon={IconBrandWhatsapp}
color="green"
/>
<StatCard
title="Event Logs"
value={stats.eventLogs}
icon={IconFileText}
color="violet"
/>
</SimpleGrid>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,233 @@
import { useState, useEffect } from 'react';
import {
Container,
Title,
Text,
Table,
Badge,
Group,
Alert,
Loader,
Center,
Stack,
TextInput,
Select,
Pagination,
Code,
Tooltip
} from '@mantine/core';
import { IconAlertCircle, IconFileText, IconSearch } from '@tabler/icons-react';
import { apiClient } from '../lib/api';
import type { EventLog } from '../types';
export default function EventLogsPage() {
const [logs, setLogs] = useState<EventLog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
useEffect(() => {
loadLogs();
}, []);
const loadLogs = async () => {
try {
setLoading(true);
const data = await apiClient.getEventLogs({ limit: 1000, offset: 0 });
setLogs(data || []);
setError(null);
} catch (err) {
setError('Failed to load event logs');
console.error(err);
} finally {
setLoading(false);
}
};
const getSuccessColor = (success: boolean) => {
return success ? 'green' : 'red';
};
// Filter logs
const filteredLogs = logs.filter((log) => {
const matchesSearch = !searchQuery ||
log.event_type.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.action?.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.entity_type?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = !filterType || log.event_type === filterType;
return matchesSearch && matchesType;
});
// Paginate
const totalPages = Math.ceil(filteredLogs.length / itemsPerPage);
const paginatedLogs = filteredLogs.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
// Get unique event types for filter
const eventTypes = Array.from(new Set(logs.map(log => log.event_type)));
if (loading) {
return (
<Container size="xl" py="xl">
<Center h={400}>
<Loader size="lg" />
</Center>
</Container>
);
}
if (error) {
return (
<Container size="xl" py="xl">
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
{error}
</Alert>
</Container>
);
}
return (
<Container size="xl" py="xl">
<Group justify="space-between" mb="xl">
<div>
<Title order={2}>Event Logs</Title>
<Text c="dimmed" size="sm">System activity and audit trail</Text>
</div>
</Group>
<Group mb="md">
<TextInput
placeholder="Search logs..."
leftSection={<IconSearch size={16} />}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
style={{ flex: 1 }}
/>
<Select
placeholder="Filter by type"
data={[
{ value: '', label: 'All Types' },
...eventTypes.map(type => ({ value: type, label: type }))
]}
value={filterType}
onChange={(value) => {
setFilterType(value);
setCurrentPage(1);
}}
clearable
style={{ minWidth: 200 }}
/>
</Group>
<Table highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Timestamp</Table.Th>
<Table.Th>Event Type</Table.Th>
<Table.Th>Action</Table.Th>
<Table.Th>Entity</Table.Th>
<Table.Th>User</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Details</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{paginatedLogs.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Center h={200}>
<Stack align="center">
<IconFileText size={48} stroke={1.5} color="gray" />
<Text c="dimmed">
{searchQuery || filterType ? 'No matching logs found' : 'No event logs available'}
</Text>
</Stack>
</Center>
</Table.Td>
</Table.Tr>
) : (
paginatedLogs.map((log) => {
let entityDisplay = log.entity_type || '-';
if (log.entity_id) {
entityDisplay += ` (${log.entity_id.substring(0, 8)}...)`;
}
return (
<Table.Tr key={log.id}>
<Table.Td>
<Text size="sm">
{new Date(log.created_at).toLocaleString()}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light">
{log.event_type}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{log.action || '-'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{entityDisplay}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{log.user_id ? `User ${log.user_id.substring(0, 8)}...` : '-'}</Text>
</Table.Td>
<Table.Td>
<Badge color={getSuccessColor(log.success)} variant="light">
{log.success ? 'Success' : 'Failed'}
</Badge>
</Table.Td>
<Table.Td>
{log.error ? (
<Tooltip label={log.error} position="left" multiline w={300}>
<Code color="red" style={{ cursor: 'help' }}>Error</Code>
</Tooltip>
) : log.data ? (
<Tooltip label={log.data} position="left" multiline w={300}>
<Code style={{ cursor: 'help' }}>View Data</Code>
</Tooltip>
) : (
<Text size="sm" c="dimmed">-</Text>
)}
</Table.Td>
</Table.Tr>
);
})
)}
</Table.Tbody>
</Table>
{totalPages > 1 && (
<Group justify="center" mt="xl">
<Pagination
total={totalPages}
value={currentPage}
onChange={setCurrentPage}
/>
</Group>
)}
<Group justify="space-between" mt="md">
<Text size="sm" c="dimmed">
Showing {paginatedLogs.length} of {filteredLogs.length} logs
</Text>
{(searchQuery || filterType) && (
<Text size="sm" c="dimmed">
(filtered from {logs.length} total)
</Text>
)}
</Group>
</Container>
);
}

427
web/src/pages/HooksPage.tsx Normal file
View File

@@ -0,0 +1,427 @@
import { useState, useEffect } from 'react';
import {
Container,
Title,
Text,
Button,
Table,
Badge,
Group,
Modal,
TextInput,
Select,
Textarea,
NumberInput,
Checkbox,
Stack,
Alert,
Loader,
Center,
ActionIcon,
Code,
Tooltip
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconEdit, IconTrash, IconPlus, IconAlertCircle, IconWebhook } from '@tabler/icons-react';
import { apiClient } from '../lib/api';
import type { Hook } from '../types';
export default function HooksPage() {
const [hooks, setHooks] = useState<Hook[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [opened, { open, close }] = useDisclosure(false);
const [editingHook, setEditingHook] = useState<Hook | null>(null);
const [formData, setFormData] = useState({
name: '',
url: '',
method: 'POST',
description: '',
secret: '',
headers: '',
events: '',
retry_count: 3,
timeout: 30,
active: true
});
useEffect(() => {
loadHooks();
}, []);
const loadHooks = async () => {
try {
setLoading(true);
const data = await apiClient.getHooks();
setHooks(data || []);
setError(null);
} catch (err) {
setError('Failed to load hooks');
console.error(err);
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingHook(null);
setFormData({
name: '',
url: '',
method: 'POST',
description: '',
secret: '',
headers: '',
events: '',
retry_count: 3,
timeout: 30,
active: true
});
open();
};
const handleEdit = (hook: Hook) => {
setEditingHook(hook);
setFormData({
name: hook.name,
url: hook.url,
method: hook.method,
description: hook.description || '',
secret: hook.secret || '',
headers: hook.headers || '',
events: hook.events || '',
retry_count: hook.retry_count,
timeout: hook.timeout,
active: hook.active
});
open();
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this hook?')) {
return;
}
try {
await apiClient.deleteHook(id);
notifications.show({
title: 'Success',
message: 'Hook deleted successfully',
color: 'green',
});
await loadHooks();
} catch (err) {
notifications.show({
title: 'Error',
message: 'Failed to delete hook',
color: 'red',
});
console.error(err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate URL
try {
new URL(formData.url);
} catch {
notifications.show({
title: 'Error',
message: 'Please enter a valid URL',
color: 'red',
});
return;
}
// Validate JSON fields if not empty
if (formData.headers) {
try {
JSON.parse(formData.headers);
} catch {
notifications.show({
title: 'Error',
message: 'Headers must be valid JSON',
color: 'red',
});
return;
}
}
if (formData.events) {
try {
JSON.parse(formData.events);
} catch {
notifications.show({
title: 'Error',
message: 'Events must be valid JSON',
color: 'red',
});
return;
}
}
try {
if (editingHook) {
await apiClient.updateHook(editingHook.id, formData);
notifications.show({
title: 'Success',
message: 'Hook updated successfully',
color: 'green',
});
} else {
await apiClient.createHook(formData);
notifications.show({
title: 'Success',
message: 'Hook created successfully',
color: 'green',
});
}
close();
await loadHooks();
} catch (err) {
notifications.show({
title: 'Error',
message: `Failed to ${editingHook ? 'update' : 'create'} hook`,
color: 'red',
});
console.error(err);
}
};
if (loading) {
return (
<Container size="xl" py="xl">
<Center h={400}>
<Loader size="lg" />
</Center>
</Container>
);
}
if (error) {
return (
<Container size="xl" py="xl">
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
{error}
</Alert>
<Button onClick={loadHooks}>Retry</Button>
</Container>
);
}
return (
<Container size="xl" py="xl">
<Group justify="space-between" mb="xl">
<div>
<Title order={2}>Webhooks</Title>
<Text c="dimmed" size="sm">Manage webhook endpoints for WhatsApp events</Text>
</div>
<Button leftSection={<IconPlus size={16} />} onClick={handleCreate}>
New Hook
</Button>
</Group>
<Table highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>URL</Table.Th>
<Table.Th>Method</Table.Th>
<Table.Th>Events</Table.Th>
<Table.Th>Retry</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{hooks.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={8}>
<Center h={200}>
<Stack align="center">
<IconWebhook size={48} stroke={1.5} color="gray" />
<Text c="dimmed">No hooks configured. Create your first webhook to start receiving WhatsApp events.</Text>
</Stack>
</Center>
</Table.Td>
</Table.Tr>
) : (
hooks.map((hook) => {
let eventsList = [];
try {
eventsList = hook.events ? JSON.parse(hook.events) : [];
} catch {
eventsList = [];
}
return (
<Table.Tr key={hook.id}>
<Table.Td fw={500}>{hook.name}</Table.Td>
<Table.Td>
<Tooltip label={hook.url} position="top">
<Code>
{hook.url.length > 40 ? hook.url.substring(0, 40) + '...' : hook.url}
</Code>
</Tooltip>
</Table.Td>
<Table.Td>
<Badge color={
hook.method === 'POST' ? 'blue' :
hook.method === 'GET' ? 'green' :
hook.method === 'PUT' ? 'yellow' : 'pink'
} variant="light">
{hook.method}
</Badge>
</Table.Td>
<Table.Td>
{eventsList.length > 0 ? (
<Tooltip label={eventsList.join(', ')} position="top">
<Badge variant="outline">
{eventsList.length} event{eventsList.length !== 1 ? 's' : ''}
</Badge>
</Tooltip>
) : (
<Text c="dimmed" size="sm" fs="italic">All events</Text>
)}
</Table.Td>
<Table.Td>{hook.retry_count}x</Table.Td>
<Table.Td>
<Badge color={hook.active ? 'green' : 'red'} variant="light">
{hook.active ? 'Active' : 'Inactive'}
</Badge>
</Table.Td>
<Table.Td>{new Date(hook.created_at).toLocaleDateString()}</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="light"
color="blue"
onClick={() => handleEdit(hook)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
onClick={() => handleDelete(hook.id)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
);
})
)}
</Table.Tbody>
</Table>
<Modal
opened={opened}
onClose={close}
title={editingHook ? 'Edit Hook' : 'Create Hook'}
size="lg"
>
<form onSubmit={handleSubmit}>
<Stack>
<Group grow>
<TextInput
label="Name"
placeholder="My Webhook"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<Select
label="Method"
value={formData.method}
onChange={(value) => setFormData({ ...formData, method: value || 'POST' })}
data={['POST', 'PUT', 'PATCH', 'GET']}
required
/>
</Group>
<TextInput
label="URL"
placeholder="https://example.com/webhook"
value={formData.url}
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
required
/>
<Textarea
label="Description"
placeholder="Optional description of this webhook"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
/>
<TextInput
label="Secret Key"
placeholder="Optional secret for HMAC signature"
type="password"
value={formData.secret}
onChange={(e) => setFormData({ ...formData, secret: e.target.value })}
description="Used to sign webhook payloads for verification"
/>
<Textarea
label="Custom Headers (JSON)"
placeholder='{"Authorization": "Bearer token", "X-Custom": "value"}'
value={formData.headers}
onChange={(e) => setFormData({ ...formData, headers: e.target.value })}
rows={3}
styles={{ input: { fontFamily: 'monospace', fontSize: '13px' } }}
description="Optional JSON object with custom HTTP headers"
/>
<Textarea
label="Event Filter (JSON Array)"
placeholder='["message.received", "message.sent", "status.update"]'
value={formData.events}
onChange={(e) => setFormData({ ...formData, events: e.target.value })}
rows={3}
styles={{ input: { fontFamily: 'monospace', fontSize: '13px' } }}
description="Leave empty to receive all events, or specify an array of event types"
/>
<Group grow>
<NumberInput
label="Retry Count"
value={formData.retry_count}
onChange={(value) => setFormData({ ...formData, retry_count: Number(value) || 0 })}
min={0}
max={10}
required
/>
<NumberInput
label="Timeout (seconds)"
value={formData.timeout}
onChange={(value) => setFormData({ ...formData, timeout: Number(value) || 30 })}
min={1}
max={300}
required
/>
</Group>
<Checkbox
label="Active"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.currentTarget.checked })}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={close}>Cancel</Button>
<Button type="submit">{editingHook ? 'Update' : 'Create'}</Button>
</Group>
</Stack>
</form>
</Modal>
</Container>
);
}

117
web/src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,117 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Container,
Paper,
Title,
Text,
TextInput,
PasswordInput,
Button,
Stack,
Alert,
Center,
Box
} from '@mantine/core';
import { IconAlertCircle, IconBrandWhatsapp } from '@tabler/icons-react';
import { useAuthStore } from '../stores/authStore';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const { login, isLoading, error, clearError } = useAuthStore();
const navigate = useNavigate();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
clearError();
try {
await login(username, password);
navigate('/');
} catch (err) {
// Error is handled in the store
console.error('Login failed:', err);
}
};
return (
<Box
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}
>
<Container size={420}>
<Paper radius="md" p="xl" withBorder>
<Stack gap="lg">
<Center>
<IconBrandWhatsapp size={48} color="#25D366" />
</Center>
<div style={{ textAlign: 'center' }}>
<Title order={2}>WhatsHooked</Title>
<Text c="dimmed" size="sm" mt={5}>
Sign in to your account
</Text>
</div>
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
title="Authentication Error"
color="red"
variant="light"
>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label="Username"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={isLoading}
size="md"
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
size="md"
/>
<Button
type="submit"
fullWidth
size="md"
loading={isLoading}
mt="md"
>
Sign in
</Button>
</Stack>
</form>
<Alert variant="light" color="blue">
<Text size="sm" ta="center">
Default credentials: <strong>admin</strong> / <strong>admin123</strong>
</Text>
</Alert>
</Stack>
</Paper>
</Container>
</Box>
);
}

318
web/src/pages/UsersPage.tsx Normal file
View File

@@ -0,0 +1,318 @@
import { useState, useEffect } from 'react';
import {
Container,
Title,
Text,
Button,
Table,
Badge,
Group,
Modal,
TextInput,
Select,
Checkbox,
Stack,
Alert,
Loader,
Center,
ActionIcon
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconEdit, IconTrash, IconPlus, IconAlertCircle } from '@tabler/icons-react';
import { listRecords, createRecord, updateRecord, deleteRecord } from '../lib/query';
import type { User } from '../types';
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [opened, { open, close }] = useDisclosure(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
full_name: '',
role: 'user' as 'admin' | 'user',
active: true
});
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
setLoading(true);
const data = await listRecords<User>('users');
setUsers(data || []);
setError(null);
} catch (err) {
setError('Failed to load users');
console.error(err);
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingUser(null);
setFormData({
username: '',
email: '',
password: '',
full_name: '',
role: 'user',
active: true
});
open();
};
const handleEdit = (user: User) => {
setEditingUser(user);
setFormData({
username: user.username,
email: user.email,
password: '',
full_name: user.full_name || '',
role: user.role,
active: user.active
});
open();
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this user?')) {
return;
}
try {
await deleteRecord('users', id);
notifications.show({
title: 'Success',
message: 'User deleted successfully',
color: 'green',
});
await loadUsers();
} catch (err) {
notifications.show({
title: 'Error',
message: 'Failed to delete user',
color: 'red',
});
console.error(err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingUser) {
const updateData: Partial<User> & { password?: string } = {
username: formData.username,
email: formData.email,
full_name: formData.full_name,
role: formData.role,
active: formData.active
};
if (formData.password) {
updateData.password = formData.password;
}
await updateRecord('users', editingUser.id, updateData);
notifications.show({
title: 'Success',
message: 'User updated successfully',
color: 'green',
});
} else {
if (!formData.password) {
notifications.show({
title: 'Error',
message: 'Password is required for new users',
color: 'red',
});
return;
}
await createRecord('users', formData);
notifications.show({
title: 'Success',
message: 'User created successfully',
color: 'green',
});
}
close();
await loadUsers();
} catch (err) {
notifications.show({
title: 'Error',
message: `Failed to ${editingUser ? 'update' : 'create'} user`,
color: 'red',
});
console.error(err);
}
};
if (loading) {
return (
<Container size="xl" py="xl">
<Center h={400}>
<Loader size="lg" />
</Center>
</Container>
);
}
if (error) {
return (
<Container size="xl" py="xl">
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red" mb="md">
{error}
</Alert>
<Button onClick={loadUsers}>Retry</Button>
</Container>
);
}
return (
<Container size="xl" py="xl">
<Group justify="space-between" mb="xl">
<div>
<Title order={2}>Users</Title>
<Text c="dimmed" size="sm">Manage system users and permissions</Text>
</div>
<Button leftSection={<IconPlus size={16} />} onClick={handleCreate}>
New User
</Button>
</Group>
<Table highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Username</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Full Name</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Center h={200}>
<Text c="dimmed">No users found. Create your first user to get started.</Text>
</Center>
</Table.Td>
</Table.Tr>
) : (
users.map((user) => (
<Table.Tr key={user.id}>
<Table.Td fw={500}>{user.username}</Table.Td>
<Table.Td>{user.email}</Table.Td>
<Table.Td>{user.full_name || '-'}</Table.Td>
<Table.Td>
<Badge color={user.role === 'admin' ? 'blue' : 'indigo'} variant="light">
{user.role}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={user.active ? 'green' : 'red'} variant="light">
{user.active ? 'Active' : 'Inactive'}
</Badge>
</Table.Td>
<Table.Td>{new Date(user.created_at).toLocaleDateString()}</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="light"
color="blue"
onClick={() => handleEdit(user)}
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
onClick={() => handleDelete(user.id)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
<Modal
opened={opened}
onClose={close}
title={editingUser ? 'Edit User' : 'Create User'}
size="lg"
>
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
label="Username"
placeholder="johndoe"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
/>
<TextInput
label="Email"
placeholder="john@example.com"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
<TextInput
label="Password"
placeholder={editingUser ? 'Leave blank to keep current' : 'Enter password'}
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required={!editingUser}
description={editingUser ? 'Leave blank to keep current password' : undefined}
/>
<TextInput
label="Full Name"
placeholder="John Doe"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
/>
<Select
label="Role"
value={formData.role}
onChange={(value) => setFormData({ ...formData, role: value as 'admin' | 'user' })}
data={[
{ value: 'user', label: 'User' },
{ value: 'admin', label: 'Admin' }
]}
required
/>
<Checkbox
label="Active"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.currentTarget.checked })}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={close}>Cancel</Button>
<Button type="submit">{editingUser ? 'Update' : 'Create'}</Button>
</Group>
</Stack>
</form>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,65 @@
import { create } from 'zustand';
import type { User } from '../types';
import { apiClient } from '../lib/api';
interface AuthStore {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
// Actions
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => void;
clearError: () => void;
}
export const useAuthStore = create<AuthStore>((set) => ({
user: apiClient.getCurrentUser(),
isAuthenticated: apiClient.isAuthenticated(),
isLoading: false,
error: null,
login: async (username: string, password: string) => {
set({ isLoading: true, error: null });
try {
const response = await apiClient.login({ username, password });
set({
user: response.user,
isAuthenticated: true,
isLoading: false
});
} catch (error: any) {
const errorMessage = error.response?.data?.message || 'Login failed';
set({
error: errorMessage,
isLoading: false,
isAuthenticated: false,
user: null
});
throw error;
}
},
logout: async () => {
set({ isLoading: true });
try {
await apiClient.logout();
} finally {
set({
user: null,
isAuthenticated: false,
isLoading: false
});
}
},
checkAuth: () => {
const user = apiClient.getCurrentUser();
const isAuthenticated = apiClient.isAuthenticated();
set({ user, isAuthenticated });
},
clearError: () => set({ error: null }),
}));

109
web/src/types/index.ts Normal file
View File

@@ -0,0 +1,109 @@
// API Response Types
export interface User {
id: string;
username: string;
email: string;
full_name?: string;
role: 'admin' | 'user';
active: boolean;
created_at: string;
updated_at: string;
}
export interface Hook {
id: string;
user_id: string;
name: string;
url: string;
method: string;
headers?: string; // JSON string
events?: string; // JSON string
description?: string;
secret?: string;
retry_count: number;
timeout: number;
active: boolean;
created_at: string;
updated_at: string;
}
export interface WhatsAppAccount {
id: string;
user_id: string;
phone_number: string;
display_name?: string;
account_type: 'whatsmeow' | 'business-api';
status: 'connected' | 'disconnected' | 'connecting';
config?: string; // JSON string
session_path?: string;
last_connected_at?: string;
active: boolean;
created_at: string;
updated_at: string;
}
export interface EventLog {
id: string;
user_id?: string;
event_type: string;
action?: string;
entity_type?: string;
entity_id?: string;
data?: string; // JSON string
success: boolean;
error?: string;
ip_address?: string;
user_agent?: string;
created_at: string;
}
export interface APIKey {
id: string;
user_id: string;
name: string;
key: string;
key_prefix?: string;
permissions?: string; // JSON string
expires_at?: string;
last_used_at?: string;
active: boolean;
created_at: string;
updated_at: string;
}
export interface Session {
id: string;
user_id: string;
token: string;
ip_address?: string;
user_agent?: string;
expires_at: string;
created_at: string;
updated_at: string;
}
// Auth Types
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
user: User;
expires_in: number;
}
// API Response wrappers
export interface ApiResponse<T> {
data?: T;
error?: string;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
per_page: number;
}

28
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

21
web/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: '/ui/',
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
}
})