Merge branch 'development'

This commit is contained in:
2026-03-24 19:39:41 +01:00
9 changed files with 232 additions and 5 deletions
@@ -27,3 +27,9 @@ def get_current_user(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user 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
+12 -1
View File
@@ -2,9 +2,10 @@ import os
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from .database import engine, Base 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) Base.metadata.create_all(bind=engine)
@@ -24,6 +25,16 @@ app.include_router(users.router)
app.include_router(purchases.router) app.include_router(purchases.router)
app.include_router(stats.router) app.include_router(stats.router)
app.include_router(history.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("/") @app.get("/")
+2 -1
View File
@@ -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 sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
from .database import Base from .database import Base
@@ -10,6 +10,7 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False) username = Column(String, unique=True, index=True, nullable=False)
password = Column(String, 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") purchases = relationship("Purchase", back_populates="owner", cascade="all, delete")
+57
View File
@@ -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()
+4 -1
View File
@@ -17,6 +17,7 @@ class UserCreate(BaseModel):
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
token_type: str token_type: str
is_admin: bool
@router.post("/register", status_code=status.HTTP_201_CREATED) @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() existing = db.query(models.User).filter(models.User.username == user_in.username).first()
if existing: if existing:
raise HTTPException(status_code=400, detail="Username already taken") raise HTTPException(status_code=400, detail="Username already taken")
no_users_yet = db.query(models.User).first() is None
user = models.User( user = models.User(
username=user_in.username, username=user_in.username,
password=hash_password(user_in.password), password=hash_password(user_in.password),
is_admin=no_users_yet,
) )
db.add(user) db.add(user)
db.commit() 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): if not user or not verify_password(user_in.password, user.password):
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token({"sub": user.username}) 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}
+8
View File
@@ -3,11 +3,18 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login'; import Login from './pages/Login';
import Register from './pages/Register'; import Register from './pages/Register';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import AdminPage from './pages/AdminPage';
function PrivateRoute({ children }) { function PrivateRoute({ children }) {
return localStorage.getItem('token') ? children : <Navigate to="/login" />; return localStorage.getItem('token') ? children : <Navigate to="/login" />;
} }
function AdminRoute({ children }) {
if (!localStorage.getItem('token')) return <Navigate to="/login" />;
if (localStorage.getItem('is_admin') !== 'true') return <Navigate to="/" />;
return children;
}
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
@@ -15,6 +22,7 @@ export default function App() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/" element={<PrivateRoute><Dashboard /></PrivateRoute>} /> <Route path="/" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
<Route path="*" element={<Navigate to="/" />} /> <Route path="*" element={<Navigate to="/" />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
@@ -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 (
<div style={styles.app}>
<div style={styles.header}>
<div style={styles.title}>User Management</div>
<button style={styles.backBtn} onClick={() => navigate('/')}>Back to Dashboard</button>
</div>
<div style={styles.card}>
<div style={styles.sectionTitle}>All Users</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Username</th>
<th style={styles.th}>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td style={styles.td}>
{u.username}
{u.is_admin && <span style={styles.adminBadge}>admin</span>}
</td>
<td style={styles.td}>
{u.username !== currentUsername && (
<button style={styles.deleteBtn} onClick={() => handleDelete(u.id, u.username)}>Delete</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={styles.card}>
<div style={styles.sectionTitle}>Create User</div>
<form style={styles.form} onSubmit={handleCreate}>
{error && <div style={styles.error}>{error}</div>}
{success && <div style={styles.success}>{success}</div>}
<div style={styles.row}>
<input style={styles.input} placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required />
<input style={styles.input} type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<label style={styles.checkLabel}>
<input type="checkbox" checked={isAdmin} onChange={e => setIsAdmin(e.target.checked)} />
Admin
</label>
<button style={styles.submitBtn} type="submit">Create User</button>
</form>
</div>
</div>
);
}
+10 -2
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; 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 AddPurchase from '../components/AddPurchase';
import PurchaseList from '../components/PurchaseList'; import PurchaseList from '../components/PurchaseList';
import PortfolioChart from '../components/PortfolioChart'; import PortfolioChart from '../components/PortfolioChart';
@@ -12,6 +12,8 @@ const styles = {
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
logo: { fontSize: '1.4rem', fontWeight: 700, color: '#f7931a' }, 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' }, 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' }, statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' },
statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' }, statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' }, statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
@@ -68,8 +70,11 @@ export default function Dashboard() {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
const isAdmin = localStorage.getItem('is_admin') === 'true';
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('is_admin');
navigate('/login'); navigate('/login');
}; };
@@ -81,7 +86,10 @@ export default function Dashboard() {
<div style={styles.app}> <div style={styles.app}>
<div style={styles.header}> <div style={styles.header}>
<div style={styles.logo}> BTC Portfolio</div> <div style={styles.logo}> BTC Portfolio</div>
<button style={styles.logoutBtn} onClick={handleLogout}>Logout</button> <div style={styles.headerBtns}>
{isAdmin && <Link to="/admin" style={styles.adminBtn}>Manage Users</Link>}
<button style={styles.logoutBtn} onClick={handleLogout}>Logout</button>
</div>
</div> </div>
{stats && ( {stats && (
@@ -35,6 +35,7 @@ export default function Login() {
} }
const data = await res.json(); const data = await res.json();
localStorage.setItem('token', data.access_token); localStorage.setItem('token', data.access_token);
localStorage.setItem('is_admin', data.is_admin ? 'true' : 'false');
navigate('/'); navigate('/');
} catch { } catch {
setError('Network error'); setError('Network error');