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() { } /> } /> } /> - {/* Phase 4+ routes: } /> + } /> + } /> + } /> + {/* Phase 5+ routes: } /> */} } /> diff --git a/frontend/src/index.css b/frontend/src/index.css index a195e44..0320a5b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -18,8 +18,8 @@ --text-secondary: #9ca3af; --text-muted: #9ca3af; --text-heading: #e3e5ea; - --text-white: #fbfbfb; - --text-link: #93befa; + --text-white: #ffffff; + --text-link: #589cfa; --accent: #74b816; --accent-hover: #82c91e; @@ -114,6 +114,35 @@ input[type="checkbox"] { /* Range slider */ input[type="range"] { accent-color: var(--accent) !important; + -webkit-appearance: none; + appearance: none; + height: 6px; + border-radius: 4px; + background: var(--border-primary); + outline: none; +} +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: 2px solid var(--bg-primary); +} +input[type="range"]::-moz-range-track { + height: 6px; + border-radius: 4px; + background: var(--border-primary); +} +input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: 2px solid var(--bg-primary); } /* File input */ diff --git a/frontend/src/users/UserDetail.jsx b/frontend/src/users/UserDetail.jsx index 1a62367..a11ab3b 100644 --- a/frontend/src/users/UserDetail.jsx +++ b/frontend/src/users/UserDetail.jsx @@ -1 +1,488 @@ -// TODO: User detail view +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import api from "../api/client"; +import { useAuth } from "../auth/AuthContext"; +import ConfirmDialog from "../components/ConfirmDialog"; + +function Field({ label, children }) { + return ( +
+
+ {label} +
+
+ {children || "-"} +
+
+ ); +} + +export default function UserDetail() { + const { id } = useParams(); + const navigate = useNavigate(); + const { hasRole } = useAuth(); + const canEdit = hasRole("superadmin", "user_manager"); + + const [user, setUser] = useState(null); + const [devices, setDevices] = useState([]); + const [allDevices, setAllDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [showDelete, setShowDelete] = useState(false); + const [blocking, setBlocking] = useState(false); + const [assigningDevice, setAssigningDevice] = useState(false); + const [selectedDeviceId, setSelectedDeviceId] = useState(""); + const [showAssignPanel, setShowAssignPanel] = useState(false); + + useEffect(() => { + loadData(); + }, [id]); + + const loadData = async () => { + setLoading(true); + try { + const [u, d] = await Promise.all([ + api.get(`/users/${id}`), + api.get(`/users/${id}/devices`), + ]); + setUser(u); + setDevices(d); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const loadAllDevices = async () => { + try { + const data = await api.get("/devices"); + setAllDevices(data.devices || []); + } catch (err) { + setError(err.message); + } + }; + + const handleDelete = async () => { + try { + await api.delete(`/users/${id}`); + navigate("/users"); + } catch (err) { + setError(err.message); + setShowDelete(false); + } + }; + + const handleBlockToggle = async () => { + setBlocking(true); + setError(""); + try { + const endpoint = user.status === "blocked" + ? `/users/${id}/unblock` + : `/users/${id}/block`; + const updated = await api.post(endpoint); + setUser(updated); + } catch (err) { + setError(err.message); + } finally { + setBlocking(false); + } + }; + + const handleAssignDevice = async () => { + if (!selectedDeviceId) return; + setAssigningDevice(true); + setError(""); + try { + await api.post(`/users/${id}/devices/${selectedDeviceId}`); + setSelectedDeviceId(""); + setShowAssignPanel(false); + const d = await api.get(`/users/${id}/devices`); + setDevices(d); + } catch (err) { + setError(err.message); + } finally { + setAssigningDevice(false); + } + }; + + const handleUnassignDevice = async (deviceId) => { + setError(""); + try { + await api.delete(`/users/${id}/devices/${deviceId}`); + const d = await api.get(`/users/${id}/devices`); + setDevices(d); + } catch (err) { + setError(err.message); + } + }; + + const openAssignPanel = () => { + setShowAssignPanel(true); + loadAllDevices(); + }; + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + if (error && !user) { + return ( +
+ {error} +
+ ); + } + + if (!user) return null; + + const isBlocked = user.status === "blocked"; + + const assignedDeviceIds = new Set(devices.map((d) => d.id)); + const availableDevices = allDevices.filter((d) => !assignedDeviceIds.has(d.id)); + + return ( +
+
+
+ +
+

+ {user.display_name || "Unnamed User"} +

+ + {user.status || "unknown"} + +
+
+ {canEdit && ( +
+ + + +
+ )} +
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Left column */} +
+ {/* Account Info */} +
+

+ Account Information +

+
+ + + {user.id} + + + + {user.uid} + + + + {user.status || "unknown"} + + + {user.email} + {user.phone_number} + {user.userTitle} +
+
+ + {/* Profile */} +
+

+ Profile +

+
+ {user.bio} + {user.photo_url} +
+
+ + {/* Timestamps */} +
+

+ Activity +

+
+ {user.created_time} + {user.createdAt} + {user.lastActive} +
+
+
+ + {/* Right column */} +
+ {/* Security */} +
+

+ Security +

+
+ {user.settingsPIN ? "****" : "-"} + {user.quickSettingsPIN ? "****" : "-"} +
+
+ + {/* Friends */} +
+

+ Friends +

+
+ + {user.friendsList?.length ?? 0} + + + {user.friendsInvited?.length ?? 0} + +
+
+ + {/* Assigned Devices */} +
+
+

+ Assigned Devices ({devices.length}) +

+ {canEdit && ( + + )} +
+ + {showAssignPanel && ( +
+
+
+ + +
+ + +
+
+ )} + + {devices.length === 0 ? ( +

+ No devices assigned. +

+ ) : ( +
+ {devices.map((device) => ( +
+
+ +
+ +

+ {device.device_id || device.id} +

+ {device.device_location && ( +

+ {device.device_location} +

+ )} +
+
+ {canEdit && ( + + )} +
+ ))} +
+ )} +
+
+
+ + setShowDelete(false)} + /> +
+ ); +} diff --git a/frontend/src/users/UserForm.jsx b/frontend/src/users/UserForm.jsx index b45768a..352ede4 100644 --- a/frontend/src/users/UserForm.jsx +++ b/frontend/src/users/UserForm.jsx @@ -1 +1,296 @@ -// TODO: Add / Edit user form +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import api from "../api/client"; + +export default function UserForm() { + const { id } = useParams(); + const isEdit = Boolean(id); + const navigate = useNavigate(); + + const [email, setEmail] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [photoUrl, setPhotoUrl] = useState(""); + const [uid, setUid] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [status, setStatus] = useState(""); + const [bio, setBio] = useState(""); + const [userTitle, setUserTitle] = useState(""); + const [settingsPIN, setSettingsPIN] = useState(""); + const [quickSettingsPIN, setQuickSettingsPIN] = useState(""); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (isEdit) loadUser(); + }, [id]); + + const loadUser = async () => { + setLoading(true); + try { + const user = await api.get(`/users/${id}`); + setEmail(user.email || ""); + setDisplayName(user.display_name || ""); + setPhotoUrl(user.photo_url || ""); + setUid(user.uid || ""); + setPhoneNumber(user.phone_number || ""); + setStatus(user.status || ""); + setBio(user.bio || ""); + setUserTitle(user.userTitle || ""); + setSettingsPIN(user.settingsPIN || ""); + setQuickSettingsPIN(user.quickSettingsPIN || ""); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + setError(""); + + try { + const body = { + email, + display_name: displayName, + photo_url: photoUrl, + uid, + phone_number: phoneNumber, + status, + bio, + userTitle, + settingsPIN, + quickSettingsPIN, + }; + + let userId = id; + if (isEdit) { + await api.put(`/users/${id}`, body); + } else { + const created = await api.post("/users", body); + userId = created.id; + } + + navigate(`/users/${userId}`); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + if (loading) { + return
Loading...
; + } + + const inputClass = "w-full px-3 py-2 rounded-md text-sm border"; + + return ( +
+
+

+ {isEdit ? "Edit User" : "Add User"} +

+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ {/* ===== Left Column ===== */} +
+ {/* --- Account Info --- */} +
+

+ Account Information +

+
+
+ + setDisplayName(e.target.value)} + className={inputClass} + /> +
+
+ + setEmail(e.target.value)} + className={inputClass} + /> +
+
+ + setPhoneNumber(e.target.value)} + placeholder="e.g. +1234567890" + className={inputClass} + /> +
+
+ + setUid(e.target.value)} + className={inputClass} + disabled={isEdit} + style={isEdit ? { opacity: 0.5 } : undefined} + /> +
+
+ + +
+
+
+ + {/* --- Profile --- */} +
+

+ Profile +

+
+
+ + setUserTitle(e.target.value)} + placeholder="e.g. Church Administrator" + className={inputClass} + /> +
+
+ + setPhotoUrl(e.target.value)} + className={inputClass} + /> +
+
+ +