diff --git a/USER_MANAGEMENT.md b/USER_MANAGEMENT.md new file mode 100644 index 0000000..8b76bfc --- /dev/null +++ b/USER_MANAGEMENT.md @@ -0,0 +1,124 @@ +# 🛡️ FiremeX — User Management System Documentation + +## Executive Overview +FiremeX implements a **Multi-Tenant, Role-Based User Management System** built using **Go (Gin framework)**, **GORM ORM**, **PostgreSQL**, and **Preact (TypeScript)**. + +The architecture supports multi-tenant organization boundaries, automated Organization Code generation, an admin approval gate for operators, and strict JWT authentication coupled with Role-Based Access Control (RBAC) middleware. + +--- + +## 👥 Roles & Permission Matrix + +| Role | Default Status | Scope | Permissions & Capabilities | +|---|---|---|---| +| **`admin`** | `active` | Organization-wide | Full control. Registers company profile, views system dashboards, configures CCTV hardware, approves/denies/revokes operators. | +| **`operator`** | `pending` | Assigned Organization | On-duty monitoring agent. Views live video streams, receives real-time AI fire alerts, acknowledges alerts, and updates incident statuses. | + +--- + +## 🔄 User Lifecycle & Approval Workflow + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ REGISTRATION GATEWAY │ +└────────────────────────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ + ┌────────────────────────────┐ ┌────────────────────────────┐ + │ 1. REGISTER ORGANIZATION │ │ 2. JOIN AS AN OPERATOR │ + └────────────────────────────┘ └────────────────────────────┘ + │ │ + Creates Organization record Requires valid Organization Code + + Admin user (Status: ACTIVE) Creates Operator (Status: PENDING) + │ │ + ▼ ▼ + Admin logs in immediately Operator login is BLOCKED + Accesses Admin Dashboard ("Pending Admin Approval") + │ │ + │ ┌──────────────────────┐ │ + └───►│ ADMIN APPROVAL PANEL │◄───┘ + └──────────────────────┘ + │ + ┌─────────────┴─────────────┐ + ▼ ▼ + [APPROVE OPERATOR] [DENY OPERATOR] + │ │ + Status = ACTIVE User permanently + Operator can log in deleted from database +``` + +--- + +## 🔑 Key Workflows Explained + +### 1. Organization & Admin Onboarding +- When a new company registers on FiremeX, an **Organization** record and its primary **Admin user** are created together in an atomic database transaction. +- The system automatically generates a unique 3-digit Organization Code (e.g. `ORG-492`). +- The Admin's account is automatically set to `status: "active"`, allowing immediate login. +- The Organization Code (`ORG-492`) is shared by the Admin with their monitoring staff. + +### 2. Operator Onboarding & Gatekeeping +- An Operator registers by providing their name, personal work email, password, and their company's Organization Code (`ORG-492`). +- The backend validates the Organization Code in PostgreSQL. +- If valid, the Operator user is created with `role: "operator"` and `status: "pending"`. +- **Gatekeeping:** If a pending operator attempts to sign in, the authentication system rejects the request: `"Your account is pending administrator approval"`. + +### 3. Admin Control Panel (Approve / Deny / Revoke) +- Logged-in Admins access the **User Management** page (`/FiremeX/admin/users`). +- The panel fetches real-time user lists from the backend split into **Active Operators** and **Pending Requests**. +- **Approve:** Updates operator status to `active`. The operator can now log in. +- **Deny:** Deletes the pending registration request from PostgreSQL. +- **Revoke:** Changes an active operator's status to `revoked`. Instantly cuts off their access to the system. + +--- + +## 🔒 Security Architecture + +``` +[Incoming Request] ──► [1. CORS Check] ──► [2. JWT Auth Guard] ──► [3. Admin RBAC Guard] ──► [Controller Action] +``` + +1. **Password Encryption (`bcrypt`)**: All user passwords are encrypted using `bcrypt` hashing before storage. Raw passwords are never stored. +2. **Hidden Passwords in API (`json:"-"`)**: The User model explicitly hides password hashes from JSON serialization (`json:"-"`), preventing hash leakage in API responses. +3. **JWT Authentication**: Upon valid login, the backend issues a signed 24-hour JSON Web Token (JWT). +4. **Role-Based Middleware (`RequireAdmin`)**: Protects user management endpoints. Requests without an `admin` role receive a `403 Forbidden` response. +5. **Status Enforcement**: The `Login` controller verifies account status before token generation. Accounts with `pending` or `revoked` status are rejected. + +--- + +## 🗄️ Database Entity Schema + +### `organizations` Table +| Column | Type | Description | +|---|---|---| +| `id` | `uint` (PK) | Auto-increment primary key | +| `name` | `string` | Company name (e.g. "SafeGuard Logistics") | +| `code` | `string` (Unique) | Auto-generated code (e.g. "ORG-492") | +| `sector` | `string` | Industry sector (Industrial, Commercial, Healthcare, etc.) | +| `email` | `string` | Business contact email | +| `phone` | `string` | Contact phone number | + +### `users` Table +| Column | Type | Description | +|---|---|---| +| `id` | `uint` (PK) | Auto-increment primary key | +| `name` | `string` | User's full name | +| `email` | `string` (Unique) | User's email address (login username) | +| `password` | `string` | Encrypted bcrypt hash (hidden from JSON output) | +| `role` | `string` | System role: `"admin"` or `"operator"` | +| `status` | `string` | Approval state: `"pending"`, `"active"`, or `"revoked"` | +| `organization_id` | `uint` (FK) | References `organizations.id` | + +--- + +## 🌐 API Endpoint Reference + +| Method | Endpoint | Access Level | Description | +|---|---|---|---| +| `POST` | `/login` | Public | Authenticates credentials and returns JWT token | +| `POST` | `/register/organization` | Public | Registers a new Org + Admin user (returns `org_code`) | +| `POST` | `/register/operator` | Public | Submits a pending operator registration request | +| `GET` | `/api/users` | Protected (Admin) | Returns active operators & pending registration requests | +| `PATCH` | `/api/users/:id/approve` | Protected (Admin) | Approves a pending operator (`status -> active`) | +| `DELETE` | `/api/users/:id/deny` | Protected (Admin) | Denies and deletes a pending request | +| `PATCH` | `/api/users/:id/revoke` | Protected (Admin) | Revokes an operator's access (`status -> revoked`) | diff --git a/backend/controllers/auth.go b/backend/controllers/auth.go index e05c7f3..3a2094f 100644 --- a/backend/controllers/auth.go +++ b/backend/controllers/auth.go @@ -79,6 +79,16 @@ func Login(c *gin.Context) { return } + // 3.5 Check if the user's account is approved + if user.Status == "pending" { + c.JSON(http.StatusForbidden, gin.H{"error": "Your account is pending administrator approval"}) + return + } + if user.Status == "revoked" { + c.JSON(http.StatusForbidden, gin.H{"error": "Your account access has been revoked"}) + return + } + // 4. Create the JWT digital ticket // We store their User ID and when the ticket expires (e.g., in 24 hours) token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ diff --git a/backend/controllers/organization.go b/backend/controllers/organization.go new file mode 100644 index 0000000..85ae6a6 --- /dev/null +++ b/backend/controllers/organization.go @@ -0,0 +1,135 @@ +package controllers + +import ( + "fmt" + "math/rand" + "net/http" + + "github.com/firemex/backend/database" + "github.com/firemex/backend/models" + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +// RegisterOrganization creates a new organization AND its admin user in one step +func RegisterOrganization(c *gin.Context) { + var input struct { + OrgName string `json:"org_name" binding:"required"` + Sector string `json:"sector" binding:"required"` + Email string `json:"email" binding:"required,email"` + Phone string `json:"phone"` + AdminName string `json:"admin_name" binding:"required"` + Password string `json:"password" binding:"required,min=6"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: " + err.Error()}) + return + } + + // 1. Generate a unique org code like ORG-384 + orgCode := fmt.Sprintf("ORG-%d", 100+rand.Intn(900)) + + // Make sure the code is unique + var existingOrg models.Organization + for database.DB.Where("code = ?", orgCode).First(&existingOrg).Error == nil { + orgCode = fmt.Sprintf("ORG-%d", 100+rand.Intn(900)) + } + + // 2. Create the organization + org := models.Organization{ + Name: input.OrgName, + Code: orgCode, + Sector: input.Sector, + Email: input.Email, + Phone: input.Phone, + } + + if err := database.DB.Create(&org).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to create organization"}) + return + } + + // 3. Hash the admin password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + + // 4. Create the admin user linked to this organization + adminUser := models.User{ + Name: input.AdminName, + Email: input.Email, + Password: string(hashedPassword), + Role: "admin", + Status: "active", + OrganizationID: &org.ID, + } + + if err := database.DB.Create(&adminUser).Error; err != nil { + // If user creation fails, clean up the org we just created + database.DB.Unscoped().Delete(&org) + c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Organization registered successfully!", + "org_code": orgCode, + "org": gin.H{ + "id": org.ID, + "name": org.Name, + "code": org.Code, + "sector": org.Sector, + }, + }) +} + +// RegisterOperator creates a new operator user linked to an existing organization +func RegisterOperator(c *gin.Context) { + var input struct { + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + OrgCode string `json:"org_code" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: " + err.Error()}) + return + } + + // 1. Find the organization by its code + var org models.Organization + if err := database.DB.Where("code = ?", input.OrgCode).First(&org).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Organization code not found. Check with your administrator."}) + return + } + + // 2. Hash the password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + + // 3. Create the operator user as "pending" + user := models.User{ + Name: input.Name, + Email: input.Email, + Password: string(hashedPassword), + Role: "operator", + Status: "pending", + OrganizationID: &org.ID, + } + + if err := database.DB.Create(&user).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Email already registered"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Operator registration request submitted! Pending admin approval.", + }) +} diff --git a/backend/controllers/user.go b/backend/controllers/user.go new file mode 100644 index 0000000..caef52e --- /dev/null +++ b/backend/controllers/user.go @@ -0,0 +1,98 @@ +package controllers + +import ( + "net/http" + + "github.com/firemex/backend/database" + "github.com/firemex/backend/models" + "github.com/gin-gonic/gin" +) + +// GetAllUsers returns all users, split into active and pending lists +func GetAllUsers(c *gin.Context) { + var activeUsers []models.User + var pendingUsers []models.User + + // Get active users (with their organization data loaded) + database.DB.Preload("Organization").Where("status = ?", "active").Find(&activeUsers) + + // Get pending users (with their organization data loaded) + database.DB.Preload("Organization").Where("status = ?", "pending").Find(&pendingUsers) + + c.JSON(http.StatusOK, gin.H{ + "active": activeUsers, + "pending": pendingUsers, + }) +} + +// ApproveUser changes a pending user's status to active +func ApproveUser(c *gin.Context) { + id := c.Param("id") + + var user models.User + if err := database.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + if user.Status != "pending" { + c.JSON(http.StatusBadRequest, gin.H{"error": "User is not in pending status"}) + return + } + + user.Status = "active" + database.DB.Save(&user) + + c.JSON(http.StatusOK, gin.H{ + "message": "User approved successfully", + "user": user, + }) +} + +// DenyUser deletes a pending user from the database +func DenyUser(c *gin.Context) { + id := c.Param("id") + + var user models.User + if err := database.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + if user.Status != "pending" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Can only deny pending users"}) + return + } + + // Permanently delete (not soft delete) since they were never approved + database.DB.Unscoped().Delete(&user) + + c.JSON(http.StatusOK, gin.H{"message": "User denied and removed"}) +} + +// RevokeUser changes an active user's status to revoked +func RevokeUser(c *gin.Context) { + id := c.Param("id") + + var user models.User + if err := database.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + if user.Status != "active" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Can only revoke active users"}) + return + } + + // Don't allow revoking admin users + if user.Role == "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Cannot revoke an admin user"}) + return + } + + user.Status = "revoked" + database.DB.Save(&user) + + c.JSON(http.StatusOK, gin.H{"message": "User access revoked"}) +} diff --git a/backend/main.go b/backend/main.go index ba1c332..c2f6c14 100644 --- a/backend/main.go +++ b/backend/main.go @@ -17,8 +17,8 @@ func main() { log.Println("Starting FiremeX backend...") database.ConnectDB() - // 2. Run the AutoMigrate - err := database.DB.AutoMigrate(&models.User{}) + // 2. Run the AutoMigrate for all models + err := database.DB.AutoMigrate(&models.Organization{}, &models.User{}) if err != nil { log.Fatal("Failed to migrate database: ", err) } @@ -40,15 +40,17 @@ func main() { c.JSON(200, gin.H{"message": "pong! FiremeX API is running."}) }) - // 5. Authentication Routes - router.POST("/register", controllers.Register) + // 5. Public Authentication Routes router.POST("/login", controllers.Login) + // 5.1 Public Registration Routes + router.POST("/register/organization", controllers.RegisterOrganization) + router.POST("/register/operator", controllers.RegisterOperator) + // 6. Protected Routes (Require a valid JWT token) protected := router.Group("/api") - protected.Use(middleware.RequireAuth) // Attach the Security Guard! + protected.Use(middleware.RequireAuth) { - // This route is now protected! protected.GET("/dashboard", func(c *gin.Context) { userID, _ := c.Get("userID") c.JSON(200, gin.H{ @@ -58,7 +60,17 @@ func main() { }) } - // 7. Start the server + // 7. Admin-Only Routes (Require JWT + Admin role) + admin := protected.Group("/") + admin.Use(middleware.RequireAdmin) + { + admin.GET("/users", controllers.GetAllUsers) + admin.PATCH("/users/:id/approve", controllers.ApproveUser) + admin.DELETE("/users/:id/deny", controllers.DenyUser) + admin.PATCH("/users/:id/revoke", controllers.RevokeUser) + } + + // 8. Start the server log.Println("Server is running on port 8080...") router.Run(":8080") } diff --git a/backend/middleware/adminMiddleware.go b/backend/middleware/adminMiddleware.go new file mode 100644 index 0000000..ecba999 --- /dev/null +++ b/backend/middleware/adminMiddleware.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "net/http" + + "github.com/firemex/backend/database" + "github.com/firemex/backend/models" + "github.com/gin-gonic/gin" +) + +// RequireAdmin checks if the authenticated user has the 'admin' role. +// This middleware must be used AFTER RequireAuth (JWT middleware). +func RequireAdmin(c *gin.Context) { + // 1. Get the userID that was set by the JWT middleware + userIDValue, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + c.Abort() + return + } + + // 2. Convert the userID to a float64 (JWT stores numbers as float64) + userIDFloat, ok := userIDValue.(float64) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID format"}) + c.Abort() + return + } + userID := uint(userIDFloat) + + // 3. Look up this user in the database + var user models.User + if err := database.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) + c.Abort() + return + } + + // 4. Check if they are an admin + if user.Role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + c.Abort() + return + } + + // 5. Store the full user object for controllers to use + c.Set("currentUser", user) + + // 6. Allow the request to continue + c.Next() +} diff --git a/backend/models/organization.go b/backend/models/organization.go new file mode 100644 index 0000000..8896fde --- /dev/null +++ b/backend/models/organization.go @@ -0,0 +1,15 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Organization represents a company/entity registered on FiremeX +type Organization struct { + gorm.Model + Name string `json:"name" gorm:"not null"` + Code string `json:"code" gorm:"unique;not null"` + Sector string `json:"sector" gorm:"not null"` + Email string `json:"email" gorm:"not null"` + Phone string `json:"phone"` +} diff --git a/backend/models/user.go b/backend/models/user.go index 202fe0f..7bd9a72 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -7,8 +7,11 @@ import ( // User represents the structure of our users table in the database type User struct { gorm.Model - Name string `json:"name" gorm:"not null"` - Email string `json:"email" gorm:"unique;not null"` - Password string `json:"password" gorm:"not null"` - Role string `json:"role" gorm:"default:'user'"` // Can be 'admin' or 'user' + Name string `json:"name" gorm:"not null"` + Email string `json:"email" gorm:"unique;not null"` + Password string `json:"-" gorm:"not null"` + Role string `json:"role" gorm:"default:'operator'"` + Status string `json:"status" gorm:"default:'pending'"` + OrganizationID *uint `json:"organization_id"` + Organization *Organization `json:"organization,omitempty" gorm:"foreignKey:OrganizationID"` } diff --git a/frontend/src/pages/admin/User.tsx b/frontend/src/pages/admin/User.tsx index 5885608..82b1155 100644 --- a/frontend/src/pages/admin/User.tsx +++ b/frontend/src/pages/admin/User.tsx @@ -17,96 +17,100 @@ type RequestDetail = { date: string } -const defaultActiveUsers: UserDetail[] = [ - { - id: 'usr-01', - name: 'John Doe', - email: 'operator@gmail.com', - role: 'Operator', - status: 'Active', - date: '2026-06-01' - }, - { - id: 'usr-02', - name: 'Jane Smith', - email: 'jane@gmail.com', - role: 'Operator', - status: 'Active', - date: '2026-06-15' - }, - { - id: 'usr-03', - name: 'J. Silva', - email: 'jsilva@firemex.com', - role: 'Administrator', - status: 'Active', - date: '2026-05-10' - } -] - -const defaultPendingRequests: RequestDetail[] = [ - { - id: 'usr-101', - name: 'Bob Johnson', - email: 'bob@gmail.com', - role: 'Operator', - date: '2026-07-09' - }, - { - id: 'usr-102', - name: 'Alice Williams', - email: 'alice@gmail.com', - role: 'Operator', - date: '2026-07-10' - } -] - export function User() { - const [activeUsers, setActiveUsers] = useState(() => { - const saved = localStorage.getItem('firemex_active_users') - return saved ? JSON.parse(saved) : defaultActiveUsers - }) + const [activeUsers, setActiveUsers] = useState([]) + const [pendingRequests, setPendingRequests] = useState([]) + const [activeTab, setActiveTab] = useState<'active' | 'pending'>('active') + const [loading, setLoading] = useState(true) - const [pendingRequests, setPendingRequests] = useState(() => { - const saved = localStorage.getItem('firemex_pending_users') - return saved ? JSON.parse(saved) : defaultPendingRequests - }) + const token = localStorage.getItem('firemex_token') - const [activeTab, setActiveTab] = useState<'active' | 'pending'>('active') + // Helper function to get auth headers + const authHeaders = () => ({ + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }) - // Persist active users - useEffect(() => { - localStorage.setItem('firemex_active_users', JSON.stringify(activeUsers)) - }, [activeUsers]) + // Fetch all users from the backend + const fetchUsers = async () => { + try { + const response = await fetch('http://localhost:8080/api/users', { + headers: authHeaders() + }) + const data = await response.json() + if (response.ok) { + setActiveUsers(data.active?.map((u: any) => ({ + id: String(u.ID), + name: u.name, + email: u.email, + role: u.role, + status: u.status, + date: u.CreatedAt?.split('T')[0] || '' + })) || []) + setPendingRequests(data.pending?.map((u: any) => ({ + id: String(u.ID), + name: u.name, + email: u.email, + role: u.role, + date: u.CreatedAt?.split('T')[0] || '' + })) || []) + } + } catch (err) { + console.error('Failed to fetch users:', err) + } finally { + setLoading(false) + } + } - // Persist pending requests + // Load users on mount useEffect(() => { - localStorage.setItem('firemex_pending_users', JSON.stringify(pendingRequests)) - }, [pendingRequests]) + fetchUsers() + }, []) // Approve request handler - const handleApprove = (req: RequestDetail) => { - const newUser: UserDetail = { - id: req.id, - name: req.name, - email: req.email, - role: req.role, - status: 'Active', - date: new Date().toISOString().split('T')[0] + const handleApprove = async (req: RequestDetail) => { + try { + const response = await fetch(`http://localhost:8080/api/users/${req.id}/approve`, { + method: 'PATCH', + headers: authHeaders() + }) + if (response.ok) { + fetchUsers() // Refresh the list from the backend + } + } catch (err) { + console.error('Failed to approve user:', err) } - setActiveUsers([...activeUsers, newUser]) - setPendingRequests(pendingRequests.filter((r) => r.id !== req.id)) } // Deny request handler - const handleDeny = (id: string) => { - setPendingRequests(pendingRequests.filter((r) => r.id !== id)) + const handleDeny = async (id: string) => { + try { + const response = await fetch(`http://localhost:8080/api/users/${id}/deny`, { + method: 'DELETE', + headers: authHeaders() + }) + if (response.ok) { + fetchUsers() // Refresh the list from the backend + } + } catch (err) { + console.error('Failed to deny user:', err) + } } // Revoke active user handler - const handleRevoke = (id: string) => { + const handleRevoke = async (id: string) => { if (confirm('Are you sure you want to revoke access for this user?')) { - setActiveUsers(activeUsers.filter((u) => u.id !== id)) + try { + const response = await fetch(`http://localhost:8080/api/users/${id}/revoke`, { + method: 'PATCH', + headers: authHeaders() + }) + if (response.ok) { + fetchUsers() // Refresh the list from the backend + } + } catch (err) { + console.error('Failed to revoke user:', err) + } } } @@ -130,7 +134,7 @@ export function User() { : 'text-slate-400 hover:text-slate-200' }`} > - Active Operators ({activeUsers.filter(u => u.role === 'Operator').length}) + Active Operators ({activeUsers.filter(u => u.role === 'operator' || u.role === 'Operator').length}) @@ -298,7 +325,7 @@ export function RegisterGateway({ onNavigate }: Props) {
+ {error &&

{error}

} +