chore: initial project setup with backend, frontend, and infrastructure
Some checks failed
CI / backend (push) Failing after 31s
CI / frontend (push) Successful in 40s
CI / docker (push) Has been skipped

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:
2026-06-04 10:26:38 +02:00
commit 3792ca55e7
74 changed files with 13417 additions and 0 deletions

16
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"])

View File

@@ -0,0 +1 @@

View 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"}

View 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))

View 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"}

View 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"}

View 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}

View 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)

View 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

View 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")

View 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()

View 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
View 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
View 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")

View 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",
]

View 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)

View 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

View 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 {},
)
)

View 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."

View 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

View 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
]

View 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)

View 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"

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

43
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View 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

View 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
View 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 />;
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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);
}

View 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
View 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>
);

View 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 &gt; backup.sql{'\n'}docker compose exec -T postgres psql -U $POSTGRES_USER $POSTGRES_DB &lt; backup.sql</pre></Panel>;
}

View 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');
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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' }
})
);

View 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;
};

View 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
View 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"]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

View 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
View 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
View 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
View 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
View 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