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>
499 lines
19 KiB
Markdown
499 lines
19 KiB
Markdown
# 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 provider’s **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 provider’s 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
|