diff --git a/backend/main.py b/backend/main.py
index 1cb2650..9dc2caf 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -6,6 +6,7 @@ from auth.router import router as auth_router
from melodies.router import router as melodies_router
from devices.router import router as devices_router
from settings.router import router as settings_router
+from users.router import router as users_router
app = FastAPI(
title="BellSystems Admin Panel",
@@ -26,6 +27,7 @@ app.include_router(auth_router)
app.include_router(melodies_router)
app.include_router(devices_router)
app.include_router(settings_router)
+app.include_router(users_router)
@app.on_event("startup")
diff --git a/backend/users/models.py b/backend/users/models.py
index 7f06e56..4babd47 100644
--- a/backend/users/models.py
+++ b/backend/users/models.py
@@ -1 +1,43 @@
-# TODO: User Pydantic schemas
+from pydantic import BaseModel
+from typing import List, Optional
+
+
+# --- Request / Response schemas ---
+
+class UserCreate(BaseModel):
+ email: str = ""
+ display_name: str = ""
+ photo_url: str = ""
+ uid: str = ""
+ phone_number: str = ""
+ status: str = ""
+ bio: str = ""
+ userTitle: str = ""
+ settingsPIN: str = ""
+ quickSettingsPIN: str = ""
+
+
+class UserUpdate(BaseModel):
+ email: Optional[str] = None
+ display_name: Optional[str] = None
+ photo_url: Optional[str] = None
+ phone_number: Optional[str] = None
+ status: Optional[str] = None
+ bio: Optional[str] = None
+ userTitle: Optional[str] = None
+ settingsPIN: Optional[str] = None
+ quickSettingsPIN: Optional[str] = None
+
+
+class UserInDB(UserCreate):
+ id: str
+ created_time: str = ""
+ lastActive: str = ""
+ createdAt: str = ""
+ friendsList: List[str] = []
+ friendsInvited: List[str] = []
+
+
+class UserListResponse(BaseModel):
+ users: List[UserInDB]
+ total: int
diff --git a/backend/users/router.py b/backend/users/router.py
index c0a2886..4f05a5a 100644
--- a/backend/users/router.py
+++ b/backend/users/router.py
@@ -1 +1,95 @@
-# TODO: CRUD endpoints for users
+from fastapi import APIRouter, Depends, Query
+from typing import Optional, List
+from auth.models import TokenPayload
+from auth.dependencies import require_user_access, require_viewer
+from users.models import (
+ UserCreate, UserUpdate, UserInDB, UserListResponse,
+)
+from users import service
+
+router = APIRouter(prefix="/api/users", tags=["users"])
+
+
+@router.get("", response_model=UserListResponse)
+async def list_users(
+ search: Optional[str] = Query(None),
+ status: Optional[str] = Query(None),
+ _user: TokenPayload = Depends(require_viewer),
+):
+ users = service.list_users(search=search, status=status)
+ return UserListResponse(users=users, total=len(users))
+
+
+@router.get("/{user_id}", response_model=UserInDB)
+async def get_user(
+ user_id: str,
+ _user: TokenPayload = Depends(require_viewer),
+):
+ return service.get_user(user_id)
+
+
+@router.post("", response_model=UserInDB, status_code=201)
+async def create_user(
+ body: UserCreate,
+ _user: TokenPayload = Depends(require_user_access),
+):
+ return service.create_user(body)
+
+
+@router.put("/{user_id}", response_model=UserInDB)
+async def update_user(
+ user_id: str,
+ body: UserUpdate,
+ _user: TokenPayload = Depends(require_user_access),
+):
+ return service.update_user(user_id, body)
+
+
+@router.delete("/{user_id}", status_code=204)
+async def delete_user(
+ user_id: str,
+ _user: TokenPayload = Depends(require_user_access),
+):
+ service.delete_user(user_id)
+
+
+@router.post("/{user_id}/block", response_model=UserInDB)
+async def block_user(
+ user_id: str,
+ _user: TokenPayload = Depends(require_user_access),
+):
+ return service.block_user(user_id)
+
+
+@router.post("/{user_id}/unblock", response_model=UserInDB)
+async def unblock_user(
+ user_id: str,
+ _user: TokenPayload = Depends(require_user_access),
+):
+ return service.unblock_user(user_id)
+
+
+@router.get("/{user_id}/devices", response_model=List[dict])
+async def get_user_devices(
+ user_id: str,
+ _user: TokenPayload = Depends(require_viewer),
+):
+ return service.get_user_devices(user_id)
+
+
+@router.post("/{user_id}/devices/{device_id}", response_model=UserInDB)
+async def assign_device(
+ user_id: str,
+ device_id: str,
+ _user: TokenPayload = Depends(require_user_access),
+):
+ return service.assign_device(user_id, device_id)
+
+
+@router.delete("/{user_id}/devices/{device_id}", response_model=UserInDB)
+async def unassign_device(
+ user_id: str,
+ device_id: str,
+ _user: TokenPayload = Depends(require_user_access),
+):
+ return service.unassign_device(user_id, device_id)
diff --git a/backend/users/service.py b/backend/users/service.py
index 88535c6..50b0178 100644
--- a/backend/users/service.py
+++ b/backend/users/service.py
@@ -1 +1,252 @@
-# TODO: User Firestore operations
+from datetime import datetime
+
+from google.cloud.firestore_v1 import DocumentReference
+
+from shared.firebase import get_db
+from shared.exceptions import NotFoundError
+from users.models import UserCreate, UserUpdate, UserInDB
+
+COLLECTION = "users"
+
+
+def _convert_firestore_value(val):
+ """Convert Firestore-specific types (Timestamp, DocumentReference) to strings."""
+ if isinstance(val, datetime):
+ return val.strftime("%d %B %Y at %H:%M:%S UTC%z")
+ if isinstance(val, DocumentReference):
+ return val.path
+ return val
+
+
+def _sanitize_dict(d: dict) -> dict:
+ """Recursively convert Firestore-native types in a dict to plain strings."""
+ result = {}
+ for k, v in d.items():
+ if isinstance(v, dict):
+ result[k] = _sanitize_dict(v)
+ elif isinstance(v, list):
+ result[k] = [
+ _sanitize_dict(item) if isinstance(item, dict)
+ else _convert_firestore_value(item)
+ for item in v
+ ]
+ else:
+ result[k] = _convert_firestore_value(v)
+ return result
+
+
+def _doc_to_user(doc) -> UserInDB:
+ """Convert a Firestore document snapshot to a UserInDB model."""
+ data = _sanitize_dict(doc.to_dict())
+ return UserInDB(id=doc.id, **data)
+
+
+def list_users(
+ search: str | None = None,
+ status: str | None = None,
+) -> list[UserInDB]:
+ """List users with optional filters."""
+ db = get_db()
+ ref = db.collection(COLLECTION)
+ query = ref
+
+ if status:
+ query = query.where("status", "==", status)
+
+ docs = query.stream()
+ results = []
+
+ for doc in docs:
+ user = _doc_to_user(doc)
+
+ if search:
+ search_lower = search.lower()
+ name_match = search_lower in (user.display_name or "").lower()
+ email_match = search_lower in (user.email or "").lower()
+ phone_match = search_lower in (user.phone_number or "").lower()
+ uid_match = search_lower in (user.uid or "").lower()
+ if not (name_match or email_match or phone_match or uid_match):
+ continue
+
+ results.append(user)
+
+ return results
+
+
+def get_user(user_doc_id: str) -> UserInDB:
+ """Get a single user by Firestore document ID."""
+ db = get_db()
+ doc = db.collection(COLLECTION).document(user_doc_id).get()
+ if not doc.exists:
+ raise NotFoundError("User")
+ return _doc_to_user(doc)
+
+
+def create_user(data: UserCreate) -> UserInDB:
+ """Create a new user document in Firestore."""
+ db = get_db()
+ doc_data = data.model_dump()
+ doc_data["friendsList"] = []
+ doc_data["friendsInvited"] = []
+
+ _, doc_ref = db.collection(COLLECTION).add(doc_data)
+
+ return UserInDB(id=doc_ref.id, **doc_data)
+
+
+def update_user(user_doc_id: str, data: UserUpdate) -> UserInDB:
+ """Update an existing user document. Only provided fields are updated."""
+ db = get_db()
+ doc_ref = db.collection(COLLECTION).document(user_doc_id)
+ doc = doc_ref.get()
+ if not doc.exists:
+ raise NotFoundError("User")
+
+ update_data = data.model_dump(exclude_none=True)
+ doc_ref.update(update_data)
+
+ updated_doc = doc_ref.get()
+ return _doc_to_user(updated_doc)
+
+
+def delete_user(user_doc_id: str) -> None:
+ """Delete a user document from Firestore."""
+ db = get_db()
+ doc_ref = db.collection(COLLECTION).document(user_doc_id)
+ doc = doc_ref.get()
+ if not doc.exists:
+ raise NotFoundError("User")
+
+ doc_ref.delete()
+
+
+def block_user(user_doc_id: str) -> UserInDB:
+ """Block a user by setting their status to 'blocked'."""
+ db = get_db()
+ doc_ref = db.collection(COLLECTION).document(user_doc_id)
+ doc = doc_ref.get()
+ if not doc.exists:
+ raise NotFoundError("User")
+
+ doc_ref.update({"status": "blocked"})
+ updated_doc = doc_ref.get()
+ return _doc_to_user(updated_doc)
+
+
+def unblock_user(user_doc_id: str) -> UserInDB:
+ """Unblock a user by setting their status to 'active'."""
+ db = get_db()
+ doc_ref = db.collection(COLLECTION).document(user_doc_id)
+ doc = doc_ref.get()
+ if not doc.exists:
+ raise NotFoundError("User")
+
+ doc_ref.update({"status": "active"})
+ updated_doc = doc_ref.get()
+ return _doc_to_user(updated_doc)
+
+
+def assign_device(user_doc_id: str, device_doc_id: str) -> UserInDB:
+ """Assign a device to a user by adding user ref to device's user_list."""
+ db = get_db()
+
+ # Verify user exists
+ user_ref = db.collection(COLLECTION).document(user_doc_id)
+ user_doc = user_ref.get()
+ if not user_doc.exists:
+ raise NotFoundError("User")
+
+ # Verify device exists
+ device_ref = db.collection("devices").document(device_doc_id)
+ device_doc = device_ref.get()
+ if not device_doc.exists:
+ raise NotFoundError("Device")
+
+ # Add user path to device's user_list if not already there
+ device_data = device_doc.to_dict()
+ user_list = device_data.get("user_list", [])
+ user_path = f"users/{user_doc_id}"
+
+ # Check if already assigned (handle both string paths and DocumentReferences)
+ already_assigned = False
+ for entry in user_list:
+ if isinstance(entry, DocumentReference):
+ if entry.path == user_path:
+ already_assigned = True
+ break
+ elif entry == user_path:
+ already_assigned = True
+ break
+
+ if not already_assigned:
+ user_list.append(user_path)
+ device_ref.update({"user_list": user_list})
+
+ return _doc_to_user(user_ref.get())
+
+
+def unassign_device(user_doc_id: str, device_doc_id: str) -> UserInDB:
+ """Remove a user from a device's user_list."""
+ db = get_db()
+
+ # Verify user exists
+ user_ref = db.collection(COLLECTION).document(user_doc_id)
+ user_doc = user_ref.get()
+ if not user_doc.exists:
+ raise NotFoundError("User")
+
+ # Verify device exists
+ device_ref = db.collection("devices").document(device_doc_id)
+ device_doc = device_ref.get()
+ if not device_doc.exists:
+ raise NotFoundError("Device")
+
+ # Remove user from device's user_list
+ device_data = device_doc.to_dict()
+ user_list = device_data.get("user_list", [])
+ user_path = f"users/{user_doc_id}"
+
+ new_list = []
+ for entry in user_list:
+ if isinstance(entry, DocumentReference):
+ if entry.path != user_path:
+ new_list.append(entry)
+ elif entry != user_path:
+ new_list.append(entry)
+
+ device_ref.update({"user_list": new_list})
+
+ return _doc_to_user(user_ref.get())
+
+
+def get_user_devices(user_doc_id: str) -> list[dict]:
+ """Get all devices assigned to a user."""
+ db = get_db()
+
+ # Verify user exists
+ user_ref = db.collection(COLLECTION).document(user_doc_id)
+ user_doc = user_ref.get()
+ if not user_doc.exists:
+ raise NotFoundError("User")
+
+ user_path = f"users/{user_doc_id}"
+
+ # Search all devices for this user in their user_list
+ devices = []
+ for doc in db.collection("devices").stream():
+ data = doc.to_dict()
+ user_list = data.get("user_list", [])
+
+ for entry in user_list:
+ entry_path = entry.path if isinstance(entry, DocumentReference) else entry
+ if entry_path == user_path:
+ devices.append({
+ "id": doc.id,
+ "device_name": data.get("device_name", ""),
+ "device_id": data.get("device_id", ""),
+ "device_location": data.get("device_location", ""),
+ "is_Online": data.get("is_Online", False),
+ })
+ break
+
+ return devices
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 05eb451..44672ef 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -9,6 +9,9 @@ import MelodySettings from "./melodies/MelodySettings";
import DeviceList from "./devices/DeviceList";
import DeviceDetail from "./devices/DeviceDetail";
import DeviceForm from "./devices/DeviceForm";
+import UserList from "./users/UserList";
+import UserDetail from "./users/UserDetail";
+import UserForm from "./users/UserForm";
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
@@ -62,8 +65,11 @@ export default function App() {
+ No devices assigned. +
+ ) : ( ++ {device.device_id || device.id} +
+ {device.device_location && ( ++ {device.device_location} +
+ )} +| Status | +Name | +Phone | +Title | +Last Active | + {canEdit && ( ++ )} + | |
|---|---|---|---|---|---|---|
| + + {user.status || "unknown"} + + | ++ {user.display_name || "Unnamed User"} + | ++ {user.email || "-"} + | ++ {user.phone_number || "-"} + | ++ {user.userTitle || "-"} + | ++ {user.lastActive || "-"} + | + {canEdit && ( +
+ e.stopPropagation()}
+ >
+
+
+
+ |
+ )}
+