Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions USER_MANAGEMENT.md
Original file line number Diff line number Diff line change
@@ -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`) |
10 changes: 10 additions & 0 deletions backend/controllers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
135 changes: 135 additions & 0 deletions backend/controllers/organization.go
Original file line number Diff line number Diff line change
@@ -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.",
})
}
98 changes: 98 additions & 0 deletions backend/controllers/user.go
Original file line number Diff line number Diff line change
@@ -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"})
}
Loading
Loading