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:
116
README.md
116
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
|
## 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
|
- **Daily log** — record egg collections with one entry per day
|
||||||
- **History** — browse, edit, and delete past egg collection records
|
- **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
|
- **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)
|
- **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
|
- **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
|
- **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
|
## 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:
|
2. Create a `.env` file in the project root:
|
||||||
```env
|
```env
|
||||||
|
# MySQL
|
||||||
MYSQL_ROOT_PASSWORD=your_root_password
|
MYSQL_ROOT_PASSWORD=your_root_password
|
||||||
MYSQL_DATABASE=eggtracker
|
MYSQL_DATABASE=eggtracker
|
||||||
MYSQL_USER=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:
|
3. Start the stack:
|
||||||
```bash
|
```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
|
## API
|
||||||
|
|
||||||
The FastAPI backend is available at `/api`. Interactive docs (Swagger UI) are at `/api/docs`.
|
The FastAPI backend is available at `/api`. Interactive docs (Swagger UI) are at `/api/docs`.
|
||||||
|
|
||||||
| Prefix | Description |
|
| Prefix | Description |
|
||||||
|---------------|--------------------------|
|
|------------------|------------------------------------------|
|
||||||
| `/api/eggs` | Egg collection records |
|
| `/api/auth` | Login, register, change password, timezone |
|
||||||
| `/api/flock` | Flock size history |
|
| `/api/admin` | User management (admin only) |
|
||||||
| `/api/feed` | Feed purchase records |
|
| `/api/eggs` | Egg collection records |
|
||||||
| `/api/stats` | Dashboard, budget, and monthly summary stats |
|
| `/api/flock` | Flock size history |
|
||||||
|
| `/api/feed` | Feed purchase records |
|
||||||
|
| `/api/other` | Other purchases (bedding, snacks, etc.) |
|
||||||
|
| `/api/stats` | Dashboard, budget, and monthly summary |
|
||||||
|
|
||||||
|
All data endpoints require a valid JWT (`Authorization: Bearer <token>`). Data is always scoped to the authenticated user.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
eggtracker/
|
yolkbook/
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── main.py # FastAPI app entry point
|
│ ├── main.py # FastAPI app entry point + startup seeding
|
||||||
│ ├── models.py # SQLAlchemy models
|
│ ├── auth.py # JWT utilities, password hashing, auth dependencies
|
||||||
│ ├── schemas.py # Pydantic schemas
|
│ ├── models.py # SQLAlchemy models
|
||||||
│ ├── database.py # DB connection
|
│ ├── schemas.py # Pydantic schemas
|
||||||
│ ├── routers/ # Route handlers (eggs, flock, feed, stats)
|
│ ├── database.py # DB connection
|
||||||
|
│ ├── routers/
|
||||||
|
│ │ ├── auth_router.py # /api/auth — login, register, settings
|
||||||
|
│ │ ├── admin.py # /api/admin — user management
|
||||||
|
│ │ ├── eggs.py
|
||||||
|
│ │ ├── flock.py
|
||||||
|
│ │ ├── feed.py
|
||||||
|
│ │ ├── other.py
|
||||||
|
│ │ └── stats.py
|
||||||
│ ├── requirements.txt
|
│ ├── requirements.txt
|
||||||
│ └── Dockerfile
|
│ └── Dockerfile
|
||||||
├── nginx/
|
├── nginx/
|
||||||
│ ├── html/ # Frontend (HTML, CSS, JS)
|
│ ├── html/ # Frontend (HTML, CSS, JS)
|
||||||
|
│ │ ├── login.html
|
||||||
|
│ │ ├── admin.html
|
||||||
|
│ │ ├── index.html # Dashboard
|
||||||
|
│ │ ├── js/
|
||||||
|
│ │ │ ├── api.js # Shared fetch helpers
|
||||||
|
│ │ │ └── auth.js # Auth utilities, nav, settings modal
|
||||||
|
│ │ └── css/style.css
|
||||||
│ └── nginx.conf
|
│ └── nginx.conf
|
||||||
├── mysql/
|
├── 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
|
├── docker-compose.yml
|
||||||
└── .env # Secrets — not committed
|
└── .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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -13,6 +83,8 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.include_router(auth_router.router)
|
||||||
|
app.include_router(admin.router)
|
||||||
app.include_router(eggs.router)
|
app.include_router(eggs.router)
|
||||||
app.include_router(flock.router)
|
app.include_router(flock.router)
|
||||||
app.include_router(feed.router)
|
app.include_router(feed.router)
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
from datetime import date, datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
from database import Base
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
is_disabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default='UTC')
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
class EggCollection(Base):
|
class EggCollection(Base):
|
||||||
__tablename__ = "egg_collections"
|
__tablename__ = "egg_collections"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "date", name="uq_user_date"),)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
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)
|
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||||
eggs: Mapped[int] = mapped_column(Integer, nullable=False)
|
eggs: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
notes: Mapped[str] = mapped_column(Text, nullable=True)
|
notes: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
@@ -18,6 +32,7 @@ class FlockHistory(Base):
|
|||||||
__tablename__ = "flock_history"
|
__tablename__ = "flock_history"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
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)
|
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||||
chicken_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
chicken_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
notes: Mapped[str] = mapped_column(Text, nullable=True)
|
notes: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
@@ -28,6 +43,7 @@ class FeedPurchase(Base):
|
|||||||
__tablename__ = "feed_purchases"
|
__tablename__ = "feed_purchases"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
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)
|
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||||
bags: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False)
|
bags: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False)
|
||||||
price_per_bag: Mapped[float] = mapped_column(Numeric(10, 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"
|
__tablename__ = "other_purchases"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
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)
|
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||||
total: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
total: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||||
notes: Mapped[str] = mapped_column(Text, nullable=True)
|
notes: Mapped[str] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ sqlalchemy==2.0.36
|
|||||||
pymysql==1.1.1
|
pymysql==1.1.1
|
||||||
cryptography==43.0.3
|
cryptography==43.0.3
|
||||||
pydantic==2.9.2
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import EggCollection
|
from models import EggCollection, User
|
||||||
from schemas import EggCollectionCreate, EggCollectionUpdate, EggCollectionOut
|
from schemas import EggCollectionCreate, EggCollectionUpdate, EggCollectionOut
|
||||||
|
from auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/eggs", tags=["eggs"])
|
router = APIRouter(prefix="/api/eggs", tags=["eggs"])
|
||||||
|
|
||||||
@@ -17,8 +18,13 @@ def list_eggs(
|
|||||||
start: Optional[date] = None,
|
start: Optional[date] = None,
|
||||||
end: Optional[date] = None,
|
end: Optional[date] = None,
|
||||||
db: Session = Depends(get_db),
|
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:
|
if start:
|
||||||
q = q.where(EggCollection.date >= start)
|
q = q.where(EggCollection.date >= start)
|
||||||
if end:
|
if end:
|
||||||
@@ -27,8 +33,12 @@ def list_eggs(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=EggCollectionOut, status_code=201)
|
@router.post("", response_model=EggCollectionOut, status_code=201)
|
||||||
def create_egg_collection(body: EggCollectionCreate, db: Session = Depends(get_db)):
|
def create_egg_collection(
|
||||||
record = EggCollection(**body.model_dump())
|
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)
|
db.add(record)
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -44,8 +54,12 @@ def update_egg_collection(
|
|||||||
record_id: int,
|
record_id: int,
|
||||||
body: EggCollectionUpdate,
|
body: EggCollectionUpdate,
|
||||||
db: Session = Depends(get_db),
|
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:
|
if not record:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
for field, value in body.model_dump(exclude_none=True).items():
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
@@ -54,14 +68,21 @@ def update_egg_collection(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
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)
|
db.refresh(record)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{record_id}", status_code=204)
|
@router.delete("/{record_id}", status_code=204)
|
||||||
def delete_egg_collection(record_id: int, db: Session = Depends(get_db)):
|
def delete_egg_collection(
|
||||||
record = db.get(EggCollection, record_id)
|
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:
|
if not record:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
db.delete(record)
|
db.delete(record)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import FeedPurchase
|
from models import FeedPurchase, User
|
||||||
from schemas import FeedPurchaseCreate, FeedPurchaseUpdate, FeedPurchaseOut
|
from schemas import FeedPurchaseCreate, FeedPurchaseUpdate, FeedPurchaseOut
|
||||||
|
from auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/feed", tags=["feed"])
|
router = APIRouter(prefix="/api/feed", tags=["feed"])
|
||||||
|
|
||||||
@@ -16,8 +17,13 @@ def list_feed_purchases(
|
|||||||
start: Optional[date] = None,
|
start: Optional[date] = None,
|
||||||
end: Optional[date] = None,
|
end: Optional[date] = None,
|
||||||
db: Session = Depends(get_db),
|
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:
|
if start:
|
||||||
q = q.where(FeedPurchase.date >= start)
|
q = q.where(FeedPurchase.date >= start)
|
||||||
if end:
|
if end:
|
||||||
@@ -26,8 +32,12 @@ def list_feed_purchases(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=FeedPurchaseOut, status_code=201)
|
@router.post("", response_model=FeedPurchaseOut, status_code=201)
|
||||||
def create_feed_purchase(body: FeedPurchaseCreate, db: Session = Depends(get_db)):
|
def create_feed_purchase(
|
||||||
record = FeedPurchase(**body.model_dump())
|
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.add(record)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(record)
|
db.refresh(record)
|
||||||
@@ -39,8 +49,12 @@ def update_feed_purchase(
|
|||||||
record_id: int,
|
record_id: int,
|
||||||
body: FeedPurchaseUpdate,
|
body: FeedPurchaseUpdate,
|
||||||
db: Session = Depends(get_db),
|
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:
|
if not record:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
for field, value in body.model_dump(exclude_none=True).items():
|
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)
|
@router.delete("/{record_id}", status_code=204)
|
||||||
def delete_feed_purchase(record_id: int, db: Session = Depends(get_db)):
|
def delete_feed_purchase(
|
||||||
record = db.get(FeedPurchase, record_id)
|
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:
|
if not record:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
db.delete(record)
|
db.delete(record)
|
||||||
|
|||||||
@@ -5,30 +5,49 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import FlockHistory
|
from models import FlockHistory, User
|
||||||
from schemas import FlockHistoryCreate, FlockHistoryUpdate, FlockHistoryOut
|
from schemas import FlockHistoryCreate, FlockHistoryUpdate, FlockHistoryOut
|
||||||
|
from auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/flock", tags=["flock"])
|
router = APIRouter(prefix="/api/flock", tags=["flock"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[FlockHistoryOut])
|
@router.get("", response_model=list[FlockHistoryOut])
|
||||||
def list_flock_history(db: Session = Depends(get_db)):
|
def list_flock_history(
|
||||||
q = select(FlockHistory).order_by(FlockHistory.date.desc())
|
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()
|
return db.scalars(q).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/current", response_model=Optional[FlockHistoryOut])
|
@router.get("/current", response_model=Optional[FlockHistoryOut])
|
||||||
def get_current_flock(db: Session = Depends(get_db)):
|
def get_current_flock(
|
||||||
"""Returns the most recent flock entry — the current flock size."""
|
db: Session = Depends(get_db),
|
||||||
q = select(FlockHistory).order_by(FlockHistory.date.desc()).limit(1)
|
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()
|
return db.scalars(q).first()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/at/{target_date}", response_model=Optional[FlockHistoryOut])
|
@router.get("/at/{target_date}", response_model=Optional[FlockHistoryOut])
|
||||||
def get_flock_at_date(target_date: date, db: Session = Depends(get_db)):
|
def get_flock_at_date(
|
||||||
"""Returns the flock size that was in effect on a given date."""
|
target_date: date,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
q = (
|
q = (
|
||||||
select(FlockHistory)
|
select(FlockHistory)
|
||||||
|
.where(FlockHistory.user_id == current_user.id)
|
||||||
.where(FlockHistory.date <= target_date)
|
.where(FlockHistory.date <= target_date)
|
||||||
.order_by(FlockHistory.date.desc())
|
.order_by(FlockHistory.date.desc())
|
||||||
.limit(1)
|
.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)
|
@router.post("", response_model=FlockHistoryOut, status_code=201)
|
||||||
def create_flock_entry(body: FlockHistoryCreate, db: Session = Depends(get_db)):
|
def create_flock_entry(
|
||||||
record = FlockHistory(**body.model_dump())
|
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.add(record)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(record)
|
db.refresh(record)
|
||||||
@@ -50,8 +73,12 @@ def update_flock_entry(
|
|||||||
record_id: int,
|
record_id: int,
|
||||||
body: FlockHistoryUpdate,
|
body: FlockHistoryUpdate,
|
||||||
db: Session = Depends(get_db),
|
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:
|
if not record:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
for field, value in body.model_dump(exclude_none=True).items():
|
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)
|
@router.delete("/{record_id}", status_code=204)
|
||||||
def delete_flock_entry(record_id: int, db: Session = Depends(get_db)):
|
def delete_flock_entry(
|
||||||
record = db.get(FlockHistory, record_id)
|
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:
|
if not record:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
db.delete(record)
|
db.delete(record)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import OtherPurchase
|
from models import OtherPurchase, User
|
||||||
from schemas import OtherPurchaseCreate, OtherPurchaseUpdate, OtherPurchaseOut
|
from schemas import OtherPurchaseCreate, OtherPurchaseUpdate, OtherPurchaseOut
|
||||||
|
from auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/other", tags=["other"])
|
router = APIRouter(prefix="/api/other", tags=["other"])
|
||||||
|
|
||||||
@@ -16,8 +17,13 @@ def list_other_purchases(
|
|||||||
start: Optional[date] = None,
|
start: Optional[date] = None,
|
||||||
end: Optional[date] = None,
|
end: Optional[date] = None,
|
||||||
db: Session = Depends(get_db),
|
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:
|
if start:
|
||||||
q = q.where(OtherPurchase.date >= start)
|
q = q.where(OtherPurchase.date >= start)
|
||||||
if end:
|
if end:
|
||||||
@@ -26,8 +32,12 @@ def list_other_purchases(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=OtherPurchaseOut, status_code=201)
|
@router.post("", response_model=OtherPurchaseOut, status_code=201)
|
||||||
def create_other_purchase(body: OtherPurchaseCreate, db: Session = Depends(get_db)):
|
def create_other_purchase(
|
||||||
record = OtherPurchase(**body.model_dump())
|
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.add(record)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(record)
|
db.refresh(record)
|
||||||
@@ -39,8 +49,12 @@ def update_other_purchase(
|
|||||||
record_id: int,
|
record_id: int,
|
||||||
body: OtherPurchaseUpdate,
|
body: OtherPurchaseUpdate,
|
||||||
db: Session = Depends(get_db),
|
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:
|
if not record:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
for field, value in body.model_dump(exclude_none=True).items():
|
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)
|
@router.delete("/{record_id}", status_code=204)
|
||||||
def delete_other_purchase(record_id: int, db: Session = Depends(get_db)):
|
def delete_other_purchase(
|
||||||
record = db.get(OtherPurchase, record_id)
|
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:
|
if not record:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
db.delete(record)
|
db.delete(record)
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
import calendar
|
import calendar
|
||||||
from datetime import date, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
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 schemas import DashboardStats, BudgetStats, MonthlySummary
|
||||||
|
from auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
||||||
|
|
||||||
|
|
||||||
def _avg_per_hen_30d(db: Session, start_30d: date) -> float | None:
|
def _today(user_timezone: str) -> date:
|
||||||
"""
|
try:
|
||||||
For each collection in the last 30 days, look up the flock size that was
|
return datetime.now(ZoneInfo(user_timezone)).date()
|
||||||
in effect on that date using a correlated subquery, then average eggs/hen
|
except ZoneInfoNotFoundError:
|
||||||
across those days. This gives an accurate result even when flock size changed.
|
return date.today()
|
||||||
"""
|
|
||||||
|
|
||||||
|
def _avg_per_hen_30d(db: Session, user_id: int, start_30d: date) -> float | None:
|
||||||
flock_at_date = (
|
flock_at_date = (
|
||||||
select(FlockHistory.chicken_count)
|
select(FlockHistory.chicken_count)
|
||||||
|
.where(FlockHistory.user_id == user_id)
|
||||||
.where(FlockHistory.date <= EggCollection.date)
|
.where(FlockHistory.date <= EggCollection.date)
|
||||||
.order_by(FlockHistory.date.desc())
|
.order_by(FlockHistory.date.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -29,6 +34,7 @@ def _avg_per_hen_30d(db: Session, start_30d: date) -> float | None:
|
|||||||
|
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(EggCollection.eggs, flock_at_date.label('flock_count'))
|
select(EggCollection.eggs, flock_at_date.label('flock_count'))
|
||||||
|
.where(EggCollection.user_id == user_id)
|
||||||
.where(EggCollection.date >= start_30d)
|
.where(EggCollection.date >= start_30d)
|
||||||
).all()
|
).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)
|
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(
|
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()
|
).first()
|
||||||
return row.chicken_count if row else None
|
return row.chicken_count if row else None
|
||||||
|
|
||||||
|
|
||||||
def _total_eggs(db: Session, start: date | None = None, end: date | None = None) -> int:
|
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))
|
q = select(func.coalesce(func.sum(EggCollection.eggs), 0)).where(EggCollection.user_id == user_id)
|
||||||
if start:
|
if start:
|
||||||
q = q.where(EggCollection.date >= start)
|
q = q.where(EggCollection.date >= start)
|
||||||
if end:
|
if end:
|
||||||
@@ -54,10 +63,10 @@ def _total_eggs(db: Session, start: date | None = None, end: date | None = None)
|
|||||||
return db.scalar(q)
|
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(
|
q = select(
|
||||||
func.coalesce(func.sum(FeedPurchase.bags * FeedPurchase.price_per_bag), 0)
|
func.coalesce(func.sum(FeedPurchase.bags * FeedPurchase.price_per_bag), 0)
|
||||||
)
|
).where(FeedPurchase.user_id == user_id)
|
||||||
if start:
|
if start:
|
||||||
q = q.where(FeedPurchase.date >= start)
|
q = q.where(FeedPurchase.date >= start)
|
||||||
if end:
|
if end:
|
||||||
@@ -65,8 +74,8 @@ def _total_feed_cost(db: Session, start: date | None = None, end: date | None =
|
|||||||
return db.scalar(q)
|
return db.scalar(q)
|
||||||
|
|
||||||
|
|
||||||
def _total_other_cost(db: Session, start: date | None = None, end: date | None = None):
|
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))
|
q = select(func.coalesce(func.sum(OtherPurchase.total), 0)).where(OtherPurchase.user_id == user_id)
|
||||||
if start:
|
if start:
|
||||||
q = q.where(OtherPurchase.date >= start)
|
q = q.where(OtherPurchase.date >= start)
|
||||||
if end:
|
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)
|
@router.get("/dashboard", response_model=DashboardStats)
|
||||||
def dashboard_stats(db: Session = Depends(get_db)):
|
def dashboard_stats(
|
||||||
today = date.today()
|
db: Session = Depends(get_db),
|
||||||
start_30d = today - timedelta(days=30)
|
current_user: User = Depends(get_current_user),
|
||||||
start_7d = today - timedelta(days=7)
|
):
|
||||||
|
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_alltime = _total_eggs(db, uid)
|
||||||
total_30d = _total_eggs(db, start=start_30d)
|
total_30d = _total_eggs(db, uid, start=start_30d)
|
||||||
total_7d = _total_eggs(db, start=start_7d)
|
total_7d = _total_eggs(db, uid, start=start_7d)
|
||||||
flock = _current_flock(db)
|
flock = _current_flock(db, uid)
|
||||||
|
|
||||||
# Count how many distinct days have a collection logged
|
|
||||||
days_tracked = db.scalar(
|
days_tracked = db.scalar(
|
||||||
select(func.count(func.distinct(EggCollection.date)))
|
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(
|
days_with_data_30d = db.scalar(
|
||||||
select(func.count(func.distinct(EggCollection.date)))
|
select(func.count(func.distinct(EggCollection.date)))
|
||||||
|
.where(EggCollection.user_id == uid)
|
||||||
.where(EggCollection.date >= start_30d)
|
.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_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(
|
return DashboardStats(
|
||||||
current_flock=flock,
|
current_flock=flock,
|
||||||
@@ -111,10 +124,13 @@ def dashboard_stats(db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/monthly", response_model=list[MonthlySummary])
|
@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']
|
MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
|
||||||
|
|
||||||
# Monthly egg totals
|
|
||||||
egg_rows = db.execute(
|
egg_rows = db.execute(
|
||||||
select(
|
select(
|
||||||
func.year(EggCollection.date).label('year'),
|
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.sum(EggCollection.eggs).label('total_eggs'),
|
||||||
func.count(EggCollection.date).label('days_logged'),
|
func.count(EggCollection.date).label('days_logged'),
|
||||||
)
|
)
|
||||||
|
.where(EggCollection.user_id == uid)
|
||||||
.group_by(func.year(EggCollection.date), func.month(EggCollection.date))
|
.group_by(func.year(EggCollection.date), func.month(EggCollection.date))
|
||||||
.order_by(func.year(EggCollection.date).desc(), func.month(EggCollection.date).desc())
|
.order_by(func.year(EggCollection.date).desc(), func.month(EggCollection.date).desc())
|
||||||
).all()
|
).all()
|
||||||
@@ -129,25 +146,25 @@ def monthly_stats(db: Session = Depends(get_db)):
|
|||||||
if not egg_rows:
|
if not egg_rows:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Monthly feed costs
|
|
||||||
feed_rows = db.execute(
|
feed_rows = db.execute(
|
||||||
select(
|
select(
|
||||||
func.year(FeedPurchase.date).label('year'),
|
func.year(FeedPurchase.date).label('year'),
|
||||||
func.month(FeedPurchase.date).label('month'),
|
func.month(FeedPurchase.date).label('month'),
|
||||||
func.sum(FeedPurchase.bags * FeedPurchase.price_per_bag).label('feed_cost'),
|
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))
|
.group_by(func.year(FeedPurchase.date), func.month(FeedPurchase.date))
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
feed_map = {(r.year, r.month): r.feed_cost for r in feed_rows}
|
feed_map = {(r.year, r.month): r.feed_cost for r in feed_rows}
|
||||||
|
|
||||||
# Monthly other costs
|
|
||||||
other_rows = db.execute(
|
other_rows = db.execute(
|
||||||
select(
|
select(
|
||||||
func.year(OtherPurchase.date).label('year'),
|
func.year(OtherPurchase.date).label('year'),
|
||||||
func.month(OtherPurchase.date).label('month'),
|
func.month(OtherPurchase.date).label('month'),
|
||||||
func.sum(OtherPurchase.total).label('other_cost'),
|
func.sum(OtherPurchase.total).label('other_cost'),
|
||||||
)
|
)
|
||||||
|
.where(OtherPurchase.user_id == uid)
|
||||||
.group_by(func.year(OtherPurchase.date), func.month(OtherPurchase.date))
|
.group_by(func.year(OtherPurchase.date), func.month(OtherPurchase.date))
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
@@ -159,9 +176,9 @@ def monthly_stats(db: Session = Depends(get_db)):
|
|||||||
last_day = calendar.monthrange(y, m)[1]
|
last_day = calendar.monthrange(y, m)[1]
|
||||||
month_end = date(y, m, last_day)
|
month_end = date(y, m, last_day)
|
||||||
|
|
||||||
# Flock size in effect at the end of this month
|
|
||||||
flock_row = db.scalars(
|
flock_row = db.scalars(
|
||||||
select(FlockHistory)
|
select(FlockHistory)
|
||||||
|
.where(FlockHistory.user_id == uid)
|
||||||
.where(FlockHistory.date <= month_end)
|
.where(FlockHistory.date <= month_end)
|
||||||
.order_by(FlockHistory.date.desc())
|
.order_by(FlockHistory.date.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -201,16 +218,20 @@ def monthly_stats(db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/budget", response_model=BudgetStats)
|
@router.get("/budget", response_model=BudgetStats)
|
||||||
def budget_stats(db: Session = Depends(get_db)):
|
def budget_stats(
|
||||||
today = date.today()
|
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_30d = today - timedelta(days=30)
|
||||||
|
|
||||||
total_feed_cost = _total_feed_cost(db)
|
total_feed_cost = _total_feed_cost(db, uid)
|
||||||
total_feed_cost_30d = _total_feed_cost(db, start=start_30d)
|
total_feed_cost_30d = _total_feed_cost(db, uid, start=start_30d)
|
||||||
total_other_cost = _total_other_cost(db)
|
total_other_cost = _total_other_cost(db, uid)
|
||||||
total_other_cost_30d = _total_other_cost(db, start=start_30d)
|
total_other_cost_30d = _total_other_cost(db, uid, start=start_30d)
|
||||||
total_eggs = _total_eggs(db)
|
total_eggs = _total_eggs(db, uid)
|
||||||
total_eggs_30d = _total_eggs(db, start=start_30d)
|
total_eggs_30d = _total_eggs(db, uid, start=start_30d)
|
||||||
|
|
||||||
def cost_per_egg(cost, eggs):
|
def cost_per_egg(cost, eggs):
|
||||||
if not eggs or not cost:
|
if not eggs or not cost:
|
||||||
|
|||||||
@@ -4,6 +4,44 @@ from typing import Optional
|
|||||||
from pydantic import BaseModel, Field
|
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 ───────────────────────────────────────────────────────────
|
# ── Egg Collections ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class EggCollectionCreate(BaseModel):
|
class EggCollectionCreate(BaseModel):
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db/${MYSQL_DATABASE}
|
DATABASE_URL: mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db/${MYSQL_DATABASE}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy # wait for MySQL to be ready before starting
|
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.
|
-- This file runs automatically on first container startup only.
|
||||||
-- To re-run it, remove the mysql_data volume: docker compose down -v
|
-- 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;
|
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 ───────────────────────────────────────────────────────────
|
-- ── Egg collections ───────────────────────────────────────────────────────────
|
||||||
CREATE TABLE IF NOT EXISTS egg_collections (
|
CREATE TABLE IF NOT EXISTS egg_collections (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
date DATE NOT NULL,
|
date DATE NOT NULL,
|
||||||
eggs INT UNSIGNED NOT NULL,
|
eggs INT UNSIGNED NOT NULL,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
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;
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
-- ── Flock history ─────────────────────────────────────────────────────────────
|
-- ── 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 (
|
CREATE TABLE IF NOT EXISTS flock_history (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
date DATE NOT NULL,
|
date DATE NOT NULL,
|
||||||
chicken_count INT UNSIGNED NOT NULL,
|
chicken_count INT UNSIGNED NOT NULL,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
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;
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
-- ── Feed purchases ────────────────────────────────────────────────────────────
|
-- ── Feed purchases ────────────────────────────────────────────────────────────
|
||||||
CREATE TABLE IF NOT EXISTS feed_purchases (
|
CREATE TABLE IF NOT EXISTS feed_purchases (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
date DATE 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,
|
price_per_bag DECIMAL(10, 2) NOT NULL,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
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;
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
-- ── Other purchases ───────────────────────────────────────────────────────────
|
-- ── Other purchases ───────────────────────────────────────────────────────────
|
||||||
-- Catch-all for non-feed costs: bedding, snacks, shelter, etc.
|
|
||||||
CREATE TABLE IF NOT EXISTS other_purchases (
|
CREATE TABLE IF NOT EXISTS other_purchases (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
date DATE NOT NULL,
|
date DATE NOT NULL,
|
||||||
total DECIMAL(10, 2) NOT NULL,
|
total DECIMAL(10, 2) NOT NULL,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
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;
|
) 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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
<style>
|
<style>
|
||||||
.error-center { text-align: center; padding: 5rem 1rem; }
|
.error-center { text-align: center; padding: 5rem 1rem; }
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a class="nav-brand" href="/">🥚 Eggtracker</a>
|
<a class="nav-brand" href="/">🥚 Yolkbook</a>
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li><a href="/">Dashboard</a></li>
|
<li><a href="/">Dashboard</a></li>
|
||||||
<li><a href="/log">Log Eggs</a></li>
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
<style>
|
<style>
|
||||||
.error-center { text-align: center; padding: 5rem 1rem; }
|
.error-center { text-align: center; padding: 5rem 1rem; }
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a class="nav-brand" href="/">🥚 Eggtracker</a>
|
<a class="nav-brand" href="/">🥚 Yolkbook</a>
|
||||||
</nav>
|
</nav>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<div class="error-center">
|
<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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<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">
|
<ul class="nav-links">
|
||||||
<li><a href="/">Dashboard</a></li>
|
<li><a href="/">Dashboard</a></li>
|
||||||
<li><a href="/log">Log Eggs</a></li>
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
@@ -123,7 +123,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
<script src="/js/budget.js?v=3"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ label { font-size: 0.875rem; font-weight: 500; }
|
|||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
input[type="date"],
|
input[type="date"],
|
||||||
|
input[type="password"],
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
@@ -259,4 +260,120 @@ td input[type="date"] {
|
|||||||
.nav-links { overflow-x: auto; scrollbar-width: none; }
|
.nav-links { overflow-x: auto; scrollbar-width: none; }
|
||||||
.nav-links::-webkit-scrollbar { display: none; }
|
.nav-links::-webkit-scrollbar { display: none; }
|
||||||
.nav-links a { padding: 0.4rem 0.55rem; font-size: 0.82rem; white-space: nowrap; }
|
.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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<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">
|
<ul class="nav-links">
|
||||||
<li><a href="/">Dashboard</a></li>
|
<li><a href="/">Dashboard</a></li>
|
||||||
<li><a href="/log">Log Eggs</a></li>
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
@@ -80,7 +80,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
<script src="/js/flock.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<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">
|
<ul class="nav-links">
|
||||||
<li><a href="/">Dashboard</a></li>
|
<li><a href="/">Dashboard</a></li>
|
||||||
<li><a href="/log">Log Eggs</a></li>
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
@@ -63,7 +63,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
<script src="/js/history.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<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">
|
<ul class="nav-links">
|
||||||
<li><a href="/">Dashboard</a></li>
|
<li><a href="/">Dashboard</a></li>
|
||||||
<li><a href="/log">Log Eggs</a></li>
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
@@ -73,7 +73,8 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
<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>
|
<script src="/js/dashboard.js?v=2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 = {
|
const API = {
|
||||||
async _fetch(url, options = {}) {
|
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, {
|
const res = await fetch(url, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
throw new Error(err.detail || `Request failed (${res.status})`);
|
throw new Error(err.detail || `Request failed (${res.status})`);
|
||||||
@@ -27,13 +38,13 @@ function showMessage(el, text, type = 'success') {
|
|||||||
setTimeout(() => { el.className = 'message'; }, 4000);
|
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) {
|
function setToday(inputEl) {
|
||||||
const now = new Date();
|
const tz = (typeof Auth !== 'undefined' && Auth.getUser()?.timezone)
|
||||||
const y = now.getFullYear();
|
|| Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|| 'UTC';
|
||||||
const d = String(now.getDate()).padStart(2, '0');
|
// en-CA locale produces YYYY-MM-DD which is what date inputs expect
|
||||||
inputEl.value = `${y}-${m}-${d}`;
|
inputEl.value = new Date().toLocaleDateString('en-CA', { timeZone: tz });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format YYYY-MM-DD → MM/DD/YYYY for display
|
// 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;
|
a.href = url;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const fileDate = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
|
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();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<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">
|
<ul class="nav-links">
|
||||||
<li><a href="/">Dashboard</a></li>
|
<li><a href="/">Dashboard</a></li>
|
||||||
<li><a href="/log">Log Eggs</a></li>
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
@@ -66,7 +66,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
<script src="/js/log.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav">
|
<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">
|
<ul class="nav-links">
|
||||||
<li><a href="/">Dashboard</a></li>
|
<li><a href="/">Dashboard</a></li>
|
||||||
<li><a href="/log">Log Eggs</a></li>
|
<li><a href="/log">Log Eggs</a></li>
|
||||||
@@ -68,7 +68,8 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
<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>
|
<script src="/js/summary.js?v=2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user