The c7eeaf6 refactor split the monolithic AdminPage into AdminStatsBar,
AdminOrdersTable, and AdminOrderDetailPanel. All CSS rules for the admin
sub-components were left in AdminPage.vue's `<style scoped>` block. Vue 3
scoped styles only apply to elements in the parent's own template — they
do not penetrate multi-root child components. Every element rendered by
the extracted sub-components lost its styling (stats grid, table layout,
column widths, status badges, expand icons, row highlighting, expanded
detail panels, etc.), making the admin page appear visually broken.
Changes:
- frontend/src/pages/AdminPage.vue: remove `scoped` attribute from the
`<style>` block. All selectors are BEM-namespaced under `.admin__*`
so making them global is safe and is the minimal fix.
Visual verification: N/A (sandbox cannot reach production). See git diff
for the one-character change.
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).
Enable pageview tracking when VITE_UMAMI_WEBSITE_ID is set at frontend
build time (Forgejo secret + deploy workflow), with SPA route updates
and no script in local dev. Document setup in docs/umami-analytics.md,
extend integritetspolicy, and add admin Webbstatistik link in prod builds.
Co-authored-by: Cursor <cursoragent@cursor.com>
Extract AdminOrderWorkflowService and status rules API; split AdminPage
into composables and components; share order status constants; update tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
Run admin-dashboard with other DB/Mailpit specs after parallel tests.
Stop admin-dashboard from mutating the sent seed order before fulfillment.
Wait longer for backend readiness in the E2E stack.
Co-authored-by: Cursor <cursoragent@cursor.com>
Document that ./gradlew check must pass before every commit. Add scripts
to run the same verification as CI and optionally install a git hook.
Co-authored-by: Cursor <cursoragent@cursor.com>
account-settings and password-reset called clearMailpit in parallel with
other tests, wiping emails before waitForEmailChangeToken could read them.
Co-authored-by: Cursor <cursoragent@cursor.com>
Align AdminDashboard unit test with API error messages shown in UI.
Stabilize account-settings E2E by relying on waitForEmailChangeToken only.
Co-authored-by: Cursor <cursoragent@cursor.com>
Remove the brittle filter-empty assertion from admin search helpers.
Run deferred-payment-admin in an isolated Playwright project with one
worker so serial tests keep shared order state. Stabilize unpaid-order
lookup by searching order id first, then plate text from the admin row.
Co-authored-by: Cursor <cursoragent@cursor.com>
Merge admin lookup checks into one serial test, create the plate when
the order is created, and search using the plate shown in the admin row.
Co-authored-by: Cursor <cursoragent@cursor.com>
CI intermittently failed when searching admin orders by registration
number because the table was queried before data and filters settled.
Wait for the admin list to load, clear the search field between queries,
and use a longer timeout when expecting the matching row.
Co-authored-by: Cursor <cursoragent@cursor.com>
Mobile traffic was breaking on narrow viewports because the header nav
overflowed and several pages used desktop-only spacing. This adds a
shared phone breakpoint, a hamburger menu, and scroll-to-top on route
changes so footer and menu navigation always land at the top of the page.
- Add --page-gutter and max-width 639px rules in base.css
- AppHeader: hamburger panel on small screens; flat account links on mobile
- AppFooter: stack footer links vertically on phones
- Home, compose, edit order, orders, auth, and legal pages: tighter gutters
and responsive layout (orders card actions stack; home grids single-column)
- Router scrollBehavior: scroll to top on navigation; restore on browser back
- Tests: AppHeader menu toggle, Router scrollBehavior, mobile Playwright checks
Admin page is intentionally unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the header "Byt lösenord" link with an Inställningar menu for
changing email or password. Email changes are two-step: request with
password, confirmation link to the new address, then password again on
confirm so a wrong inbox cannot take over the account.
- Backend: EmailChangeService, V10 email_change_tokens, confirm API
- Frontend: ChangeEmailPage, ConfirmEmailChangePage, header dropdown
- E2E: account-settings round-trips, Mailpit verification, wrong-password guard
- Flyway: V9 restore for dev DBs, CI migration checks, V10 for email tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
- Create TermsOfServicePage covering Swish payment, letter content rules, and liability
- Describe edit/cancel before payment and refund expectations after posting
- Link to integritetspolicy and support contact in footer CTA
- Add TermsOfServicePage unit tests
Co-authored-by: Cursor <cursoragent@cursor.com>
- Create PrivacyPolicyPage with sections on data, rights, and letter recipients
- Use plain Swedish without technical jargon or operational detail
- Link to kontakt page and mailto for privacy questions
- Add PrivacyPolicyPage unit tests
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add support@bilhej.se for orders and technical issues
- Move complaints to klagomal@bilhej.se instead of personal Gmail
- Show one mailto chip per card instead of duplicate link and button
- Update ContactPage tests and production email checklist for all @bilhej.se addresses
Co-authored-by: Cursor <cursoragent@cursor.com>
- Move the three explanatory paragraphs into the hero card under the lead
- Remove the separate "Vad vi gör" section that repeated the same framing
- Add a light divider between lead and body text for readability
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add production checklist section for Resend inbound on bilhej.se
- Note that mail is read in the Resend dashboard unless a webhook is added later
- Update GDPR letter footer example in REQUIREMENTS.md to kontakt@bilhej.se
Co-authored-by: Cursor <cursoragent@cursor.com>
- Truncate previews over 120 characters with a Visa mer toggle
- Allow per-order expand state on pending and completed cards
- Add styles for expanded preview and toggle button
- Cover long and short message behavior in OrdersPage tests
Co-authored-by: Cursor <cursoragent@cursor.com>
- Replace obsolete hej@bilhalsning.se in ComposePage GDPR footer
- Apply the same correction on EditOrderPage for edited orders
Co-authored-by: Cursor <cursoragent@cursor.com>
- Replace single placeholder mailto with two contact cards
- Show kontakt@bilhej.se for general questions and service issues
- Show jcamorling@gmail.com for complaints with clearer labeling
- Add tips section pointing users to Mina beställningar first
- Extend ContactPage tests for both email addresses
Co-authored-by: Cursor <cursoragent@cursor.com>
- Replace placeholder about card with hero, prose, steps, and CTA
- Add primary route /om-oss with redirect from legacy /om
- Update footer tagline and Om oss link to match new URL
- Extend AboutPage and AppFooter tests for new content and routing
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add structured use-case cards mapped to real letter templates
- Replace generic hero with bullets on traceability, anonymity, and timing
- Add three-step how-it-works flow with manual posting disclaimer
- Reframe trust section around convenience rather than hidden addresses
- Refresh layout with gradient heroes, icons, and consistent section styling
Co-authored-by: Cursor <cursoragent@cursor.com>
The cancel API returned 500 because ck_orders_status did not include cancelled.
Adds Flyway V9 and an E2E test for cancelling a pending order from /orders.
Co-authored-by: Cursor <cursoragent@cursor.com>
Redesigns the order list so unpaid and paid orders share a consistent
card layout, with clearer payment context and labeled metadata users
need before paying via Swish.
- Split list into Obetalda/Tidigare sections with pending orders first
- Pending cards: preview box, labeled Beställnings-ID, price row, Betala 49 kr
- Completed cards: same header/preview layout, prominent Spåra brev button
- Replace em-dash pay label and update unit/E2E selectors
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds backend endpoints and frontend edit page so pending orders can be updated or soft-cancelled without admin intervention.
Co-authored-by: Cursor <cursoragent@cursor.com>
Forgejo workflow_dispatch requires an explicit input type; without it the
UI showed invalidinputtype. Clarify README: workflow ref vs version tag.
Co-authored-by: Cursor <cursoragent@cursor.com>
The docker profile disables mail.smtp.starttls for Mailpit; prod runs
docker+prod so Resend saw AUTH before STARTTLS (538). Re-enable auth and
STARTTLS in application-prod.yml.
Co-authored-by: Cursor <cursoragent@cursor.com>
Deploy workflow now writes MAIL_* and APP_PUBLIC_BASE_URL from Actions
secrets into the server .env so Resend SMTP works after domain verify.
Document Resend-only setup, Forgejo secret names, and prod expose-token off.
Co-authored-by: Cursor <cursoragent@cursor.com>
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>
First-time host nginx setup needs HTTP-only vhost before certbot can
issue certs; the full bilhej.nginx.conf 443 block fails nginx -t until
those files exist.
- Add docker/bilhej.nginx.http.conf for ACME phase
- Reorder README one-time setup: HTTP vhost, certbot, then full config
Backend crashed on startup because the prod DB still records V6 (and
possibly V2/V4) from when seeds lived in db/migration, while prod only
loads schema migrations. ignore-migration-patterns alone did not prevent
validate failure on the runner.
- Run Flyway repair before migrate on the prod profile
- Add ProdFlywayConfigTest for repair-then-migrate order
- Document the V6 error in README deploy troubleshooting