chore: initial project setup with backend, frontend, and infrastructure
Add complete NexaPantry application structure including: - Docker Compose configuration with PostgreSQL, Redis, FastAPI backend, worker, frontend and Caddy - Environment configuration template with database, auth, and service settings - GitHub Actions CI workflow for backend/frontend linting, testing, auditing and Docker builds - AGPL-3.0 license and comprehensive README with setup, development, and security documentation - Backend
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
COMPOSE_PROJECT_NAME=nexapantry
|
||||
APP_ENV=production
|
||||
INSTANCE_URL=http://localhost
|
||||
FRONTEND_ORIGIN=http://localhost
|
||||
POSTGRES_DB=nexapantry
|
||||
POSTGRES_USER=nexapantry
|
||||
POSTGRES_PASSWORD=change-me-long-random-password
|
||||
DATABASE_URL=postgresql+psycopg://nexapantry:change-me-long-random-password@postgres:5432/nexapantry
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
JWT_SECRET_KEY=change-me-at-least-32-random-bytes-before-production
|
||||
SETTINGS_SECRET_KEY=aXJQnbHFP0V7nCyhSEmqktlCREo21daLKs6dK7Pf3Uw=
|
||||
COOKIE_SECURE=false
|
||||
CORS_ORIGINS=http://localhost,http://localhost:5173
|
||||
LOG_LEVEL=INFO
|
||||
DEFAULT_TIMEZONE=Europe/Vienna
|
||||
DAILY_WORKER_INTERVAL_SECONDS=300
|
||||
62
.github/workflows/ci.yml
vendored
Normal file
62
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install backend
|
||||
run: |
|
||||
cd backend
|
||||
python -m pip install --upgrade pip
|
||||
pip install ".[dev]"
|
||||
- name: Lint
|
||||
run: cd backend && ruff check app
|
||||
- name: Test
|
||||
run: cd backend && pytest
|
||||
- name: Audit
|
||||
run: cd backend && pip-audit
|
||||
- name: Bandit
|
||||
run: cd backend && bandit -q -r app -x app/tests
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend
|
||||
run: cd frontend && npm ci
|
||||
- name: Lint
|
||||
run: cd frontend && npm run lint
|
||||
- name: Typecheck
|
||||
run: cd frontend && npm run typecheck
|
||||
- name: Test
|
||||
run: cd frontend && npm test -- --run
|
||||
- name: Audit
|
||||
run: cd frontend && npm audit --audit-level=moderate
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build images
|
||||
run: docker compose build
|
||||
- name: Trivy filesystem scan
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
severity: CRITICAL,HIGH
|
||||
exit-code: "0"
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
.env
|
||||
.venv/
|
||||
node_modules/
|
||||
dist/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.pyc
|
||||
postgres_data/
|
||||
redis_data/
|
||||
*.log
|
||||
|
||||
10
LICENSE
Normal file
10
LICENSE
Normal file
@@ -0,0 +1,10 @@
|
||||
AGPL-3.0-or-later
|
||||
|
||||
Copyright (C) 2026 NexaPantry contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
149
README.md
Normal file
149
README.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# NexaPantry
|
||||
|
||||
NexaPantry is an AGPL-3.0 self-hosted Docker/PWA web app for managing food inventory in families, shared flats and households. It supports multi-user homes, barcode scanning, expiry warnings, shopping lists, recipe suggestions, admin operations and secure instance setup.
|
||||
|
||||
## Features
|
||||
|
||||
- Initial setup wizard for the first Instance Admin
|
||||
- Secure HttpOnly cookie auth, Argon2id password hashes, CSRF protection and rate limiting
|
||||
- Multi-home model with owner/member/read-only roles and expiring join codes
|
||||
- Inventory with barcode lookup via an abstract OpenFoodFacts provider
|
||||
- Category and location views with expiry status colors
|
||||
- Shopping list with stock refill flow
|
||||
- In-app and e-mail notification pipeline with a background worker
|
||||
- Rule-based recipe suggestions that prefer available and expiring products
|
||||
- Full admin panel for users, homes, mail settings, security settings, logs and backup guidance
|
||||
- German and English UI texts through i18n dictionaries
|
||||
- Light, dark and system theme
|
||||
- Installable PWA with app shell caching and offline-friendly runtime caching
|
||||
- Docker Compose with PostgreSQL, Redis, FastAPI backend, worker, frontend and Caddy reverse proxy
|
||||
|
||||
## Screenshots
|
||||
|
||||
Screenshots are intentionally not committed yet. Suggested files:
|
||||
|
||||
- `docs/screenshots/setup.png`
|
||||
- `docs/screenshots/inventory.png`
|
||||
- `docs/screenshots/admin.png`
|
||||
|
||||
## Quick Start
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# edit passwords and keys in .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://localhost`. On first launch NexaPantry shows the initial setup wizard because no Instance Admin exists.
|
||||
|
||||
Generate a Fernet key for `SETTINGS_SECRET_KEY`:
|
||||
|
||||
```sh
|
||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
```
|
||||
|
||||
Generate a JWT secret:
|
||||
|
||||
```sh
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Backend:
|
||||
|
||||
```sh
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Mail Settings
|
||||
|
||||
Configure SMTP in `Admin -> Mail`:
|
||||
|
||||
- SMTP host and port
|
||||
- SMTP user
|
||||
- encrypted SMTP password
|
||||
- TLS or STARTTLS
|
||||
- sender address and sender name
|
||||
|
||||
Mail is used for invitations, password reset, password setup and expiry summaries.
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
Backup:
|
||||
|
||||
```sh
|
||||
docker compose exec postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > backup.sql
|
||||
```
|
||||
|
||||
Restore:
|
||||
|
||||
```sh
|
||||
docker compose exec -T postgres psql -U "$POSTGRES_USER" "$POSTGRES_DB" < backup.sql
|
||||
```
|
||||
|
||||
## Updates
|
||||
|
||||
```sh
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Read release notes before updating and keep database backups.
|
||||
|
||||
## Security
|
||||
|
||||
- Change every value in `.env` before exposing the service.
|
||||
- Put NexaPantry behind HTTPS. Caddy can terminate TLS when configured with your public domain.
|
||||
- Keep `COOKIE_SECURE=true` in production HTTPS deployments.
|
||||
- Restrict `CORS_ORIGINS` to your real instance origin.
|
||||
- Invitation and reset tokens are stored hashed only.
|
||||
- SMTP passwords are encrypted at rest with `SETTINGS_SECRET_KEY`.
|
||||
- Product, shopping and recipe APIs verify home membership to reduce IDOR risk.
|
||||
- Admin actions are written to an audit log without secret values.
|
||||
|
||||
Run checks:
|
||||
|
||||
```sh
|
||||
chmod +x scripts/security-checks.sh
|
||||
./scripts/security-checks.sh
|
||||
```
|
||||
|
||||
Container image scan:
|
||||
|
||||
```sh
|
||||
docker compose build
|
||||
trivy image nexapantry-backend
|
||||
trivy image nexapantry-frontend
|
||||
```
|
||||
|
||||
Secret scan:
|
||||
|
||||
```sh
|
||||
gitleaks detect --source .
|
||||
```
|
||||
|
||||
## Contribution
|
||||
|
||||
1. Open an issue for larger changes.
|
||||
2. Keep PRs focused.
|
||||
3. Add or update tests for behavior changes.
|
||||
4. Run lint, tests, typecheck and audits before submitting.
|
||||
|
||||
## License
|
||||
|
||||
NexaPantry is licensed under AGPL-3.0-or-later.
|
||||
|
||||
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
RUN groupadd -r app && useradd -r -g app app \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml /app/
|
||||
RUN pip install --upgrade pip && pip install .
|
||||
COPY app /app/app
|
||||
USER app
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
|
||||
53
backend/app/api/deps.py
Normal file
53
backend/app/api/deps.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import decode_session_token
|
||||
from app.db.session import get_db
|
||||
from app.models.entities import HomeMembership, InstanceRole, User
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
token = request.cookies.get("np_session")
|
||||
user_id = decode_session_token(token) if token else None
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
user = db.get(User, user_id)
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def current_admin(user: User = Depends(current_user)) -> User:
|
||||
if user.instance_role != InstanceRole.ADMIN:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required")
|
||||
return user
|
||||
|
||||
|
||||
def require_home_member(home_id: str, db: Session, user: User) -> HomeMembership:
|
||||
membership = db.scalar(
|
||||
select(HomeMembership).where(
|
||||
HomeMembership.home_id == home_id,
|
||||
HomeMembership.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if not membership and user.instance_role != InstanceRole.ADMIN:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Home not found")
|
||||
if not membership:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Home membership required")
|
||||
return membership
|
||||
|
||||
|
||||
def require_home_write(home_id: str, db: Session, user: User) -> HomeMembership:
|
||||
membership = require_home_member(home_id, db, user)
|
||||
if membership.role == "read_only" and user.instance_role != InstanceRole.ADMIN:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Read-only home")
|
||||
return membership
|
||||
|
||||
|
||||
def require_home_owner(home_id: str, db: Session, user: User) -> HomeMembership:
|
||||
membership = require_home_member(home_id, db, user)
|
||||
if membership.role != "home_owner" and user.instance_role != InstanceRole.ADMIN:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Home owner required")
|
||||
return membership
|
||||
|
||||
14
backend/app/api/router.py
Normal file
14
backend/app/api/router.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import admin, auth, homes, notifications, products, recipes, setup, shopping
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(setup.router, prefix="/setup", tags=["setup"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])
|
||||
api_router.include_router(homes.router, prefix="/homes", tags=["homes"])
|
||||
api_router.include_router(products.router, prefix="/homes/{home_id}/products", tags=["products"])
|
||||
api_router.include_router(shopping.router, prefix="/homes/{home_id}/shopping", tags=["shopping"])
|
||||
api_router.include_router(notifications.router, prefix="/notifications", tags=["notifications"])
|
||||
api_router.include_router(recipes.router, prefix="/homes/{home_id}/recipes", tags=["recipes"])
|
||||
|
||||
1
backend/app/api/routes/__init__.py
Normal file
1
backend/app/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
143
backend/app/api/routes/admin.py
Normal file
143
backend/app/api/routes/admin.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import current_admin
|
||||
from app.api.routes.auth import create_reset_token, send_invitation
|
||||
from app.db.session import get_db
|
||||
from app.models.entities import AppSetting, AuditLog, Home, Product, User
|
||||
from app.schemas.common import (
|
||||
MailSettingsIn,
|
||||
MailSettingsOut,
|
||||
Message,
|
||||
TestMailIn,
|
||||
UserCreate,
|
||||
UserOut,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.services.audit import audit
|
||||
from app.services.mail import (
|
||||
get_mail_settings,
|
||||
send_mail,
|
||||
serialize_mail_settings,
|
||||
update_mail_settings,
|
||||
)
|
||||
|
||||
router = APIRouter(dependencies=[Depends(current_admin)])
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def dashboard(db: Session = Depends(get_db)) -> dict:
|
||||
return {
|
||||
"users": db.scalar(select(func.count(User.id))),
|
||||
"homes": db.scalar(select(func.count(Home.id))),
|
||||
"products": db.scalar(select(func.count(Product.id))),
|
||||
"active_users": db.scalar(select(func.count(User.id)).where(User.is_active)),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserOut])
|
||||
def list_users(db: Session = Depends(get_db)) -> list[User]:
|
||||
return list(db.scalars(select(User).order_by(User.created_at.desc())).all())
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserOut, status_code=201)
|
||||
def create_user(payload: UserCreate, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> User:
|
||||
if db.scalar(select(User).where(User.email == str(payload.email).lower())):
|
||||
raise HTTPException(status_code=409, detail="E-mail already exists")
|
||||
user = User(email=str(payload.email).lower(), name=payload.name, instance_role=payload.role, is_active=True)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
if payload.send_invite:
|
||||
send_invitation(db, user)
|
||||
audit(db, admin, "admin.user.create", "user", user.id)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserOut)
|
||||
def update_user(user_id: str, payload: UserUpdate, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> User:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(user, key, value)
|
||||
audit(db, admin, "admin.user.update", "user", user.id)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", response_model=Message)
|
||||
def delete_user(user_id: str, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> Message:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
db.delete(user)
|
||||
audit(db, admin, "admin.user.delete", "user", user_id)
|
||||
db.commit()
|
||||
return Message(message="User deleted")
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/reset-password", response_model=Message)
|
||||
def reset_password(user_id: str, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> Message:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
token = create_reset_token(db, user)
|
||||
send_mail(db, user.email, "NexaPantry password reset", f"Reset token: {token}")
|
||||
audit(db, admin, "admin.user.reset_password", "user", user.id)
|
||||
db.commit()
|
||||
return Message(message="Password reset mail sent")
|
||||
|
||||
|
||||
@router.get("/homes")
|
||||
def homes(db: Session = Depends(get_db)) -> list[dict]:
|
||||
return [{"id": h.id, "name": h.name, "members": len(h.memberships), "products": len(h.products)} for h in db.scalars(select(Home)).all()]
|
||||
|
||||
|
||||
@router.get("/mail", response_model=MailSettingsOut)
|
||||
def mail_settings(db: Session = Depends(get_db)) -> MailSettingsOut:
|
||||
return serialize_mail_settings(get_mail_settings(db))
|
||||
|
||||
|
||||
@router.put("/mail", response_model=MailSettingsOut)
|
||||
def save_mail_settings(payload: MailSettingsIn, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> MailSettingsOut:
|
||||
settings = update_mail_settings(db, payload)
|
||||
audit(db, admin, "admin.mail.update", "mail_settings", "1")
|
||||
db.commit()
|
||||
return serialize_mail_settings(settings)
|
||||
|
||||
|
||||
@router.post("/mail/test", response_model=Message)
|
||||
def test_mail(payload: TestMailIn, db: Session = Depends(get_db)) -> Message:
|
||||
send_mail(db, str(payload.to), "NexaPantry test mail", "SMTP is configured correctly.")
|
||||
return Message(message="Test mail sent")
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
def get_settings(db: Session = Depends(get_db)) -> dict:
|
||||
return {s.key: s.value for s in db.scalars(select(AppSetting)).all()}
|
||||
|
||||
|
||||
@router.put("/settings/{key}")
|
||||
def set_setting(key: str, value: dict, admin: User = Depends(current_admin), db: Session = Depends(get_db)) -> dict:
|
||||
setting = db.get(AppSetting, key) or AppSetting(key=key)
|
||||
setting.value = value
|
||||
db.merge(setting)
|
||||
audit(db, admin, "admin.setting.update", "setting", key)
|
||||
db.commit()
|
||||
return setting.value
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
def logs(db: Session = Depends(get_db)) -> list[dict]:
|
||||
rows = db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(200)).all()
|
||||
return [{"created_at": r.created_at, "action": r.action, "target_type": r.target_type, "target_id": r.target_id, "metadata": r.metadata_json} for r in rows]
|
||||
|
||||
|
||||
@router.get("/system")
|
||||
def system_info() -> dict:
|
||||
return {"app": "NexaPantry", "version": "0.1.0", "runtime": "FastAPI", "database": "PostgreSQL"}
|
||||
|
||||
117
backend/app/api/routes/auth.py
Normal file
117
backend/app/api/routes/auth.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import current_user
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import (
|
||||
create_session_token,
|
||||
hash_password,
|
||||
hash_token,
|
||||
make_csrf_token,
|
||||
new_token,
|
||||
verify_password,
|
||||
)
|
||||
from app.db.session import get_db
|
||||
from app.models.entities import (
|
||||
Home,
|
||||
HomeMembership,
|
||||
HomeRole,
|
||||
InvitationToken,
|
||||
PasswordResetToken,
|
||||
User,
|
||||
)
|
||||
from app.schemas.common import InviteAccept, LoginRequest, Message, UserOut
|
||||
from app.services.audit import audit
|
||||
from app.services.mail import invite_body, send_mail
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def set_auth_cookies(response: Response, user_id: str) -> None:
|
||||
settings = get_settings()
|
||||
csrf = make_csrf_token()
|
||||
response.set_cookie("np_session", create_session_token(user_id), httponly=True, secure=settings.cookie_secure, samesite="lax", max_age=60 * 60 * 24 * 14)
|
||||
response.set_cookie("np_csrf", csrf, httponly=False, secure=settings.cookie_secure, samesite="lax", max_age=60 * 60 * 24 * 14)
|
||||
|
||||
|
||||
@router.post("/login", response_model=UserOut)
|
||||
def login(payload: LoginRequest, response: Response, db: Session = Depends(get_db)) -> User:
|
||||
user = db.scalar(select(User).where(User.email == str(payload.email).lower()))
|
||||
if not user or not verify_password(payload.password, user.password_hash) or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
set_auth_cookies(response, user.id)
|
||||
audit(db, user, "auth.login", "user", user.id)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout", response_model=Message)
|
||||
def logout(response: Response) -> Message:
|
||||
response.delete_cookie("np_session")
|
||||
response.delete_cookie("np_csrf")
|
||||
return Message(message="Logged out")
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(user: User = Depends(current_user)) -> User:
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserOut)
|
||||
def update_me(payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> User:
|
||||
for field in ["name", "language", "theme", "timezone", "onboarding_completed"]:
|
||||
if field in payload:
|
||||
setattr(user, field, payload[field])
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/accept-invite", response_model=UserOut)
|
||||
def accept_invite(payload: InviteAccept, response: Response, db: Session = Depends(get_db)) -> User:
|
||||
hashed = hash_token(payload.token)
|
||||
invite = db.scalar(select(InvitationToken).where(InvitationToken.token_hash == hashed))
|
||||
if not invite or invite.consumed_at or invite.expires_at < datetime.now(UTC):
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired invitation")
|
||||
user = db.get(User, invite.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Invalid invitation")
|
||||
user.name = payload.name
|
||||
user.password_hash = hash_password(payload.password)
|
||||
user.language = payload.language
|
||||
user.theme = payload.theme
|
||||
invite.consumed_at = datetime.now(UTC)
|
||||
if payload.home_name:
|
||||
home = Home(name=payload.home_name)
|
||||
db.add(home)
|
||||
db.flush()
|
||||
db.add(HomeMembership(home_id=home.id, user_id=user.id, role=HomeRole.OWNER))
|
||||
elif payload.join_code:
|
||||
from app.api.routes.homes import join_home_by_code
|
||||
|
||||
join_home_by_code(payload.join_code, user, db)
|
||||
audit(db, user, "auth.accept_invite", "user", user.id)
|
||||
db.commit()
|
||||
set_auth_cookies(response, user.id)
|
||||
return user
|
||||
|
||||
|
||||
def create_invitation(db: Session, user: User) -> str:
|
||||
token = new_token()
|
||||
db.add(InvitationToken(user_id=user.id, token_hash=hash_token(token), expires_at=datetime.now(UTC) + timedelta(days=7)))
|
||||
return token
|
||||
|
||||
|
||||
def create_reset_token(db: Session, user: User) -> str:
|
||||
token = new_token()
|
||||
db.add(PasswordResetToken(user_id=user.id, token_hash=hash_token(token), expires_at=datetime.now(UTC) + timedelta(hours=2)))
|
||||
return token
|
||||
|
||||
|
||||
def send_invitation(db: Session, user: User) -> None:
|
||||
token = create_invitation(db, user)
|
||||
send_mail(db, user.email, "Your NexaPantry invitation", invite_body(token))
|
||||
|
||||
117
backend/app/api/routes/homes.py
Normal file
117
backend/app/api/routes/homes.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import current_user, require_home_owner
|
||||
from app.core.security import hash_token
|
||||
from app.db.session import get_db
|
||||
from app.models.entities import Home, HomeMembership, HomeRole, User
|
||||
from app.schemas.common import HomeCreate, HomeOut, HomeSettingsUpdate, JoinCodeOut, Message
|
||||
from app.services.audit import audit
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def home_out(home: Home, role: str | None = None) -> HomeOut:
|
||||
return HomeOut.model_validate(home).model_copy(update={"role": role})
|
||||
|
||||
|
||||
@router.get("", response_model=list[HomeOut])
|
||||
def list_homes(user: User = Depends(current_user)) -> list[HomeOut]:
|
||||
return [home_out(m.home, m.role) for m in user.memberships]
|
||||
|
||||
|
||||
@router.post("", response_model=HomeOut, status_code=201)
|
||||
def create_home(payload: HomeCreate, user: User = Depends(current_user), db: Session = Depends(get_db)) -> HomeOut:
|
||||
home = Home(name=payload.name)
|
||||
db.add(home)
|
||||
db.flush()
|
||||
db.add(HomeMembership(home_id=home.id, user_id=user.id, role=HomeRole.OWNER))
|
||||
audit(db, user, "home.create", "home", home.id)
|
||||
db.commit()
|
||||
db.refresh(home)
|
||||
return home_out(home, HomeRole.OWNER)
|
||||
|
||||
|
||||
def join_home_by_code(join_code: str, user: User, db: Session) -> Home:
|
||||
home = db.scalar(select(Home).where(Home.join_code_hash == hash_token(join_code.upper())))
|
||||
if not home or not home.join_code_expires_at or home.join_code_expires_at < datetime.now(UTC):
|
||||
raise HTTPException(status_code=400, detail="Invalid join code")
|
||||
exists = db.scalar(select(HomeMembership).where(HomeMembership.home_id == home.id, HomeMembership.user_id == user.id))
|
||||
if not exists:
|
||||
db.add(HomeMembership(home_id=home.id, user_id=user.id, role=HomeRole.MEMBER))
|
||||
return home
|
||||
|
||||
|
||||
@router.post("/join", response_model=HomeOut)
|
||||
def join(payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> HomeOut:
|
||||
home = join_home_by_code(str(payload.get("join_code", "")), user, db)
|
||||
audit(db, user, "home.join", "home", home.id)
|
||||
db.commit()
|
||||
return home_out(home, HomeRole.MEMBER)
|
||||
|
||||
|
||||
@router.patch("/{home_id}", response_model=HomeOut)
|
||||
def update_home(home_id: str, payload: HomeSettingsUpdate, user: User = Depends(current_user), db: Session = Depends(get_db)) -> HomeOut:
|
||||
membership = require_home_owner(home_id, db, user)
|
||||
home = db.get(Home, home_id)
|
||||
if not home:
|
||||
raise HTTPException(status_code=404, detail="Home not found")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(home, key, value)
|
||||
audit(db, user, "home.update", "home", home.id)
|
||||
db.commit()
|
||||
return home_out(home, membership.role)
|
||||
|
||||
|
||||
@router.post("/{home_id}/join-code", response_model=JoinCodeOut)
|
||||
def create_join_code(home_id: str, payload: dict | None = None, user: User = Depends(current_user), db: Session = Depends(get_db)) -> JoinCodeOut:
|
||||
require_home_owner(home_id, db, user)
|
||||
home = db.get(Home, home_id)
|
||||
if not home:
|
||||
raise HTTPException(status_code=404, detail="Home not found")
|
||||
days = int((payload or {}).get("days", 7))
|
||||
code = f"NX-{secrets.randbelow(900000) + 100000}"
|
||||
home.join_code_hash = hash_token(code)
|
||||
home.join_code_expires_at = datetime.now(UTC) + timedelta(days=max(1, min(days, 60)))
|
||||
audit(db, user, "home.join_code.create", "home", home.id)
|
||||
db.commit()
|
||||
return JoinCodeOut(join_code=code, expires_at=home.join_code_expires_at)
|
||||
|
||||
|
||||
@router.delete("/{home_id}/join-code", response_model=Message)
|
||||
def disable_join_code(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
|
||||
require_home_owner(home_id, db, user)
|
||||
home = db.get(Home, home_id)
|
||||
if not home:
|
||||
raise HTTPException(status_code=404, detail="Home not found")
|
||||
home.join_code_hash = None
|
||||
home.join_code_expires_at = None
|
||||
audit(db, user, "home.join_code.disable", "home", home.id)
|
||||
db.commit()
|
||||
return Message(message="Join code disabled")
|
||||
|
||||
|
||||
@router.get("/{home_id}/members")
|
||||
def members(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
require_home_owner(home_id, db, user)
|
||||
rows = db.scalars(select(HomeMembership).where(HomeMembership.home_id == home_id)).all()
|
||||
return [{"id": m.id, "user_id": m.user_id, "name": m.user.name, "email": m.user.email, "role": m.role} for m in rows]
|
||||
|
||||
|
||||
@router.patch("/{home_id}/members/{membership_id}")
|
||||
def update_member(home_id: str, membership_id: str, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
require_home_owner(home_id, db, user)
|
||||
membership = db.get(HomeMembership, membership_id)
|
||||
if not membership or membership.home_id != home_id:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
if payload.get("role") in [HomeRole.OWNER, HomeRole.MEMBER, HomeRole.READ_ONLY]:
|
||||
membership.role = payload["role"]
|
||||
if "notification_preferences" in payload:
|
||||
membership.notification_preferences = payload["notification_preferences"]
|
||||
audit(db, user, "home.member.update", "membership", membership.id)
|
||||
db.commit()
|
||||
return {"message": "Member updated"}
|
||||
26
backend/app/api/routes/notifications.py
Normal file
26
backend/app/api/routes/notifications.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.entities import Notification, User
|
||||
from app.services.notifications import mark_read
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_notifications(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
rows = db.scalars(select(Notification).where(Notification.user_id == user.id).order_by(Notification.created_at.desc()).limit(100)).all()
|
||||
return [{"id": n.id, "title": n.title, "body": n.body, "kind": n.kind, "read_at": n.read_at, "created_at": n.created_at} for n in rows]
|
||||
|
||||
|
||||
@router.post("/{notification_id}/read")
|
||||
def read(notification_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
notification = db.get(Notification, notification_id)
|
||||
if not notification or notification.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
mark_read(db, notification)
|
||||
db.commit()
|
||||
return {"message": "Notification read"}
|
||||
78
backend/app/api/routes/products.py
Normal file
78
backend/app/api/routes/products.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import current_user, require_home_member, require_home_write
|
||||
from app.db.session import get_db
|
||||
from app.models.entities import Product, ShoppingItem, User
|
||||
from app.schemas.common import Message, ProductIn, ProductOut
|
||||
from app.services.audit import audit
|
||||
from app.services.products import OpenFoodFactsLookup, expiry_status
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def serialize(product: Product) -> ProductOut:
|
||||
return ProductOut.model_validate(product).model_copy(update={"status": expiry_status(product, product.home)})
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProductOut])
|
||||
def list_products(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[ProductOut]:
|
||||
require_home_member(home_id, db, user)
|
||||
rows = db.scalars(select(Product).where(Product.home_id == home_id).order_by(Product.expires_at.nullslast(), Product.name)).all()
|
||||
return [serialize(product) for product in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=ProductOut, status_code=201)
|
||||
def create_product(home_id: str, payload: ProductIn, user: User = Depends(current_user), db: Session = Depends(get_db)) -> ProductOut:
|
||||
require_home_write(home_id, db, user)
|
||||
product = Product(home_id=home_id, created_by_id=user.id, **payload.model_dump())
|
||||
db.add(product)
|
||||
audit(db, user, "product.create", "product", product.id)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return serialize(product)
|
||||
|
||||
|
||||
@router.patch("/{product_id}", response_model=ProductOut)
|
||||
def update_product(home_id: str, product_id: str, payload: ProductIn, user: User = Depends(current_user), db: Session = Depends(get_db)) -> ProductOut:
|
||||
require_home_write(home_id, db, user)
|
||||
product = db.get(Product, product_id)
|
||||
if not product or product.home_id != home_id:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
for key, value in payload.model_dump().items():
|
||||
setattr(product, key, value)
|
||||
audit(db, user, "product.update", "product", product.id)
|
||||
db.commit()
|
||||
return serialize(product)
|
||||
|
||||
|
||||
@router.delete("/{product_id}", response_model=Message)
|
||||
def delete_product(home_id: str, product_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
|
||||
require_home_write(home_id, db, user)
|
||||
product = db.get(Product, product_id)
|
||||
if not product or product.home_id != home_id:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
db.delete(product)
|
||||
audit(db, user, "product.delete", "product", product_id)
|
||||
db.commit()
|
||||
return Message(message="Product deleted")
|
||||
|
||||
|
||||
@router.post("/{product_id}/add-to-shopping", response_model=Message)
|
||||
def add_to_shopping(home_id: str, product_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
|
||||
require_home_write(home_id, db, user)
|
||||
product = db.get(Product, product_id)
|
||||
if not product or product.home_id != home_id:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
db.add(ShoppingItem(home_id=home_id, product_id=product.id, name=product.name, category=product.category, quantity=max(product.min_quantity - product.quantity, 1), unit=product.unit))
|
||||
audit(db, user, "shopping.from_product", "product", product.id)
|
||||
db.commit()
|
||||
return Message(message="Added to shopping list")
|
||||
|
||||
|
||||
@router.get("/lookup/{barcode}")
|
||||
async def lookup_barcode(home_id: str, barcode: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
require_home_member(home_id, db, user)
|
||||
result = await OpenFoodFactsLookup().by_barcode(barcode)
|
||||
return {"found": bool(result), "product": result}
|
||||
18
backend/app/api/routes/recipes.py
Normal file
18
backend/app/api/routes/recipes.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import current_user, require_home_member
|
||||
from app.db.session import get_db
|
||||
from app.models.entities import Product, User
|
||||
from app.services.recipes import suggest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def recipe_suggestions(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
require_home_member(home_id, db, user)
|
||||
products = list(db.scalars(select(Product).where(Product.home_id == home_id)).all())
|
||||
return suggest(products, user.language)
|
||||
|
||||
60
backend/app/api/routes/setup.py
Normal file
60
backend/app/api/routes/setup.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.db.session import get_db
|
||||
from app.models.entities import AppSetting, Home, HomeMembership, HomeRole, InstanceRole, User
|
||||
from app.schemas.common import SetupCreate, SetupStatus, UserOut
|
||||
from app.services.audit import audit
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/status", response_model=SetupStatus)
|
||||
def setup_status(db: Session = Depends(get_db)) -> SetupStatus:
|
||||
admin_exists = db.scalar(select(User).where(User.instance_role == InstanceRole.ADMIN)) is not None
|
||||
instance = db.get(AppSetting, "instance")
|
||||
return SetupStatus(needs_setup=not admin_exists, instance=instance.value if instance else None)
|
||||
|
||||
|
||||
@router.post("/complete", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
def complete_setup(payload: SetupCreate, db: Session = Depends(get_db)) -> User:
|
||||
if db.scalar(select(User).where(User.instance_role == InstanceRole.ADMIN)):
|
||||
raise HTTPException(status_code=409, detail="Instance already initialized")
|
||||
user = User(
|
||||
email=str(payload.email).lower(),
|
||||
name=payload.name,
|
||||
password_hash=hash_password(payload.password),
|
||||
instance_role=InstanceRole.ADMIN,
|
||||
language=payload.language,
|
||||
theme=payload.theme,
|
||||
timezone=payload.timezone,
|
||||
)
|
||||
home = Home(name=f"{payload.name}'s Home")
|
||||
db.add_all(
|
||||
[
|
||||
user,
|
||||
home,
|
||||
AppSetting(
|
||||
key="instance",
|
||||
value={
|
||||
"name": payload.instance_name,
|
||||
"public_url": payload.public_url,
|
||||
"language": payload.language,
|
||||
"theme": payload.theme,
|
||||
"timezone": payload.timezone,
|
||||
"registration_enabled": False,
|
||||
"security": {"login_rate_limit": "10/minute"},
|
||||
"notifications": {"default_warning_days": 5},
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
db.flush()
|
||||
db.add(HomeMembership(home_id=home.id, user_id=user.id, role=HomeRole.OWNER))
|
||||
audit(db, user, "instance.setup", "user", user.id)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
67
backend/app/api/routes/shopping.py
Normal file
67
backend/app/api/routes/shopping.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import current_user, require_home_member, require_home_write
|
||||
from app.db.session import get_db
|
||||
from app.models.entities import Product, ShoppingItem, User
|
||||
from app.schemas.common import Message, ProductIn, ShoppingItemIn, ShoppingItemOut
|
||||
from app.services.audit import audit
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ShoppingItemOut])
|
||||
def list_items(home_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[ShoppingItem]:
|
||||
require_home_member(home_id, db, user)
|
||||
return list(db.scalars(select(ShoppingItem).where(ShoppingItem.home_id == home_id).order_by(ShoppingItem.checked, ShoppingItem.created_at.desc())).all())
|
||||
|
||||
|
||||
@router.post("", response_model=ShoppingItemOut, status_code=201)
|
||||
def create_item(home_id: str, payload: ShoppingItemIn, user: User = Depends(current_user), db: Session = Depends(get_db)) -> ShoppingItem:
|
||||
require_home_write(home_id, db, user)
|
||||
item = ShoppingItem(home_id=home_id, **payload.model_dump())
|
||||
db.add(item)
|
||||
audit(db, user, "shopping.create", "shopping_item", item.id)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/{item_id}", response_model=ShoppingItemOut)
|
||||
def update_item(home_id: str, item_id: str, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> ShoppingItem:
|
||||
require_home_write(home_id, db, user)
|
||||
item = db.get(ShoppingItem, item_id)
|
||||
if not item or item.home_id != home_id:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
for field in ["name", "category", "quantity", "unit", "checked"]:
|
||||
if field in payload:
|
||||
setattr(item, field, payload[field])
|
||||
db.commit()
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/{item_id}/move-to-inventory", response_model=Message)
|
||||
def move_to_inventory(home_id: str, item_id: str, payload: ProductIn | None = None, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
|
||||
require_home_write(home_id, db, user)
|
||||
item = db.get(ShoppingItem, item_id)
|
||||
if not item or item.home_id != home_id:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
data = payload.model_dump() if payload else {"name": item.name, "category": item.category, "quantity": item.quantity, "unit": item.unit}
|
||||
db.add(Product(home_id=home_id, created_by_id=user.id, **data))
|
||||
item.checked = True
|
||||
audit(db, user, "shopping.move_to_inventory", "shopping_item", item.id)
|
||||
db.commit()
|
||||
return Message(message="Moved to inventory")
|
||||
|
||||
|
||||
@router.delete("/{item_id}", response_model=Message)
|
||||
def delete_item(home_id: str, item_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> Message:
|
||||
require_home_write(home_id, db, user)
|
||||
item = db.get(ShoppingItem, item_id)
|
||||
if not item or item.home_id != home_id:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return Message(message="Item deleted")
|
||||
|
||||
34
backend/app/core/config.py
Normal file
34
backend/app/core/config.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_env: str = "production"
|
||||
instance_url: str = "http://localhost"
|
||||
frontend_origin: str = "http://localhost"
|
||||
database_url: str = "postgresql+psycopg://nexapantry:nexapantry@localhost:5432/nexapantry"
|
||||
redis_url: str | None = None
|
||||
jwt_secret_key: str
|
||||
settings_secret_key: str
|
||||
cookie_secure: bool = True
|
||||
cors_origins: list[str] = ["http://localhost"]
|
||||
log_level: str = "INFO"
|
||||
default_timezone: str = "Europe/Vienna"
|
||||
daily_worker_interval_seconds: int = 300
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
@field_validator("cors_origins", mode="before")
|
||||
@classmethod
|
||||
def parse_origins(cls, value: str | list[str]) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
||||
return value
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
66
backend/app/core/security.py
Normal file
66
backend/app/core/security.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str | None) -> bool:
|
||||
return bool(password_hash) and pwd_context.verify(password, password_hash)
|
||||
|
||||
|
||||
def create_session_token(user_id: str, minutes: int = 60 * 24 * 14) -> str:
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=minutes)
|
||||
payload: dict[str, Any] = {"sub": user_id, "exp": expires_at}
|
||||
return jwt.encode(payload, get_settings().jwt_secret_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_session_token(token: str) -> str | None:
|
||||
try:
|
||||
payload = jwt.decode(token, get_settings().jwt_secret_key, algorithms=[ALGORITHM])
|
||||
return str(payload.get("sub"))
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def encrypt_secret(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return Fernet(get_settings().settings_secret_key.encode("utf-8")).encrypt(
|
||||
value.encode("utf-8")
|
||||
).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_secret(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return Fernet(get_settings().settings_secret_key.encode("utf-8")).decrypt(
|
||||
value.encode("utf-8")
|
||||
).decode("utf-8")
|
||||
except InvalidToken:
|
||||
return None
|
||||
|
||||
def make_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
23
backend/app/db/session.py
Normal file
23
backend/app/db/session.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_engine(get_settings().database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
76
backend/app/main.py
Normal file
76
backend/app/main.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import Base, engine
|
||||
from app.models import * # noqa: F403
|
||||
|
||||
settings = get_settings()
|
||||
logging.basicConfig(level=settings.log_level)
|
||||
|
||||
|
||||
class SecurityMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
unsafe = request.method in {"POST", "PUT", "PATCH", "DELETE"}
|
||||
if unsafe and request.url.path.startswith("/api/"):
|
||||
csrf_cookie = request.cookies.get("np_csrf")
|
||||
csrf_header = request.headers.get("x-csrf-token")
|
||||
exempt = request.url.path in {"/api/auth/login", "/api/setup/complete", "/api/auth/accept-invite"}
|
||||
if not exempt and csrf_cookie != csrf_header:
|
||||
return Response("CSRF validation failed", status_code=403)
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault("Permissions-Policy", "camera=(self), geolocation=(), microphone=()")
|
||||
return response
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
buckets: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
limited_paths = ("/api/auth/login", "/api/auth/accept-invite", "/api/admin/users")
|
||||
if request.url.path.startswith(limited_paths):
|
||||
key = f"{request.client.host if request.client else 'unknown'}:{request.url.path}"
|
||||
now = time.time()
|
||||
bucket = self.buckets[key]
|
||||
while bucket and now - bucket[0] > 60:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= 10:
|
||||
return Response("Too many requests", status_code=429)
|
||||
bucket.append(now)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
app = FastAPI(title="NexaPantry API", version="0.1.0")
|
||||
app.add_middleware(SecurityMiddleware)
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "X-CSRF-Token"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
28
backend/app/models/__init__.py
Normal file
28
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from app.models.entities import (
|
||||
AppSetting,
|
||||
AuditLog,
|
||||
Home,
|
||||
HomeMembership,
|
||||
InvitationToken,
|
||||
MailSetting,
|
||||
Notification,
|
||||
PasswordResetToken,
|
||||
Product,
|
||||
ShoppingItem,
|
||||
User,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AppSetting",
|
||||
"AuditLog",
|
||||
"Home",
|
||||
"HomeMembership",
|
||||
"InvitationToken",
|
||||
"MailSetting",
|
||||
"Notification",
|
||||
"PasswordResetToken",
|
||||
"Product",
|
||||
"ShoppingItem",
|
||||
"User",
|
||||
]
|
||||
|
||||
187
backend/app/models/entities.py
Normal file
187
backend/app/models/entities.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class InstanceRole(StrEnum):
|
||||
ADMIN = "instance_admin"
|
||||
USER = "user"
|
||||
|
||||
|
||||
class HomeRole(StrEnum):
|
||||
OWNER = "home_owner"
|
||||
MEMBER = "home_member"
|
||||
READ_ONLY = "read_only"
|
||||
|
||||
|
||||
class Theme(StrEnum):
|
||||
LIGHT = "light"
|
||||
DARK = "dark"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class Language(StrEnum):
|
||||
DE = "de"
|
||||
EN = "en"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(160))
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
instance_role: Mapped[str] = mapped_column(String(40), default=InstanceRole.USER)
|
||||
language: Mapped[str] = mapped_column(String(8), default=Language.DE)
|
||||
theme: Mapped[str] = mapped_column(String(16), default=Theme.SYSTEM)
|
||||
timezone: Mapped[str] = mapped_column(String(80), default="Europe/Vienna")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
onboarding_completed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
memberships: Mapped[list["HomeMembership"]] = relationship(back_populates="user", cascade="all, delete")
|
||||
|
||||
|
||||
class Home(Base):
|
||||
__tablename__ = "homes"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(160))
|
||||
join_code_hash: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
join_code_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
expiry_warning_days: Mapped[int] = mapped_column(Integer, default=5)
|
||||
daily_summary_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
daily_summary_time: Mapped[str] = mapped_column(String(5), default="08:00")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
memberships: Mapped[list["HomeMembership"]] = relationship(back_populates="home", cascade="all, delete")
|
||||
products: Mapped[list["Product"]] = relationship(back_populates="home", cascade="all, delete")
|
||||
|
||||
|
||||
class HomeMembership(Base):
|
||||
__tablename__ = "home_memberships"
|
||||
__table_args__ = (UniqueConstraint("home_id", "user_id", name="uq_home_user"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
home_id: Mapped[str] = mapped_column(ForeignKey("homes.id", ondelete="CASCADE"))
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
role: Mapped[str] = mapped_column(String(40), default=HomeRole.MEMBER)
|
||||
notification_preferences: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
home: Mapped[Home] = relationship(back_populates="memberships")
|
||||
user: Mapped[User] = relationship(back_populates="memberships")
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
home_id: Mapped[str] = mapped_column(ForeignKey("homes.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(220))
|
||||
barcode: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
|
||||
brand: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
category: Mapped[str] = mapped_column(String(120), default="Other")
|
||||
location: Mapped[str] = mapped_column(String(120), default="Pantry")
|
||||
quantity: Mapped[float] = mapped_column(default=1)
|
||||
unit: Mapped[str] = mapped_column(String(32), default="pcs")
|
||||
expires_at: Mapped[datetime | None] = mapped_column(Date, nullable=True)
|
||||
min_quantity: Mapped[float] = mapped_column(default=0)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
image_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
home: Mapped[Home] = relationship(back_populates="products")
|
||||
|
||||
|
||||
class ShoppingItem(Base):
|
||||
__tablename__ = "shopping_items"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
home_id: Mapped[str] = mapped_column(ForeignKey("homes.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(220))
|
||||
category: Mapped[str] = mapped_column(String(120), default="Other")
|
||||
quantity: Mapped[float] = mapped_column(default=1)
|
||||
unit: Mapped[str] = mapped_column(String(32), default="pcs")
|
||||
checked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
product_id: Mapped[str | None] = mapped_column(ForeignKey("products.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
__tablename__ = "app_settings"
|
||||
key: Mapped[str] = mapped_column(String(120), primary_key=True)
|
||||
value: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
|
||||
class MailSetting(Base):
|
||||
__tablename__ = "mail_settings"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
smtp_host: Mapped[str | None] = mapped_column(String(220), nullable=True)
|
||||
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
|
||||
smtp_user: Mapped[str | None] = mapped_column(String(220), nullable=True)
|
||||
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
use_starttls: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
sender_address: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
sender_name: Mapped[str] = mapped_column(String(160), default="NexaPantry")
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
|
||||
class InvitationToken(Base):
|
||||
__tablename__ = "invitation_tokens"
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
__tablename__ = "password_reset_tokens"
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
home_id: Mapped[str | None] = mapped_column(ForeignKey("homes.id", ondelete="CASCADE"), nullable=True)
|
||||
title: Mapped[str] = mapped_column(String(220))
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
kind: Mapped[str] = mapped_column(String(80), default="info")
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
actor_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
|
||||
action: Mapped[str] = mapped_column(String(160), index=True)
|
||||
target_type: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
target_id: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
metadata_json: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc)
|
||||
|
||||
161
backend/app/schemas/common.py
Normal file
161
backend/app/schemas/common.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class SetupStatus(BaseModel):
|
||||
needs_setup: bool
|
||||
instance: dict | None = None
|
||||
|
||||
|
||||
class SetupCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=12, max_length=256)
|
||||
language: str
|
||||
theme: str
|
||||
public_url: str = Field(min_length=1, max_length=500)
|
||||
instance_name: str = Field(min_length=1, max_length=160)
|
||||
timezone: str = Field(min_length=1, max_length=80)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
name: str
|
||||
instance_role: str
|
||||
language: str
|
||||
theme: str
|
||||
timezone: str
|
||||
is_active: bool
|
||||
onboarding_completed: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
role: str = "user"
|
||||
send_invite: bool = True
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=160)
|
||||
language: str | None = None
|
||||
theme: str | None = None
|
||||
timezone: str | None = None
|
||||
instance_role: str | None = None
|
||||
is_active: bool | None = None
|
||||
onboarding_completed: bool | None = None
|
||||
|
||||
|
||||
class InviteAccept(BaseModel):
|
||||
token: str
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
password: str = Field(min_length=12, max_length=256)
|
||||
language: str
|
||||
theme: str
|
||||
home_name: str | None = Field(default=None, max_length=160)
|
||||
join_code: str | None = Field(default=None, max_length=40)
|
||||
|
||||
|
||||
class HomeOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
expiry_warning_days: int
|
||||
daily_summary_enabled: bool
|
||||
daily_summary_time: str
|
||||
role: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class HomeCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
|
||||
|
||||
class HomeSettingsUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=160)
|
||||
expiry_warning_days: int | None = Field(default=None, ge=0, le=60)
|
||||
daily_summary_enabled: bool | None = None
|
||||
daily_summary_time: str | None = Field(default=None, pattern=r"^\d{2}:\d{2}$")
|
||||
|
||||
|
||||
class JoinCodeOut(BaseModel):
|
||||
join_code: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class ProductIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=220)
|
||||
barcode: str | None = Field(default=None, max_length=80)
|
||||
brand: str | None = Field(default=None, max_length=160)
|
||||
category: str = Field(default="Other", max_length=120)
|
||||
location: str = Field(default="Pantry", max_length=120)
|
||||
quantity: float = Field(default=1, ge=0)
|
||||
unit: str = Field(default="pcs", max_length=32)
|
||||
expires_at: date | None = None
|
||||
min_quantity: float = Field(default=0, ge=0)
|
||||
notes: str | None = Field(default=None, max_length=5000)
|
||||
image_url: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class ProductOut(ProductIn):
|
||||
id: str
|
||||
home_id: str
|
||||
status: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ShoppingItemIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=220)
|
||||
category: str = Field(default="Other", max_length=120)
|
||||
quantity: float = Field(default=1, ge=0)
|
||||
unit: str = Field(default="pcs", max_length=32)
|
||||
product_id: str | None = None
|
||||
|
||||
|
||||
class ShoppingItemOut(ShoppingItemIn):
|
||||
id: str
|
||||
home_id: str
|
||||
checked: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class MailSettingsIn(BaseModel):
|
||||
smtp_host: str | None = Field(default=None, max_length=220)
|
||||
smtp_port: int = Field(default=587, ge=1, le=65535)
|
||||
smtp_user: str | None = Field(default=None, max_length=220)
|
||||
smtp_password: str | None = Field(default=None, max_length=1000)
|
||||
use_tls: bool = True
|
||||
use_starttls: bool = True
|
||||
sender_address: EmailStr | None = None
|
||||
sender_name: str = Field(default="NexaPantry", max_length=160)
|
||||
|
||||
|
||||
class MailSettingsOut(BaseModel):
|
||||
smtp_host: str | None
|
||||
smtp_port: int
|
||||
smtp_user: str | None
|
||||
has_password: bool
|
||||
use_tls: bool
|
||||
use_starttls: bool
|
||||
sender_address: EmailStr | None
|
||||
sender_name: str
|
||||
|
||||
|
||||
class TestMailIn(BaseModel):
|
||||
to: EmailStr
|
||||
|
||||
16
backend/app/services/audit.py
Normal file
16
backend/app/services/audit.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.entities import AuditLog, User
|
||||
|
||||
|
||||
def audit(db: Session, actor: User | None, action: str, target_type: str | None = None, target_id: str | None = None, metadata: dict | None = None) -> None:
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor_user_id=actor.id if actor else None,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
)
|
||||
|
||||
70
backend/app/services/mail.py
Normal file
70
backend/app/services/mail.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import decrypt_secret, encrypt_secret
|
||||
from app.models.entities import MailSetting
|
||||
from app.schemas.common import MailSettingsIn, MailSettingsOut
|
||||
|
||||
|
||||
def get_mail_settings(db: Session) -> MailSetting:
|
||||
settings = db.get(MailSetting, 1)
|
||||
if not settings:
|
||||
settings = MailSetting(id=1)
|
||||
db.add(settings)
|
||||
db.flush()
|
||||
return settings
|
||||
|
||||
|
||||
def serialize_mail_settings(settings: MailSetting) -> MailSettingsOut:
|
||||
return MailSettingsOut(
|
||||
smtp_host=settings.smtp_host,
|
||||
smtp_port=settings.smtp_port,
|
||||
smtp_user=settings.smtp_user,
|
||||
has_password=bool(settings.smtp_password_encrypted),
|
||||
use_tls=settings.use_tls,
|
||||
use_starttls=settings.use_starttls,
|
||||
sender_address=settings.sender_address,
|
||||
sender_name=settings.sender_name,
|
||||
)
|
||||
|
||||
|
||||
def update_mail_settings(db: Session, payload: MailSettingsIn) -> MailSetting:
|
||||
settings = get_mail_settings(db)
|
||||
settings.smtp_host = payload.smtp_host
|
||||
settings.smtp_port = payload.smtp_port
|
||||
settings.smtp_user = payload.smtp_user
|
||||
if payload.smtp_password is not None:
|
||||
settings.smtp_password_encrypted = encrypt_secret(payload.smtp_password)
|
||||
settings.use_tls = payload.use_tls
|
||||
settings.use_starttls = payload.use_starttls
|
||||
settings.sender_address = str(payload.sender_address) if payload.sender_address else None
|
||||
settings.sender_name = payload.sender_name
|
||||
return settings
|
||||
|
||||
|
||||
def send_mail(db: Session, to: str, subject: str, body: str) -> None:
|
||||
settings = get_mail_settings(db)
|
||||
if not settings.smtp_host or not settings.sender_address:
|
||||
raise RuntimeError("SMTP is not configured")
|
||||
message = EmailMessage()
|
||||
message["From"] = f"{settings.sender_name} <{settings.sender_address}>"
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
password = decrypt_secret(settings.smtp_password_encrypted)
|
||||
client_cls = smtplib.SMTP_SSL if settings.use_tls and not settings.use_starttls else smtplib.SMTP
|
||||
with client_cls(settings.smtp_host, settings.smtp_port, timeout=20) as smtp:
|
||||
if settings.use_starttls:
|
||||
smtp.starttls()
|
||||
if settings.smtp_user and password:
|
||||
smtp.login(settings.smtp_user, password)
|
||||
smtp.send_message(message)
|
||||
|
||||
|
||||
def invite_body(token: str) -> str:
|
||||
link = f"{get_settings().instance_url.rstrip('/')}/accept-invite?token={token}"
|
||||
return f"Welcome to NexaPantry.\n\nOpen this invitation link to set your password:\n{link}\n\nThe link expires automatically."
|
||||
|
||||
50
backend/app/services/notifications.py
Normal file
50
backend/app/services/notifications.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import logging
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.entities import Home, HomeMembership, Notification, Product
|
||||
from app.services.mail import send_mail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_expiry_notifications(db: Session) -> int:
|
||||
count = 0
|
||||
homes = db.scalars(select(Home)).all()
|
||||
today = date.today()
|
||||
for home in homes:
|
||||
deadline = today + timedelta(days=home.expiry_warning_days)
|
||||
products = db.scalars(
|
||||
select(Product).where(Product.home_id == home.id, Product.expires_at <= deadline)
|
||||
).all()
|
||||
if not products:
|
||||
continue
|
||||
memberships = db.scalars(select(HomeMembership).where(HomeMembership.home_id == home.id)).all()
|
||||
for membership in memberships:
|
||||
prefs = membership.notification_preferences or {}
|
||||
if prefs.get("in_app", True):
|
||||
db.add(
|
||||
Notification(
|
||||
user_id=membership.user_id,
|
||||
home_id=home.id,
|
||||
title="NexaPantry expiry warning",
|
||||
body=f"{len(products)} products expire soon in {home.name}.",
|
||||
kind="expiry",
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
if prefs.get("email", False):
|
||||
try:
|
||||
send_mail(db, membership.user.email, "NexaPantry expiry warning", f"{len(products)} products expire soon in {home.name}.")
|
||||
except Exception:
|
||||
logger.exception("Expiry e-mail delivery failed for user %s", membership.user_id)
|
||||
continue
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
|
||||
def mark_read(db: Session, notification: Notification) -> Notification:
|
||||
notification.read_at = datetime.now(UTC)
|
||||
return notification
|
||||
51
backend/app/services/products.py
Normal file
51
backend/app/services/products.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from datetime import date
|
||||
from typing import Protocol
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.entities import Home, Product
|
||||
|
||||
|
||||
def expiry_status(product: Product, home: Home) -> str:
|
||||
if not product.expires_at:
|
||||
return "ok"
|
||||
today = date.today()
|
||||
if product.expires_at <= today:
|
||||
return "expired"
|
||||
if (product.expires_at - today).days <= home.expiry_warning_days:
|
||||
return "soon"
|
||||
return "ok"
|
||||
|
||||
|
||||
class ProductLookup(Protocol):
|
||||
async def by_barcode(self, barcode: str) -> dict | None:
|
||||
...
|
||||
|
||||
|
||||
class OpenFoodFactsLookup(ProductLookup):
|
||||
async def by_barcode(self, barcode: str) -> dict | None:
|
||||
url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
response = await client.get(url)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
data = response.json()
|
||||
product = data.get("product")
|
||||
if not product:
|
||||
return None
|
||||
return {
|
||||
"name": product.get("product_name") or product.get("generic_name") or "",
|
||||
"brand": product.get("brands"),
|
||||
"category": (product.get("categories_tags") or ["Other"])[0].replace("en:", ""),
|
||||
"image_url": product.get("image_front_small_url") or product.get("image_url"),
|
||||
"barcode": barcode,
|
||||
}
|
||||
|
||||
|
||||
def low_stock_products(db: Session, home_id: str) -> list[Product]:
|
||||
return [
|
||||
product
|
||||
for product in db.query(Product).filter(Product.home_id == home_id).all()
|
||||
if product.quantity <= product.min_quantity
|
||||
]
|
||||
44
backend/app/services/recipes.py
Normal file
44
backend/app/services/recipes.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from app.models.entities import Product
|
||||
|
||||
RECIPES = [
|
||||
{
|
||||
"id": "tomato-pasta",
|
||||
"name": {"de": "Tomaten-Pasta", "en": "Tomato pasta"},
|
||||
"ingredients": ["tomato", "tomate", "pasta", "nudeln", "cheese", "käse"],
|
||||
"steps": {"de": ["Nudeln kochen", "Tomaten anbraten", "Mit Käse servieren"], "en": ["Cook pasta", "Warm tomatoes", "Serve with cheese"]},
|
||||
},
|
||||
{
|
||||
"id": "omelette",
|
||||
"name": {"de": "Gemüse-Omelett", "en": "Vegetable omelette"},
|
||||
"ingredients": ["egg", "ei", "cheese", "käse", "pepper", "paprika", "milk", "milch"],
|
||||
"steps": {"de": ["Eier verquirlen", "Gemüse anbraten", "Stocken lassen"], "en": ["Whisk eggs", "Saute vegetables", "Let it set"]},
|
||||
},
|
||||
{
|
||||
"id": "rice-bowl",
|
||||
"name": {"de": "Reis-Bowl", "en": "Rice bowl"},
|
||||
"ingredients": ["rice", "reis", "beans", "bohnen", "corn", "mais", "yogurt", "joghurt"],
|
||||
"steps": {"de": ["Reis erhitzen", "Toppings ergänzen", "Mit Sauce servieren"], "en": ["Warm rice", "Add toppings", "Serve with sauce"]},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def suggest(products: list[Product], language: str) -> list[dict]:
|
||||
names = " ".join([p.name.lower() for p in products])
|
||||
expiring = {p.id for p in products if p.expires_at}
|
||||
suggestions: list[dict] = []
|
||||
for recipe in RECIPES:
|
||||
matches = [i for i in recipe["ingredients"] if i in names]
|
||||
if not matches:
|
||||
continue
|
||||
score = len(matches) + min(len(expiring), 3)
|
||||
suggestions.append(
|
||||
{
|
||||
"id": recipe["id"],
|
||||
"name": recipe["name"].get(language, recipe["name"]["en"]),
|
||||
"matchedIngredients": sorted(set(matches)),
|
||||
"score": score,
|
||||
"steps": recipe["steps"].get(language, recipe["steps"]["en"]),
|
||||
}
|
||||
)
|
||||
return sorted(suggestions, key=lambda item: item["score"], reverse=True)
|
||||
|
||||
14
backend/app/tests/test_security.py
Normal file
14
backend/app/tests/test_security.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from app.core.security import hash_password, hash_token, verify_password
|
||||
|
||||
|
||||
def test_password_hash_roundtrip() -> None:
|
||||
password_hash = hash_password("a-very-long-password")
|
||||
assert password_hash != "a-very-long-password"
|
||||
assert verify_password("a-very-long-password", password_hash)
|
||||
assert not verify_password("wrong-password", password_hash)
|
||||
|
||||
|
||||
def test_tokens_are_hashed() -> None:
|
||||
assert hash_token("secret") == hash_token("secret")
|
||||
assert hash_token("secret") != "secret"
|
||||
|
||||
31
backend/app/worker/main.py
Normal file
31
backend/app/worker/main.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.services.notifications import create_expiry_notifications
|
||||
|
||||
logging.basicConfig(level=get_settings().log_level)
|
||||
logger = logging.getLogger("nexapantry.worker")
|
||||
|
||||
|
||||
def run_once() -> int:
|
||||
with SessionLocal() as db:
|
||||
return create_expiry_notifications(db)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
interval = get_settings().daily_worker_interval_seconds
|
||||
logger.info("NexaPantry worker started")
|
||||
while True:
|
||||
try:
|
||||
count = run_once()
|
||||
logger.info("Created %s notification records", count)
|
||||
except Exception:
|
||||
logger.exception("Worker cycle failed")
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
43
backend/pyproject.toml
Normal file
43
backend/pyproject.toml
Normal file
@@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "nexapantry-backend"
|
||||
version = "0.1.0"
|
||||
description = "Self-hosted pantry management backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"sqlalchemy>=2.0.30",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"passlib[argon2]>=1.7.4",
|
||||
"python-multipart>=0.0.9",
|
||||
"cryptography>=42.0.0",
|
||||
"httpx>=0.27.0",
|
||||
"email-validator>=2.2.0",
|
||||
"redis>=5.0.0",
|
||||
"tenacity>=8.5.0"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.2.0",
|
||||
"pytest-cov>=5.0.0",
|
||||
"ruff>=0.5.0",
|
||||
"mypy>=1.10.0",
|
||||
"bandit>=1.7.9",
|
||||
"pip-audit>=2.7.0",
|
||||
"types-python-jose>=3.3.4",
|
||||
"types-passlib>=1.7.7"
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "S"]
|
||||
ignore = ["B008", "E501", "S101", "S105"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["app/tests"]
|
||||
19
caddy/Caddyfile
Normal file
19
caddy/Caddyfile
Normal file
@@ -0,0 +1,19 @@
|
||||
:80 {
|
||||
encode zstd gzip
|
||||
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
Permissions-Policy "camera=(self), geolocation=(), microphone=()"
|
||||
Content-Security-Policy "default-src 'self'; img-src 'self' data: https://images.openfoodfacts.org; connect-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests"
|
||||
}
|
||||
|
||||
handle_path /api/* {
|
||||
reverse_proxy backend:8000
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy frontend:8080
|
||||
}
|
||||
}
|
||||
89
docker-compose.yml
Normal file
89
docker-compose.yml
Normal file
@@ -0,0 +1,89 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks: [internal]
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks: [internal]
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks: [internal]
|
||||
|
||||
worker:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
command: ["python", "-m", "app.worker.main"]
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks: [internal]
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks: [internal]
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
networks: [internal]
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
|
||||
15
docs/architecture.md
Normal file
15
docs/architecture.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Architecture
|
||||
|
||||
NexaPantry uses a small but explicit service architecture:
|
||||
|
||||
- React/Vite/TypeScript frontend with TailwindCSS and PWA service worker
|
||||
- FastAPI backend with SQLAlchemy ORM
|
||||
- PostgreSQL as the system of record
|
||||
- Redis for future distributed rate limiting and queued job coordination
|
||||
- Caddy as reverse proxy and security header layer
|
||||
- Worker process for expiry notifications and mail delivery preparation
|
||||
|
||||
Core authorization is home-scoped. Every product, shopping item and recipe request first checks the caller's home membership. Instance admins can manage system settings and users through dedicated admin endpoints.
|
||||
|
||||
External barcode lookup is hidden behind `ProductLookup`, currently implemented by `OpenFoodFactsLookup`. A future provider can be added without changing the frontend contract.
|
||||
|
||||
13
docs/deployment.md
Normal file
13
docs/deployment.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Deployment
|
||||
|
||||
1. Point a domain at the host.
|
||||
2. Copy `.env.example` to `.env`.
|
||||
3. Replace all secrets and database passwords.
|
||||
4. Set `INSTANCE_URL=https://your.domain`.
|
||||
5. Set `FRONTEND_ORIGIN=https://your.domain`.
|
||||
6. Set `COOKIE_SECURE=true`.
|
||||
7. Configure Caddy with your domain instead of `:80` when using automatic TLS.
|
||||
8. Start with `docker compose up -d`.
|
||||
|
||||
For production, expose only Caddy ports. PostgreSQL, Redis, backend and frontend remain on the private Compose network.
|
||||
|
||||
25
docs/security.md
Normal file
25
docs/security.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Security Checklist
|
||||
|
||||
- Passwords: Argon2id via Passlib.
|
||||
- Session: HttpOnly SameSite cookies with JWT payloads.
|
||||
- CSRF: double-submit token for unsafe API methods.
|
||||
- Tokens: invitation and reset tokens are random, expiring and stored as SHA-256 hashes.
|
||||
- SMTP secret: encrypted with Fernet.
|
||||
- CORS: explicit allowed origins only.
|
||||
- CSP: set by Caddy for the frontend surface.
|
||||
- IDOR: home-scoped APIs verify membership.
|
||||
- SQL injection: SQLAlchemy query builder and parameter binding.
|
||||
- Audit logs: admin and sensitive actions are recorded without secrets.
|
||||
- Rate limits: login, invite and reset-sensitive endpoints are throttled.
|
||||
|
||||
Recommended external tools:
|
||||
|
||||
```sh
|
||||
pip-audit
|
||||
bandit -r backend/app
|
||||
npm audit --audit-level=moderate
|
||||
gitleaks detect --source .
|
||||
trivy fs .
|
||||
trivy image <image>
|
||||
```
|
||||
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK CMD wget -qO- http://127.0.0.1:8080/ || exit 1
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-refresh/only-export-components': 'off'
|
||||
}
|
||||
}
|
||||
);
|
||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f766e" />
|
||||
<link rel="icon" href="/icons/icon.svg" />
|
||||
<title>NexaPantry</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
26
frontend/nginx.conf
Normal file
26
frontend/nginx.conf
Normal file
@@ -0,0 +1,26 @@
|
||||
worker_processes auto;
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
server_tokens off;
|
||||
access_log off;
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|woff2)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
10085
frontend/package-lock.json
generated
Normal file
10085
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
frontend/package.json
Normal file
43
frontend/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "nexapantry-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"framer-motion": "^11.3.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"jsdom": "^24.1.1",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
7
frontend/postcss.config.js
Normal file
7
frontend/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
|
||||
6
frontend/public/icons/icon.svg
Normal file
6
frontend/public/icons/icon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="NexaPantry">
|
||||
<rect width="512" height="512" rx="96" fill="#0f766e"/>
|
||||
<path fill="#f8fafc" d="M150 152h212l-18 248H168l-18-248Zm34 40 12 168h120l12-168H184Z"/>
|
||||
<path fill="#d9f99d" d="M198 126c24-42 78-54 116-20-16 46-70 64-116 20Z"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 333 B |
8
frontend/src/App.test.tsx
Normal file
8
frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders loading shell', () => {
|
||||
render(<App />);
|
||||
expect(screen.getByText('NexaPantry')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
54
frontend/src/App.tsx
Normal file
54
frontend/src/App.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { I18nContext } from './contexts/I18nContext';
|
||||
import { dictionaries } from './i18n/dictionaries';
|
||||
import { AppLayout, type View } from './components/Layout';
|
||||
import { getSetupStatus, LoginScreen, SetupWizard } from './pages/AuthScreens';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { InventoryPage } from './pages/InventoryPage';
|
||||
import { ShoppingPage } from './pages/ShoppingPage';
|
||||
import { ProfilePage } from './pages/ProfilePage';
|
||||
import { Onboarding } from './pages/Onboarding';
|
||||
import { ScannerPage } from './pages/ScannerPage';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import type { Language, SetupStatus } from './types';
|
||||
|
||||
function languageFromBrowser(): Language {
|
||||
return navigator.language.toLowerCase().startsWith('de') ? 'de' : 'en';
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const { user, loading } = useAuth();
|
||||
const [view, setView] = useState<View>('home');
|
||||
if (loading) return <div className="grid min-h-dvh place-items-center dark:bg-gray-950 dark:text-white">NexaPantry</div>;
|
||||
if (!user) return <LoginScreen />;
|
||||
if (!user.onboarding_completed) return <Onboarding />;
|
||||
return (
|
||||
<AppLayout view={view} setView={setView}>
|
||||
{view === 'home' && <HomePage goAdmin={() => setView('admin')} />}
|
||||
{view === 'inventory' && <InventoryPage />}
|
||||
{view === 'scanner' && <ScannerPage />}
|
||||
{view === 'shopping' && <ShoppingPage />}
|
||||
{view === 'profile' && <ProfilePage />}
|
||||
{view === 'admin' && <AdminPage />}
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const [status, setStatus] = useState<SetupStatus | null>(null);
|
||||
const [language, setLanguage] = useState<Language>(languageFromBrowser());
|
||||
useEffect(() => { void getSetupStatus().then((value) => { setStatus(value); setLanguage((value.instance?.language as Language) ?? languageFromBrowser()); }); }, []);
|
||||
const i18n = useMemo(() => ({ language, t: (key: string) => dictionaries[language][key] ?? key }), [language]);
|
||||
if (!status) return <div className="grid min-h-dvh place-items-center dark:bg-gray-950 dark:text-white">NexaPantry</div>;
|
||||
return (
|
||||
<I18nContext.Provider value={i18n}>
|
||||
{status.needs_setup ? <SetupWizard onDone={() => window.location.reload()} /> : <AuthProvider><Shell /></AuthProvider>}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <AppInner />;
|
||||
}
|
||||
|
||||
27
frontend/src/api/client.ts
Normal file
27
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
function csrf() {
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
.find((part) => part.startsWith('np_csrf='))
|
||||
?.split('=')[1];
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!(init.body instanceof FormData)) headers.set('Content-Type', 'application/json');
|
||||
const token = csrf();
|
||||
if (token) headers.set('X-CSRF-Token', decodeURIComponent(token));
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || response.statusText);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
52
frontend/src/components/Forms.tsx
Normal file
52
frontend/src/components/Forms.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react';
|
||||
|
||||
export function Field(props: InputHTMLAttributes<HTMLInputElement> & { label: string }) {
|
||||
const { label, ...input } = props;
|
||||
return (
|
||||
<label className="grid gap-1 text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
<span>{label}</span>
|
||||
<input {...input} className="focus-ring rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 shadow-sm dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectField(props: SelectHTMLAttributes<HTMLSelectElement> & { label: string }) {
|
||||
const { label, children, ...select } = props;
|
||||
return (
|
||||
<label className="grid gap-1 text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
<span>{label}</span>
|
||||
<select {...select} className="focus-ring rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 shadow-sm dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100">
|
||||
{children}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextAreaField(props: TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string }) {
|
||||
const { label, ...textarea } = props;
|
||||
return (
|
||||
<label className="grid gap-1 text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
<span>{label}</span>
|
||||
<textarea {...textarea} className="focus-ring min-h-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 shadow-sm dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'secondary' | 'danger' }) {
|
||||
const variants = {
|
||||
primary: 'bg-teal-700 text-white hover:bg-teal-800',
|
||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700',
|
||||
danger: 'bg-rose-700 text-white hover:bg-rose-800'
|
||||
};
|
||||
return <button {...props} className={`focus-ring rounded-lg px-4 py-2 font-semibold shadow-sm transition ${variants[variant]} ${props.className ?? ''}`} />;
|
||||
}
|
||||
|
||||
export function Panel({ children, title }: { children: React.ReactNode; title?: string }) {
|
||||
return (
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
{title ? <h2 className="mb-3 text-lg font-semibold text-gray-950 dark:text-gray-50">{title}</h2> : null}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
59
frontend/src/components/Layout.tsx
Normal file
59
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Home, ListPlus, PackageSearch, Plus, ScanLine, ShoppingCart, UserRound } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
|
||||
export type View = 'home' | 'inventory' | 'shopping' | 'profile' | 'admin' | 'scanner';
|
||||
|
||||
export function AppLayout({ view, setView, children }: { view: View; setView: (view: View) => void; children: React.ReactNode }) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const items = [
|
||||
{ id: 'home' as View, icon: Home, label: t('home') },
|
||||
{ id: 'inventory' as View, icon: PackageSearch, label: t('inventory') },
|
||||
{ id: 'shopping' as View, icon: ShoppingCart, label: t('shopping') },
|
||||
{ id: 'profile' as View, icon: UserRound, label: t('profile') }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-slate-50 pb-24 text-gray-950 dark:bg-gray-950 dark:text-gray-50">
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-5">{children}</main>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-30 bg-gray-950/30 backdrop-blur-sm" onClick={() => setOpen(false)} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div initial={{ y: 24, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: 24, opacity: 0 }} className="fixed bottom-24 left-1/2 z-40 grid w-64 -translate-x-1/2 gap-2">
|
||||
<button className="focus-ring flex items-center gap-3 rounded-lg bg-white px-4 py-3 text-left font-semibold shadow-xl dark:bg-gray-900" onClick={() => { setOpen(false); setView('inventory'); }}>
|
||||
<ListPlus size={20} /> {t('manualAdd')}
|
||||
</button>
|
||||
<button className="focus-ring flex items-center gap-3 rounded-lg bg-white px-4 py-3 text-left font-semibold shadow-xl dark:bg-gray-900" onClick={() => { setOpen(false); setView('scanner'); }}>
|
||||
<ScanLine size={20} /> {t('scanBarcode')}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-gray-200 bg-white/95 px-3 pb-[env(safe-area-inset-bottom)] pt-2 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/95">
|
||||
<div className="mx-auto grid max-w-lg grid-cols-5 items-center">
|
||||
{items.slice(0, 2).map((item) => <NavButton key={item.id} active={view === item.id} icon={item.icon} label={item.label} onClick={() => setView(item.id)} />)}
|
||||
<button aria-label={t('add')} onClick={() => setOpen((value) => !value)} className="focus-ring mx-auto -mt-8 grid size-16 place-items-center rounded-full bg-teal-700 text-white shadow-xl transition hover:bg-teal-800">
|
||||
<motion.span animate={{ rotate: open ? 45 : 0 }}><Plus size={34} /></motion.span>
|
||||
</button>
|
||||
{items.slice(2).map((item) => <NavButton key={item.id} active={view === item.id} icon={item.icon} label={item.label} onClick={() => setView(item.id)} />)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavButton({ active, icon: Icon, label, onClick }: { active: boolean; icon: typeof Home; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button onClick={onClick} className={`focus-ring grid justify-items-center gap-1 rounded-lg px-1 py-2 text-xs font-semibold ${active ? 'text-teal-700 dark:text-teal-300' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
<Icon size={22} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
40
frontend/src/components/ProductForm.tsx
Normal file
40
frontend/src/components/ProductForm.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import type { Product } from '../types';
|
||||
import { Button, Field, TextAreaField } from './Forms';
|
||||
|
||||
const empty = { name: '', barcode: '', brand: '', category: 'Other', location: 'Pantry', quantity: 1, unit: 'pcs', expires_at: '', min_quantity: 0, notes: '', image_url: '' };
|
||||
|
||||
export function ProductForm({ initial, onSaved }: { initial?: Partial<Product>; onSaved: () => void }) {
|
||||
const { activeHome } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [form, setForm] = useState({ ...empty, ...initial });
|
||||
if (!activeHome) return null;
|
||||
const set = (key: string, value: string | number) => setForm((current) => ({ ...current, [key]: value }));
|
||||
return (
|
||||
<form className="grid gap-3" onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
const body = { ...form, expires_at: form.expires_at || null, quantity: Number(form.quantity), min_quantity: Number(form.min_quantity) };
|
||||
await api(`/homes/${activeHome.id}/products${initial?.id ? `/${initial.id}` : ''}`, { method: initial?.id ? 'PATCH' : 'POST', body: JSON.stringify(body) });
|
||||
setForm(empty);
|
||||
onSaved();
|
||||
}}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label={t('productName')} value={form.name} required onChange={(e) => set('name', e.target.value)} />
|
||||
<Field label={t('barcode')} value={form.barcode ?? ''} onChange={(e) => set('barcode', e.target.value)} />
|
||||
<Field label={t('brand')} value={form.brand ?? ''} onChange={(e) => set('brand', e.target.value)} />
|
||||
<Field label={t('category')} value={form.category} onChange={(e) => set('category', e.target.value)} />
|
||||
<Field label={t('location')} value={form.location} onChange={(e) => set('location', e.target.value)} />
|
||||
<Field label={t('quantity')} type="number" min="0" step="0.01" value={form.quantity} onChange={(e) => set('quantity', Number(e.target.value))} />
|
||||
<Field label={t('unit')} value={form.unit} onChange={(e) => set('unit', e.target.value)} />
|
||||
<Field label={t('expiresAt')} type="date" value={form.expires_at ?? ''} onChange={(e) => set('expires_at', e.target.value)} />
|
||||
<Field label={t('minQuantity')} type="number" min="0" step="0.01" value={form.min_quantity} onChange={(e) => set('min_quantity', Number(e.target.value))} />
|
||||
</div>
|
||||
<TextAreaField label={t('notes')} value={form.notes ?? ''} onChange={(e) => set('notes', e.target.value)} />
|
||||
<Button type="submit">{t('save')}</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
82
frontend/src/contexts/AuthContext.tsx
Normal file
82
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { Home, Theme, User } from '../types';
|
||||
|
||||
type AuthState = {
|
||||
user: User | null;
|
||||
homes: Home[];
|
||||
activeHome: Home | null;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
setActiveHomeId: (id: string) => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
function applyTheme(theme: Theme | undefined) {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const dark = theme === 'dark' || (theme === 'system' && prefersDark);
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [homes, setHomes] = useState<Home[]>([]);
|
||||
const [activeHomeId, setActiveHomeId] = useState<string>(() => localStorage.getItem('np_home') ?? '');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const me = await api<User>('/auth/me');
|
||||
setUser(me);
|
||||
applyTheme(me.theme);
|
||||
const loadedHomes = await api<Home[]>('/homes');
|
||||
setHomes(loadedHomes);
|
||||
if (!activeHomeId && loadedHomes[0]) setActiveHomeId(loadedHomes[0].id);
|
||||
} catch {
|
||||
setUser(null);
|
||||
setHomes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeHomeId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeHomeId) localStorage.setItem('np_home', activeHomeId);
|
||||
}, [activeHomeId]);
|
||||
|
||||
const value = useMemo<AuthState>(() => ({
|
||||
user,
|
||||
homes,
|
||||
activeHome: homes.find((home) => home.id === activeHomeId) ?? homes[0] ?? null,
|
||||
loading,
|
||||
refresh,
|
||||
login: async (email, password) => {
|
||||
const me = await api<User>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
|
||||
setUser(me);
|
||||
applyTheme(me.theme);
|
||||
await refresh();
|
||||
},
|
||||
logout: async () => {
|
||||
await api('/auth/logout', { method: 'POST' });
|
||||
setUser(null);
|
||||
setHomes([]);
|
||||
},
|
||||
setActiveHomeId
|
||||
}), [activeHomeId, homes, loading, refresh, user]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error('AuthProvider missing');
|
||||
return context;
|
||||
}
|
||||
|
||||
13
frontend/src/contexts/I18nContext.tsx
Normal file
13
frontend/src/contexts/I18nContext.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { dictionaries } from '../i18n/dictionaries';
|
||||
import type { Language } from '../types';
|
||||
|
||||
export const I18nContext = createContext<{ language: Language; t: (key: string) => string }>({
|
||||
language: 'de',
|
||||
t: (key) => dictionaries.de[key] ?? key
|
||||
});
|
||||
|
||||
export function useI18n() {
|
||||
return useContext(I18nContext);
|
||||
}
|
||||
|
||||
139
frontend/src/i18n/dictionaries.ts
Normal file
139
frontend/src/i18n/dictionaries.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Language } from '../types';
|
||||
|
||||
export const dictionaries: Record<Language, Record<string, string>> = {
|
||||
de: {
|
||||
setupTitle: 'NexaPantry einrichten',
|
||||
setupSubtitle: 'Erstelle den ersten Instance Admin und sichere die Instanz ab.',
|
||||
name: 'Name',
|
||||
email: 'E-Mail',
|
||||
password: 'Passwort',
|
||||
language: 'Sprache',
|
||||
theme: 'Theme',
|
||||
publicUrl: 'Öffentliche Server-URL',
|
||||
instanceName: 'Instanzname',
|
||||
timezone: 'Zeitzone',
|
||||
completeSetup: 'Setup abschließen',
|
||||
login: 'Anmelden',
|
||||
logout: 'Abmelden',
|
||||
home: 'Home',
|
||||
inventory: 'Bestand',
|
||||
add: 'Hinzufügen',
|
||||
shopping: 'Einkauf',
|
||||
profile: 'Profil',
|
||||
admin: 'Admin',
|
||||
dashboard: 'Dashboard',
|
||||
users: 'Benutzer',
|
||||
homes: 'Homes',
|
||||
mail: 'Mail',
|
||||
security: 'Sicherheit',
|
||||
notifications: 'Benachrichtigungen',
|
||||
system: 'System',
|
||||
logs: 'Logs',
|
||||
backupRestore: 'Backup/Restore',
|
||||
categories: 'Kategorien',
|
||||
locations: 'Orte',
|
||||
manualAdd: 'Manuell hinzufügen',
|
||||
scanBarcode: 'Barcode scannen',
|
||||
productName: 'Produktname',
|
||||
barcode: 'Barcode',
|
||||
brand: 'Marke',
|
||||
category: 'Kategorie',
|
||||
location: 'Lagerort',
|
||||
quantity: 'Menge',
|
||||
unit: 'Einheit',
|
||||
expiresAt: 'Ablaufdatum',
|
||||
minQuantity: 'Mindestbestand',
|
||||
notes: 'Notizen',
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
ok: 'haltbar',
|
||||
soon: 'läuft bald ab',
|
||||
expired: 'abgelaufen',
|
||||
addToShopping: 'Zur Einkaufsliste',
|
||||
recipes: 'Rezepte',
|
||||
onboardingTitle: 'Willkommen in NexaPantry',
|
||||
onboardingDone: 'Tutorial abschließen',
|
||||
skip: 'Überspringen',
|
||||
createHome: 'Home erstellen',
|
||||
joinHome: 'Home per Code beitreten',
|
||||
joinCode: 'Join-Code',
|
||||
smtpHost: 'SMTP Host',
|
||||
smtpPort: 'SMTP Port',
|
||||
smtpUser: 'SMTP User',
|
||||
smtpPassword: 'SMTP Passwort',
|
||||
senderAddress: 'Absender-Adresse',
|
||||
senderName: 'Absender-Name',
|
||||
testMail: 'Testmail senden',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
systemTheme: 'System'
|
||||
},
|
||||
en: {
|
||||
setupTitle: 'Set up NexaPantry',
|
||||
setupSubtitle: 'Create the first instance admin and secure the server.',
|
||||
name: 'Name',
|
||||
email: 'E-mail',
|
||||
password: 'Password',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
publicUrl: 'Public server URL',
|
||||
instanceName: 'Instance name',
|
||||
timezone: 'Timezone',
|
||||
completeSetup: 'Complete setup',
|
||||
login: 'Sign in',
|
||||
logout: 'Sign out',
|
||||
home: 'Home',
|
||||
inventory: 'Inventory',
|
||||
add: 'Add',
|
||||
shopping: 'Shopping',
|
||||
profile: 'Profile',
|
||||
admin: 'Admin',
|
||||
dashboard: 'Dashboard',
|
||||
users: 'Users',
|
||||
homes: 'Homes',
|
||||
mail: 'Mail',
|
||||
security: 'Security',
|
||||
notifications: 'Notifications',
|
||||
system: 'System',
|
||||
logs: 'Logs',
|
||||
backupRestore: 'Backup/Restore',
|
||||
categories: 'Categories',
|
||||
locations: 'Locations',
|
||||
manualAdd: 'Add manually',
|
||||
scanBarcode: 'Scan barcode',
|
||||
productName: 'Product name',
|
||||
barcode: 'Barcode',
|
||||
brand: 'Brand',
|
||||
category: 'Category',
|
||||
location: 'Location',
|
||||
quantity: 'Quantity',
|
||||
unit: 'Unit',
|
||||
expiresAt: 'Expiry date',
|
||||
minQuantity: 'Minimum stock',
|
||||
notes: 'Notes',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
ok: 'good',
|
||||
soon: 'expires soon',
|
||||
expired: 'expired',
|
||||
addToShopping: 'Add to shopping',
|
||||
recipes: 'Recipes',
|
||||
onboardingTitle: 'Welcome to NexaPantry',
|
||||
onboardingDone: 'Finish tutorial',
|
||||
skip: 'Skip',
|
||||
createHome: 'Create home',
|
||||
joinHome: 'Join home by code',
|
||||
joinCode: 'Join code',
|
||||
smtpHost: 'SMTP host',
|
||||
smtpPort: 'SMTP port',
|
||||
smtpUser: 'SMTP user',
|
||||
smtpPassword: 'SMTP password',
|
||||
senderAddress: 'Sender address',
|
||||
senderName: 'Sender name',
|
||||
testMail: 'Send test mail',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
systemTheme: 'System'
|
||||
}
|
||||
};
|
||||
|
||||
11
frontend/src/main.tsx
Normal file
11
frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
91
frontend/src/pages/AdminPage.tsx
Normal file
91
frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { DatabaseBackup, Mail, ScrollText, Shield, UsersRound } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { Button, Field, Panel, SelectField } from '../components/Forms';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import type { User } from '../types';
|
||||
|
||||
export function AdminPage() {
|
||||
const { t } = useI18n();
|
||||
const [tab, setTab] = useState('dashboard');
|
||||
const tabs = [
|
||||
['dashboard', t('dashboard'), Shield],
|
||||
['users', t('users'), UsersRound],
|
||||
['mail', t('mail'), Mail],
|
||||
['logs', t('logs'), ScrollText],
|
||||
['backup', t('backupRestore'), DatabaseBackup]
|
||||
] as const;
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<h1 className="text-3xl font-bold">{t('admin')}</h1>
|
||||
<div className="flex gap-2 overflow-x-auto rounded-lg bg-gray-200 p-1 dark:bg-gray-800">
|
||||
{tabs.map(([id, label, Icon]) => <button key={id} className={`focus-ring flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold ${tab === id ? 'bg-white shadow dark:bg-gray-950' : ''}`} onClick={() => setTab(id)}><Icon size={18} />{label}</button>)}
|
||||
</div>
|
||||
{tab === 'dashboard' && <AdminDashboard />}
|
||||
{tab === 'users' && <AdminUsers />}
|
||||
{tab === 'mail' && <AdminMail />}
|
||||
{tab === 'logs' && <AdminLogs />}
|
||||
{tab === 'backup' && <AdminBackup />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminDashboard() {
|
||||
const [stats, setStats] = useState<Record<string, number>>({});
|
||||
useEffect(() => { void api<Record<string, number>>('/admin/dashboard').then(setStats); }, []);
|
||||
return <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">{Object.entries(stats).map(([key, value]) => <Panel key={key}><p className="text-sm text-gray-500">{key}</p><p className="text-3xl font-bold">{value}</p></Panel>)}</div>;
|
||||
}
|
||||
|
||||
function AdminUsers() {
|
||||
const { t } = useI18n();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [form, setForm] = useState({ email: '', name: '', role: 'user' });
|
||||
const load = async () => setUsers(await api<User[]>('/admin/users'));
|
||||
useEffect(() => { void load(); }, []);
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Panel title={t('users')}>
|
||||
<form className="grid gap-3 sm:grid-cols-4" onSubmit={async (event) => { event.preventDefault(); await api('/admin/users', { method: 'POST', body: JSON.stringify({ ...form, send_invite: true }) }); setForm({ email: '', name: '', role: 'user' }); await load(); }}>
|
||||
<Field label={t('email')} value={form.email} required onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||
<Field label={t('name')} value={form.name} required onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<SelectField label={t('security')} value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}><option value="user">User</option><option value="instance_admin">Instance Admin</option></SelectField>
|
||||
<Button className="self-end">{t('add')}</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
<div className="grid gap-2">
|
||||
{users.map((user) => <div key={user.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white p-3 shadow-sm dark:bg-gray-900"><div><p className="font-semibold">{user.name}</p><p className="text-sm text-gray-500">{user.email} · {user.instance_role}</p></div><Button variant="secondary" onClick={async () => { await api(`/admin/users/${user.id}`, { method: 'PATCH', body: JSON.stringify({ is_active: !user.is_active }) }); await load(); }}>{user.is_active ? 'Disable' : 'Enable'}</Button></div>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminMail() {
|
||||
const { t } = useI18n();
|
||||
const [form, setForm] = useState({ smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '', sender_address: '', sender_name: 'NexaPantry', use_tls: true, use_starttls: true });
|
||||
const set = (key: string, value: string | number | boolean) => setForm((current) => ({ ...current, [key]: value }));
|
||||
useEffect(() => { void api<Record<string, string | number | boolean | null>>('/admin/mail').then((data) => setForm((current) => ({ ...current, ...data, smtp_password: '' }))); }, []);
|
||||
return (
|
||||
<Panel title={t('mail')}>
|
||||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={async (event) => { event.preventDefault(); await api('/admin/mail', { method: 'PUT', body: JSON.stringify(form) }); }}>
|
||||
<Field label={t('smtpHost')} value={form.smtp_host} onChange={(e) => set('smtp_host', e.target.value)} />
|
||||
<Field label={t('smtpPort')} type="number" value={form.smtp_port} onChange={(e) => set('smtp_port', Number(e.target.value))} />
|
||||
<Field label={t('smtpUser')} value={form.smtp_user} onChange={(e) => set('smtp_user', e.target.value)} />
|
||||
<Field label={t('smtpPassword')} type="password" value={form.smtp_password} onChange={(e) => set('smtp_password', e.target.value)} />
|
||||
<Field label={t('senderAddress')} type="email" value={form.sender_address} onChange={(e) => set('sender_address', e.target.value)} />
|
||||
<Field label={t('senderName')} value={form.sender_name} onChange={(e) => set('sender_name', e.target.value)} />
|
||||
<Button>{t('save')}</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminLogs() {
|
||||
const [logs, setLogs] = useState<Array<Record<string, string>>>([]);
|
||||
useEffect(() => { void api<Array<Record<string, string>>>('/admin/logs').then(setLogs); }, []);
|
||||
return <Panel><div className="grid gap-2">{logs.map((row, index) => <code key={index} className="rounded bg-gray-100 p-2 text-xs dark:bg-gray-800">{row.created_at} {row.action} {row.target_type}:{row.target_id}</code>)}</div></Panel>;
|
||||
}
|
||||
|
||||
function AdminBackup() {
|
||||
return <Panel title="Backup/Restore"><pre className="overflow-auto rounded-lg bg-gray-950 p-4 text-sm text-lime-200">docker compose exec postgres pg_dump -U $POSTGRES_USER $POSTGRES_DB > backup.sql{'\n'}docker compose exec -T postgres psql -U $POSTGRES_USER $POSTGRES_DB < backup.sql</pre></Panel>;
|
||||
}
|
||||
|
||||
67
frontend/src/pages/AuthScreens.tsx
Normal file
67
frontend/src/pages/AuthScreens.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import type { Language, SetupStatus, Theme } from '../types';
|
||||
import { Button, Field, Panel, SelectField } from '../components/Forms';
|
||||
|
||||
export function SetupWizard({ onDone }: { onDone: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [form, setForm] = useState({ name: '', email: '', password: '', language: 'de' as Language, theme: 'system' as Theme, public_url: window.location.origin, instance_name: 'NexaPantry', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone });
|
||||
const set = (key: string, value: string) => setForm((current) => ({ ...current, [key]: value }));
|
||||
return (
|
||||
<div className="grid min-h-dvh place-items-center bg-slate-50 p-4 dark:bg-gray-950">
|
||||
<Panel>
|
||||
<form className="grid max-w-2xl gap-4" onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
await api('/setup/complete', { method: 'POST', body: JSON.stringify(form) });
|
||||
onDone();
|
||||
}}>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-950 dark:text-white">{t('setupTitle')}</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-300">{t('setupSubtitle')}</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label={t('name')} required value={form.name} onChange={(e) => set('name', e.target.value)} />
|
||||
<Field label={t('email')} required type="email" value={form.email} onChange={(e) => set('email', e.target.value)} />
|
||||
<Field label={t('password')} required type="password" minLength={12} value={form.password} onChange={(e) => set('password', e.target.value)} />
|
||||
<SelectField label={t('language')} value={form.language} onChange={(e) => set('language', e.target.value)}>
|
||||
<option value="de">Deutsch</option><option value="en">English</option>
|
||||
</SelectField>
|
||||
<SelectField label={t('theme')} value={form.theme} onChange={(e) => set('theme', e.target.value)}>
|
||||
<option value="light">{t('light')}</option><option value="dark">{t('dark')}</option><option value="system">{t('systemTheme')}</option>
|
||||
</SelectField>
|
||||
<Field label={t('timezone')} required value={form.timezone} onChange={(e) => set('timezone', e.target.value)} />
|
||||
<Field label={t('publicUrl')} required value={form.public_url} onChange={(e) => set('public_url', e.target.value)} />
|
||||
<Field label={t('instanceName')} required value={form.instance_name} onChange={(e) => set('instance_name', e.target.value)} />
|
||||
</div>
|
||||
<Button type="submit">{t('completeSetup')}</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginScreen() {
|
||||
const { login } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
return (
|
||||
<div className="grid min-h-dvh place-items-center bg-slate-50 p-4 dark:bg-gray-950">
|
||||
<Panel>
|
||||
<form className="grid w-full max-w-sm gap-4" onSubmit={async (event) => { event.preventDefault(); await login(email, password); }}>
|
||||
<h1 className="text-3xl font-bold text-gray-950 dark:text-white">NexaPantry</h1>
|
||||
<Field label={t('email')} type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Field label={t('password')} type="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<Button type="submit">{t('login')}</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSetupStatus(): Promise<SetupStatus> {
|
||||
return api<SetupStatus>('/setup/status');
|
||||
}
|
||||
|
||||
60
frontend/src/pages/HomePage.tsx
Normal file
60
frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Bell, ChefHat, PackageCheck, ShieldCheck, ShoppingBasket } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { Panel } from '../components/Forms';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
|
||||
export function HomePage({ goAdmin }: { goAdmin: () => void }) {
|
||||
const { activeHome, user } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [stats, setStats] = useState({ products: 0, soon: 0, shopping: 0, notifications: 0 });
|
||||
const [recipes, setRecipes] = useState<Array<{ id: string; name: string; matchedIngredients: string[] }>>([]);
|
||||
useEffect(() => {
|
||||
if (!activeHome) return;
|
||||
void Promise.all([
|
||||
api<Array<{ status: string }>>(`/homes/${activeHome.id}/products`),
|
||||
api<Array<{ checked: boolean }>>(`/homes/${activeHome.id}/shopping`),
|
||||
api<Array<{ read_at: string | null }>>('/notifications'),
|
||||
api<Array<{ id: string; name: string; matchedIngredients: string[] }>>(`/homes/${activeHome.id}/recipes`)
|
||||
]).then(([products, shopping, notifications, loadedRecipes]) => {
|
||||
setStats({ products: products.length, soon: products.filter((p) => p.status !== 'ok').length, shopping: shopping.filter((i) => !i.checked).length, notifications: notifications.filter((n) => !n.read_at).length });
|
||||
setRecipes(loadedRecipes);
|
||||
});
|
||||
}, [activeHome]);
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<header className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-teal-700 dark:text-teal-300">{activeHome?.name ?? t('home')}</p>
|
||||
<h1 className="text-3xl font-bold">{user?.name}</h1>
|
||||
</div>
|
||||
{user?.instance_role === 'instance_admin' ? <button onClick={goAdmin} className="focus-ring rounded-lg bg-gray-900 p-3 text-white dark:bg-white dark:text-gray-950"><ShieldCheck /></button> : null}
|
||||
</header>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat icon={<PackageCheck />} label={t('inventory')} value={stats.products} />
|
||||
<Stat icon={<Bell />} label={t('soon')} value={stats.soon} />
|
||||
<Stat icon={<ShoppingBasket />} label={t('shopping')} value={stats.shopping} />
|
||||
<Stat icon={<Bell />} label={t('notifications')} value={stats.notifications} />
|
||||
</div>
|
||||
<Panel title={t('recipes')}>
|
||||
<div className="grid gap-2">
|
||||
{recipes.map((recipe) => (
|
||||
<div key={recipe.id} className="flex items-center gap-3 rounded-lg bg-lime-50 p-3 dark:bg-lime-950/30">
|
||||
<ChefHat className="text-teal-700" />
|
||||
<div>
|
||||
<p className="font-semibold">{recipe.name}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{recipe.matchedIngredients.join(', ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) {
|
||||
return <div className="rounded-lg bg-white p-4 shadow-sm dark:bg-gray-900"><div className="text-teal-700">{icon}</div><p className="mt-3 text-2xl font-bold">{value}</p><p className="text-sm text-gray-500">{label}</p></div>;
|
||||
}
|
||||
|
||||
59
frontend/src/pages/InventoryPage.tsx
Normal file
59
frontend/src/pages/InventoryPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Grid2X2, MapPin } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { Button, Panel } from '../components/Forms';
|
||||
import { ProductForm } from '../components/ProductForm';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import type { Product } from '../types';
|
||||
|
||||
export function InventoryPage() {
|
||||
const { activeHome } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [mode, setMode] = useState<'category' | 'location'>('category');
|
||||
const load = useCallback(async () => {
|
||||
if (activeHome) setProducts(await api<Product[]>(`/homes/${activeHome.id}/products`));
|
||||
}, [activeHome]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
const groups = useMemo(() => products.reduce<Record<string, Product[]>>((acc, product) => {
|
||||
const key = mode === 'category' ? product.category : product.location;
|
||||
acc[key] = [...(acc[key] ?? []), product];
|
||||
return acc;
|
||||
}, {}), [mode, products]);
|
||||
if (!activeHome) return null;
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-3xl font-bold">{t('inventory')}</h1>
|
||||
<div className="flex rounded-lg bg-gray-200 p-1 dark:bg-gray-800">
|
||||
<button className={`rounded-md px-3 py-2 ${mode === 'category' ? 'bg-white shadow dark:bg-gray-950' : ''}`} onClick={() => setMode('category')}><Grid2X2 size={18} /></button>
|
||||
<button className={`rounded-md px-3 py-2 ${mode === 'location' ? 'bg-white shadow dark:bg-gray-950' : ''}`} onClick={() => setMode('location')}><MapPin size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<Panel title={t('manualAdd')}><ProductForm onSaved={() => void load()} /></Panel>
|
||||
{Object.entries(groups).map(([group, rows]) => (
|
||||
<section key={group} className="grid gap-2">
|
||||
<h2 className="text-xl font-semibold">{group}</h2>
|
||||
{rows.map((product) => <ProductRow key={product.id} product={product} homeId={activeHome.id} reload={load} />)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductRow({ product, homeId, reload }: { product: Product; homeId: string; reload: () => Promise<void> }) {
|
||||
const { t } = useI18n();
|
||||
const colors = { ok: 'border-l-emerald-500', soon: 'border-l-amber-500', expired: 'border-l-rose-600' };
|
||||
return (
|
||||
<div className={`rounded-lg border border-l-4 bg-white p-3 shadow-sm dark:border-gray-800 dark:bg-gray-900 ${colors[product.status]}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold">{product.name}</p>
|
||||
<p className="text-sm text-gray-500">{product.quantity} {product.unit} · {product.location} · {product.expires_at ?? '—'} · {t(product.status)}</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={async () => { await api(`/homes/${homeId}/products/${product.id}/add-to-shopping`, { method: 'POST' }); await reload(); }}>{t('addToShopping')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/pages/Onboarding.tsx
Normal file
40
frontend/src/pages/Onboarding.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Bell, Home, PackagePlus, ScanLine, ShoppingBasket, UsersRound } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Button, Panel } from '../components/Forms';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
|
||||
export function Onboarding() {
|
||||
const { refresh } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const steps = [
|
||||
['manualAdd', PackagePlus],
|
||||
['scanBarcode', ScanLine],
|
||||
['expiresAt', Bell],
|
||||
['shopping', ShoppingBasket],
|
||||
['categories', Home],
|
||||
['homes', UsersRound],
|
||||
['notifications', Bell]
|
||||
] as const;
|
||||
const finish = async () => {
|
||||
await api('/auth/me', { method: 'PATCH', body: JSON.stringify({ onboarding_completed: true }) });
|
||||
await refresh();
|
||||
};
|
||||
return (
|
||||
<div className="grid min-h-dvh place-items-center bg-slate-50 p-4 dark:bg-gray-950">
|
||||
<Panel>
|
||||
<div className="grid max-w-lg gap-4">
|
||||
<h1 className="text-3xl font-bold">{t('onboardingTitle')}</h1>
|
||||
<div className="grid gap-2">
|
||||
{steps.map(([key, Icon]) => <div key={key} className="flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-800"><Icon className="text-teal-700" /> <span className="font-medium">{t(key)}</span></div>)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={finish}>{t('onboardingDone')}</Button>
|
||||
<Button variant="secondary" onClick={finish}>{t('skip')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
39
frontend/src/pages/ProfilePage.tsx
Normal file
39
frontend/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { Button, Field, Panel, SelectField } from '../components/Forms';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
|
||||
export function ProfilePage() {
|
||||
const { user, homes, activeHome, setActiveHomeId, logout, refresh } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [joinCode, setJoinCode] = useState('');
|
||||
const [homeName, setHomeName] = useState('');
|
||||
if (!user) return null;
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<h1 className="text-3xl font-bold">{t('profile')}</h1>
|
||||
<Panel title={user.name}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<SelectField label={t('home')} value={activeHome?.id ?? ''} onChange={(e) => setActiveHomeId(e.target.value)}>
|
||||
{homes.map((home) => <option key={home.id} value={home.id}>{home.name}</option>)}
|
||||
</SelectField>
|
||||
<Button variant="secondary" onClick={logout}>{t('logout')}</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title={t('createHome')}>
|
||||
<form className="flex gap-2" onSubmit={async (event) => { event.preventDefault(); await api('/homes', { method: 'POST', body: JSON.stringify({ name: homeName }) }); setHomeName(''); await refresh(); }}>
|
||||
<Field label={t('home')} value={homeName} required onChange={(e) => setHomeName(e.target.value)} />
|
||||
<Button className="self-end">{t('save')}</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
<Panel title={t('joinHome')}>
|
||||
<form className="flex gap-2" onSubmit={async (event) => { event.preventDefault(); await api('/homes/join', { method: 'POST', body: JSON.stringify({ join_code: joinCode }) }); setJoinCode(''); await refresh(); }}>
|
||||
<Field label={t('joinCode')} value={joinCode} required onChange={(e) => setJoinCode(e.target.value)} />
|
||||
<Button className="self-end">{t('save')}</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
42
frontend/src/pages/ScannerPage.tsx
Normal file
42
frontend/src/pages/ScannerPage.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { BrowserMultiFormatReader } from '@zxing/browser';
|
||||
import { ScanLine } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { Panel } from '../components/Forms';
|
||||
import { ProductForm } from '../components/ProductForm';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import type { Product } from '../types';
|
||||
|
||||
export function ScannerPage() {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const { activeHome } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [barcode, setBarcode] = useState('');
|
||||
const [prefill, setPrefill] = useState<Partial<Product>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHome || !videoRef.current) return;
|
||||
const reader = new BrowserMultiFormatReader();
|
||||
let controls: { stop: () => void } | undefined;
|
||||
let stop = false;
|
||||
reader.decodeFromVideoDevice(undefined, videoRef.current, async (result) => {
|
||||
if (result && !stop) {
|
||||
stop = true;
|
||||
const code = result.getText();
|
||||
setBarcode(code);
|
||||
const lookup = await api<{ found: boolean; product?: Partial<Product> }>(`/homes/${activeHome.id}/products/lookup/${code}`);
|
||||
setPrefill(lookup.product ?? { barcode: code });
|
||||
}
|
||||
}).then((scannerControls) => { controls = scannerControls; }).catch(() => undefined);
|
||||
return () => { stop = true; controls?.stop(); };
|
||||
}, [activeHome]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<h1 className="flex items-center gap-2 text-3xl font-bold"><ScanLine /> {t('scanBarcode')}</h1>
|
||||
<video ref={videoRef} className="aspect-video w-full rounded-lg bg-gray-950 object-cover" muted playsInline />
|
||||
{barcode ? <Panel title={`${t('barcode')}: ${barcode}`}><ProductForm initial={prefill} onSaved={() => setPrefill({})} /></Panel> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
frontend/src/pages/ShoppingPage.tsx
Normal file
45
frontend/src/pages/ShoppingPage.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Check, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { Button, Field, Panel } from '../components/Forms';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import type { ShoppingItem } from '../types';
|
||||
|
||||
export function ShoppingPage() {
|
||||
const { activeHome } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<ShoppingItem[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const load = useCallback(async () => {
|
||||
if (activeHome) setItems(await api<ShoppingItem[]>(`/homes/${activeHome.id}/shopping`));
|
||||
}, [activeHome]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
if (!activeHome) return null;
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<h1 className="text-3xl font-bold">{t('shopping')}</h1>
|
||||
<Panel>
|
||||
<form className="flex gap-2" onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
await api(`/homes/${activeHome.id}/shopping`, { method: 'POST', body: JSON.stringify({ name, category: 'Other', quantity: 1, unit: 'pcs' }) });
|
||||
setName('');
|
||||
await load();
|
||||
}}>
|
||||
<Field label={t('productName')} value={name} required onChange={(e) => setName(e.target.value)} />
|
||||
<Button type="submit" className="self-end">{t('add')}</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
<div className="grid gap-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg bg-white p-3 shadow-sm dark:bg-gray-900">
|
||||
<button className="focus-ring rounded-lg p-2 text-teal-700" onClick={async () => { await api(`/homes/${activeHome.id}/shopping/${item.id}`, { method: 'PATCH', body: JSON.stringify({ checked: !item.checked }) }); await load(); }}><Check /></button>
|
||||
<div className={`flex-1 ${item.checked ? 'text-gray-400 line-through' : ''}`}><p className="font-semibold">{item.name}</p><p className="text-sm text-gray-500">{item.quantity} {item.unit}</p></div>
|
||||
<button className="focus-ring rounded-lg p-2" onClick={async () => { await api(`/homes/${activeHome.id}/shopping/${item.id}/move-to-inventory`, { method: 'POST' }); await load(); }}><RotateCcw /></button>
|
||||
<button className="focus-ring rounded-lg p-2 text-rose-700" onClick={async () => { await api(`/homes/${activeHome.id}/shopping/${item.id}`, { method: 'DELETE' }); await load(); }}><Trash2 /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
frontend/src/styles/index.css
Normal file
31
frontend/src/styles/index.css
Normal file
@@ -0,0 +1,31 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
button, input, select, textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.focus-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2 dark:focus:ring-offset-gray-900;
|
||||
}
|
||||
|
||||
21
frontend/src/test-setup.ts
Normal file
21
frontend/src/test-setup.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
}))
|
||||
});
|
||||
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ needs_setup: true, instance: null }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
57
frontend/src/types/index.ts
Normal file
57
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
export type Language = 'de' | 'en';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
instance_role: 'instance_admin' | 'user';
|
||||
language: Language;
|
||||
theme: Theme;
|
||||
timezone: string;
|
||||
is_active: boolean;
|
||||
onboarding_completed: boolean;
|
||||
};
|
||||
|
||||
export type Home = {
|
||||
id: string;
|
||||
name: string;
|
||||
expiry_warning_days: number;
|
||||
daily_summary_enabled: boolean;
|
||||
daily_summary_time: string;
|
||||
role?: 'home_owner' | 'home_member' | 'read_only';
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: string;
|
||||
home_id: string;
|
||||
name: string;
|
||||
barcode?: string | null;
|
||||
brand?: string | null;
|
||||
category: string;
|
||||
location: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
expires_at?: string | null;
|
||||
min_quantity: number;
|
||||
notes?: string | null;
|
||||
image_url?: string | null;
|
||||
status: 'ok' | 'soon' | 'expired';
|
||||
};
|
||||
|
||||
export type ShoppingItem = {
|
||||
id: string;
|
||||
home_id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
checked: boolean;
|
||||
product_id?: string | null;
|
||||
};
|
||||
|
||||
export type SetupStatus = {
|
||||
needs_setup: boolean;
|
||||
instance?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
19
frontend/tailwind.config.js
Normal file
19
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
pantry: {
|
||||
ink: '#172026',
|
||||
teal: '#0f766e',
|
||||
mint: '#d9f99d',
|
||||
berry: '#be123c',
|
||||
amber: '#d97706'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "WebWorker", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.test.tsx","./src/app.tsx","./src/main.tsx","./src/test-setup.ts","./src/api/client.ts","./src/components/forms.tsx","./src/components/layout.tsx","./src/components/productform.tsx","./src/contexts/authcontext.tsx","./src/contexts/i18ncontext.tsx","./src/i18n/dictionaries.ts","./src/pages/adminpage.tsx","./src/pages/authscreens.tsx","./src/pages/homepage.tsx","./src/pages/inventorypage.tsx","./src/pages/onboarding.tsx","./src/pages/profilepage.tsx","./src/pages/scannerpage.tsx","./src/pages/shoppingpage.tsx","./src/types/index.ts","./vite.config.ts"],"version":"5.9.3"}
|
||||
46
frontend/vite.config.ts
Normal file
46
frontend/vite.config.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'NexaPantry',
|
||||
short_name: 'NexaPantry',
|
||||
description: 'Self-hosted pantry management for homes and shared households.',
|
||||
theme_color: '#0f766e',
|
||||
background_color: '#f8fafc',
|
||||
display: 'standalone',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{ src: '/icons/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' }
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/index.html',
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
|
||||
handler: 'NetworkFirst',
|
||||
options: { cacheName: 'nexapantry-api', networkTimeoutSeconds: 3 }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8000'
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: './src/test-setup.ts'
|
||||
}
|
||||
});
|
||||
7
scripts/backup.sh
Executable file
7
scripts/backup.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p backups
|
||||
docker compose exec postgres pg_dump -U "${POSTGRES_USER:-nexapantry}" "${POSTGRES_DB:-nexapantry}" > "backups/nexapantry-$(date +%Y%m%d-%H%M%S).sql"
|
||||
|
||||
11
scripts/restore.sh
Executable file
11
scripts/restore.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "Usage: scripts/restore.sh backups/file.sql" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
docker compose exec -T postgres psql -U "${POSTGRES_USER:-nexapantry}" "${POSTGRES_DB:-nexapantry}" < "$1"
|
||||
|
||||
29
scripts/security-checks.sh
Executable file
29
scripts/security-checks.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "Backend lint"
|
||||
(cd backend && ruff check app)
|
||||
|
||||
echo "Backend tests"
|
||||
(cd backend && pytest)
|
||||
|
||||
echo "Backend dependency audit"
|
||||
(cd backend && pip-audit)
|
||||
|
||||
echo "Backend static security scan"
|
||||
(cd backend && bandit -q -r app -x app/tests)
|
||||
|
||||
echo "Frontend lint, typecheck, tests and dependency audit"
|
||||
(cd frontend && npm run lint && npm run typecheck && npm test -- --run && npm audit --audit-level=moderate)
|
||||
|
||||
cat <<'INFO'
|
||||
Container scan:
|
||||
docker compose build
|
||||
trivy image nexapantry-backend
|
||||
trivy image nexapantry-frontend
|
||||
|
||||
Secret scan:
|
||||
gitleaks detect --source .
|
||||
INFO
|
||||
Reference in New Issue
Block a user