diff --git a/.env.example b/.env.example index fd1a72d..447cfe5 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,6 @@ MYSQL_ROOT_PASSWORD=sproutly_root_secret MYSQL_USER=sproutly MYSQL_PASSWORD=sproutly_secret SECRET_KEY=your-secret-key-change-this + +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=change-this-password diff --git a/README.md b/README.md index 4a86660..23bf910 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Sproutly takes the guesswork out of seed starting. Enter your plant varieties on ## Features - **Multi-user** — each user has their own account with fully isolated data +- **Admin panel** — manage all user accounts: view content, reset passwords, disable, or delete - **Dashboard** — at-a-glance view of overdue, today's, and upcoming tasks with a full year planting timeline - **Seed Library** — manage plant varieties with frost-relative timing, germination days, sun/water requirements - **Garden Tracker** — log growing batches and track status from `planned` → `germinating` → `seedling` → `potted up` → `hardening off` → `garden` → `harvested` @@ -60,6 +61,8 @@ MYSQL_ROOT_PASSWORD=sproutly_root_secret MYSQL_USER=sproutly MYSQL_PASSWORD=sproutly_secret SECRET_KEY=your-secret-key-change-this +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=change-this-password ``` `SECRET_KEY` is used to sign JWT tokens. Generate a secure value with: @@ -68,6 +71,8 @@ SECRET_KEY=your-secret-key-change-this python3 -c "import secrets; print(secrets.token_hex(32))" ``` +`ADMIN_EMAIL` and `ADMIN_PASSWORD` define the super admin account. This account is created (or updated) automatically every time the backend starts — changing these values in `.env` and restarting is all that's needed to update the credentials. + ## Project Structure ``` @@ -84,6 +89,7 @@ sproutly/ │ ├── database.py │ └── routers/ │ ├── auth.py # /auth/register, /auth/login, /auth/me +│ ├── admin.py # /admin/users — admin-only user management │ ├── varieties.py │ ├── batches.py │ ├── dashboard.py @@ -114,6 +120,24 @@ Key endpoints: - `POST /api/notifications/test` — send test ntfy notification - `POST /api/notifications/daily` — trigger daily summary - `GET /api/notifications/log` — recent notification history +- `GET /api/admin/users` — list all users with stats (admin only) +- `GET /api/admin/users/{id}/varieties` — view a user's seed library (admin only) +- `GET /api/admin/users/{id}/batches` — view a user's batches (admin only) +- `POST /api/admin/users/{id}/reset-password` — reset a user's password (admin only) +- `POST /api/admin/users/{id}/disable` — toggle account disabled state (admin only) +- `DELETE /api/admin/users/{id}` — delete a user and all their data (admin only) + +## Admin Panel + +Log in with the `ADMIN_EMAIL` / `ADMIN_PASSWORD` credentials from your `.env`. Once logged in, an **Admin** link appears in the sidebar. From there you can: + +- View all registered users with their variety and batch counts +- Browse any user's seed library and growing batches +- Reset a user's password +- Disable or re-enable an account +- Permanently delete an account and all associated data + +The admin account itself cannot be disabled or deleted through the panel. ## Ntfy Authentication diff --git a/backend/auth.py b/backend/auth.py index 06c1685..1f657a8 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -45,3 +45,9 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends( if not user: raise exc return user + + +def get_admin_user(current_user: User = Depends(get_current_user)) -> 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/backend/main.py b/backend/main.py index 4eef104..9f7e6c5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,6 @@ import asyncio import logging +import os from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -7,10 +8,11 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from database import SessionLocal -from models import Settings, NotificationLog +from models import Settings, NotificationLog, User from routers import varieties, batches, dashboard, settings, notifications -from routers import auth as auth_router +from routers import auth as auth_router, admin as admin_router from routers.notifications import build_daily_summary, send_ntfy +from auth import hash_password logging.basicConfig(level=logging.INFO) logger = logging.getLogger("sproutly") @@ -49,8 +51,37 @@ def get_notification_schedule(db) -> tuple[int, int]: return 7, 0 +def bootstrap_admin(retries: int = 5, delay: float = 2.0): + import time + admin_email = os.environ.get("ADMIN_EMAIL") + admin_password = os.environ.get("ADMIN_PASSWORD") + if not admin_email or not admin_password: + return + for attempt in range(retries): + db = SessionLocal() + try: + admin = db.query(User).filter(User.email == admin_email).first() + if admin: + admin.hashed_password = hash_password(admin_password) + admin.is_admin = True + admin.is_disabled = False + else: + admin = User(email=admin_email, hashed_password=hash_password(admin_password), is_admin=True) + db.add(admin) + db.commit() + logger.info(f"Admin user ready: {admin_email}") + return + except Exception as e: + logger.warning(f"Admin bootstrap attempt {attempt + 1} failed: {e}") + time.sleep(delay) + finally: + db.close() + logger.error("Admin bootstrap failed after all retries") + + @asynccontextmanager async def lifespan(app: FastAPI): + bootstrap_admin() db = SessionLocal() hour, minute = get_notification_schedule(db) db.close() @@ -77,6 +108,7 @@ app.add_middleware( ) app.include_router(auth_router.router) +app.include_router(admin_router.router) app.include_router(varieties.router) app.include_router(batches.router) app.include_router(dashboard.router) diff --git a/backend/models.py b/backend/models.py index 06508b0..07845e7 100644 --- a/backend/models.py +++ b/backend/models.py @@ -41,6 +41,8 @@ class User(Base): id = Column(Integer, primary_key=True, autoincrement=True) email = Column(String(255), unique=True, nullable=False) hashed_password = Column(String(255), nullable=False) + is_admin = Column(Boolean, default=False, nullable=False) + is_disabled = Column(Boolean, default=False, nullable=False) created_at = Column(DateTime, server_default=func.now()) varieties = relationship("Variety", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/routers/admin.py b/backend/routers/admin.py new file mode 100644 index 0000000..f438c9b --- /dev/null +++ b/backend/routers/admin.py @@ -0,0 +1,88 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List + +from auth import get_admin_user, hash_password +from database import get_db +from models import Batch, User, Variety +from schemas import AdminResetPassword, AdminUserOut, BatchOut, VarietyOut + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +def _user_stats(db: Session, user_id: int) -> dict: + return { + "variety_count": db.query(Variety).filter(Variety.user_id == user_id).count(), + "batch_count": db.query(Batch).filter(Batch.user_id == user_id).count(), + } + + +@router.get("/users", response_model=List[AdminUserOut]) +def list_users(db: Session = Depends(get_db), _: User = Depends(get_admin_user)): + users = db.query(User).order_by(User.created_at).all() + return [ + AdminUserOut( + id=u.id, email=u.email, is_admin=u.is_admin, is_disabled=u.is_disabled, + created_at=u.created_at, **_user_stats(db, u.id) + ) + for u in users + ] + + +@router.get("/users/{user_id}/varieties", response_model=List[VarietyOut]) +def user_varieties(user_id: int, db: Session = Depends(get_db), _: User = Depends(get_admin_user)): + _require_user(db, user_id) + return db.query(Variety).filter(Variety.user_id == user_id).order_by(Variety.category, Variety.name).all() + + +@router.get("/users/{user_id}/batches", response_model=List[BatchOut]) +def user_batches(user_id: int, db: Session = Depends(get_db), _: User = Depends(get_admin_user)): + from sqlalchemy.orm import joinedload + _require_user(db, user_id) + return ( + db.query(Batch) + .options(joinedload(Batch.variety)) + .filter(Batch.user_id == user_id) + .order_by(Batch.created_at.desc()) + .all() + ) + + +@router.post("/users/{user_id}/reset-password") +def reset_password(user_id: int, data: AdminResetPassword, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)): + user = _require_user(db, user_id) + if not data.new_password or len(data.new_password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + user.hashed_password = hash_password(data.new_password) + db.commit() + return {"status": "ok"} + + +@router.post("/users/{user_id}/disable") +def toggle_disable(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)): + user = _require_user(db, user_id) + if user.id == admin.id: + raise HTTPException(status_code=400, detail="Cannot disable your own account") + if user.is_admin: + raise HTTPException(status_code=400, detail="Cannot disable another admin account") + user.is_disabled = not user.is_disabled + db.commit() + return {"is_disabled": user.is_disabled} + + +@router.delete("/users/{user_id}", status_code=204) +def delete_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)): + user = _require_user(db, user_id) + if user.id == admin.id: + raise HTTPException(status_code=400, detail="Cannot delete your own account") + if user.is_admin: + raise HTTPException(status_code=400, detail="Cannot delete another admin account") + db.delete(user) + db.commit() + + +def _require_user(db: Session, user_id: int) -> User: + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 36a6708..46e0db9 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -46,6 +46,8 @@ def login(data: UserLogin, db: Session = Depends(get_db)): user = db.query(User).filter(User.email == data.email).first() if not user or not verify_password(data.password, user.hashed_password): raise HTTPException(status_code=401, detail="Invalid email or password") + if user.is_disabled: + raise HTTPException(status_code=403, detail="Account has been disabled") return {"access_token": create_access_token(user.id), "token_type": "bearer"} diff --git a/backend/schemas.py b/backend/schemas.py index 61d96b6..3fd0fb5 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -13,18 +13,34 @@ class UserCreate(BaseModel): class UserLogin(BaseModel): - email: EmailStr + email: str password: str class UserOut(BaseModel): id: int email: str + is_admin: bool = False + is_disabled: bool = False created_at: Optional[datetime] = None model_config = {"from_attributes": True} +class AdminUserOut(BaseModel): + id: int + email: str + is_admin: bool + is_disabled: bool + created_at: Optional[datetime] + variety_count: int + batch_count: int + + +class AdminResetPassword(BaseModel): + new_password: str + + class Token(BaseModel): access_token: str token_type: str diff --git a/docker-compose.yml b/docker-compose.yml index 5df2482..3d30771 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,8 @@ services: DB_USER: ${MYSQL_USER} DB_PASSWORD: ${MYSQL_PASSWORD} SECRET_KEY: ${SECRET_KEY} + ADMIN_EMAIL: ${ADMIN_EMAIL} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} depends_on: mysql: condition: service_healthy diff --git a/mysql/init.sql b/mysql/init.sql index 20315f4..e00e1ad 100644 --- a/mysql/init.sql +++ b/mysql/init.sql @@ -4,6 +4,8 @@ CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, hashed_password VARCHAR(255) NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + is_disabled BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); diff --git a/nginx/html/css/style.css b/nginx/html/css/style.css index 8c70bf1..76897be 100644 --- a/nginx/html/css/style.css +++ b/nginx/html/css/style.css @@ -681,3 +681,27 @@ a:hover { text-decoration: underline; } transition: background 0.15s, color 0.15s; } .btn-logout:hover { background: var(--border); color: var(--text); } + +/* ===== Admin ===== */ +.admin-table-wrap { overflow-x: auto; } +.admin-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } +.admin-table th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid var(--border); color: var(--text-light); font-weight: 600; white-space: nowrap; } +.admin-table td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: middle; } +.admin-table tr:last-child td { border-bottom: none; } +.admin-email { font-weight: 500; } +.admin-date, .admin-num { color: var(--text-light); white-space: nowrap; } +.admin-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; } +.badge-admin { background: var(--green-dark); color: #fff; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 99px; vertical-align: middle; } +.status-pill { font-size: 0.78rem; padding: 0.2rem 0.55rem; border-radius: 99px; font-weight: 500; } +.status-pill.active { background: #d1fae5; color: #065f46; } +.status-pill.disabled { background: #fee2e2; color: #991b1b; } +.btn-xs { padding: 0.2rem 0.55rem; font-size: 0.78rem; } +.btn-warn { background: #fef3c7; color: #92400e; border: 1px solid #fde68a; } +.btn-warn:hover { background: #fde68a; } +.btn-danger { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; } +.btn-danger:hover { background: #fca5a5; } +.variety-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 0.35rem; vertical-align: middle; } +.admin-view-tabs { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 2px solid var(--border); } +.admin-view-tab { background: none; border: none; padding: 0.5rem 1rem; font-size: 0.9rem; cursor: pointer; color: var(--text-light); border-bottom: 2px solid transparent; margin-bottom: -2px; } +.admin-view-tab.active { color: var(--green-dark); border-bottom-color: var(--green-dark); font-weight: 500; } +.admin-view-panel { max-height: 60vh; overflow-y: auto; } diff --git a/nginx/html/index.html b/nginx/html/index.html index 5ef50ac..894d12d 100644 --- a/nginx/html/index.html +++ b/nginx/html/index.html @@ -73,6 +73,9 @@ Settings +
+ +Manage user accounts
+| Joined | +Varieties | +Batches | +Status | +Actions | +|
|---|---|---|---|---|---|
| ${esc(u.email)}${u.is_admin ? ' admin' : ''} | +${fmt(u.created_at)} | +${u.variety_count} | +${u.batch_count} | +${u.is_disabled ? 'Disabled' : 'Active'} | ++ + + ${!u.is_admin ? `` : ''} + ${!u.is_admin ? `` : ''} + | +
| Name | Category | Start wks | Garden wks |
|---|---|---|---|
| ${esc(v.name)}${v.variety_name ? ` ${esc(v.variety_name)}` : ''} | +${v.category} | +${v.weeks_to_start ?? '—'} | +${v.weeks_to_garden ?? '—'} | +