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

499 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](https://stripe.com) account (test mode for development)
---
## Quick Start
```bash
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 `.env``POSTGRES_DB` (default `bilhej`) |
| User / password | `POSTGRES_USER` / `POSTGRES_PASSWORD` |
**IntelliJ IDEA:** Database tool window → `+` → Data Source → PostgreSQL → fill in above →
Test Connection → OK.
**CLI:**
```bash
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:
```bash
ssh -N -L 5433:127.0.0.1:5433 you@srvr.nu
```
3. In IntelliJ / DBeaver:
| Setting | Value |
|---------|--------|
| Host | `localhost` |
| Port | `5433` |
| Database | prod `POSTGRES_DB` |
| User / password | prod secrets |
**CLI on the server** (no GUI):
```bash
docker exec -it bilhej-postgres-prod psql -U bilhej -d bilhej
```
### Manual prod cleanup (keep data, remove dev seeds)
```sql
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](https://mailpit.axllent.org/)
(`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:
```bash
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](https://resend.com) or
[Brevo](https://www.brevo.com)—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):
```bash
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
```
5. Deploy via **Deploy to Production**, then test forgot-password on https://bilhej.se
See [docs/production-email-checklist.md](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:
```bash
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):
```bash
./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 (`/api` → `backend: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](#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`](docker/bilhej.nginx.conf) references TLS files that do not
exist yet. Deploy the HTTP-only config first:
```bash
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**
```bash
docker exec certbot certbot certonly \
--webroot -w /var/www/certbot \
-d bilhej.se -d www.bilhej.se
```
5. **Enable HTTPS proxy to the frontend**
```bash
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:
```bash
# 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)
```bash
./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)
```bash
cd frontend
npm install # first time only
npm run dev # :3000 with HMR
```
### Backend (IDE or CLI)
```bash
./gradlew :backend:bootRun # :8080, profile: default (H2)
```
### Stripe Webhooks (local testing)
```bash
stripe listen --forward-to localhost:8080/api/webhooks/stripe
```
### Database reset
```bash
./gradlew reset # wipes DB volume and restarts containers
```
---
## Related Documents
- [REQUIREMENTS.md](./REQUIREMENTS.md) — Full product requirements and business model
- [CODING_GUIDELINES.md](./CODING_GUIDELINES.md) — Code conventions and standards