bilhej/README.md
Joakim Mörling 86fb946e33
All checks were successful
CI / Lint, type check, unit tests, coverage (push) Successful in 2m2s
CI / E2E browser tests (push) Successful in 1m55s
Add password reset, logged-in change password, and Mailpit email dev/E2E.
Operators can fix prod admin passwords without email via Byt lösenord;
end users can use forgot-password when SMTP is configured. Local and CI
use Mailpit to capture outbound mail and verify reset links end-to-end.

- Backend: V8 password_reset_tokens, PasswordResetService, EmailService,
  POST /api/auth/forgot-password, reset-password, change-password
- Optional testToken in forgot-password response (docker profile only, for E2E)
- Frontend: ForgotPasswordPage, ResetPasswordPage, ChangePasswordPage,
  routes, login link, header Byt lösenord
- Mailpit (ghcr.io/axllent/mailpit:v1.28) in docker-compose + e2e stack
- E2E: password-reset.spec.ts + Mailpit API helper tests SMTP delivery
- Separate dev/e2e Docker image names to avoid overwriting bilhej-frontend
- Docs: README email section, production-email-checklist, .env.example
- Unit/integration tests for reset, change password, and Vitest page specs

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 18:05:15 +02:00

19 KiB
Raw Blame History

BilHej / Bilhälsning.se

Send a physical letter to a Swedish car owner — just by knowing their license plate.

The user enters a registration number, composes a letter (from a template or free text), pays, and BilHej handles the rest: owner address lookup via Transportstyrelsen, printing and mailing via PostNord. The sender never sees the recipient's name or address.


Tech Stack

Layer Technology
Frontend Vue.js 3 (Composition API), Vite, Pinia
Backend Java 21, Spring Boot 4, Gradle
Database PostgreSQL 16
Auth Spring Security + JWT
Payments Stripe (cards + Swish)
Deployment Docker, Docker Compose

Prerequisites

  • Docker & Docker Compose
  • Java 21 (for local IDE development)
  • Node.js 20+ (for local frontend dev)
  • A Stripe account (test mode for development)

Quick Start

git clone <repo-url> bilhej
cd bilhej
cp .env.example .env          # fill in your keys
docker compose up -d          # or: ./gradlew up

The app will be available at:

  • Frontend: http://localhost:3000
  • Backend API: http://localhost:8080
  • PostgreSQL: localhost:5432
  • Mailpit (dev SMTP inbox): http://localhost:8025

Architecture inside Docker Compose

 Browser                  Docker Compose network
 ───────                  ─────────────────────
 │                         ┌──────────────────┐
 │  http://localhost:3000  │  frontend (Vite)  │
 ├────────────────────────→│  :3000            │
 │                         │  proxy: /api →    │
 │  GET /api/orders        │  backend:8080     │
 │                         └────────┬─────────┘
 │                                  │
 │                         ┌────────▼─────────┐
 │                         │  backend (Spring) │
 │                         │  :8080            │
 │                         │  profile: docker  │
 │                         └────────┬─────────┘
 │                                  │
 │                         ┌────────▼─────────┐
 │                         │  postgres (16)    │
 │                         │  :5432            │
 │                         └──────────────────┘
 │                         ┌──────────────────┐
 │                         │  mailpit          │
 │                         │  SMTP :1025       │
 │                         │  UI   :8025       │
 │                         └──────────────────┘

Vite proxy: The Vite dev server proxies /api/* requests to the backend container. No CORS configuration needed in development — the browser never calls the backend directly.

Spring profiles:

Profile Datasource Use
default H2 in-memory Local IDE dev (./gradlew :backend:bootRun)
docker PostgreSQL + dev Flyway seeds Docker Compose dev / CI
docker,prod PostgreSQL, schema only, admin bootstrap Deploy (docker-compose.prod.yml)

Environment Variables

Copy .env.example to .env and fill in:

Variable Description
POSTGRES_DB Database name (default: bilhej)
POSTGRES_USER Database user
POSTGRES_PASSWORD Database password
JWT_SECRET Secret key for JWT signing
STRIPE_SECRET_KEY Stripe secret key
STRIPE_WEBHOOK_SECRET Stripe webhook signing secret
STRIPE_PRICE_ID Stripe price ID for single letter
SWISH_NUMBER Swish number for payment instructions
APP_PUBLIC_BASE_URL Base URL for password-reset links (dev: http://localhost:3000)
MAIL_HOST SMTP host (Docker dev uses mailpit automatically; leave empty to log links only)
MAIL_PORT SMTP port (1025 for Mailpit, 587 for most providers)
MAIL_USERNAME SMTP username (empty for Mailpit)
MAIL_PASSWORD SMTP password (empty for Mailpit)
MAIL_FROM From address (e.g. noreply@bilhej.se)
ADMIN_EMAIL Production admin login (e.g. admin@bilhej.se)
ADMIN_PASSWORD Strong production admin password (not test1234)

Dev-only accounts (from db/dev-migration, not used in production):

Email Password Role
test@bilhej.se test1234 user (e2e / local)
admin@bilhalsning.se test1234 admin (local docker only)

Database access

PostgreSQL runs in Docker. Any GUI client works (IntelliJ IDEA, DBeaver, TablePlus, pgAdmin) — same idea as a normal remote database.

Local dev (docker compose up)

Setting Value
Host localhost
Port 5432
Database from .envPOSTGRES_DB (default bilhej)
User / password POSTGRES_USER / POSTGRES_PASSWORD

IntelliJ IDEA: Database tool window → + → Data Source → PostgreSQL → fill in above → Test Connection → OK.

CLI:

docker exec -it bilhej-postgres psql -U bilhej -d bilhej

Production (server)

Postgres is bound to localhost only on the server (127.0.0.1:5433) so it is not exposed to the internet. Use an SSH tunnel from your laptop, then point IntelliJ (or DBeaver) at localhost.

  1. On the server, recreate the stack once so the port mapping is active (after deploy).

  2. From your laptop:

ssh -N -L 5433:127.0.0.1:5433 you@srvr.nu
  1. In IntelliJ / DBeaver:
Setting Value
Host localhost
Port 5433
Database prod POSTGRES_DB
User / password prod secrets

CLI on the server (no GUI):

docker exec -it bilhej-postgres-prod psql -U bilhej -d bilhej

Manual prod cleanup (keep data, remove dev seeds)

DELETE FROM orders
WHERE user_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';

DELETE FROM users
WHERE email IN ('test@bilhalsning.se', 'test@bilhej.se', 'admin@bilhalsning.se');

Then deploy with ADMIN_EMAIL / ADMIN_PASSWORD set — the app creates the production admin on startup. No need to insert a password hash by hand.

Email (password reset)

The app only needs to send mail (forgot-password). You do not need Office 365 or a mailbox on the server unless you want human addresses like support@bilhej.se.

Local Docker (Mailpit): docker compose up starts Mailpit (ghcr.io/axllent/mailpit:v1.28). All outbound mail is captured—nothing is sent to the internet.

If docker compose pull fails on Docker Hub, pull explicitly:

docker pull ghcr.io/axllent/mailpit:v1.28
  1. Open http://localhost:8025
  2. Use Glömt lösenord? on the login page (or Byt lösenord in the header when logged in)
  3. Open the message in Mailpit and click the reset link

To disable Mailpit and log links only, remove MAIL_HOST from the backend service in docker-compose.yml or set MAIL_HOST= in .env.

Production (transactional provider): Use Resend or Brevo—not a self-hosted mail server on the VPS.

  1. Sign up and add domain bilhej.se
  2. Add the providers SPF and DKIM DNS records at your registrar (no MX needed for send-only)
  3. Create SMTP credentials in the provider dashboard
  4. On the production server .env (or Forgejo deploy secrets):
APP_PUBLIC_BASE_URL=https://bilhej.se
MAIL_HOST=smtp.resend.com          # example; use your providers host
MAIL_PORT=587
MAIL_USERNAME=resend               # example
MAIL_PASSWORD=re_xxxxxxxx
MAIL_FROM=noreply@bilhej.se
  1. Deploy via Deploy to Production, then test forgot-password on https://bilhej.se

See docs/production-email-checklist.md for a step-by-step operator checklist.

If SMTP is not configured (MAIL_HOST empty), the reset link is written to the backend log:

docker logs bilhej-backend-prod 2>&1 | grep "Password reset link"

Optional later: real inboxes (support@bilhej.se) via Migadu, Fastmail, or Purelymail—separate from app MAIL_* (different MX records).

To generate a bcrypt hash manually (optional):

./gradlew hashPassword -Ppassword='your-strong-password'

Project Structure

bilhej/
├── frontend/                 # Vue.js 3 SPA
│   ├── src/
│   │   ├── components/       # Reusable UI components
│   │   ├── composables/      # Shared composition functions
│   │   ├── layouts/          # Page layouts
│   │   ├── pages/            # Route-level page components
│   │   ├── router/           # Vue Router config
│   │   ├── stores/           # Pinia stores
│   │   ├── api/              # API client and endpoints
│   │   ├── assets/           # Static assets, CSS
│   │   ├── App.vue
│   │   └── main.ts
│   ├── index.html
│   ├── vite.config.ts
│   └── package.json
├── backend/                  # Spring Boot 4
│   ├── src/main/java/se/bilhalsning/
│   │   ├── BilHejApplication.java
│   │   ├── config/           # Security, CORS, Stripe config
│   │   ├── controller/       # REST controllers
│   │   ├── dto/              # Data transfer objects
│   │   ├── entity/           # JPA entities
│   │   ├── repository/       # Spring Data repositories
│   │   ├── service/          # Business logic
│   │   └── security/         # JWT filter, user details
│   └── src/main/resources/
│       ├── application.yml              # default profile (H2)
│       ├── application-docker.yml       # docker profile (PostgreSQL)
│       └── db/migration/                # Flyway migrations
├── docker-compose.yml         # dev: postgres + backend (bootRun) + frontend (Vite HMR)
├── docker-compose.prod.yml    # prod: multi-stage builds, no source mounts, restart: unless-stopped
├── docker/
│   ├── backend.Dockerfile         # dev: JDK + gradle :backend:bootRun
│   ├── backend.prod.Dockerfile    # prod: multi-stage (Gradle → JRE Alpine, non-root)
│   ├── frontend.Dockerfile        # dev: Node + vite dev server
│   ├── frontend.prod.Dockerfile   # prod: multi-stage (Node → nginx)
│   ├── nginx.conf                 # prod: SPA fallback + /api proxy
│   └── entrypoint.sh              # prod: self-signed cert generation
├── gradlew                    # Gradle wrapper (run from repo root)
├── gradle/
│   └── wrapper/
├── settings.gradle            # rootProject.name + include 'backend'
├── build.gradle               # convenience tasks: check, up, down, reset
├── .env.example
├── README.md
├── REQUIREMENTS.md
└── CODING_GUIDELINES.md

Development vs Production

Aspect docker compose up -d docker compose -f docker-compose.prod.yml up -d
Backend ./gradlew :backend:bootRun (compiles on change) Multi-stage build → java -jar app.jar
Backend image eclipse-temurin:21-jdk (~400 MB) eclipse-temurin:21-jre-alpine (~200 MB)
Backend user root bilhej (non-root)
Frontend Vite dev server (HMR, --host 0.0.0.0) nginx serving static dist/
API proxy Vite built-in proxy (/apibackend:8080) nginx proxy_pass
SSL None Self-signed cert auto-generated on first start (.certs/ volume)
Source mounts Yes (live edit) No (files baked into image)
Restart policy Manual unless-stopped
Purpose Write code, instant feedback Verify production build

Production Deployment

Deployments are fully automated via Forgejo Actions. The pipeline builds production Docker images and starts them on the server.

One-time Setup

Before the first deploy, complete these steps on the production server (srvr.nu):

  1. Add Forgejo Actions Secrets

    Go to Forgejo → Repository Settings → Actions → Secrets and add:

    Secret Description
    POSTGRES_DB Database name (e.g., bilhej)
    POSTGRES_USER Database user
    POSTGRES_PASSWORD Strong database password
    JWT_SECRET openssl rand -hex 32
    STRIPE_SECRET_KEY Stripe secret key
    STRIPE_WEBHOOK_SECRET Stripe webhook signing secret
    STRIPE_PRICE_ID Stripe price ID for single letter
    SWISH_NUMBER Swish phone number for payment instructions
    ADMIN_EMAIL Production admin email (e.g. admin@bilhej.se)
    ADMIN_PASSWORD Strong unique admin password (password manager)

    Passwords may contain $ — the deploy workflow escapes these for Docker Compose. Production does not seed test@bilhej.se or demo orders. On first start, the backend creates one admin from ADMIN_EMAIL / ADMIN_PASSWORD if no admin exists.

    If prod already has dev seed users, clean them with SQL (see Database access) instead of wiping the volume. Then redeploy with the new secrets so bootstrap can create ADMIN_EMAIL.

  2. Point DNS

    Set bilhej.se (and www.bilhej.se) A record to the server's public IP.

  3. Add HTTP-only Nginx vhost (required before certs exist)

    The full docker/bilhej.nginx.conf references TLS files that do not exist yet. Deploy the HTTP-only config first:

    docker cp docker/bilhej.nginx.http.conf nginx:/etc/nginx/conf.d/bilhej.conf
    docker exec nginx nginx -t
    docker exec nginx nginx -s reload
    
  4. Obtain SSL Certificate

    docker exec certbot certbot certonly \
      --webroot -w /var/www/certbot \
      -d bilhej.se -d www.bilhej.se
    
  5. Enable HTTPS proxy to the frontend

    docker cp docker/bilhej.nginx.conf nginx:/etc/nginx/conf.d/bilhej.conf
    docker exec nginx nginx -t
    docker exec nginx nginx -s reload
    

Deploy

  1. Go to Actions → Deploy to Production in Forgejo.
  2. Click Run workflow.
  3. Enter a version tag (e.g., v0.1.0).
  4. Click Run workflow.

Deploy failed (backend health check)

If the job passes the frontend check but the backend never becomes healthy:

  1. Open the failed job log and read Backend logs (printed before rollback).
  2. Match the error to a fix — do not guess:
    • Detected applied migration not resolved locally: 6 (or 2, 4) — prod DB still lists dev seed migrations removed from db/migration. Fixed in app via ProdFlywayConfig (repair before migrate); redeploy after that commit is on master.
    • password authentication failed — DB credentials in the running stack do not match what Postgres was initialized with; fix credentials or Postgres password to match (only wipe the volume if you accept losing prod data).
    • Production requires ADMIN_EMAIL and ADMIN_PASSWORD — add those Forgejo secrets.
    • Other Flyway / migration errors — read the stack trace; do not wipe the volume unless the log clearly requires it.
  3. DBeaver from your laptop — prod Postgres binds to 127.0.0.1:5433 on the server only. Use an SSH tunnel, then host localhost port 5433 (not 192.168.0.59 directly).

What Happens

Step Action
Tag Git tag v0.1.0 is created and pushed
Build Production backend JAR and frontend bundle are built
Images Multi-stage Docker images are built locally on the server
Start docker compose -f docker-compose.prod.yml up -d (SPRING_PROFILES_ACTIVE=docker,prod)
Verify Health checks confirm backend API and frontend are responding

Architecture on Server

  User
   │
   │ https://bilhej.se
   ▼
┌─────────────────────────────────────┐
│  nginx (srvr.nu)                    │
│  SSL termination (Let's Encrypt)    │
│  proxy_pass → bilhej-frontend-prod  │
└─────────────┬───────────────────────┘
              │ Docker 'web' network
┌─────────────▼───────────────────────┐
│  bilhej-frontend-prod (nginx)       │
│  :80                                │
│  /api/* → bilhej-backend-prod:8080  │
└─────────────┬───────────────────────┘
              │
┌─────────────▼───────────────────────┐
│  bilhej-backend-prod (Spring Boot)  │
│  :8080                              │
└─────────────┬───────────────────────┘
              │
┌─────────────▼───────────────────────┐
│  bilhej-postgres-prod (PostgreSQL)  │
│  :5432                              │
└─────────────────────────────────────┘

Rollback

To rollback to a previous version:

# On srvr.nu
cd /path/to/bilhej/repo
git fetch --tags
git checkout v0.1.0   # or any previous tag
docker compose -f docker-compose.prod.yml up --build -d

Development

All-in-one (from repo root)

./gradlew check         # lint → frontend test → backend test → integration test
./gradlew up            # docker compose up -d
./gradlew down          # docker compose down
./gradlew reset         # docker compose down -v && docker compose up -d (full DB reset)

Frontend (dev server with HMR)

cd frontend
npm install          # first time only
npm run dev          # :3000 with HMR

Backend (IDE or CLI)

./gradlew :backend:bootRun    # :8080, profile: default (H2)

Stripe Webhooks (local testing)

stripe listen --forward-to localhost:8080/api/webhooks/stripe

Database reset

./gradlew reset    # wipes DB volume and restarts containers