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 (
+
-
- )
+ );
+ }
+
+ 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}
+
+ )}
+
+
+
+
+ );
+}
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 (
+
+ );
+}
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(
-
- ,
-)
+
+
+
+
+
+
+);