Compare commits
13 commits
chore/pre-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a9d2fe688 | |||
|
|
d9aa2d60af | ||
|
|
00d1f48218 | ||
| 56cbc6fedf | |||
| f60af7237e | |||
| 9a63ff69e7 | |||
| d7739bcd58 | |||
|
|
4b35d8ff30 | ||
| c88fa142d3 | |||
| 81e3968e31 | |||
| 5335ba4f12 | |||
|
|
3d2db1471f | ||
|
|
da54a67d9d |
36 changed files with 1245 additions and 114 deletions
|
|
@ -1,6 +1,66 @@
|
|||
# Exclude everything that isn't strictly needed to build or run the dev images.
|
||||
# The dev Dockerfiles COPY source subpaths (frontend/, backend/, gradlew,
|
||||
# settings.gradle, etc.), so without this the image would bloat with docs,
|
||||
# scripts, git history, etc.
|
||||
|
||||
# Build artifacts and caches (mounted as named volumes at runtime)
|
||||
.gradle
|
||||
.env
|
||||
.git
|
||||
frontend/node_modules
|
||||
backend/build
|
||||
frontend/dist
|
||||
frontend/coverage
|
||||
frontend/node_modules
|
||||
backend/.gradle
|
||||
|
||||
# Test outputs
|
||||
**/build/test-results
|
||||
**/build/reports
|
||||
**/coverage
|
||||
**/.pytest_cache
|
||||
frontend/playwright-report
|
||||
frontend/test-results
|
||||
|
||||
# Local config and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/application-local.yml
|
||||
|
||||
# VCS and editor state
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.github
|
||||
.forgejo
|
||||
.idea
|
||||
.vscode
|
||||
*.iml
|
||||
.DS_Store
|
||||
|
||||
# Documentation (not needed at runtime)
|
||||
README.md
|
||||
REQUIREMENTS.md
|
||||
AGENTS.md
|
||||
CODING_GUIDELINES.md
|
||||
docs/
|
||||
|
||||
# Ops scripts (not needed at runtime)
|
||||
scripts/
|
||||
|
||||
# Test source dirs that aren't built into runtime artifacts
|
||||
frontend/src/__tests__
|
||||
backend/src/test
|
||||
|
||||
# Docker metadata — Dockerfiles, .dockerignore, and compose files are not
|
||||
# needed inside the running image. Keep docker/*.conf and docker/entrypoint.sh
|
||||
# because frontend.prod.Dockerfile copies them into the production nginx image.
|
||||
docker/*.Dockerfile
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
logs/
|
||||
tmp/
|
||||
*.bak
|
||||
*.tmp
|
||||
|
|
@ -24,6 +24,12 @@ STRIPE_WEBHOOK_SECRET=whsec_...
|
|||
STRIPE_PRICE_ID=price_...
|
||||
|
||||
# ---------- Swish (Phase 0) ----------
|
||||
# The Swish number customers pay to. Two formats accepted:
|
||||
# - Swedish phone number: 0701234567 (normalised to 46… for the payment URL)
|
||||
# - Swish Business number: 1234567890 (starts with 123, used as-is)
|
||||
# A Swish Business number (123…) is recommended — get one from your bank
|
||||
# via a "Swish Företag" agreement. No Swish Commerce API certificate needed;
|
||||
# the frontend generates a pre-filled QR code + payment link automatically.
|
||||
SWISH_NUMBER=0701234567
|
||||
|
||||
# ---------- App URL (password reset links in email) ----------
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Git tag to create for this deploy (e.g. v0.1.2) — not the branch/tag above'
|
||||
required: true
|
||||
default: 'v0.1.0'
|
||||
description: 'Leave as "auto" to bump from latest git tag, or enter a specific version (e.g. v0.1.2)'
|
||||
required: false
|
||||
default: 'auto'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
|
|
@ -21,12 +21,36 @@ jobs:
|
|||
git fetch --depth 1 origin ${GITHUB_SHA}
|
||||
git checkout FETCH_HEAD
|
||||
|
||||
- name: Resolve version
|
||||
run: |
|
||||
INPUT_VERSION="${{ github.event.inputs.version }}"
|
||||
if [ -z "$INPUT_VERSION" ] || [ "$INPUT_VERSION" = "auto" ]; then
|
||||
git fetch --tags origin
|
||||
LATEST=$(git tag --list 'v*' --sort=-v:refname | head -1)
|
||||
if [ -z "$LATEST" ]; then LATEST="v0.0.0"; fi
|
||||
BASE="${LATEST#v}"
|
||||
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||
PATCH=$(echo "$BASE" | cut -d. -f3)
|
||||
PATCH=$(( ${PATCH:-0} + 1 ))
|
||||
VERSION="v${MAJOR:-0}.${MINOR:-0}.${PATCH}"
|
||||
echo "Latest tag: $LATEST → auto-bumped to $VERSION"
|
||||
else
|
||||
VERSION="$INPUT_VERSION"
|
||||
echo "Using manual version: $VERSION"
|
||||
fi
|
||||
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "ERROR: resolved version '$VERSION' is not valid semver (expected vX.Y.Z)"
|
||||
exit 1
|
||||
fi
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Tag version
|
||||
run: |
|
||||
git tag -d ${{ github.event.inputs.version }} 2>/dev/null || true
|
||||
git push origin --delete ${{ github.event.inputs.version }} 2>/dev/null || true
|
||||
git tag ${{ github.event.inputs.version }}
|
||||
git push origin ${{ github.event.inputs.version }}
|
||||
git tag -d ${{ env.VERSION }} 2>/dev/null || true
|
||||
git push origin --delete ${{ env.VERSION }} 2>/dev/null || true
|
||||
git tag ${{ env.VERSION }}
|
||||
git push origin ${{ env.VERSION }}
|
||||
|
||||
- name: Write production .env
|
||||
env:
|
||||
|
|
@ -134,7 +158,7 @@ jobs:
|
|||
run: |
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════"
|
||||
echo " Deployed ${{ github.event.inputs.version }} to production"
|
||||
echo " Deployed ${{ env.VERSION }} to production"
|
||||
echo "═══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
docker compose -p bilhej-prod -f docker-compose.prod.yml ps
|
||||
|
|
|
|||
|
|
@ -170,11 +170,16 @@ export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PR
|
|||
./gradlew check
|
||||
```
|
||||
|
||||
This runs frontend lint, frontend unit tests (242), backend tests (163), coverage
|
||||
thresholds, Flyway checks, and **all 90 E2E tests in Docker**. **Do not commit or
|
||||
This runs frontend lint, frontend unit tests, backend tests, coverage
|
||||
thresholds, Flyway checks, and **all E2E tests in Docker**. **Do not commit or
|
||||
push if this fails.** Optional local guard: `./scripts/install-pre-commit-hook.sh`
|
||||
(runs the same `check` on every `git commit`).
|
||||
|
||||
**Note for agents:** The pre-commit hook runs the full `./gradlew check` which
|
||||
takes ~3.5 minutes. If your tool enforces a default timeout (e.g. 120 s on
|
||||
agent tool calls), increase it to ≥300 000 ms, or use `--no-verify` and run
|
||||
`./gradlew check` manually before committing.
|
||||
|
||||
### Frontend (Vue.js 3)
|
||||
- `<script setup>` with Composition API only. Never Options API.
|
||||
- File naming: PascalCase for pages/components, camelCase (`useXxx`) for composables.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
package se.bilhalsning.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
|
|
@ -10,6 +14,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import se.bilhalsning.dto.ErrorResponse;
|
||||
import se.bilhalsning.security.JwtAuthenticationFilter;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
|
||||
|
|
@ -17,6 +22,13 @@ import se.bilhalsning.security.JwtService;
|
|||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
static final String UNAUTHENTICATED_MESSAGE =
|
||||
"Din session har löpt ut eller är ogiltig. Logga in igen.";
|
||||
static final String FORBIDDEN_MESSAGE =
|
||||
"Du har inte behörighet att utföra denna åtgärd.";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
|
|
@ -46,8 +58,21 @@ public class SecurityConfig {
|
|||
.requestMatchers("/api/vehicles/**").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(eh -> eh
|
||||
.authenticationEntryPoint((request, response, ex) ->
|
||||
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
|
||||
.accessDeniedHandler((request, response, ex) ->
|
||||
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private void writeError(HttpServletResponse response, HttpStatus status, String message)
|
||||
throws java.io.IOException {
|
||||
response.setStatus(status.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(message)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ public class JwtService {
|
|||
this(secret, DEFAULT_EXPIRATION_MS);
|
||||
}
|
||||
|
||||
JwtService(String secret, long expirationMs) {
|
||||
public JwtService(String secret, long expirationMs) {
|
||||
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||
this.expirationMs = expirationMs;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,16 +42,18 @@ class AdminControllerTest {
|
|||
private AdminOrderWorkflowService adminOrderWorkflowService;
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/orders"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se", roles = "USER")
|
||||
void shouldReturn403ForNonAdminUser() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/orders"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -225,7 +225,8 @@ class AuthControllerTest {
|
|||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -263,6 +264,7 @@ class AuthControllerTest {
|
|||
mockMvc.perform(post("/api/auth/change-email")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,16 +18,22 @@ import org.springframework.boot.test.context.SpringBootTest;
|
|||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import se.bilhalsning.dto.OrderResponse;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.security.JwtService;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
|
||||
class OrderControllerTest {
|
||||
|
||||
private static final String TEST_SECRET =
|
||||
"this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
|
|
@ -38,9 +44,10 @@ class OrderControllerTest {
|
|||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -100,11 +107,31 @@ class OrderControllerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenPostingWithoutAuth() throws Exception {
|
||||
void shouldReturn401WhenPostingWithoutAuth() throws Exception {
|
||||
mockMvc.perform(post("/api/orders")
|
||||
.contentType("application/json")
|
||||
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401WithSwedishMessageWhenTokenExpired() throws Exception {
|
||||
JwtService expiredJwtService = new JwtService(TEST_SECRET, -1000L);
|
||||
String expiredToken = expiredJwtService.generateToken("test@bilhej.se");
|
||||
|
||||
mockMvc.perform(get("/api/orders")
|
||||
.header("Authorization", "Bearer " + expiredToken))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401WithMessageWhenNoAuthHeader() throws Exception {
|
||||
mockMvc.perform(get("/api/orders"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message")
|
||||
.value(org.hamcrest.Matchers.containsString("session")));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -39,10 +39,11 @@ class PaymentControllerTest {
|
|||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
void shouldReturn401WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/payment/{orderId}/pay",
|
||||
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.message").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
106
docker-compose.dev-bindless.yml
Normal file
106
docker-compose.dev-bindless.yml
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Bindless dev stack — standalone variant of docker-compose.yml.
|
||||
#
|
||||
# Why this exists as a standalone file (not an override):
|
||||
# Docker Compose merges `volumes:` by list concatenation, not by entry
|
||||
# replacement, so an override can't drop the bind mounts from the base file —
|
||||
# only append to them. A standalone file lets us redefine services with only
|
||||
# the volumes we want.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.dev-bindless.yml up -d --build
|
||||
#
|
||||
# Use this when the Docker daemon can't bind-mount the host repo correctly:
|
||||
# - Docker-in-Docker setups (e.g. this Hermes sandbox)
|
||||
# - rootless Docker with restricted mount paths
|
||||
# - Some CI runners
|
||||
#
|
||||
# For normal local dev, use docker-compose.yml — it bind-mounts the repo for
|
||||
# Vite HMR and gradle bootRun hot reload.
|
||||
#
|
||||
# Trade-off vs. the bind-mounted dev compose:
|
||||
# - The image is "frozen" at build time. Editing source on the host does not
|
||||
# affect the running container. Edit + rebuild + restart, or run
|
||||
# `docker compose up -d --build` after changes.
|
||||
# - All source lives inside the image (docker/backend.Dockerfile and
|
||||
# docker/frontend.Dockerfile COPY it in at build time).
|
||||
#
|
||||
# What you still get:
|
||||
# - Gradle caches in named volumes (.gradle, backend/build, gradle-cache)
|
||||
# so dependency downloads persist between `up` cycles.
|
||||
# - Postgres data persists across `down` (via the pgdata volume).
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: bilhej-postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
mailpit:
|
||||
image: ghcr.io/axllent/mailpit:v1.28
|
||||
container_name: bilhej-mailpit
|
||||
ports:
|
||||
- "1025:1025"
|
||||
- "8025:8025"
|
||||
|
||||
backend:
|
||||
image: bilhej-backend-dev
|
||||
build:
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
context: .
|
||||
container_name: bilhej-backend
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: docker
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SWISH_NUMBER: ${SWISH_NUMBER}
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}
|
||||
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost:3000}
|
||||
MAIL_HOST: mailpit
|
||||
MAIL_PORT: "1025"
|
||||
MAIL_USERNAME: ""
|
||||
MAIL_PASSWORD: ""
|
||||
MAIL_FROM: ${MAIL_FROM:-noreply@bilhej.se}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
mailpit:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- backend-gradle-project:/app/.gradle
|
||||
- backend-build:/app/backend/build
|
||||
- gradle-cache:/root/.gradle
|
||||
|
||||
frontend:
|
||||
image: bilhej-frontend-dev
|
||||
build:
|
||||
dockerfile: docker/frontend.Dockerfile
|
||||
context: .
|
||||
container_name: bilhej-frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
gradle-cache:
|
||||
backend-gradle-project:
|
||||
backend-build:
|
||||
|
|
@ -1,3 +1,15 @@
|
|||
FROM eclipse-temurin:21-jdk
|
||||
WORKDIR /app
|
||||
|
||||
# Copy build configuration and wrapper first so this layer caches well.
|
||||
COPY gradlew settings.gradle build.gradle ./
|
||||
COPY gradle/ gradle/
|
||||
RUN chmod +x gradlew
|
||||
|
||||
# Copy backend module. The dev compose overlays this with a host bind mount
|
||||
# for live source changes; if the bind mount is absent (DinD, CI, k8s) the
|
||||
# image is still self-contained and `gradlew :backend:bootRun` will work.
|
||||
COPY backend/ backend/
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["./gradlew", ":backend:bootRun", "--no-daemon"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first so this layer caches independently of source changes.
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the frontend. The dev compose overlays individual paths
|
||||
# (./frontend/src, ./frontend/public, ./frontend/index.html) with host bind
|
||||
# mounts for live reload; if those bind mounts are absent (DinD, CI, k8s)
|
||||
# the image is still self-contained and `npm run dev` will serve from the
|
||||
# COPY'd files.
|
||||
COPY frontend/ .
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
|
|
|
|||
|
|
@ -70,6 +70,13 @@ test.describe('Auth guards', () => {
|
|||
})
|
||||
|
||||
test('allows admin user to access /admin', async ({ page }) => {
|
||||
await page.route('**/api/admin/orders', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '[]',
|
||||
}),
|
||||
)
|
||||
const jwt = makeJwt({ role: 'admin' })
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
|
|
|
|||
55
frontend/e2e/expired-token.spec.ts
Normal file
55
frontend/e2e/expired-token.spec.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Expired token logout', () => {
|
||||
test('router guard redirects expired token to login and logs out', async ({
|
||||
page,
|
||||
}) => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: past })
|
||||
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
|
||||
await page.goto('/orders')
|
||||
|
||||
await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/)
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(header.getByRole('link', { name: 'Logga in' })).toBeVisible()
|
||||
await expect(
|
||||
header.getByRole('button', { name: 'Logga ut' }),
|
||||
).not.toBeVisible()
|
||||
|
||||
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
|
||||
expect(stored).toBeNull()
|
||||
})
|
||||
|
||||
test('API 401 logs out and redirects when guard accepts token but backend rejects it', async ({
|
||||
page,
|
||||
}) => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user', exp: future })
|
||||
|
||||
await page.goto('/')
|
||||
await page.evaluate((token) => localStorage.setItem('auth_token', token), jwt)
|
||||
|
||||
await page.goto('/orders')
|
||||
await page.waitForURL(/\/logga-in\?redirect=\/orders/)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible()
|
||||
|
||||
const header = page.locator('header')
|
||||
await expect(header.getByRole('button', { name: 'Logga ut' })).not.toBeVisible()
|
||||
|
||||
const stored = await page.evaluate(() => localStorage.getItem('auth_token'))
|
||||
expect(stored).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
const signature = 'test-sig'
|
||||
return `${header}.${body}.${signature}`
|
||||
}
|
||||
|
|
@ -100,6 +100,13 @@ test.describe('Header auth state', () => {
|
|||
})
|
||||
|
||||
test('logout redirects to home page', async ({ page }) => {
|
||||
await page.route('**/api/orders', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '[]',
|
||||
}),
|
||||
)
|
||||
const jwt = makeJwt({ sub: 'test@bilhej.se', role: 'user' })
|
||||
await page.goto('/orders')
|
||||
await page.evaluate(
|
||||
|
|
|
|||
|
|
@ -49,6 +49,31 @@ test.describe('Payment redirect', () => {
|
|||
|
||||
await page.waitForURL(/\/betalning\//)
|
||||
await expect(page.getByText('Swisha till')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Jag har betalat' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows QR code for desktop scanning', async ({ page }) => {
|
||||
await page.goto('/compose?plate=QRA222')
|
||||
await page.getByLabel('Ditt meddelande').fill('Fin bil!')
|
||||
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
|
||||
|
||||
await page.waitForURL(/\/betalning\//)
|
||||
await expect(page.getByRole('img', { name: 'Swish QR-kod' })).toBeVisible()
|
||||
await expect(page.getByText('Skanna QR-koden')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows Swish payment link with pre-filled data', async ({ page }) => {
|
||||
await page.goto('/compose?plate=MNO345')
|
||||
await page.getByLabel('Ditt meddelande').fill('Hej där!')
|
||||
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
|
||||
|
||||
await page.waitForURL(/\/betalning\//)
|
||||
const swishLink = page.getByRole('link', { name: 'Betala med Swish' })
|
||||
await expect(swishLink).toBeVisible()
|
||||
const href = await swishLink.getAttribute('href')
|
||||
expect(href).toContain('app.swish.nu')
|
||||
expect(href).toContain('amt=49.00')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
356
frontend/package-lock.json
generated
356
frontend/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
|
|
@ -16,6 +17,7 @@
|
|||
"@playwright/test": "^1.60.0",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
|
|
@ -791,9 +793,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -811,9 +810,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -831,9 +827,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -851,9 +844,6 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -871,9 +861,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -891,9 +878,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1054,6 +1038,16 @@
|
|||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
|
||||
|
|
@ -1962,6 +1956,15 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
|
|
@ -1987,11 +1990,91 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
|
|
@ -2004,7 +2087,6 @@
|
|||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
|
|
@ -2136,6 +2218,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
|
|
@ -2160,6 +2251,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
|
|
@ -2718,6 +2815,15 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
|
|
@ -2863,7 +2969,6 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -3285,9 +3390,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3309,9 +3411,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3333,9 +3432,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3357,9 +3453,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3743,6 +3836,15 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
|
|
@ -3787,7 +3889,6 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -3936,6 +4037,15 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.13",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||
|
|
@ -4034,6 +4144,23 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
|
|
@ -4084,6 +4211,15 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
|
|
@ -4094,6 +4230,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
|
|
@ -4208,6 +4350,12 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
|
@ -5094,6 +5242,12 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
|
@ -5236,6 +5390,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
|
|
@ -5251,6 +5411,134 @@
|
|||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
"@playwright/test": "^1.60.0",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,26 @@ import { createRouter, createMemoryHistory } from 'vue-router'
|
|||
import { createPinia } from 'pinia'
|
||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||
|
||||
const sessionMocks = vi.hoisted(() => {
|
||||
const mockLogout = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
return {
|
||||
mockLogout,
|
||||
mockPush,
|
||||
mockAuth: { isAuthenticated: true, logout: mockLogout },
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => sessionMocks.mockAuth,
|
||||
}))
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
currentRoute: { value: { fullPath: '/orders' } },
|
||||
push: sessionMocks.mockPush,
|
||||
},
|
||||
}))
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
|
|
@ -376,3 +396,34 @@ describe('OrdersPage', () => {
|
|||
expect(wrapper.find('.orders__preview-toggle').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OrdersPage — expired session (401)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
sessionMocks.mockLogout.mockClear()
|
||||
sessionMocks.mockPush.mockClear()
|
||||
sessionMocks.mockAuth.isAuthenticated = true
|
||||
})
|
||||
|
||||
it('does not show generic error and triggers global logout/redirect on 401', async () => {
|
||||
vi.mocked(globalThis.fetch).mockImplementation((url) => {
|
||||
const urlStr = String(url)
|
||||
if (urlStr.includes('/payment/swish-info')) {
|
||||
return mockFetchResponse(200, { number: '123', amount: 49 })
|
||||
}
|
||||
return mockFetchResponse(401, { message: 'Din session har löpt ut.' })
|
||||
})
|
||||
localStorage.setItem('auth_token', 'expired-token')
|
||||
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(wrapper.text()).not.toContain('Kunde inte hämta beställningar')
|
||||
expect(sessionMocks.mockLogout).toHaveBeenCalledTimes(1)
|
||||
expect(sessionMocks.mockPush).toHaveBeenCalledWith({
|
||||
name: 'login',
|
||||
query: { redirect: '/orders' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,14 +5,26 @@ import { createRouter, createMemoryHistory } from 'vue-router'
|
|||
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,mock-qr'),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/payment', () => ({
|
||||
payOrder: vi.fn(),
|
||||
fetchSwishInfo: vi.fn(),
|
||||
buildSwishPaymentUrl: vi.fn(
|
||||
(number: string, amount: number, message: string) =>
|
||||
`https://app.swish.nu/1/p/sw/?sw=${number}&amt=${amount.toFixed(2)}&msg=${message}`,
|
||||
),
|
||||
}))
|
||||
|
||||
import { payOrder, fetchSwishInfo } from '@/api/payment'
|
||||
import QRCode from 'qrcode'
|
||||
const mockPayOrder = vi.mocked(payOrder)
|
||||
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
|
||||
const mockToDataURL = vi.mocked(QRCode.toDataURL)
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
|
|
@ -59,6 +71,7 @@ describe('PaymentRedirect', () => {
|
|||
number: '0701234567',
|
||||
amount: 49,
|
||||
})
|
||||
mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr')
|
||||
})
|
||||
|
||||
it('renders heading and amount', async () => {
|
||||
|
|
@ -81,7 +94,7 @@ describe('PaymentRedirect', () => {
|
|||
expect(wrapper.text()).toContain('Beställnings-ID')
|
||||
expect(wrapper.text()).toContain(orderId)
|
||||
expect(wrapper.text()).toContain(
|
||||
'Ange beställnings-ID ovan som meddelande i Swish-appen',
|
||||
'fylls i automatiskt via QR-kod eller länk',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -93,13 +106,30 @@ describe('PaymentRedirect', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('renders QR code after fetching swish info', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
|
||||
})
|
||||
expect(mockToDataURL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders a Swish payment link', async () => {
|
||||
const { wrapper } = await mountPage('test-order', 'ABC123')
|
||||
await vi.waitFor(() => {
|
||||
const link = wrapper.find('.payment__swish-link')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.attributes('href')).toContain('app.swish.nu')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation dialog after clicking pay button', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
|
||||
expect(wrapper.text()).toContain('0701234567')
|
||||
|
|
@ -110,15 +140,15 @@ describe('PaymentRedirect', () => {
|
|||
it('can cancel confirmation dialog', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Avbryt')
|
||||
})
|
||||
|
||||
await wrapper.find('.btn--ghost').trigger('click')
|
||||
await wrapper.find('.payment__confirm-cancel').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Swisha till')
|
||||
expect(wrapper.text()).not.toContain('Avbryt')
|
||||
|
|
@ -137,16 +167,15 @@ describe('PaymentRedirect', () => {
|
|||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
||||
})
|
||||
|
||||
const confirmButtons = wrapper.findAll('.btn--primary')
|
||||
await confirmButtons[confirmButtons.length - 1].trigger('click')
|
||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||
|
||||
expect(mockPayOrder).toHaveBeenCalledWith('order-1')
|
||||
})
|
||||
|
|
@ -156,16 +185,15 @@ describe('PaymentRedirect', () => {
|
|||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
||||
})
|
||||
|
||||
const confirmButtons = wrapper.findAll('.btn--primary')
|
||||
await confirmButtons[confirmButtons.length - 1].trigger('click')
|
||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen')
|
||||
|
|
@ -184,16 +212,15 @@ describe('PaymentRedirect', () => {
|
|||
|
||||
const { wrapper, router } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('.btn--primary').exists()).toBe(true)
|
||||
expect(wrapper.find('.payment__submit').exists()).toBe(true)
|
||||
})
|
||||
|
||||
await wrapper.find('.btn--primary').trigger('click')
|
||||
await wrapper.find('.payment__submit').trigger('click')
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Ja, jag har betalat')
|
||||
})
|
||||
|
||||
const confirmButtons = wrapper.findAll('.btn--primary')
|
||||
await confirmButtons[confirmButtons.length - 1].trigger('click')
|
||||
await wrapper.find('.payment__confirm .btn--primary').trigger('click')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(router.currentRoute.value.name).toBe('orders')
|
||||
|
|
|
|||
|
|
@ -214,6 +214,64 @@ describe('Router guards', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Router guards — expired tokens', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('redirects expired-token user from /orders to /logga-in with redirect query', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/orders')
|
||||
})
|
||||
|
||||
it('clears the expired token from localStorage on redirect', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows access with a token whose exp is in the future', async () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
|
||||
|
||||
await router.push('/orders')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('orders')
|
||||
})
|
||||
|
||||
it('lets expired-token user open /logga-in instead of bouncing to home', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/logga-in')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
it('lets expired-token user open /registrera instead of bouncing to home', async () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
|
||||
await router.push('/registrera')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('register')
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
|
|
|
|||
|
|
@ -212,6 +212,52 @@ describe('authStore', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('authStore.isTokenExpired', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns true when there is no token', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.isTokenExpired()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for a token with an expired exp claim', () => {
|
||||
const past = Math.floor(Date.now() / 1000) - 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: past }))
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a token with a future exp claim', () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a token without an exp claim', () => {
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user' }))
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true after logout clears the token', async () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600
|
||||
localStorage.setItem('auth_token', makeJwt({ role: 'user', exp: future }))
|
||||
const store = useAuthStore()
|
||||
expect(store.isTokenExpired()).toBe(false)
|
||||
|
||||
store.logout()
|
||||
|
||||
expect(store.isTokenExpired()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
|
|
|
|||
125
frontend/src/__tests__/client.spec.ts
Normal file
125
frontend/src/__tests__/client.spec.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const mockLogout = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
return {
|
||||
mockLogout,
|
||||
mockPush,
|
||||
mockAuth: { isAuthenticated: true, logout: mockLogout },
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => mocks.mockAuth,
|
||||
}))
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
currentRoute: { value: { fullPath: '/orders' } },
|
||||
push: mocks.mockPush,
|
||||
},
|
||||
}))
|
||||
|
||||
import { request, ApiError, isSessionExpired, isForbidden } from '@/api/client'
|
||||
|
||||
function mockFetchResponse(status: number, body: unknown) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('api client', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
mocks.mockLogout.mockClear()
|
||||
mocks.mockPush.mockClear()
|
||||
mocks.mockAuth.isAuthenticated = true
|
||||
})
|
||||
|
||||
it('logs out and redirects to login on 401 from a protected endpoint', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
|
||||
)
|
||||
localStorage.setItem('auth_token', 'expired-token')
|
||||
|
||||
await expect(request('/orders')).rejects.toThrow('Din session har löpt ut.')
|
||||
|
||||
expect(mocks.mockLogout).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.mockPush).toHaveBeenCalledWith({
|
||||
name: 'login',
|
||||
query: { redirect: '/orders' },
|
||||
})
|
||||
})
|
||||
|
||||
it('still throws ApiError with 401 status after handling expired session', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
|
||||
)
|
||||
|
||||
try {
|
||||
await request('/orders')
|
||||
throw new Error('should have thrown')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect((err as ApiError).status).toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not log out on 401 from an auth endpoint (wrong credentials)', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Felaktig e-post eller lösenord' }),
|
||||
)
|
||||
|
||||
await expect(request('/auth/login', { method: 'POST' })).rejects.toThrow(
|
||||
'Felaktig e-post eller lösenord',
|
||||
)
|
||||
|
||||
expect(mocks.mockLogout).not.toHaveBeenCalled()
|
||||
expect(mocks.mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not log out on 403 (forbidden is not session expiry)', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(403, { message: 'Du har inte behörighet' }),
|
||||
)
|
||||
localStorage.setItem('auth_token', 'valid-token')
|
||||
|
||||
await expect(request('/admin/orders')).rejects.toThrow(
|
||||
'Du har inte behörighet',
|
||||
)
|
||||
|
||||
expect(mocks.mockLogout).not.toHaveBeenCalled()
|
||||
expect(mocks.mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not redirect when there is no token on 401', async () => {
|
||||
mocks.mockAuth.isAuthenticated = false
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(401, { message: 'Din session har löpt ut.' }),
|
||||
)
|
||||
|
||||
await expect(request('/orders')).rejects.toThrow()
|
||||
|
||||
expect(mocks.mockLogout).not.toHaveBeenCalled()
|
||||
expect(mocks.mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isSessionExpired returns true only for a 401 ApiError', () => {
|
||||
expect(isSessionExpired(new ApiError(401, 'x'))).toBe(true)
|
||||
expect(isSessionExpired(new ApiError(403, 'x'))).toBe(false)
|
||||
expect(isSessionExpired(new ApiError(500, 'x'))).toBe(false)
|
||||
expect(isSessionExpired(new Error('x'))).toBe(false)
|
||||
expect(isSessionExpired(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('isForbidden returns true only for a 403 ApiError', () => {
|
||||
expect(isForbidden(new ApiError(403, 'x'))).toBe(true)
|
||||
expect(isForbidden(new ApiError(401, 'x'))).toBe(false)
|
||||
expect(isForbidden(new Error('x'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
import { useAuthStore } from '@/stores/authStore'
|
||||
import router from '@/router'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
|
@ -10,10 +13,27 @@ export class ApiError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export function isSessionExpired(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.status === 401
|
||||
}
|
||||
|
||||
export function isForbidden(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.status === 403
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('auth_token')
|
||||
}
|
||||
|
||||
function handleExpiredSession(): void {
|
||||
const auth = useAuthStore()
|
||||
if (auth.isAuthenticated) {
|
||||
auth.logout()
|
||||
const redirect = router.currentRoute.value.fullPath
|
||||
router.push({ name: 'login', query: { redirect } })
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
|
|
@ -34,6 +54,9 @@ export async function request<T>(
|
|||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && !url.startsWith('/auth/')) {
|
||||
handleExpiredSession()
|
||||
}
|
||||
const body = await response.json().catch(() => ({}))
|
||||
throw new ApiError(response.status, body.message || 'Något gick fel')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,3 +15,44 @@ export function payOrder(orderId: string): Promise<Order> {
|
|||
export function fetchSwishInfo(): Promise<SwishInfo> {
|
||||
return request<SwishInfo>('/payment/swish-info')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a pre-filled Swish payment URL.
|
||||
*
|
||||
* On mobile, tapping this URL opens the Swish app with the amount and
|
||||
* message pre-filled. On desktop, embed it in a QR code for the user
|
||||
* to scan with their phone.
|
||||
*
|
||||
* Uses the Swish "C2B pre-fill" URL scheme documented at
|
||||
* https://developer.swish.nu — no Swish Commerce API certificate required.
|
||||
* The `sw` parameter accepts either a phone number or a Swish Business
|
||||
* number (123…). Phone numbers in Swedish national format (leading 0)
|
||||
* are normalised to international format (46…).
|
||||
*/
|
||||
export function buildSwishPaymentUrl(
|
||||
swishNumber: string,
|
||||
amount: number,
|
||||
message: string,
|
||||
): string {
|
||||
const payee = normalizeSwishNumber(swishNumber)
|
||||
const params = new URLSearchParams({
|
||||
sw: payee,
|
||||
amt: amount.toFixed(2),
|
||||
msg: message,
|
||||
})
|
||||
return `https://app.swish.nu/1/p/sw/?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a Swish number to the format the Swish URL expects.
|
||||
* - 123… (Swish Business number) → unchanged
|
||||
* - 46… (already international) → unchanged
|
||||
* - 0… (Swedish national format) → 46 + rest without leading 0
|
||||
*/
|
||||
function normalizeSwishNumber(number: string): string {
|
||||
const trimmed = number.replace(/\s/g, '')
|
||||
if (trimmed.startsWith('123')) return trimmed
|
||||
if (trimmed.startsWith('46')) return trimmed
|
||||
if (trimmed.startsWith('0')) return '46' + trimmed.slice(1)
|
||||
return trimmed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ref, reactive, type Ref } from 'vue'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { ApiError, isSessionExpired } from '@/api/client'
|
||||
import {
|
||||
updateOrderStatus,
|
||||
registerShipment,
|
||||
|
|
@ -69,10 +69,12 @@ export function useAdminOrderActions(
|
|||
replaceOrder(updated)
|
||||
} catch (err) {
|
||||
order.status = previousStatus
|
||||
statusError.value =
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: 'Kunde inte uppdatera status. Försök igen.'
|
||||
if (!isSessionExpired(err)) {
|
||||
statusError.value =
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: 'Kunde inte uppdatera status. Försök igen.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,11 +102,13 @@ export function useAdminOrderActions(
|
|||
)
|
||||
replaceOrder(updated)
|
||||
trackingInputValues[orderId] = updated.trackingId ?? trackingInput
|
||||
} catch {
|
||||
} catch (err) {
|
||||
order.status = previousStatus
|
||||
order.trackingId = previousTrackingId
|
||||
trackingError.value =
|
||||
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.'
|
||||
if (!isSessionExpired(err)) {
|
||||
trackingError.value =
|
||||
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.'
|
||||
}
|
||||
} finally {
|
||||
registeringId.value = null
|
||||
}
|
||||
|
|
@ -123,10 +127,12 @@ export function useAdminOrderActions(
|
|||
const updated = await updateAdminNotes(orderId, notes)
|
||||
replaceOrder(updated)
|
||||
adminNotesValues[orderId] = updated.adminNotes ?? ''
|
||||
} catch {
|
||||
} catch (err) {
|
||||
order.adminNotes = previousNotes
|
||||
adminNotesValues[orderId] = previousNotes ?? ''
|
||||
notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
|
||||
if (!isSessionExpired(err)) {
|
||||
notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
|
||||
}
|
||||
} finally {
|
||||
savingNotesId.value = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { fetchAllOrders, type AdminOrder } from '@/api/admin'
|
||||
import { isSessionExpired } from '@/api/client'
|
||||
import { PAID_GROUP_STATUSES } from '@/constants/orderStatus'
|
||||
|
||||
export type AdminOrderFilter =
|
||||
|
|
@ -61,8 +62,10 @@ export function useAdminOrders() {
|
|||
error.value = ''
|
||||
try {
|
||||
orders.value = await fetchAllOrders()
|
||||
} catch {
|
||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ onUnmounted(() => {
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
.admin {
|
||||
max-width: 72rem;
|
||||
margin: var(--space-2xl) auto 0;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { createOrder } from '@/api/orders'
|
||||
import { isSessionExpired } from '@/api/client'
|
||||
import { type LetterTemplate } from '@/data/templates'
|
||||
import TemplatePicker from '@/components/TemplatePicker.vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
|
@ -41,8 +42,10 @@ async function handleSubmit() {
|
|||
params: { orderId: order.id },
|
||||
query: { plate: plate.value },
|
||||
})
|
||||
} catch {
|
||||
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { fetchOrder, updateOrder, type Order } from '@/api/orders'
|
||||
import { isSessionExpired } from '@/api/client'
|
||||
import { type LetterTemplate } from '@/data/templates'
|
||||
import TemplatePicker from '@/components/TemplatePicker.vue'
|
||||
|
||||
|
|
@ -44,8 +45,10 @@ async function loadOrder() {
|
|||
if (fetched.status === 'pending_payment') {
|
||||
letterText.value = fetched.letterText
|
||||
}
|
||||
} catch {
|
||||
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.'
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
|
@ -64,8 +67,10 @@ async function handleSubmit() {
|
|||
params: { orderId: order.value.id },
|
||||
query: { plate: order.value.plate },
|
||||
})
|
||||
} catch {
|
||||
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.'
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { ref, computed, onMounted } from 'vue'
|
||||
import { fetchOrders, cancelOrder, type Order } from '@/api/orders'
|
||||
import { fetchSwishInfo } from '@/api/payment'
|
||||
import { isSessionExpired } from '@/api/client'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import {
|
||||
ORDER_STATUS_BADGE,
|
||||
|
|
@ -65,8 +66,10 @@ async function loadOrders() {
|
|||
])
|
||||
orders.value = fetchedOrders
|
||||
orderAmount.value = swishInfo.amount
|
||||
} catch {
|
||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
|
@ -87,8 +90,11 @@ async function handleCancel(order: Order) {
|
|||
try {
|
||||
const updated = await cancelOrder(order.id)
|
||||
orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o))
|
||||
} catch {
|
||||
actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.'
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
actionError.value =
|
||||
'Kunde inte avbryta beställningen. Försök igen senare.'
|
||||
}
|
||||
} finally {
|
||||
cancellingId.value = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { payOrder, fetchSwishInfo } from '@/api/payment'
|
||||
import QRCode from 'qrcode'
|
||||
import { payOrder, fetchSwishInfo, buildSwishPaymentUrl } from '@/api/payment'
|
||||
import { isSessionExpired } from '@/api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
|
@ -12,12 +14,27 @@ const swishAmount = ref(49)
|
|||
const paying = ref(false)
|
||||
const error = ref('')
|
||||
const showConfirmation = ref(false)
|
||||
const qrDataUrl = ref('')
|
||||
|
||||
const swishPaymentUrl = computed(() =>
|
||||
swishNumber.value
|
||||
? buildSwishPaymentUrl(swishNumber.value, swishAmount.value, orderId)
|
||||
: '',
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const info = await fetchSwishInfo()
|
||||
swishNumber.value = info.number
|
||||
swishAmount.value = info.amount
|
||||
|
||||
if (swishPaymentUrl.value) {
|
||||
qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, {
|
||||
width: 224,
|
||||
margin: 2,
|
||||
color: { dark: '#111827', light: '#ffffff' },
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
|
||||
}
|
||||
|
|
@ -38,8 +55,10 @@ async function confirmPayment() {
|
|||
try {
|
||||
await payOrder(orderId)
|
||||
await router.push({ name: 'orders' })
|
||||
} catch {
|
||||
error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
|
||||
} catch (err) {
|
||||
if (!isSessionExpired(err)) {
|
||||
error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
|
||||
}
|
||||
} finally {
|
||||
paying.value = false
|
||||
}
|
||||
|
|
@ -75,21 +94,37 @@ async function confirmPayment() {
|
|||
</div>
|
||||
|
||||
<template v-if="!showConfirmation">
|
||||
<!-- QR code — scan with the Swish app (desktop users) -->
|
||||
<div v-if="qrDataUrl" class="payment__qr">
|
||||
<img :src="qrDataUrl" alt="Swish QR-kod" class="payment__qr-img" />
|
||||
<p class="payment__qr-hint">
|
||||
Skanna QR-koden med Swish-appen för att betala
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Direct link — opens the Swish app (mobile users) -->
|
||||
<a
|
||||
v-if="swishPaymentUrl"
|
||||
:href="swishPaymentUrl"
|
||||
class="btn btn--primary btn--lg payment__swish-link"
|
||||
>
|
||||
Betala med Swish
|
||||
</a>
|
||||
|
||||
<!-- Manual fallback -->
|
||||
<div class="payment__swish">
|
||||
<p class="payment__swish-label">Swisha till</p>
|
||||
<p class="payment__swish-number">{{ swishNumber }}</p>
|
||||
<p class="payment__swish-instruction">
|
||||
Ange beställnings-ID ovan som meddelande i Swish-appen.
|
||||
Belopp och beställnings-ID fylls i automatiskt via QR-kod eller
|
||||
länk.
|
||||
</p>
|
||||
<p class="payment__swish-instruction">
|
||||
Tryck sedan på knappen nedan för att bekräfta.
|
||||
Betala manuellt om du inte har Swish-appen tillgänglig.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn--primary btn--lg payment__submit"
|
||||
@click="startPayment"
|
||||
>
|
||||
<button class="btn btn--ghost payment__submit" @click="startPayment">
|
||||
Jag har betalat
|
||||
</button>
|
||||
</template>
|
||||
|
|
@ -198,6 +233,31 @@ async function confirmPayment() {
|
|||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.payment__qr {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.payment__qr-img {
|
||||
width: 224px;
|
||||
height: 224px;
|
||||
border-radius: var(--radius-md);
|
||||
margin: 0 auto var(--space-sm);
|
||||
}
|
||||
|
||||
.payment__qr-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.payment__swish-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.payment__swish {
|
||||
background: var(--color-border-light);
|
||||
border: 1px solid var(--color-border);
|
||||
|
|
|
|||
|
|
@ -148,9 +148,11 @@ router.beforeEach((to) => {
|
|||
if (!getActivePinia()) return
|
||||
|
||||
const auth = useAuthStore()
|
||||
const authenticated = auth.isAuthenticated && !auth.isTokenExpired()
|
||||
|
||||
if (to.meta.guestOnly && auth.isAuthenticated) return { name: 'home' }
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
if (to.meta.guestOnly && authenticated) return { name: 'home' }
|
||||
if (to.meta.requiresAuth && !authenticated) {
|
||||
if (auth.isAuthenticated) auth.logout()
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'home' }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { register, login, changeEmail, confirmEmailChange } from '@/api/auth'
|
||||
import { parseJwtPayload } from '@/utils/jwt'
|
||||
import { parseJwtPayload, isTokenExpired as isJwtExpired } from '@/utils/jwt'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('auth_token'))
|
||||
|
|
@ -21,6 +21,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
return payload.role ?? null
|
||||
}
|
||||
|
||||
function isTokenExpired(): boolean {
|
||||
return isJwtExpired(token.value)
|
||||
}
|
||||
|
||||
function setToken(newToken: string) {
|
||||
token.value = newToken
|
||||
role.value = extractRole(newToken)
|
||||
|
|
@ -69,6 +73,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
email,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
isTokenExpired,
|
||||
registerUser,
|
||||
loginUser,
|
||||
changeUserEmail,
|
||||
|
|
|
|||
|
|
@ -20,3 +20,10 @@ export function parseJwtPayload(token: string): JwtPayload {
|
|||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function isTokenExpired(token: string | null): boolean {
|
||||
if (!token) return true
|
||||
const payload = parseJwtPayload(token)
|
||||
if (payload.exp === undefined || payload.exp === null) return false
|
||||
return payload.exp < Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue