Add multi-user auth, admin panel, and timezone support; rename to Yolkbook

- Rename app from Eggtracker to Yolkbook throughout
- Add JWT-based authentication (python-jose, passlib/bcrypt)
- Add users table; all data tables gain user_id FK for full data isolation
- Super admin credentials sourced from ADMIN_USERNAME/ADMIN_PASSWORD env vars,
  synced on every startup; orphaned rows auto-assigned to admin post-migration
- Login page with self-registration; JWT stored in localStorage (30-day expiry)
- Admin panel (/admin): list users, reset passwords, disable/enable, delete,
  and impersonate (Login As) with Return to Admin banner
- Settings modal (gear icon in nav): timezone selector and change password
- Timezone stored per-user; stats date windows computed in user's timezone;
  date input setToday() respects user timezone via Intl API
- migrate_v2.sql for existing single-user installs
- Auto-migration adds timezone column to users on startup
- Updated README with full setup, auth, admin, and migration docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 23:19:29 -07:00
parent 7d50af0054
commit aa12648228
31 changed files with 1572 additions and 140 deletions

116
README.md
View File

@@ -1,16 +1,19 @@
# Eggtracker
# Yolkbook
A self-hosted web app for backyard chicken keepers to track egg production, flock size, feed costs, and egg economics over time.
A self-hosted, multi-user web app for backyard chicken keepers to track egg production, flock size, feed costs, and egg economics over time.
## Features
- **Dashboard** — at-a-glance stats: total eggs, 7/30-day totals, average eggs per day and per hen
- **Dashboard** — at-a-glance stats: total eggs, 7/30-day totals, average eggs per day and per hen, cost per egg and per dozen
- **Daily log** — record egg collections with one entry per day
- **History** — browse, edit, and delete past egg collection records
- **Flock management** — track changes to your flock size over time so per-hen averages stay accurate
- **Feed tracking** — log feed purchases (bags + price per bag)
- **Budget** — cost per egg and cost per dozen, all-time and over the last 30 days
- **Monthly summary** — month-by-month breakdown of production, averages, feed cost, and cost per egg
- **Multi-user** — each user has their own isolated data; self-registration on the login page
- **Admin panel** — view all users, reset passwords, disable/enable accounts, delete accounts, and log in as any user
- **Timezone support** — each user sets their own timezone so dates and stat windows are always accurate
## Tech Stack
@@ -38,49 +41,118 @@ A self-hosted web app for backyard chicken keepers to track egg production, floc
2. Create a `.env` file in the project root:
```env
# MySQL
MYSQL_ROOT_PASSWORD=your_root_password
MYSQL_DATABASE=eggtracker
MYSQL_USER=eggtracker
MYSQL_PASSWORD=your_password
MYSQL_PASSWORD=your_db_password
# Super admin account (created/synced automatically on every startup)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=your_admin_password
# JWT signing secret — generate with: openssl rand -hex 32
JWT_SECRET=your_long_random_secret
```
3. Start the stack:
```bash
docker compose up -d
docker compose up -d --build
```
4. Open your browser at `http://localhost:8056`
4. Open your browser at `http://localhost:8056/login` and sign in with the admin credentials you set in `.env`.
The database schema is applied automatically on first start via `mysql/init.sql`.
The database schema is applied automatically on first start via `mysql/init.sql`. The admin user is created (or synced) automatically every time the API starts.
## Authentication
- **Login / Register** — the landing page (`/login`) has both a sign-in form and a self-registration link so users can create their own accounts.
- **JWT tokens** — stored in `localStorage`, valid for 30 days.
- **Admin password** — always sourced from the `ADMIN_PASSWORD` env var. Changing it in `.env` and restarting will update the admin's password.
## Admin Panel
Accessible at `/admin` for admin accounts. Features:
| Action | Description |
|--------|-------------|
| Reset password | Set a new password for any user |
| Disable / Enable | Block or restore a user's access |
| Delete | Permanently remove a user and all their data |
| Login As | Impersonate a user to view or edit their data directly |
When impersonating a user, an amber banner appears in the nav with a **Return to Admin** button.
## User Settings
The gear icon (⚙) in the top-right nav opens the Settings panel:
- **Timezone** — choose from a full list of IANA timezones or click *Detect automatically*. Affects what "today" is when pre-filling date fields and the 30-day/7-day windows on the dashboard and budget pages.
- **Change Password** — update your own password (requires current password).
## Migrating an Existing Install (pre-multi-user)
If you have an existing single-user install, run the migration script before rebuilding:
```bash
# 1. Run the migration while the database is still running
docker compose exec db mysql -u root -p"${MYSQL_ROOT_PASSWORD}" eggtracker < mysql/migrate_v2.sql
# 2. Rebuild and restart
docker compose up -d --build
```
All existing data will be automatically assigned to the admin account on first startup.
## API
The FastAPI backend is available at `/api`. Interactive docs (Swagger UI) are at `/api/docs`.
| Prefix | Description |
|---------------|--------------------------|
| `/api/eggs` | Egg collection records |
| `/api/flock` | Flock size history |
| `/api/feed` | Feed purchase records |
| `/api/stats` | Dashboard, budget, and monthly summary stats |
| Prefix | Description |
|------------------|------------------------------------------|
| `/api/auth` | Login, register, change password, timezone |
| `/api/admin` | User management (admin only) |
| `/api/eggs` | Egg collection records |
| `/api/flock` | Flock size history |
| `/api/feed` | Feed purchase records |
| `/api/other` | Other purchases (bedding, snacks, etc.) |
| `/api/stats` | Dashboard, budget, and monthly summary |
All data endpoints require a valid JWT (`Authorization: Bearer <token>`). Data is always scoped to the authenticated user.
## Project Structure
```
eggtracker/
yolkbook/
├── backend/
│ ├── main.py # FastAPI app entry point
│ ├── models.py # SQLAlchemy models
│ ├── schemas.py # Pydantic schemas
│ ├── database.py # DB connection
│ ├── routers/ # Route handlers (eggs, flock, feed, stats)
│ ├── main.py # FastAPI app entry point + startup seeding
│ ├── auth.py # JWT utilities, password hashing, auth dependencies
│ ├── models.py # SQLAlchemy models
│ ├── schemas.py # Pydantic schemas
│ ├── database.py # DB connection
│ ├── routers/
│ │ ├── auth_router.py # /api/auth — login, register, settings
│ │ ├── admin.py # /api/admin — user management
│ │ ├── eggs.py
│ │ ├── flock.py
│ │ ├── feed.py
│ │ ├── other.py
│ │ └── stats.py
│ ├── requirements.txt
│ └── Dockerfile
├── nginx/
│ ├── html/ # Frontend (HTML, CSS, JS)
│ ├── html/ # Frontend (HTML, CSS, JS)
│ │ ├── login.html
│ │ ├── admin.html
│ │ ├── index.html # Dashboard
│ │ ├── js/
│ │ │ ├── api.js # Shared fetch helpers
│ │ │ └── auth.js # Auth utilities, nav, settings modal
│ │ └── css/style.css
│ └── nginx.conf
├── mysql/
── init.sql # Schema applied on first start
── init.sql # Schema for fresh installs
│ └── migrate_v2.sql # Migration for pre-multi-user installs
├── docker-compose.yml
└── .env # Secrets — not committed
└── .env # Secrets — not committed
```

72
backend/auth.py Normal file
View File

@@ -0,0 +1,72 @@
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from database import get_db
from models import User
SECRET_KEY = os.environ["JWT_SECRET"]
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(user_id: int, username: str, is_admin: bool, user_timezone: str = "UTC") -> str:
expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
payload = {
"sub": str(user_id),
"username": username,
"is_admin": is_admin,
"timezone": user_timezone,
"exp": expire,
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id_str: Optional[str] = payload.get("sub")
if user_id_str is None:
raise credentials_exception
user_id = int(user_id_str)
except (JWTError, ValueError):
raise credentials_exception
user = db.get(User, user_id)
if user is None or user.is_disabled:
raise credentials_exception
return user
async def get_current_admin(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

View File

@@ -1,11 +1,81 @@
import os
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select, update, text
from database import Base, engine, SessionLocal
from models import User, EggCollection, FlockHistory, FeedPurchase, OtherPurchase
from auth import hash_password
from routers import eggs, flock, feed, stats, other
from routers import auth_router, admin
app = FastAPI(title="Eggtracker API")
logger = logging.getLogger("yolkbook")
def _seed_admin():
"""Create or update the admin user from environment variables.
Also assigns any records with NULL user_id to the admin (post-migration).
"""
admin_username = os.environ["ADMIN_USERNAME"]
admin_password = os.environ["ADMIN_PASSWORD"]
with SessionLocal() as db:
admin_user = db.scalars(
select(User).where(User.username == admin_username)
).first()
if admin_user is None:
admin_user = User(
username=admin_username,
hashed_password=hash_password(admin_password),
is_admin=True,
)
db.add(admin_user)
db.commit()
db.refresh(admin_user)
logger.info("Admin user '%s' created.", admin_username)
else:
# Always sync password + admin flag from env vars
admin_user.hashed_password = hash_password(admin_password)
admin_user.is_admin = True
db.commit()
# Assign orphaned records (from pre-migration data) to admin
for model in [EggCollection, FlockHistory, FeedPurchase, OtherPurchase]:
db.execute(
update(model)
.where(model.user_id == None) # noqa: E711
.values(user_id=admin_user.id)
)
db.commit()
def _run_migrations():
"""Apply incremental schema changes that create_all won't handle on existing tables."""
with SessionLocal() as db:
# v2.1 — timezone column on users
try:
db.execute(text(
"ALTER TABLE users ADD COLUMN timezone VARCHAR(64) NOT NULL DEFAULT 'UTC'"
))
db.commit()
except Exception:
db.rollback() # column already exists — safe to ignore
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
_run_migrations()
_seed_admin()
yield
app = FastAPI(title="Yolkbook API", lifespan=lifespan)
# Allow requests from the Nginx frontend (same host, different port internally)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -13,6 +83,8 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(auth_router.router)
app.include_router(admin.router)
app.include_router(eggs.router)
app.include_router(flock.router)
app.include_router(feed.router)

View File

@@ -1,13 +1,27 @@
from datetime import date, datetime
from sqlalchemy import Integer, Date, DateTime, Text, Numeric, func
from sqlalchemy import Boolean, Integer, Date, DateTime, Text, Numeric, String, ForeignKey, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_disabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default='UTC')
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class EggCollection(Base):
__tablename__ = "egg_collections"
__table_args__ = (UniqueConstraint("user_id", "date", name="uq_user_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
eggs: Mapped[int] = mapped_column(Integer, nullable=False)
notes: Mapped[str] = mapped_column(Text, nullable=True)
@@ -18,6 +32,7 @@ class FlockHistory(Base):
__tablename__ = "flock_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
chicken_count: Mapped[int] = mapped_column(Integer, nullable=False)
notes: Mapped[str] = mapped_column(Text, nullable=True)
@@ -28,6 +43,7 @@ class FeedPurchase(Base):
__tablename__ = "feed_purchases"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
bags: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False)
price_per_bag: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
@@ -39,6 +55,7 @@ class OtherPurchase(Base):
__tablename__ = "other_purchases"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
total: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
notes: Mapped[str] = mapped_column(Text, nullable=True)

View File

@@ -4,3 +4,6 @@ sqlalchemy==2.0.36
pymysql==1.1.1
cryptography==43.0.3
pydantic==2.9.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1

111
backend/routers/admin.py Normal file
View File

@@ -0,0 +1,111 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from database import get_db
from models import User
from schemas import UserCreate, UserOut, ResetPasswordRequest, TokenResponse
from auth import hash_password, create_access_token, get_current_admin, get_current_user
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.get("/users", response_model=list[UserOut])
def list_users(
_: User = Depends(get_current_admin),
db: Session = Depends(get_db),
):
return db.scalars(select(User).order_by(User.created_at)).all()
@router.post("/users", response_model=UserOut, status_code=201)
def create_user(
body: UserCreate,
_: User = Depends(get_current_admin),
db: Session = Depends(get_db),
):
existing = db.scalars(select(User).where(User.username == body.username)).first()
if existing:
raise HTTPException(status_code=409, detail="Username already taken")
user = User(
username=body.username,
hashed_password=hash_password(body.password),
is_admin=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/users/{user_id}/reset-password")
def reset_password(
user_id: int,
body: ResetPasswordRequest,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db),
):
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.hashed_password = hash_password(body.new_password)
db.commit()
return {"detail": f"Password reset for {user.username}"}
@router.post("/users/{user_id}/disable")
def disable_user(
user_id: int,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db),
):
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == current_admin.id:
raise HTTPException(status_code=400, detail="Cannot disable your own account")
user.is_disabled = True
db.commit()
return {"detail": f"User {user.username} disabled"}
@router.post("/users/{user_id}/enable")
def enable_user(
user_id: int,
_: User = Depends(get_current_admin),
db: Session = Depends(get_db),
):
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.is_disabled = False
db.commit()
return {"detail": f"User {user.username} enabled"}
@router.delete("/users/{user_id}", status_code=204)
def delete_user(
user_id: int,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db),
):
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == current_admin.id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
db.delete(user)
db.commit()
@router.post("/users/{user_id}/impersonate", response_model=TokenResponse)
def impersonate_user(
user_id: int,
_: User = Depends(get_current_admin),
db: Session = Depends(get_db),
):
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
token = create_access_token(user.id, user.username, user.is_admin, user.timezone)
return TokenResponse(access_token=token)

View File

@@ -0,0 +1,85 @@
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from database import get_db
from models import User
from schemas import LoginRequest, TokenResponse, UserOut, UserCreate, ChangePasswordRequest, TimezoneUpdate
from auth import verify_password, hash_password, create_access_token, get_current_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
def _make_token(user: User) -> str:
return create_access_token(user.id, user.username, user.is_admin, user.timezone)
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)):
user = db.scalars(select(User).where(User.username == body.username)).first()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
if user.is_disabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled. Contact your administrator.",
)
return TokenResponse(access_token=_make_token(user))
@router.post("/register", response_model=TokenResponse, status_code=201)
def register(body: UserCreate, db: Session = Depends(get_db)):
existing = db.scalars(select(User).where(User.username == body.username)).first()
if existing:
raise HTTPException(status_code=409, detail="Username already taken")
# Default timezone to UTC; user can change it in settings
user = User(
username=body.username,
hashed_password=hash_password(body.password),
is_admin=False,
timezone="UTC",
)
db.add(user)
db.commit()
db.refresh(user)
return TokenResponse(access_token=_make_token(user))
@router.get("/me", response_model=UserOut)
def me(current_user: User = Depends(get_current_user)):
return current_user
@router.post("/change-password")
def change_password(
body: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(status_code=400, detail="Current password is incorrect")
current_user.hashed_password = hash_password(body.new_password)
db.commit()
return {"detail": "Password updated"}
@router.put("/timezone", response_model=TokenResponse)
def update_timezone(
body: TimezoneUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
try:
ZoneInfo(body.timezone) # validate it's a real IANA timezone
except ZoneInfoNotFoundError:
raise HTTPException(status_code=400, detail=f"Unknown timezone: {body.timezone}")
current_user.timezone = body.timezone
db.commit()
db.refresh(current_user)
# Return a fresh token with the updated timezone embedded
return TokenResponse(access_token=_make_token(current_user))

View File

@@ -6,8 +6,9 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from database import get_db
from models import EggCollection
from models import EggCollection, User
from schemas import EggCollectionCreate, EggCollectionUpdate, EggCollectionOut
from auth import get_current_user
router = APIRouter(prefix="/api/eggs", tags=["eggs"])
@@ -17,8 +18,13 @@ def list_eggs(
start: Optional[date] = None,
end: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(EggCollection).order_by(EggCollection.date.desc())
q = (
select(EggCollection)
.where(EggCollection.user_id == current_user.id)
.order_by(EggCollection.date.desc())
)
if start:
q = q.where(EggCollection.date >= start)
if end:
@@ -27,8 +33,12 @@ def list_eggs(
@router.post("", response_model=EggCollectionOut, status_code=201)
def create_egg_collection(body: EggCollectionCreate, db: Session = Depends(get_db)):
record = EggCollection(**body.model_dump())
def create_egg_collection(
body: EggCollectionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = EggCollection(**body.model_dump(), user_id=current_user.id)
db.add(record)
try:
db.commit()
@@ -44,8 +54,12 @@ def update_egg_collection(
record_id: int,
body: EggCollectionUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = db.get(EggCollection, record_id)
record = db.scalars(
select(EggCollection)
.where(EggCollection.id == record_id, EggCollection.user_id == current_user.id)
).first()
if not record:
raise HTTPException(status_code=404, detail="Record not found")
for field, value in body.model_dump(exclude_none=True).items():
@@ -54,14 +68,21 @@ def update_egg_collection(
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail=f"An entry for that date already exists.")
raise HTTPException(status_code=409, detail="An entry for that date already exists.")
db.refresh(record)
return record
@router.delete("/{record_id}", status_code=204)
def delete_egg_collection(record_id: int, db: Session = Depends(get_db)):
record = db.get(EggCollection, record_id)
def delete_egg_collection(
record_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = db.scalars(
select(EggCollection)
.where(EggCollection.id == record_id, EggCollection.user_id == current_user.id)
).first()
if not record:
raise HTTPException(status_code=404, detail="Record not found")
db.delete(record)

View File

@@ -5,8 +5,9 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from database import get_db
from models import FeedPurchase
from models import FeedPurchase, User
from schemas import FeedPurchaseCreate, FeedPurchaseUpdate, FeedPurchaseOut
from auth import get_current_user
router = APIRouter(prefix="/api/feed", tags=["feed"])
@@ -16,8 +17,13 @@ def list_feed_purchases(
start: Optional[date] = None,
end: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(FeedPurchase).order_by(FeedPurchase.date.desc())
q = (
select(FeedPurchase)
.where(FeedPurchase.user_id == current_user.id)
.order_by(FeedPurchase.date.desc())
)
if start:
q = q.where(FeedPurchase.date >= start)
if end:
@@ -26,8 +32,12 @@ def list_feed_purchases(
@router.post("", response_model=FeedPurchaseOut, status_code=201)
def create_feed_purchase(body: FeedPurchaseCreate, db: Session = Depends(get_db)):
record = FeedPurchase(**body.model_dump())
def create_feed_purchase(
body: FeedPurchaseCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = FeedPurchase(**body.model_dump(), user_id=current_user.id)
db.add(record)
db.commit()
db.refresh(record)
@@ -39,8 +49,12 @@ def update_feed_purchase(
record_id: int,
body: FeedPurchaseUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = db.get(FeedPurchase, record_id)
record = db.scalars(
select(FeedPurchase)
.where(FeedPurchase.id == record_id, FeedPurchase.user_id == current_user.id)
).first()
if not record:
raise HTTPException(status_code=404, detail="Record not found")
for field, value in body.model_dump(exclude_none=True).items():
@@ -51,8 +65,15 @@ def update_feed_purchase(
@router.delete("/{record_id}", status_code=204)
def delete_feed_purchase(record_id: int, db: Session = Depends(get_db)):
record = db.get(FeedPurchase, record_id)
def delete_feed_purchase(
record_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = db.scalars(
select(FeedPurchase)
.where(FeedPurchase.id == record_id, FeedPurchase.user_id == current_user.id)
).first()
if not record:
raise HTTPException(status_code=404, detail="Record not found")
db.delete(record)

View File

@@ -5,30 +5,49 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from database import get_db
from models import FlockHistory
from models import FlockHistory, User
from schemas import FlockHistoryCreate, FlockHistoryUpdate, FlockHistoryOut
from auth import get_current_user
router = APIRouter(prefix="/api/flock", tags=["flock"])
@router.get("", response_model=list[FlockHistoryOut])
def list_flock_history(db: Session = Depends(get_db)):
q = select(FlockHistory).order_by(FlockHistory.date.desc())
def list_flock_history(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = (
select(FlockHistory)
.where(FlockHistory.user_id == current_user.id)
.order_by(FlockHistory.date.desc())
)
return db.scalars(q).all()
@router.get("/current", response_model=Optional[FlockHistoryOut])
def get_current_flock(db: Session = Depends(get_db)):
"""Returns the most recent flock entry — the current flock size."""
q = select(FlockHistory).order_by(FlockHistory.date.desc()).limit(1)
def get_current_flock(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = (
select(FlockHistory)
.where(FlockHistory.user_id == current_user.id)
.order_by(FlockHistory.date.desc())
.limit(1)
)
return db.scalars(q).first()
@router.get("/at/{target_date}", response_model=Optional[FlockHistoryOut])
def get_flock_at_date(target_date: date, db: Session = Depends(get_db)):
"""Returns the flock size that was in effect on a given date."""
def get_flock_at_date(
target_date: date,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = (
select(FlockHistory)
.where(FlockHistory.user_id == current_user.id)
.where(FlockHistory.date <= target_date)
.order_by(FlockHistory.date.desc())
.limit(1)
@@ -37,8 +56,12 @@ def get_flock_at_date(target_date: date, db: Session = Depends(get_db)):
@router.post("", response_model=FlockHistoryOut, status_code=201)
def create_flock_entry(body: FlockHistoryCreate, db: Session = Depends(get_db)):
record = FlockHistory(**body.model_dump())
def create_flock_entry(
body: FlockHistoryCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = FlockHistory(**body.model_dump(), user_id=current_user.id)
db.add(record)
db.commit()
db.refresh(record)
@@ -50,8 +73,12 @@ def update_flock_entry(
record_id: int,
body: FlockHistoryUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = db.get(FlockHistory, record_id)
record = db.scalars(
select(FlockHistory)
.where(FlockHistory.id == record_id, FlockHistory.user_id == current_user.id)
).first()
if not record:
raise HTTPException(status_code=404, detail="Record not found")
for field, value in body.model_dump(exclude_none=True).items():
@@ -62,8 +89,15 @@ def update_flock_entry(
@router.delete("/{record_id}", status_code=204)
def delete_flock_entry(record_id: int, db: Session = Depends(get_db)):
record = db.get(FlockHistory, record_id)
def delete_flock_entry(
record_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = db.scalars(
select(FlockHistory)
.where(FlockHistory.id == record_id, FlockHistory.user_id == current_user.id)
).first()
if not record:
raise HTTPException(status_code=404, detail="Record not found")
db.delete(record)

View File

@@ -5,8 +5,9 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from database import get_db
from models import OtherPurchase
from models import OtherPurchase, User
from schemas import OtherPurchaseCreate, OtherPurchaseUpdate, OtherPurchaseOut
from auth import get_current_user
router = APIRouter(prefix="/api/other", tags=["other"])
@@ -16,8 +17,13 @@ def list_other_purchases(
start: Optional[date] = None,
end: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(OtherPurchase).order_by(OtherPurchase.date.desc())
q = (
select(OtherPurchase)
.where(OtherPurchase.user_id == current_user.id)
.order_by(OtherPurchase.date.desc())
)
if start:
q = q.where(OtherPurchase.date >= start)
if end:
@@ -26,8 +32,12 @@ def list_other_purchases(
@router.post("", response_model=OtherPurchaseOut, status_code=201)
def create_other_purchase(body: OtherPurchaseCreate, db: Session = Depends(get_db)):
record = OtherPurchase(**body.model_dump())
def create_other_purchase(
body: OtherPurchaseCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = OtherPurchase(**body.model_dump(), user_id=current_user.id)
db.add(record)
db.commit()
db.refresh(record)
@@ -39,8 +49,12 @@ def update_other_purchase(
record_id: int,
body: OtherPurchaseUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = db.get(OtherPurchase, record_id)
record = db.scalars(
select(OtherPurchase)
.where(OtherPurchase.id == record_id, OtherPurchase.user_id == current_user.id)
).first()
if not record:
raise HTTPException(status_code=404, detail="Record not found")
for field, value in body.model_dump(exclude_none=True).items():
@@ -51,8 +65,15 @@ def update_other_purchase(
@router.delete("/{record_id}", status_code=204)
def delete_other_purchase(record_id: int, db: Session = Depends(get_db)):
record = db.get(OtherPurchase, record_id)
def delete_other_purchase(
record_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
record = db.scalars(
select(OtherPurchase)
.where(OtherPurchase.id == record_id, OtherPurchase.user_id == current_user.id)
).first()
if not record:
raise HTTPException(status_code=404, detail="Record not found")
db.delete(record)

View File

@@ -1,25 +1,30 @@
import calendar
from datetime import date, timedelta
from datetime import date, datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends
from sqlalchemy import select, func
from sqlalchemy.orm import Session
from database import get_db
from models import EggCollection, FlockHistory, FeedPurchase, OtherPurchase
from models import EggCollection, FlockHistory, FeedPurchase, OtherPurchase, User
from schemas import DashboardStats, BudgetStats, MonthlySummary
from auth import get_current_user
router = APIRouter(prefix="/api/stats", tags=["stats"])
def _avg_per_hen_30d(db: Session, start_30d: date) -> float | None:
"""
For each collection in the last 30 days, look up the flock size that was
in effect on that date using a correlated subquery, then average eggs/hen
across those days. This gives an accurate result even when flock size changed.
"""
def _today(user_timezone: str) -> date:
try:
return datetime.now(ZoneInfo(user_timezone)).date()
except ZoneInfoNotFoundError:
return date.today()
def _avg_per_hen_30d(db: Session, user_id: int, start_30d: date) -> float | None:
flock_at_date = (
select(FlockHistory.chicken_count)
.where(FlockHistory.user_id == user_id)
.where(FlockHistory.date <= EggCollection.date)
.order_by(FlockHistory.date.desc())
.limit(1)
@@ -29,6 +34,7 @@ def _avg_per_hen_30d(db: Session, start_30d: date) -> float | None:
rows = db.execute(
select(EggCollection.eggs, flock_at_date.label('flock_count'))
.where(EggCollection.user_id == user_id)
.where(EggCollection.date >= start_30d)
).all()
@@ -38,15 +44,18 @@ def _avg_per_hen_30d(db: Session, start_30d: date) -> float | None:
return round(sum(e / f for e, f in valid) / len(valid), 3)
def _current_flock(db: Session) -> int | None:
def _current_flock(db: Session, user_id: int) -> int | None:
row = db.scalars(
select(FlockHistory).order_by(FlockHistory.date.desc()).limit(1)
select(FlockHistory)
.where(FlockHistory.user_id == user_id)
.order_by(FlockHistory.date.desc())
.limit(1)
).first()
return row.chicken_count if row else None
def _total_eggs(db: Session, start: date | None = None, end: date | None = None) -> int:
q = select(func.coalesce(func.sum(EggCollection.eggs), 0))
def _total_eggs(db: Session, user_id: int, start: date | None = None, end: date | None = None) -> int:
q = select(func.coalesce(func.sum(EggCollection.eggs), 0)).where(EggCollection.user_id == user_id)
if start:
q = q.where(EggCollection.date >= start)
if end:
@@ -54,10 +63,10 @@ def _total_eggs(db: Session, start: date | None = None, end: date | None = None)
return db.scalar(q)
def _total_feed_cost(db: Session, start: date | None = None, end: date | None = None):
def _total_feed_cost(db: Session, user_id: int, start: date | None = None, end: date | None = None):
q = select(
func.coalesce(func.sum(FeedPurchase.bags * FeedPurchase.price_per_bag), 0)
)
).where(FeedPurchase.user_id == user_id)
if start:
q = q.where(FeedPurchase.date >= start)
if end:
@@ -65,8 +74,8 @@ def _total_feed_cost(db: Session, start: date | None = None, end: date | None =
return db.scalar(q)
def _total_other_cost(db: Session, start: date | None = None, end: date | None = None):
q = select(func.coalesce(func.sum(OtherPurchase.total), 0))
def _total_other_cost(db: Session, user_id: int, start: date | None = None, end: date | None = None):
q = select(func.coalesce(func.sum(OtherPurchase.total), 0)).where(OtherPurchase.user_id == user_id)
if start:
q = q.where(OtherPurchase.date >= start)
if end:
@@ -75,29 +84,33 @@ def _total_other_cost(db: Session, start: date | None = None, end: date | None =
@router.get("/dashboard", response_model=DashboardStats)
def dashboard_stats(db: Session = Depends(get_db)):
today = date.today()
start_30d = today - timedelta(days=30)
start_7d = today - timedelta(days=7)
def dashboard_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
uid = current_user.id
today = _today(current_user.timezone)
start_30d = today - timedelta(days=30)
start_7d = today - timedelta(days=7)
total_alltime = _total_eggs(db)
total_30d = _total_eggs(db, start=start_30d)
total_7d = _total_eggs(db, start=start_7d)
flock = _current_flock(db)
total_alltime = _total_eggs(db, uid)
total_30d = _total_eggs(db, uid, start=start_30d)
total_7d = _total_eggs(db, uid, start=start_7d)
flock = _current_flock(db, uid)
# Count how many distinct days have a collection logged
days_tracked = db.scalar(
select(func.count(func.distinct(EggCollection.date)))
.where(EggCollection.user_id == uid)
)
# Average eggs per day over the last 30 days (only counting days with data)
days_with_data_30d = db.scalar(
select(func.count(func.distinct(EggCollection.date)))
.where(EggCollection.user_id == uid)
.where(EggCollection.date >= start_30d)
)
avg_per_day = round(total_30d / days_with_data_30d, 2) if days_with_data_30d else None
avg_per_hen = _avg_per_hen_30d(db, start_30d)
avg_per_hen = _avg_per_hen_30d(db, uid, start_30d)
return DashboardStats(
current_flock=flock,
@@ -111,10 +124,13 @@ def dashboard_stats(db: Session = Depends(get_db)):
@router.get("/monthly", response_model=list[MonthlySummary])
def monthly_stats(db: Session = Depends(get_db)):
def monthly_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
uid = current_user.id
MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
# Monthly egg totals
egg_rows = db.execute(
select(
func.year(EggCollection.date).label('year'),
@@ -122,6 +138,7 @@ def monthly_stats(db: Session = Depends(get_db)):
func.sum(EggCollection.eggs).label('total_eggs'),
func.count(EggCollection.date).label('days_logged'),
)
.where(EggCollection.user_id == uid)
.group_by(func.year(EggCollection.date), func.month(EggCollection.date))
.order_by(func.year(EggCollection.date).desc(), func.month(EggCollection.date).desc())
).all()
@@ -129,25 +146,25 @@ def monthly_stats(db: Session = Depends(get_db)):
if not egg_rows:
return []
# Monthly feed costs
feed_rows = db.execute(
select(
func.year(FeedPurchase.date).label('year'),
func.month(FeedPurchase.date).label('month'),
func.sum(FeedPurchase.bags * FeedPurchase.price_per_bag).label('feed_cost'),
)
.where(FeedPurchase.user_id == uid)
.group_by(func.year(FeedPurchase.date), func.month(FeedPurchase.date))
).all()
feed_map = {(r.year, r.month): r.feed_cost for r in feed_rows}
# Monthly other costs
other_rows = db.execute(
select(
func.year(OtherPurchase.date).label('year'),
func.month(OtherPurchase.date).label('month'),
func.sum(OtherPurchase.total).label('other_cost'),
)
.where(OtherPurchase.user_id == uid)
.group_by(func.year(OtherPurchase.date), func.month(OtherPurchase.date))
).all()
@@ -159,9 +176,9 @@ def monthly_stats(db: Session = Depends(get_db)):
last_day = calendar.monthrange(y, m)[1]
month_end = date(y, m, last_day)
# Flock size in effect at the end of this month
flock_row = db.scalars(
select(FlockHistory)
.where(FlockHistory.user_id == uid)
.where(FlockHistory.date <= month_end)
.order_by(FlockHistory.date.desc())
.limit(1)
@@ -201,16 +218,20 @@ def monthly_stats(db: Session = Depends(get_db)):
@router.get("/budget", response_model=BudgetStats)
def budget_stats(db: Session = Depends(get_db)):
today = date.today()
def budget_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
uid = current_user.id
today = _today(current_user.timezone)
start_30d = today - timedelta(days=30)
total_feed_cost = _total_feed_cost(db)
total_feed_cost_30d = _total_feed_cost(db, start=start_30d)
total_other_cost = _total_other_cost(db)
total_other_cost_30d = _total_other_cost(db, start=start_30d)
total_eggs = _total_eggs(db)
total_eggs_30d = _total_eggs(db, start=start_30d)
total_feed_cost = _total_feed_cost(db, uid)
total_feed_cost_30d = _total_feed_cost(db, uid, start=start_30d)
total_other_cost = _total_other_cost(db, uid)
total_other_cost_30d = _total_other_cost(db, uid, start=start_30d)
total_eggs = _total_eggs(db, uid)
total_eggs_30d = _total_eggs(db, uid, start=start_30d)
def cost_per_egg(cost, eggs):
if not eggs or not cost:

View File

@@ -4,6 +4,44 @@ from typing import Optional
from pydantic import BaseModel, Field
# ── Auth ──────────────────────────────────────────────────────────────────────
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=6)
class ResetPasswordRequest(BaseModel):
new_password: str = Field(min_length=6)
class TimezoneUpdate(BaseModel):
timezone: str = Field(min_length=1, max_length=64)
# ── Users ─────────────────────────────────────────────────────────────────────
class UserCreate(BaseModel):
username: str = Field(min_length=2, max_length=64)
password: str = Field(min_length=6)
class UserOut(BaseModel):
id: int
username: str
is_admin: bool
is_disabled: bool
timezone: str
created_at: datetime
model_config = {"from_attributes": True}
# ── Egg Collections ───────────────────────────────────────────────────────────
class EggCollectionCreate(BaseModel):

View File

@@ -28,7 +28,10 @@ services:
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db/${MYSQL_DATABASE}
DATABASE_URL: mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db/${MYSQL_DATABASE}
ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
depends_on:
db:
condition: service_healthy # wait for MySQL to be ready before starting

View File

@@ -1,4 +1,4 @@
-- Eggtracker schema
-- Eggtracker schema — multi-user edition
-- This file runs automatically on first container startup only.
-- To re-run it, remove the mysql_data volume: docker compose down -v
@@ -8,50 +8,72 @@ CREATE DATABASE IF NOT EXISTS eggtracker
USE eggtracker;
-- ── Users ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
is_admin TINYINT(1) NOT NULL DEFAULT 0,
is_disabled TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_username (username)
) ENGINE=InnoDB;
-- ── Egg collections ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS egg_collections (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
date DATE NOT NULL,
eggs INT UNSIGNED NOT NULL,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_date (date)
UNIQUE KEY uq_user_date (user_id, date),
INDEX idx_user_id (user_id),
INDEX idx_date (date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ── Flock history ─────────────────────────────────────────────────────────────
-- Each row records a change in flock size. The count in effect for any given
-- date is the most recent row with date <= that date.
CREATE TABLE IF NOT EXISTS flock_history (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
date DATE NOT NULL,
chicken_count INT UNSIGNED NOT NULL,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_date (date)
INDEX idx_user_id (user_id),
INDEX idx_date (date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ── Feed purchases ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS feed_purchases (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
date DATE NOT NULL,
bags DECIMAL(5, 2) NOT NULL, -- decimal for partial bags
bags DECIMAL(5, 2) NOT NULL,
price_per_bag DECIMAL(10, 2) NOT NULL,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_date (date)
INDEX idx_user_id (user_id),
INDEX idx_date (date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- ── Other purchases ───────────────────────────────────────────────────────────
-- Catch-all for non-feed costs: bedding, snacks, shelter, etc.
CREATE TABLE IF NOT EXISTS other_purchases (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
date DATE NOT NULL,
total DECIMAL(10, 2) NOT NULL,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_date (date)
INDEX idx_user_id (user_id),
INDEX idx_date (date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;

50
mysql/migrate_v2.sql Normal file
View File

@@ -0,0 +1,50 @@
-- Eggtracker v2 migration — adds multi-user support to an existing database.
-- Run this ONCE on an existing install BEFORE restarting with the new image:
--
-- docker compose exec db mysql -u root -p"${MYSQL_ROOT_PASSWORD}" eggtracker < mysql/migrate_v2.sql
--
-- After running this script, restart the stack (docker compose up -d --build).
-- The API will automatically create the admin user (from ADMIN_USERNAME /
-- ADMIN_PASSWORD in .env) and assign all existing records to that admin account.
--
-- NOTE: Run this script only ONCE. Running it again will fail on the ADD COLUMN
-- statements since the columns will already exist.
USE eggtracker;
-- ── Create users table ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
is_admin TINYINT(1) NOT NULL DEFAULT 0,
is_disabled TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_username (username)
) ENGINE=InnoDB;
-- ── Add user_id columns (nullable so existing rows remain valid) ───────────────
ALTER TABLE egg_collections
ADD COLUMN user_id INT UNSIGNED NULL AFTER id,
ADD INDEX idx_user_id (user_id);
ALTER TABLE flock_history
ADD COLUMN user_id INT UNSIGNED NULL AFTER id,
ADD INDEX idx_user_id (user_id);
ALTER TABLE feed_purchases
ADD COLUMN user_id INT UNSIGNED NULL AFTER id,
ADD INDEX idx_user_id (user_id);
ALTER TABLE other_purchases
ADD COLUMN user_id INT UNSIGNED NULL AFTER id,
ADD INDEX idx_user_id (user_id);
-- ── Remove old single-column unique index on egg_collections.date ─────────────
-- It will be replaced by (user_id, date) once the admin is seeded.
ALTER TABLE egg_collections DROP INDEX uq_date;
-- The API startup will:
-- 1. Create the admin user from ADMIN_USERNAME / ADMIN_PASSWORD in .env
-- 2. Set user_id = admin.id on all rows where user_id IS NULL

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 Not Found — Eggtracker</title>
<title>404 Not Found — Yolkbook</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.error-center { text-align: center; padding: 5rem 1rem; }
@@ -13,7 +13,7 @@
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 Eggtracker</a>
<a class="nav-brand" href="/">🥚 Yolkbook</a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Error — Eggtracker</title>
<title>Server Error — Yolkbook</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.error-center { text-align: center; padding: 5rem 1rem; }
@@ -13,7 +13,7 @@
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 Eggtracker</a>
<a class="nav-brand" href="/">🥚 Yolkbook</a>
</nav>
<main class="container">
<div class="error-center">

85
nginx/html/admin.html Normal file
View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin — Yolkbook</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
<li><a href="/history">History</a></li>
<li><a href="/flock">Flock</a></li>
<li><a href="/budget">Budget</a></li>
<li><a href="/summary">Summary</a></li>
</ul>
</nav>
<main class="container">
<h1>Admin — User Management</h1>
<div id="msg" class="message"></div>
<!-- User list -->
<div class="section-header">
<h2>All Users</h2>
<button class="btn btn-ghost btn-sm" onclick="loadUsers()">Refresh</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-body">
<tr class="empty-row"><td colspan="5">Loading…</td></tr>
</tbody>
</table>
</div>
<!-- Reset password modal -->
<div id="reset-modal" class="modal-overlay" style="display:none">
<div class="modal-box">
<h2>Reset Password</h2>
<p style="margin-bottom:1rem;color:var(--muted)">Setting new password for: <strong id="reset-username"></strong></p>
<div id="reset-msg" class="message"></div>
<div class="form-group" style="margin-bottom:1rem">
<label>New Password</label>
<input type="password" id="reset-password" minlength="6" placeholder="min 6 characters">
</div>
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
<button class="btn btn-ghost" onclick="hideResetModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitReset()">Set Password</button>
</div>
</div>
</div>
<!-- Delete confirmation modal -->
<div id="delete-modal" class="modal-overlay" style="display:none">
<div class="modal-box">
<h2>Delete User</h2>
<p style="margin-bottom:1.5rem">Delete <strong id="delete-username"></strong>? This will permanently remove their account and all associated data.</p>
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
<button class="btn btn-ghost" onclick="hideDeleteModal()">Cancel</button>
<button class="btn btn-danger" onclick="submitDelete()">Delete</button>
</div>
</div>
</div>
</main>
<script src="/js/api.js?v=3"></script>
<script src="/js/auth.js?v=3"></script>
<script src="/js/admin.js?v=3"></script>
</body>
</html>

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Budget — Eggtracker</title>
<title>Budget — Yolkbook</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
@@ -123,7 +123,8 @@
</div>
</main>
<script src="/js/api.js?v=2"></script>
<script src="/js/api.js?v=3"></script>
<script src="/js/auth.js?v=3"></script>
<script src="/js/budget.js?v=3"></script>
</body>
</html>

View File

@@ -173,6 +173,7 @@ label { font-size: 0.875rem; font-weight: 500; }
input[type="text"],
input[type="number"],
input[type="date"],
input[type="password"],
textarea,
select {
padding: 0.5rem 0.75rem;
@@ -259,4 +260,120 @@ td input[type="date"] {
.nav-links { overflow-x: auto; scrollbar-width: none; }
.nav-links::-webkit-scrollbar { display: none; }
.nav-links a { padding: 0.4rem 0.55rem; font-size: 0.82rem; white-space: nowrap; }
.nav-username { display: none; }
}
/* ── Nav user section ─────────────────────────────────────────────────────── */
.nav-user {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.nav-username {
color: rgba(255,255,255,0.85);
font-size: 0.88rem;
white-space: nowrap;
}
.nav-impersonating {
color: #ffe08a;
font-size: 0.85rem;
white-space: nowrap;
}
.nav-admin-btn {
color: rgba(255,255,255,0.85) !important;
border-color: rgba(255,255,255,0.35) !important;
}
.nav-admin-btn:hover {
background: rgba(255,255,255,0.15) !important;
color: #fff !important;
}
.btn-amber { background: var(--amber); color: #fff; }
.btn-amber:hover { background: #b8720a; }
/* ── Login page ───────────────────────────────────────────────────────────── */
.login-body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg);
}
.login-container {
width: 100%;
max-width: 380px;
padding: 0 1rem;
}
.login-brand {
text-align: center;
font-size: 1.6rem;
font-weight: 700;
color: var(--green);
margin-bottom: 1.5rem;
}
.login-card { padding: 2rem; }
.login-title {
text-align: center;
font-size: 1.3rem;
margin-bottom: 1.25rem;
}
/* ── Modals ───────────────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
padding: 1rem;
}
.modal-box {
background: var(--card-bg);
border-radius: var(--radius);
padding: 1.75rem;
width: 100%;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
}
.modal-box h2 { margin-bottom: 1rem; }
/* ── Badges ───────────────────────────────────────────────────────────────── */
.badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 99px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-admin { background: #d4edff; color: #0055aa; }
.badge-user { background: #e8f5e9; color: #2e6b3e; }
.badge-active { background: #d4edda; color: #155724; }
.badge-disabled { background: #f8d7da; color: #721c24; }
/* ── Settings modal extras ────────────────────────────────────────────────── */
.settings-section-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--green-dark);
margin-bottom: 0.75rem;
}
.settings-divider {
border: none;
border-top: 1px solid var(--border);
margin: 1.25rem 0;
}

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flock — Eggtracker</title>
<title>Flock — Yolkbook</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
@@ -80,7 +80,8 @@
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/api.js?v=3"></script>
<script src="/js/auth.js?v=3"></script>
<script src="/js/flock.js"></script>
</body>
</html>

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>History — Eggtracker</title>
<title>History — Yolkbook</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
@@ -63,7 +63,8 @@
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/api.js?v=3"></script>
<script src="/js/auth.js?v=3"></script>
<script src="/js/history.js"></script>
</body>
</html>

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard — Eggtracker</title>
<title>Dashboard — Yolkbook</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
@@ -73,7 +73,8 @@
</main>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="/js/api.js?v=2"></script>
<script src="/js/api.js?v=3"></script>
<script src="/js/auth.js?v=3"></script>
<script src="/js/dashboard.js?v=2"></script>
</body>
</html>

157
nginx/html/js/admin.js Normal file
View File

@@ -0,0 +1,157 @@
// admin.js — admin user management page
let resetTargetId = null;
let deleteTargetId = null;
let currentAdminId = null;
document.addEventListener('DOMContentLoaded', async () => {
// Verify admin access
const user = Auth.getUser();
if (!user || !user.is_admin) {
window.location.href = '/';
return;
}
currentAdminId = parseInt(user.sub);
await loadUsers();
});
async function loadUsers() {
try {
const users = await API.get('/api/admin/users');
renderUsers(users);
} catch (err) {
showMessage(document.getElementById('msg'), err.message, 'error');
}
}
function renderUsers(users) {
const tbody = document.getElementById('users-body');
if (!users.length) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">No users found.</td></tr>';
return;
}
tbody.innerHTML = users.map(u => {
const isSelf = u.id === currentAdminId;
const roleLabel = u.is_admin
? '<span class="badge badge-admin">Admin</span>'
: '<span class="badge badge-user">User</span>';
const statusLabel = u.is_disabled
? '<span class="badge badge-disabled">Disabled</span>'
: '<span class="badge badge-active">Active</span>';
const created = new Date(u.created_at).toLocaleDateString();
const toggleBtn = u.is_disabled
? `<button class="btn btn-sm btn-primary" onclick="toggleUser(${u.id}, false)">Enable</button>`
: `<button class="btn btn-sm btn-ghost" onclick="toggleUser(${u.id}, true)" ${isSelf ? 'disabled title="Cannot disable yourself"' : ''}>Disable</button>`;
const impersonateBtn = !isSelf
? `<button class="btn btn-sm btn-ghost" onclick="impersonateUser(${u.id}, '${u.username}')">Login As</button>`
: '';
const deleteBtn = !isSelf
? `<button class="btn btn-sm btn-danger" onclick="showDeleteModal(${u.id}, '${u.username}')">Delete</button>`
: '';
return `<tr>
<td><strong>${u.username}</strong></td>
<td>${roleLabel}</td>
<td>${statusLabel}</td>
<td>${created}</td>
<td class="actions" style="display:flex;gap:0.35rem;flex-wrap:wrap">
<button class="btn btn-sm btn-ghost" onclick="showResetModal(${u.id}, '${u.username}')">Reset PW</button>
${toggleBtn}
${impersonateBtn}
${deleteBtn}
</td>
</tr>`;
}).join('');
}
function showResetModal(id, username) {
resetTargetId = id;
document.getElementById('reset-username').textContent = username;
document.getElementById('reset-password').value = '';
document.getElementById('reset-msg').className = 'message';
document.getElementById('reset-modal').style.display = 'flex';
document.getElementById('reset-password').focus();
}
function hideResetModal() {
document.getElementById('reset-modal').style.display = 'none';
resetTargetId = null;
}
async function submitReset() {
const password = document.getElementById('reset-password').value;
const msgEl = document.getElementById('reset-msg');
if (password.length < 6) {
msgEl.textContent = 'Password must be at least 6 characters';
msgEl.className = 'message error visible';
return;
}
try {
await API.post(`/api/admin/users/${resetTargetId}/reset-password`, { new_password: password });
msgEl.textContent = 'Password reset successfully.';
msgEl.className = 'message success visible';
setTimeout(hideResetModal, 1200);
} catch (err) {
msgEl.textContent = err.message;
msgEl.className = 'message error visible';
}
}
async function toggleUser(id, disable) {
const action = disable ? 'disable' : 'enable';
try {
await API.post(`/api/admin/users/${id}/${action}`, {});
await loadUsers();
} catch (err) {
showMessage(document.getElementById('msg'), err.message, 'error');
}
}
async function impersonateUser(id, username) {
try {
const data = await API.post(`/api/admin/users/${id}/impersonate`, {});
// Save admin token so user can return
sessionStorage.setItem('admin_token', Auth.getToken());
Auth.setToken(data.access_token);
window.location.href = '/';
} catch (err) {
showMessage(document.getElementById('msg'), err.message, 'error');
}
}
function showDeleteModal(id, username) {
deleteTargetId = id;
document.getElementById('delete-username').textContent = username;
document.getElementById('delete-modal').style.display = 'flex';
}
function hideDeleteModal() {
document.getElementById('delete-modal').style.display = 'none';
deleteTargetId = null;
}
async function submitDelete() {
try {
await API.del(`/api/admin/users/${deleteTargetId}`);
hideDeleteModal();
showMessage(document.getElementById('msg'), 'User deleted.');
await loadUsers();
} catch (err) {
hideDeleteModal();
showMessage(document.getElementById('msg'), err.message, 'error');
}
}
// Close modals on overlay click
document.addEventListener('click', (e) => {
if (e.target.id === 'reset-modal') hideResetModal();
if (e.target.id === 'delete-modal') hideDeleteModal();
});

View File

@@ -2,10 +2,21 @@
const API = {
async _fetch(url, options = {}) {
const token = localStorage.getItem('token');
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
headers,
...options,
});
if (res.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `Request failed (${res.status})`);
@@ -27,13 +38,13 @@ function showMessage(el, text, type = 'success') {
setTimeout(() => { el.className = 'message'; }, 4000);
}
// Set an input[type=date] to today's date (using local time, not UTC)
// Set an input[type=date] to today's date in the user's configured timezone
function setToday(inputEl) {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
inputEl.value = `${y}-${m}-${d}`;
const tz = (typeof Auth !== 'undefined' && Auth.getUser()?.timezone)
|| Intl.DateTimeFormat().resolvedOptions().timeZone
|| 'UTC';
// en-CA locale produces YYYY-MM-DD which is what date inputs expect
inputEl.value = new Date().toLocaleDateString('en-CA', { timeZone: tz });
}
// Format YYYY-MM-DD → MM/DD/YYYY for display

232
nginx/html/js/auth.js Normal file
View File

@@ -0,0 +1,232 @@
// auth.js — authentication utilities used by every authenticated page
const Auth = {
getToken() {
return localStorage.getItem('token');
},
setToken(token) {
localStorage.setItem('token', token);
},
removeToken() {
localStorage.removeItem('token');
},
getUser() {
const token = this.getToken();
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp < Date.now() / 1000) {
this.removeToken();
return null;
}
return payload;
} catch (_) {
return null;
}
},
requireAuth() {
const user = this.getUser();
if (!user) {
window.location.href = '/login';
return null;
}
return user;
},
logout() {
this.removeToken();
sessionStorage.removeItem('admin_token');
window.location.href = '/login';
},
};
function returnToAdmin() {
const adminToken = sessionStorage.getItem('admin_token');
Auth.setToken(adminToken);
sessionStorage.removeItem('admin_token');
window.location.href = '/admin';
}
// ── Timezone helpers ──────────────────────────────────────────────────────────
function getUserTimezone() {
return Auth.getUser()?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
}
function buildTimezoneOptions(selected) {
let allTz;
try {
allTz = Intl.supportedValuesOf('timeZone');
} catch (_) {
// Fallback for older browsers
allTz = ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver',
'America/Los_Angeles', 'America/Anchorage', 'Pacific/Honolulu',
'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo',
'Asia/Shanghai', 'Australia/Sydney'];
}
const groups = {};
for (const tz of allTz) {
const slash = tz.indexOf('/');
const group = slash === -1 ? 'Other' : tz.slice(0, slash);
(groups[group] = groups[group] || []).push(tz);
}
return Object.keys(groups).sort().map(group => {
const options = groups[group].map(tz => {
const label = tz.slice(tz.indexOf('/') + 1).replace(/_/g, ' ').replace(/\//g, ' / ');
return `<option value="${tz}"${tz === selected ? ' selected' : ''}>${label}</option>`;
}).join('');
return `<optgroup label="${group}">${options}</optgroup>`;
}).join('');
}
// ── Nav + settings modal ──────────────────────────────────────────────────────
function initNav() {
const user = Auth.requireAuth();
if (!user) return;
const nav = document.querySelector('.nav');
if (!nav) return;
const adminToken = sessionStorage.getItem('admin_token');
const navUser = document.createElement('div');
navUser.className = 'nav-user';
if (adminToken) {
navUser.innerHTML = `
<span class="nav-impersonating">Viewing as <strong>${user.username}</strong></span>
<button onclick="returnToAdmin()" class="btn btn-sm btn-amber">&#8617; Return to Admin</button>
`;
} else {
navUser.innerHTML = `
<span class="nav-username">${user.username}</span>
${user.is_admin ? '<a href="/admin" class="btn btn-sm btn-ghost nav-admin-btn">Admin</a>' : ''}
<button onclick="showSettingsModal()" class="btn btn-sm btn-ghost" title="Settings">&#9881;</button>
<button onclick="Auth.logout()" class="btn btn-sm btn-ghost">Logout</button>
`;
}
nav.appendChild(navUser);
if (!adminToken) {
const tzOptions = buildTimezoneOptions(user.timezone || 'UTC');
document.body.insertAdjacentHTML('beforeend', `
<div id="settings-modal" class="modal-overlay" style="display:none">
<div class="modal-box">
<h2>Settings</h2>
<div id="settings-msg" class="message"></div>
<h3 class="settings-section-title">Timezone</h3>
<div class="form-group" style="margin-bottom:0.5rem">
<label for="tz-select">Your timezone</label>
<select id="tz-select">${tzOptions}</select>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:1.5rem">
<button class="btn btn-sm btn-ghost" onclick="detectTimezone()">Detect automatically</button>
<button class="btn btn-primary btn-sm" onclick="submitTimezone()">Save Timezone</button>
</div>
<hr class="settings-divider">
<h3 class="settings-section-title">Change Password</h3>
<div class="form-group" style="margin-bottom:0.75rem">
<label>Current Password</label>
<input type="password" id="pw-current" autocomplete="current-password">
</div>
<div class="form-group" style="margin-bottom:0.75rem">
<label>New Password</label>
<input type="password" id="pw-new" autocomplete="new-password" minlength="6">
</div>
<div class="form-group" style="margin-bottom:1rem">
<label>Confirm New Password</label>
<input type="password" id="pw-confirm" autocomplete="new-password">
</div>
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
<button class="btn btn-ghost" onclick="hideSettingsModal()">Close</button>
<button class="btn btn-primary" onclick="submitPasswordChange()">Update Password</button>
</div>
</div>
</div>
`);
}
}
function showSettingsModal() {
document.getElementById('settings-modal').style.display = 'flex';
document.getElementById('settings-msg').className = 'message';
document.getElementById('pw-current').value = '';
document.getElementById('pw-new').value = '';
document.getElementById('pw-confirm').value = '';
}
function hideSettingsModal() {
document.getElementById('settings-modal').style.display = 'none';
}
function detectTimezone() {
const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
const sel = document.getElementById('tz-select');
if (sel) sel.value = detected;
}
async function submitTimezone() {
const tz = document.getElementById('tz-select').value;
const msgEl = document.getElementById('settings-msg');
try {
const data = await API.put('/api/auth/timezone', { timezone: tz });
Auth.setToken(data.access_token);
msgEl.textContent = `Timezone saved: ${tz.replace(/_/g, ' ')}`;
msgEl.className = 'message success visible';
setTimeout(() => { msgEl.className = 'message'; }, 3000);
} catch (err) {
msgEl.textContent = err.message;
msgEl.className = 'message error visible';
}
}
async function submitPasswordChange() {
const current = document.getElementById('pw-current').value;
const newPw = document.getElementById('pw-new').value;
const confirm = document.getElementById('pw-confirm').value;
const msgEl = document.getElementById('settings-msg');
if (newPw !== confirm) {
msgEl.textContent = 'New passwords do not match';
msgEl.className = 'message error visible';
return;
}
if (newPw.length < 6) {
msgEl.textContent = 'Password must be at least 6 characters';
msgEl.className = 'message error visible';
return;
}
try {
await API.post('/api/auth/change-password', {
current_password: current,
new_password: newPw,
});
msgEl.textContent = 'Password updated!';
msgEl.className = 'message success visible';
document.getElementById('pw-current').value = '';
document.getElementById('pw-new').value = '';
document.getElementById('pw-confirm').value = '';
setTimeout(() => { msgEl.className = 'message'; }, 3000);
} catch (err) {
msgEl.textContent = err.message;
msgEl.className = 'message error visible';
}
}
document.addEventListener('click', (e) => {
const modal = document.getElementById('settings-modal');
if (modal && e.target === modal) hideSettingsModal();
});
document.addEventListener('DOMContentLoaded', initNav);

View File

@@ -187,7 +187,7 @@ async function exportCSV() {
a.href = url;
const now = new Date();
const fileDate = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
a.download = `egg-tracker-${fileDate}.csv`;
a.download = `yolkbook-${fileDate}.csv`;
a.click();
URL.revokeObjectURL(url);

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log Eggs — Eggtracker</title>
<title>Log Eggs — Yolkbook</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
@@ -66,7 +66,8 @@
</div>
</main>
<script src="/js/api.js"></script>
<script src="/js/api.js?v=3"></script>
<script src="/js/auth.js?v=3"></script>
<script src="/js/log.js"></script>
</body>
</html>

161
nginx/html/login.html Normal file
View File

@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — Yolkbook</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="login-body">
<div class="login-container">
<div class="login-brand">🥚 Yolkbook</div>
<!-- Sign In -->
<div class="card login-card" id="login-panel">
<h1 class="login-title">Sign In</h1>
<div id="login-msg" class="message"></div>
<form id="login-form">
<div class="form-group" style="margin-bottom:1rem">
<label for="username">Username</label>
<input type="text" id="username" autocomplete="username" required autofocus>
</div>
<div class="form-group" style="margin-bottom:1.5rem">
<label for="password">Password</label>
<input type="password" id="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary" style="width:100%" id="login-btn">Sign In</button>
</form>
<p style="text-align:center;margin-top:1rem;font-size:0.9rem;color:var(--muted)">
No account? <a href="#" onclick="showRegister()">Create one</a>
</p>
</div>
<!-- Register -->
<div class="card login-card" id="register-panel" style="display:none">
<h1 class="login-title">Create Account</h1>
<div id="reg-msg" class="message"></div>
<form id="reg-form">
<div class="form-group" style="margin-bottom:1rem">
<label for="reg-username">Username</label>
<input type="text" id="reg-username" autocomplete="username" required minlength="2" maxlength="64">
</div>
<div class="form-group" style="margin-bottom:1rem">
<label for="reg-password">Password</label>
<input type="password" id="reg-password" autocomplete="new-password" required minlength="6" placeholder="min 6 characters">
</div>
<div class="form-group" style="margin-bottom:1.5rem">
<label for="reg-confirm">Confirm Password</label>
<input type="password" id="reg-confirm" autocomplete="new-password" required>
</div>
<button type="submit" class="btn btn-primary" style="width:100%" id="reg-btn">Create Account</button>
</form>
<p style="text-align:center;margin-top:1rem;font-size:0.9rem;color:var(--muted)">
Already have an account? <a href="#" onclick="showLogin()">Sign in</a>
</p>
</div>
</div>
<script>
// Redirect if already logged in
(function () {
const token = localStorage.getItem('token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp > Date.now() / 1000) {
window.location.href = '/';
return;
}
} catch (_) {}
localStorage.removeItem('token');
}
})();
function showLogin() {
document.getElementById('register-panel').style.display = 'none';
document.getElementById('login-panel').style.display = 'block';
document.getElementById('username').focus();
}
function showRegister() {
document.getElementById('login-panel').style.display = 'none';
document.getElementById('register-panel').style.display = 'block';
document.getElementById('reg-username').focus();
}
function showError(elId, text) {
const el = document.getElementById(elId);
el.textContent = text;
el.className = 'message error visible';
}
function showSuccess(elId, text) {
const el = document.getElementById(elId);
el.textContent = text;
el.className = 'message success visible';
}
// ── Login ──
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('login-btn');
btn.disabled = true;
btn.textContent = 'Signing in…';
document.getElementById('login-msg').className = 'message';
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) { showError('login-msg', data.detail || 'Login failed'); return; }
localStorage.setItem('token', data.access_token);
window.location.href = '/';
} catch (err) {
showError('login-msg', 'Could not reach the server. Please try again.');
} finally {
btn.disabled = false;
btn.textContent = 'Sign In';
}
});
// ── Register ──
document.getElementById('reg-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('reg-btn');
const username = document.getElementById('reg-username').value.trim();
const password = document.getElementById('reg-password').value;
const confirm = document.getElementById('reg-confirm').value;
if (password !== confirm) { showError('reg-msg', 'Passwords do not match'); return; }
btn.disabled = true;
btn.textContent = 'Creating account…';
document.getElementById('reg-msg').className = 'message';
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) { showError('reg-msg', data.detail || 'Registration failed'); return; }
localStorage.setItem('token', data.access_token);
window.location.href = '/';
} catch (err) {
showError('reg-msg', 'Could not reach the server. Please try again.');
} finally {
btn.disabled = false;
btn.textContent = 'Create Account';
}
});
</script>
</body>
</html>

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Summary — Eggtracker</title>
<title>Summary — Yolkbook</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="nav">
<a class="nav-brand" href="/">🥚 <span>Eggtracker</span></a>
<a class="nav-brand" href="/">🥚 <span>Yolkbook</span></a>
<ul class="nav-links">
<li><a href="/">Dashboard</a></li>
<li><a href="/log">Log Eggs</a></li>
@@ -68,7 +68,8 @@
</main>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="/js/api.js?v=2"></script>
<script src="/js/api.js?v=3"></script>
<script src="/js/auth.js?v=3"></script>
<script src="/js/summary.js?v=2"></script>
</body>
</html>