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:
98
README.md
98
README.md
@@ -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/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/stats` | Dashboard, budget, and monthly summary stats |
|
||||
| `/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
|
||||
│ ├── 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/ # Route handlers (eggs, flock, feed, stats)
|
||||
│ ├── 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)
|
||||
│ │ ├── 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
|
||||
```
|
||||
|
||||
72
backend/auth.py
Normal file
72
backend/auth.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 EggCollection(Base):
|
||||
__tablename__ = "egg_collections"
|
||||
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)
|
||||
|
||||
@@ -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
111
backend/routers/admin.py
Normal 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)
|
||||
85
backend/routers/auth_router.py
Normal file
85
backend/routers/auth_router.py
Normal 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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -29,6 +29,9 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
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
|
||||
|
||||
@@ -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
50
mysql/migrate_v2.sql
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
85
nginx/html/admin.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
157
nginx/html/js/admin.js
Normal 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();
|
||||
});
|
||||
@@ -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
232
nginx/html/js/auth.js
Normal 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">↩ 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">⚙</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);
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
161
nginx/html/login.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user