Previously an expired token left the frontend in a stuck state: the
router guard only checked token presence (never the exp claim), so the
user could still navigate to protected pages, and every API call then
failed with a generic Swedish "Kunde inte hämta…" message while the
header kept showing the logged-in UI. There was no global response
interceptor, and the backend returned an ambiguous 403 (no body) for
unauthenticated requests because no AuthenticationEntryPoint was
configured, making 403 mean both "no/invalid token" and "forbidden".
Backend:
- Add an AuthenticationEntryPoint in SecurityConfig that returns 401
with a Swedish {"message": ...} ErrorResponse body for
unauthenticated/expired-token requests, and an AccessDeniedHandler
returning 403 with the same body shape for genuine authorization
failures. This makes 401 = not authenticated/expired and
403 = authenticated but forbidden, the standard REST convention.
- Make JwtService(String, long) constructor public so integration
tests can mint expired tokens (was package-private).
- Update the 6 no-auth controller tests from 403 to 401
(OrderControllerTest, AdminControllerTest, PaymentControllerTest,
AuthControllerTest change-password/change-email) and assert the
message body exists; keep shouldReturn403ForNonAdminUser as 403.
- Add OrderControllerTest.shouldReturn401WithSwedishMessageWhenTokenExpired
(expired JWT via TTL -1000ms) and shouldReturn401WithMessageWhenNoAuthHeader.
Frontend:
- Add isTokenExpired() to utils/jwt.ts using the previously-unused exp
claim, and expose it on the auth store.
- Add a global 401 interceptor in api/client.ts: on a 401 from any
non-/auth/ endpoint, call auth.logout() and redirect to
/logga-in?redirect=<currentPath>. Skip /auth/ so wrong-password 401s
on login/change-password stay handled locally. Add isSessionExpired
and isForbidden helpers for per-page catch blocks.
- Harden the router guard to reject tokens whose exp is in the past
(logout + redirect to login with ?redirect=), and let expired-token
users open /logga-in and /registrera instead of bouncing to home.
- Refactor the generic-error catch blocks on OrdersPage, EditOrderPage,
ComposePage, PaymentRedirect, useAdminOrders, and useAdminOrderActions
to skip the generic Swedish message on 401 (handled globally) while
preserving wrong-password 401 handling on change-pw/email pages.
Tests:
- New frontend/src/__tests__/client.spec.ts covering 401 -> logout +
redirect, 401 from /auth/ -> no logout, 403 -> no logout, no-token
401 -> no redirect, and isSessionExpired/isForbidden helpers.
- Add authStore.spec.ts cases for isTokenExpired (no token, past exp,
future exp, missing exp, after logout).
- Add Router.spec.ts cases for expired-token redirects, token clearing,
future-exp access, and guest pages not bouncing expired users.
- Add OrdersPage.spec.ts case asserting 401 triggers no generic error
and the global logout/redirect.
- New E2E expired-token.spec.ts (Docker) covering both the router-guard
expired-token redirect and the API-401 redirect, with logged-out
header and cleared localStorage assertions.
- Mock the API in two pre-existing fake-JWT E2E tests
(auth-guards admin access, header-auth logout redirect) that broke
because the backend now correctly 401s their unsigned test-sig tokens.
Verified with ./gradlew check (frontend lint + 267 unit tests, backend
tests + coverage, Flyway, 92 E2E tests in Docker) and ./gradlew coverage;
all coverage thresholds maintained (jwt.ts at 100%).
Review feedback on PR #10: excluding the whole docker/ directory broke
frontend.prod.Dockerfile, which copies docker/nginx.conf and
docker/entrypoint.sh into the production nginx image.
- Replace docker/ with docker/*.Dockerfile so only the Dockerfiles are
removed from the build context.
- Restore docker-compose*.yml exclusion.
- Correct the header comment to reflect that dev Dockerfiles COPY source
subpaths, not the entire repo root.
Verified: docker compose -f docker-compose.prod.yml build frontend
succeeds and both COPY docker/... steps complete.
Why
---
The dev compose (docker-compose.yml) assumes the Docker daemon can bind-mount
the host repo (and several subpaths) at runtime, providing live source for
`gradle :backend:bootRun` and Vite HMR. That works on a normal Linux/macOS
host but breaks in:
- Docker-in-Docker setups (e.g. the Hermes sandbox used for agent work)
- rootless Docker with restricted mount paths
- some CI runners
The failure mode is the daemon's mount namespace only sees compose-created
named-volume subdirs at the bind source, not the real repo files. The
backend then fails with `stat ./gradlew: no such file or directory` and
the frontend fails with `mount src=.../index.html, dst=.../index.html
... not a directory`. The image itself is empty of source — there are no
`COPY` lines in the dev Dockerfiles.
Approach
--------
Make the dev images self-sufficient by COPYing the source at build time.
The compose bind mount is kept (it's still the right thing for normal
local dev with HMR), but it's no longer load-bearing. The image works
standalone in any environment.
Add a separate `docker-compose.dev-bindless.yml` for environments where
host bind mounts can't be used (DinD, CI, restricted Docker). It uses
the same images (COPY'd source) but redefines the services with no
host bind mounts — only the named cache volumes remain, so gradle and
Vite caches persist between `up` cycles.
Compose merge semantics caveat: `volumes:` lists merge by concatenation,
not by entry replacement, so the bindless workflow can't be expressed as
a compose override on top of docker-compose.yml. A standalone file is
required.
Changes
-------
* docker/backend.Dockerfile
- Add `COPY gradlew settings.gradle build.gradle ./`
- Add `COPY gradle/ gradle/`
- Add `RUN chmod +x gradlew`
- Add `COPY backend/ backend/`
- Add `EXPOSE 8080`
- Keep ENTRYPOINT unchanged.
- New image is runnable with `docker run bilhej-backend-dev` (no bind
mount needed) and works under `docker compose up -d` on any host.
* docker/frontend.Dockerfile
- Add comments documenting the two-stage COPY pattern (deps first for
layer cache, then full source).
- Keep the existing structure — it already COPYs the source, just
wasn't being relied on. Now bind-mount failures (e.g. index.html
type mismatch in DinD) don't kill the container; the COPY'd file
is already in place.
- Add `EXPOSE 3000` (was missing).
* .dockerignore
- Expand to exclude everything that isn't strictly needed at build or
run time: docs, scripts, git, editor config, build outputs, test
results, logs, env files, docker-related metadata, etc.
- Cuts the build context from ~MBs to ~800 KB (verified).
- Image contents are now: gradlew + wrapper, build.gradle, settings,
gradle/, backend/ (for backend image); package.json, package-lock,
src/, public/, index.html, node_modules (for frontend image).
* docker-compose.dev-bindless.yml (new)
- Standalone variant of docker-compose.yml with all host bind mounts
removed. Same service definitions, same image tags, same env vars,
same named cache volumes (pgdata, gradle-cache, backend-gradle-
project, backend-build). Only differences: no `.:/app`, no
`./frontend/src:/app/src`, no `./frontend/public:/app/public`, no
`./frontend/index.html:/app/index.html`.
- Usage: `docker compose -f docker-compose.dev-bindless.yml up -d`
(no `--build` needed if images already exist; include `--build`
on first run or after pulling changes).
- Trade-off vs the default dev compose: image is "frozen" at build
time, so editing source on the host doesn't trigger HMR. Edit +
`docker compose up -d --build` (or just rebuild the relevant
service) to pick up changes. Named cache volumes still keep
gradle/npm caches warm across rebuilds.
* e2e compose (docker-compose.e2e.yml, docker/*.e2e.Dockerfile) —
unchanged. They were already self-contained and continue to work as
before. Verified by running the full 90/90 Playwright suite in 54s.
Compatibility with existing dev workflow
----------------------------------------
On a normal host where bind mounts work (the common case):
- `docker compose up -d` (the existing command) keeps working
unchanged. The bind mount on `.:/app` overlays the COPY'd source
at runtime, so HMR and `gradle :backend:bootRun` hot-reload work
exactly like before.
- Image size grows (~50 MB backend, ~50 MB frontend on top of base
image; ~200 MB including node_modules). Acceptable for dev.
- First-time `docker compose build` is slightly slower because it
has to COPY the source. Subsequent builds cache well: the COPY
layer invalidates only when source files change.
Verified
--------
- Hermes DinD sandbox: bindless dev stack (`docker-compose.dev-
bindless.yml`) brings up postgres + mailpit + backend + frontend
with no bind mounts. Spring Boot starts in ~6s, Vite dev server
in ~700ms. Backend serves real API responses
(`GET /api/vehicles/ABC123 -> 404 Inget fordon hittades`).
- Hermes DinD sandbox: e2e stack runs all 90 Playwright tests in
~54s, identical to pre-patch behavior.
- Docker image self-sufficiency: `docker run --rm bilhej-backend-dev`
and `docker run --rm bilhej-frontend-dev` both work without any
bind mounts.
Refs: project AGENTS.md (Docker section, gradle check pre-commit).
2026-06-17 09:44:18 +00:00
5 changed files with 197 additions and 5 deletions