diff --git a/btc-portfolio/backend/app/dependencies.py b/btc-portfolio/backend/app/dependencies.py index edcebf0..0d18fd7 100644 --- a/btc-portfolio/backend/app/dependencies.py +++ b/btc-portfolio/backend/app/dependencies.py @@ -27,3 +27,9 @@ def get_current_user( if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") return user + + +def get_current_admin(current_user: models.User = Depends(get_current_user)) -> models.User: + if not current_user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + return current_user diff --git a/btc-portfolio/backend/app/main.py b/btc-portfolio/backend/app/main.py index 4b10b87..0a15d0d 100644 --- a/btc-portfolio/backend/app/main.py +++ b/btc-portfolio/backend/app/main.py @@ -1,8 +1,9 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text from .database import engine, Base -from .routes import users, purchases, stats, history +from .routes import users, purchases, stats, history, admin Base.metadata.create_all(bind=engine) @@ -20,6 +21,16 @@ app.include_router(users.router) app.include_router(purchases.router) app.include_router(stats.router) app.include_router(history.router) +app.include_router(admin.router, prefix="/admin") + + +@app.on_event("startup") +def migrate(): + with engine.connect() as conn: + cols = [r[1] for r in conn.execute(text("PRAGMA table_info(users)"))] + if "is_admin" not in cols: + conn.execute(text("ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0")) + conn.commit() @app.get("/") diff --git a/btc-portfolio/backend/app/models.py b/btc-portfolio/backend/app/models.py index 22fa09a..72084d0 100644 --- a/btc-portfolio/backend/app/models.py +++ b/btc-portfolio/backend/app/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime +from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime, Boolean from sqlalchemy.orm import relationship from datetime import datetime from .database import Base @@ -10,6 +10,7 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) username = Column(String, unique=True, index=True, nullable=False) password = Column(String, nullable=False) + is_admin = Column(Boolean, default=False, nullable=False, server_default='0') purchases = relationship("Purchase", back_populates="owner", cascade="all, delete") diff --git a/btc-portfolio/backend/app/routes/admin.py b/btc-portfolio/backend/app/routes/admin.py new file mode 100644 index 0000000..cf5c0da --- /dev/null +++ b/btc-portfolio/backend/app/routes/admin.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import List + +from ..database import get_db +from .. import models +from ..auth import hash_password +from ..dependencies import get_current_admin + +router = APIRouter() + + +class UserOut(BaseModel): + id: int + username: str + is_admin: bool + + class Config: + from_attributes = True + + +class UserCreate(BaseModel): + username: str + password: str + is_admin: bool = False + + +@router.get("/users", response_model=List[UserOut]) +def list_users(db: Session = Depends(get_db), _: models.User = Depends(get_current_admin)): + return db.query(models.User).all() + + +@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED) +def create_user(user_in: UserCreate, db: Session = Depends(get_db), _: models.User = Depends(get_current_admin)): + if db.query(models.User).filter(models.User.username == user_in.username).first(): + raise HTTPException(status_code=400, detail="Username already taken") + user = models.User( + username=user_in.username, + password=hash_password(user_in.password), + is_admin=user_in.is_admin, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user(user_id: int, db: Session = Depends(get_db), current_admin: models.User = Depends(get_current_admin)): + if user_id == current_admin.id: + raise HTTPException(status_code=400, detail="Cannot delete your own account") + user = db.query(models.User).filter(models.User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + db.delete(user) + db.commit() diff --git a/btc-portfolio/backend/app/routes/users.py b/btc-portfolio/backend/app/routes/users.py index 970210f..d78d118 100644 --- a/btc-portfolio/backend/app/routes/users.py +++ b/btc-portfolio/backend/app/routes/users.py @@ -17,6 +17,7 @@ class UserCreate(BaseModel): class Token(BaseModel): access_token: str token_type: str + is_admin: bool @router.post("/register", status_code=status.HTTP_201_CREATED) @@ -24,9 +25,11 @@ def register(user_in: UserCreate, db: Session = Depends(get_db)): existing = db.query(models.User).filter(models.User.username == user_in.username).first() if existing: raise HTTPException(status_code=400, detail="Username already taken") + no_users_yet = db.query(models.User).first() is None user = models.User( username=user_in.username, password=hash_password(user_in.password), + is_admin=no_users_yet, ) db.add(user) db.commit() @@ -39,4 +42,4 @@ def login(user_in: UserCreate, db: Session = Depends(get_db)): if not user or not verify_password(user_in.password, user.password): raise HTTPException(status_code=401, detail="Invalid credentials") token = create_access_token({"sub": user.username}) - return {"access_token": token, "token_type": "bearer"} + return {"access_token": token, "token_type": "bearer", "is_admin": user.is_admin} diff --git a/btc-portfolio/frontend/src/App.js b/btc-portfolio/frontend/src/App.js index 0774d39..df7f247 100644 --- a/btc-portfolio/frontend/src/App.js +++ b/btc-portfolio/frontend/src/App.js @@ -3,11 +3,18 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import Login from './pages/Login'; import Register from './pages/Register'; import Dashboard from './pages/Dashboard'; +import AdminPage from './pages/AdminPage'; function PrivateRoute({ children }) { return localStorage.getItem('token') ? children : ; } +function AdminRoute({ children }) { + if (!localStorage.getItem('token')) return ; + if (localStorage.getItem('is_admin') !== 'true') return ; + return children; +} + export default function App() { return ( @@ -15,6 +22,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/btc-portfolio/frontend/src/pages/AdminPage.js b/btc-portfolio/frontend/src/pages/AdminPage.js new file mode 100644 index 0000000..810dab0 --- /dev/null +++ b/btc-portfolio/frontend/src/pages/AdminPage.js @@ -0,0 +1,132 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; + +const API = process.env.REACT_APP_API_URL || 'http://localhost:8000'; + +const styles = { + app: { maxWidth: '900px', margin: '0 auto', padding: '1.5rem' }, + header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }, + title: { fontSize: '1.4rem', fontWeight: 700, color: '#f7931a' }, + backBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer' }, + card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' }, + sectionTitle: { fontSize: '1rem', fontWeight: 700, color: '#f7931a', marginBottom: '1rem' }, + table: { width: '100%', borderCollapse: 'collapse' }, + th: { textAlign: 'left', color: '#888', fontSize: '0.8rem', paddingBottom: '0.5rem', borderBottom: '1px solid #333' }, + td: { padding: '0.6rem 0', borderBottom: '1px solid #222', color: '#e0e0e0', fontSize: '0.95rem' }, + adminBadge: { background: 'rgba(247,147,26,0.15)', color: '#f7931a', borderRadius: '4px', padding: '0.1rem 0.5rem', fontSize: '0.75rem', marginLeft: '0.5rem' }, + deleteBtn: { background: 'none', border: '1px solid #555', color: '#ff6b6b', borderRadius: '6px', padding: '0.25rem 0.75rem', cursor: 'pointer', fontSize: '0.85rem' }, + form: { display: 'flex', flexDirection: 'column', gap: '0.75rem' }, + row: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }, + input: { flex: 1, minWidth: '140px', padding: '0.6rem 0.75rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '0.95rem' }, + checkLabel: { display: 'flex', alignItems: 'center', gap: '0.4rem', color: '#aaa', fontSize: '0.9rem', cursor: 'pointer' }, + submitBtn: { alignSelf: 'flex-start', padding: '0.6rem 1.5rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, cursor: 'pointer' }, + error: { color: '#ff6b6b', fontSize: '0.9rem' }, + success: { color: '#6bff8e', fontSize: '0.9rem' }, +}; + +export default function AdminPage() { + const [users, setUsers] = useState([]); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [isAdmin, setIsAdmin] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const navigate = useNavigate(); + + const authHeaders = () => ({ + Authorization: `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }); + + const fetchUsers = useCallback(async () => { + const res = await fetch(`${API}/admin/users`, { headers: authHeaders() }); + if (res.status === 401 || res.status === 403) { navigate('/'); return; } + setUsers(await res.json()); + }, [navigate]); + + useEffect(() => { fetchUsers(); }, [fetchUsers]); + + const handleCreate = async (e) => { + e.preventDefault(); + setError(''); setSuccess(''); + const res = await fetch(`${API}/admin/users`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ username, password, is_admin: isAdmin }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.detail || 'Failed to create user'); + return; + } + setSuccess(`User "${username}" created.`); + setUsername(''); setPassword(''); setIsAdmin(false); + fetchUsers(); + }; + + const handleDelete = async (id, name) => { + if (!window.confirm(`Delete user "${name}"? This also deletes all their purchases.`)) return; + await fetch(`${API}/admin/users/${id}`, { method: 'DELETE', headers: authHeaders() }); + fetchUsers(); + }; + + const currentUsername = (() => { + try { + const token = localStorage.getItem('token'); + return JSON.parse(atob(token.split('.')[1])).sub; + } catch { return null; } + })(); + + return ( +
+
+
User Management
+ +
+ +
+
All Users
+ + + + + + + + + {users.map(u => ( + + + + + ))} + +
UsernameActions
+ {u.username} + {u.is_admin && admin} + + {u.username !== currentUsername && ( + + )} +
+
+ +
+
Create User
+
+ {error &&
{error}
} + {success &&
{success}
} +
+ setUsername(e.target.value)} required /> + setPassword(e.target.value)} required /> +
+ + +
+
+
+ ); +} diff --git a/btc-portfolio/frontend/src/pages/Dashboard.js b/btc-portfolio/frontend/src/pages/Dashboard.js index af58a91..4ea31e1 100644 --- a/btc-portfolio/frontend/src/pages/Dashboard.js +++ b/btc-portfolio/frontend/src/pages/Dashboard.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, Link } from 'react-router-dom'; import AddPurchase from '../components/AddPurchase'; import PurchaseList from '../components/PurchaseList'; import PortfolioChart from '../components/PortfolioChart'; @@ -12,6 +12,8 @@ const styles = { header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }, logo: { fontSize: '1.4rem', fontWeight: 700, color: '#f7931a' }, logoutBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer' }, + adminBtn: { background: 'none', border: '1px solid #f7931a', color: '#f7931a', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer', textDecoration: 'none', fontSize: '1rem' }, + headerBtns: { display: 'flex', gap: '0.5rem', alignItems: 'center' }, statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' }, statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' }, statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' }, @@ -68,8 +70,11 @@ export default function Dashboard() { fetchData(); }, [fetchData]); + const isAdmin = localStorage.getItem('is_admin') === 'true'; + const handleLogout = () => { localStorage.removeItem('token'); + localStorage.removeItem('is_admin'); navigate('/login'); }; @@ -81,7 +86,10 @@ export default function Dashboard() {
₿ BTC Portfolio
- +
+ {isAdmin && Manage Users} + +
{stats && ( diff --git a/btc-portfolio/frontend/src/pages/Login.js b/btc-portfolio/frontend/src/pages/Login.js index 25f4cd9..984e60f 100644 --- a/btc-portfolio/frontend/src/pages/Login.js +++ b/btc-portfolio/frontend/src/pages/Login.js @@ -35,6 +35,7 @@ export default function Login() { } const data = await res.json(); localStorage.setItem('token', data.access_token); + localStorage.setItem('is_admin', data.is_admin ? 'true' : 'false'); navigate('/'); } catch { setError('Network error');