From 5e2d4b6b1bc512b7ffb5e852b24d85d285ad4248 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 16 Feb 2026 22:32:28 +0200 Subject: [PATCH] Phase 1 Complete by Claude Code --- .claude/settings.local.json | 3 +- ClaudePromptExample | 10 ++++ backend/auth/dependencies.py | 48 ++++++++++++++++- backend/auth/models.py | 41 ++++++++++++++- backend/auth/router.py | 43 +++++++++++++++- backend/auth/utils.py | 36 ++++++++++++- backend/main.py | 3 ++ backend/seed_admin.py | 60 ++++++++++++++++++++++ backend/shared/exceptions.py | 21 +++++++- frontend/index.html | 2 +- frontend/package-lock.json | 60 +++++++++++++++++++++- frontend/package.json | 3 +- frontend/src/App.jsx | 65 +++++++++++++++++++---- frontend/src/api/client.js | 64 ++++++++++++++++++++++- frontend/src/auth/AuthContext.jsx | 71 +++++++++++++++++++++++++- frontend/src/auth/LoginPage.jsx | 82 +++++++++++++++++++++++++++++- frontend/src/layout/Header.jsx | 29 ++++++++++- frontend/src/layout/MainLayout.jsx | 18 ++++++- frontend/src/layout/Sidebar.jsx | 43 +++++++++++++++- frontend/src/main.jsx | 22 +++++--- 20 files changed, 692 insertions(+), 32 deletions(-) create mode 100644 ClaudePromptExample create mode 100644 backend/seed_admin.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cbc16cf..a6d4f88 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(npm create:*)", - "Bash(npm install:*)" + "Bash(npm install:*)", + "Bash(npm run build:*)" ] } } diff --git a/ClaudePromptExample b/ClaudePromptExample new file mode 100644 index 0000000..852f9b1 --- /dev/null +++ b/ClaudePromptExample @@ -0,0 +1,10 @@ +You are working on the bellsystems-cp project at ~/bellsystems-cp/. +Read BellSystems_AdminPanel_Strategy.md for the full project strategy. + +We are now building Phase X — [Phase Name]. + +Review the existing codebase first, then implement the following: +[list of tasks for that phase] + +Ask me before making any major architectural decisions. +Commit when done. \ No newline at end of file diff --git a/backend/auth/dependencies.py b/backend/auth/dependencies.py index 5e89025..dd998c5 100644 --- a/backend/auth/dependencies.py +++ b/backend/auth/dependencies.py @@ -1 +1,47 @@ -# TODO: JWT verification, role checks +from fastapi import Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError +from auth.utils import decode_access_token +from auth.models import TokenPayload, Role +from shared.exceptions import AuthenticationError, AuthorizationError + +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> TokenPayload: + try: + payload = decode_access_token(credentials.credentials) + token_data = TokenPayload( + sub=payload["sub"], + email=payload["email"], + role=payload["role"], + name=payload["name"], + ) + except (JWTError, KeyError): + raise AuthenticationError() + return token_data + + +def require_roles(*allowed_roles: Role): + async def role_checker( + current_user: TokenPayload = Depends(get_current_user), + ) -> TokenPayload: + if current_user.role == Role.superadmin: + return current_user + if current_user.role not in [r.value for r in allowed_roles]: + raise AuthorizationError() + return current_user + return role_checker + + +# Pre-built convenience dependencies +require_superadmin = require_roles(Role.superadmin) +require_melody_access = require_roles(Role.superadmin, Role.melody_editor) +require_device_access = require_roles(Role.superadmin, Role.device_manager) +require_user_access = require_roles(Role.superadmin, Role.user_manager) +require_viewer = require_roles( + Role.superadmin, Role.melody_editor, Role.device_manager, + Role.user_manager, Role.viewer, +) diff --git a/backend/auth/models.py b/backend/auth/models.py index b5b3e64..1376049 100644 --- a/backend/auth/models.py +++ b/backend/auth/models.py @@ -1 +1,40 @@ -# TODO: User/token Pydantic schemas +from pydantic import BaseModel +from typing import Optional +from enum import Enum + + +class Role(str, Enum): + superadmin = "superadmin" + melody_editor = "melody_editor" + device_manager = "device_manager" + user_manager = "user_manager" + viewer = "viewer" + + +class AdminUserInDB(BaseModel): + uid: str + email: str + hashed_password: str + name: str + role: Role + is_active: bool = True + + +class LoginRequest(BaseModel): + email: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + role: str + name: str + + +class TokenPayload(BaseModel): + sub: str + email: str + role: str + name: str + exp: Optional[int] = None diff --git a/backend/auth/router.py b/backend/auth/router.py index 17d1375..0713ef7 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -1 +1,42 @@ -# TODO: Login / token endpoints +from fastapi import APIRouter +from shared.firebase import get_db +from auth.models import LoginRequest, TokenResponse +from auth.utils import verify_password, create_access_token +from shared.exceptions import AuthenticationError + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/login", response_model=TokenResponse) +async def login(body: LoginRequest): + db = get_db() + if not db: + raise AuthenticationError("Service unavailable") + + users_ref = db.collection("admin_users") + query = users_ref.where("email", "==", body.email).limit(1).get() + + if not query: + raise AuthenticationError("Invalid email or password") + + doc = query[0] + user_data = doc.to_dict() + + if not user_data.get("is_active", True): + raise AuthenticationError("Account is disabled") + + if not verify_password(body.password, user_data["hashed_password"]): + raise AuthenticationError("Invalid email or password") + + token = create_access_token({ + "sub": doc.id, + "email": user_data["email"], + "role": user_data["role"], + "name": user_data["name"], + }) + + return TokenResponse( + access_token=token, + role=user_data["role"], + name=user_data["name"], + ) diff --git a/backend/auth/utils.py b/backend/auth/utils.py index 43a6a51..b91b5a9 100644 --- a/backend/auth/utils.py +++ b/backend/auth/utils.py @@ -1 +1,35 @@ -# TODO: Password hashing, token creation +from datetime import datetime, timedelta, timezone +from jose import jwt +from passlib.context import CryptContext +from config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def create_access_token(data: dict) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.jwt_expiration_minutes + ) + to_encode.update({"exp": expire}) + return jwt.encode( + to_encode, + settings.jwt_secret_key, + algorithm=settings.jwt_algorithm, + ) + + +def decode_access_token(token: str) -> dict: + return jwt.decode( + token, + settings.jwt_secret_key, + algorithms=[settings.jwt_algorithm], + ) diff --git a/backend/main.py b/backend/main.py index 13f3eb8..208f5f2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from config import settings from shared.firebase import init_firebase, firebase_initialized +from auth.router import router as auth_router app = FastAPI( title="BellSystems Admin Panel", @@ -18,6 +19,8 @@ app.add_middleware( allow_headers=["*"], ) +app.include_router(auth_router) + @app.on_event("startup") async def startup(): diff --git a/backend/seed_admin.py b/backend/seed_admin.py new file mode 100644 index 0000000..e7d0882 --- /dev/null +++ b/backend/seed_admin.py @@ -0,0 +1,60 @@ +""" +Seed script to create the first superadmin user in Firestore. + +Usage: + python seed_admin.py + python seed_admin.py --email admin@bellsystems.com --password secret --name "Admin" +""" +import argparse +import sys +from getpass import getpass + +from shared.firebase import init_firebase, get_db +from auth.utils import hash_password + + +def seed_superadmin(email: str, password: str, name: str): + init_firebase() + db = get_db() + if not db: + print("ERROR: Firebase initialization failed.") + sys.exit(1) + + existing = ( + db.collection("admin_users") + .where("email", "==", email) + .limit(1) + .get() + ) + if existing: + print(f"User with email '{email}' already exists. Aborting.") + sys.exit(1) + + user_data = { + "email": email, + "hashed_password": hash_password(password), + "name": name, + "role": "superadmin", + "is_active": True, + } + + doc_ref = db.collection("admin_users").add(user_data) + print(f"Superadmin created successfully!") + print(f" Email: {email}") + print(f" Name: {name}") + print(f" Role: superadmin") + print(f" Doc ID: {doc_ref[1].id}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Seed a superadmin user") + parser.add_argument("--email", default=None) + parser.add_argument("--password", default=None) + parser.add_argument("--name", default=None) + args = parser.parse_args() + + email = args.email or input("Email: ") + name = args.name or input("Name: ") + password = args.password or getpass("Password: ") + + seed_superadmin(email, password, name) diff --git a/backend/shared/exceptions.py b/backend/shared/exceptions.py index 16a58c1..95bfbb4 100644 --- a/backend/shared/exceptions.py +++ b/backend/shared/exceptions.py @@ -1 +1,20 @@ -# TODO: Custom error handlers +from fastapi import HTTPException + + +class AuthenticationError(HTTPException): + def __init__(self, detail: str = "Could not validate credentials"): + super().__init__( + status_code=401, + detail=detail, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +class AuthorizationError(HTTPException): + def __init__(self, detail: str = "Insufficient permissions"): + super().__init__(status_code=403, detail=detail) + + +class NotFoundError(HTTPException): + def __init__(self, resource: str = "Resource"): + super().__init__(status_code=404, detail=f"{resource} not found") diff --git a/frontend/index.html b/frontend/index.html index c20fbd3..8ebeb72 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - frontend + BellSystems Admin
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a360b4c..a3c03ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -1944,6 +1945,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3154,6 +3168,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3225,6 +3277,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 272380f..5708fa0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c6d04fa..d161d7a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,12 +1,59 @@ -function App() { - return ( -
-
-

BellSystems Admin Panel

-

Phase 0 — Scaffolding complete

+import { Routes, Route, Navigate } from "react-router-dom"; +import { useAuth } from "./auth/AuthContext"; +import LoginPage from "./auth/LoginPage"; +import MainLayout from "./layout/MainLayout"; + +function ProtectedRoute({ children }) { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+

Loading...

-
- ) + ); + } + + if (!user) { + return ; + } + + return children; } -export default App +function DashboardPage() { + const { user } = useAuth(); + return ( +
+

Dashboard

+

+ Welcome, {user?.name}. You are logged in as{" "} + {user?.role}. +

+
+ ); +} + +export default function App() { + return ( + + } /> + + + + } + > + } /> + {/* Phase 2+ routes: + } /> + } /> + } /> + } /> + */} + } /> + + + ); +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 5325f6b..86a1c87 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1 +1,63 @@ -// TODO: Axios/fetch wrapper with JWT +const API_BASE = "/api"; + +class ApiClient { + getToken() { + return localStorage.getItem("access_token"); + } + + async request(endpoint, options = {}) { + const url = `${API_BASE}${endpoint}`; + const token = this.getToken(); + + const headers = { + "Content-Type": "application/json", + ...options.headers, + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch(url, { ...options, headers }); + + if (response.status === 401) { + localStorage.removeItem("access_token"); + localStorage.removeItem("user"); + window.location.href = "/login"; + throw new Error("Session expired"); + } + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `Request failed: ${response.status}`); + } + + if (response.status === 204) return null; + return response.json(); + } + + get(endpoint) { + return this.request(endpoint, { method: "GET" }); + } + + post(endpoint, data) { + return this.request(endpoint, { + method: "POST", + body: JSON.stringify(data), + }); + } + + put(endpoint, data) { + return this.request(endpoint, { + method: "PUT", + body: JSON.stringify(data), + }); + } + + delete(endpoint) { + return this.request(endpoint, { method: "DELETE" }); + } +} + +const api = new ApiClient(); +export default api; diff --git a/frontend/src/auth/AuthContext.jsx b/frontend/src/auth/AuthContext.jsx index df8b7dd..75d78d4 100644 --- a/frontend/src/auth/AuthContext.jsx +++ b/frontend/src/auth/AuthContext.jsx @@ -1 +1,70 @@ -// TODO: JWT token state management +import { createContext, useContext, useState, useEffect } from "react"; +import api from "../api/client"; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [user, setUser] = useState(() => { + const stored = localStorage.getItem("user"); + return stored ? JSON.parse(stored) : null; + }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (!token) { + setUser(null); + setLoading(false); + return; + } + + try { + const payload = JSON.parse(atob(token.split(".")[1])); + if (payload.exp * 1000 < Date.now()) { + localStorage.removeItem("access_token"); + localStorage.removeItem("user"); + setUser(null); + } + } catch { + localStorage.removeItem("access_token"); + localStorage.removeItem("user"); + setUser(null); + } + setLoading(false); + }, []); + + const login = async (email, password) => { + const data = await api.post("/auth/login", { email, password }); + localStorage.setItem("access_token", data.access_token); + const userInfo = { name: data.name, role: data.role }; + localStorage.setItem("user", JSON.stringify(userInfo)); + setUser(userInfo); + return data; + }; + + const logout = () => { + localStorage.removeItem("access_token"); + localStorage.removeItem("user"); + setUser(null); + }; + + const hasRole = (...roles) => { + if (!user) return false; + if (user.role === "superadmin") return true; + return roles.includes(user.role); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/frontend/src/auth/LoginPage.jsx b/frontend/src/auth/LoginPage.jsx index f5b6064..3d54d59 100644 --- a/frontend/src/auth/LoginPage.jsx +++ b/frontend/src/auth/LoginPage.jsx @@ -1 +1,81 @@ -// TODO: Login page component +import { useState } from "react"; +import { useAuth } from "./AuthContext"; +import { useNavigate } from "react-router-dom"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const { login } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + setIsLoading(true); + try { + await login(email, password); + navigate("/", { replace: true }); + } catch (err) { + setError(err.message || "Login failed"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

+ BellSystems Admin +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + required + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="admin@bellsystems.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + +
+
+
+ ); +} diff --git a/frontend/src/layout/Header.jsx b/frontend/src/layout/Header.jsx index 7daad1b..f66be16 100644 --- a/frontend/src/layout/Header.jsx +++ b/frontend/src/layout/Header.jsx @@ -1 +1,28 @@ -// TODO: Header component +import { useAuth } from "../auth/AuthContext"; + +export default function Header() { + const { user, logout } = useAuth(); + + return ( +
+

+ BellSystems Admin Panel +

+ +
+ + {user?.name} + + {user?.role} + + + +
+
+ ); +} diff --git a/frontend/src/layout/MainLayout.jsx b/frontend/src/layout/MainLayout.jsx index 2811985..8dd3cd8 100644 --- a/frontend/src/layout/MainLayout.jsx +++ b/frontend/src/layout/MainLayout.jsx @@ -1 +1,17 @@ -// TODO: Main layout wrapper +import { Outlet } from "react-router-dom"; +import Header from "./Header"; +import Sidebar from "./Sidebar"; + +export default function MainLayout() { + return ( +
+ +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/layout/Sidebar.jsx b/frontend/src/layout/Sidebar.jsx index 1dafa44..adddc29 100644 --- a/frontend/src/layout/Sidebar.jsx +++ b/frontend/src/layout/Sidebar.jsx @@ -1 +1,42 @@ -// TODO: Navigation menu +import { NavLink } from "react-router-dom"; +import { useAuth } from "../auth/AuthContext"; + +const navItems = [ + { to: "/", label: "Dashboard", roles: null }, + { to: "/melodies", label: "Melodies", roles: ["superadmin", "melody_editor", "viewer"] }, + { to: "/devices", label: "Devices", roles: ["superadmin", "device_manager", "viewer"] }, + { to: "/users", label: "Users", roles: ["superadmin", "user_manager", "viewer"] }, + { to: "/mqtt", label: "MQTT", roles: ["superadmin", "device_manager", "viewer"] }, +]; + +export default function Sidebar() { + const { hasRole } = useAuth(); + + const visibleItems = navItems.filter( + (item) => item.roles === null || hasRole(...item.roles) + ); + + return ( + + ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index b9a1a6d..64be51b 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,16 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.jsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { AuthProvider } from "./auth/AuthContext"; +import "./index.css"; +import App from "./App.jsx"; -createRoot(document.getElementById('root')).render( +createRoot(document.getElementById("root")).render( - - , -) + + + + + + +);