Compare commits
2 commits
fa7e48fe02
...
aa2cb7c4a0
| Author | SHA1 | Date | |
|---|---|---|---|
| aa2cb7c4a0 | |||
| 737bc3dc64 |
13 changed files with 296 additions and 2 deletions
|
|
@ -43,3 +43,10 @@ APP_PUBLIC_BASE_URL=http://localhost:3000
|
|||
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
|
||||
ADMIN_EMAIL=admin@bilhej.se
|
||||
ADMIN_PASSWORD=change_me_to_a_strong_password
|
||||
|
||||
# ---------- Umami analytics (production frontend build only) ----------
|
||||
# Baked into the frontend image at build time. Leave unset for local dev / docker compose up.
|
||||
# Website ID from https://analytics.bilhej.se → Settings → Websites → BilHej
|
||||
# See docs/umami-analytics.md
|
||||
# VITE_UMAMI_WEBSITE_ID=
|
||||
# VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ jobs:
|
|||
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
|
||||
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
|
||||
MAIL_FROM: ${{ secrets.MAIL_FROM }}
|
||||
VITE_UMAMI_WEBSITE_ID: ${{ secrets.VITE_UMAMI_WEBSITE_ID }}
|
||||
run: |
|
||||
# Docker Compose treats $ as variable interpolation in .env files.
|
||||
# Escape literal dollar signs (e.g. in passwords) as $$.
|
||||
|
|
@ -67,6 +68,7 @@ jobs:
|
|||
printf 'MAIL_USERNAME=%s\n' "$(escape "$MAIL_USERNAME")"
|
||||
printf 'MAIL_PASSWORD=%s\n' "$(escape "$MAIL_PASSWORD")"
|
||||
printf 'MAIL_FROM=%s\n' "$(escape "${MAIL_FROM:-noreply@bilhej.se}")"
|
||||
printf 'VITE_UMAMI_WEBSITE_ID=%s\n' "$(escape "$VITE_UMAMI_WEBSITE_ID")"
|
||||
} > .env
|
||||
|
||||
- name: Build and start production stack
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@ Before the first deploy, complete these steps on the production server (`srvr.nu
|
|||
| `MAIL_USERNAME` | `resend` (literal string) |
|
||||
| `MAIL_PASSWORD` | Resend API key (`re_...`; rotate if ever exposed) |
|
||||
| `MAIL_FROM` | `noreply@bilhej.se` (must be on verified domain) |
|
||||
| `VITE_UMAMI_WEBSITE_ID` | Umami website UUID for `bilhej.se` (see `docs/umami-analytics.md`) |
|
||||
|
||||
Passwords may contain `$` — the deploy workflow escapes these for Docker Compose.
|
||||
Production does **not** seed `test@bilhej.se` or demo orders. On first start, the
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ services:
|
|||
build:
|
||||
dockerfile: docker/frontend.prod.Dockerfile
|
||||
context: .
|
||||
args:
|
||||
VITE_UMAMI_WEBSITE_ID: ${VITE_UMAMI_WEBSITE_ID:-}
|
||||
VITE_UMAMI_SCRIPT_URL: ${VITE_UMAMI_SCRIPT_URL:-https://analytics.bilhej.se/script.js}
|
||||
container_name: bilhej-frontend-prod
|
||||
ports:
|
||||
- "3001:80"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
ARG VITE_UMAMI_WEBSITE_ID=
|
||||
ARG VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
|
||||
ENV VITE_UMAMI_WEBSITE_ID=$VITE_UMAMI_WEBSITE_ID
|
||||
ENV VITE_UMAMI_SCRIPT_URL=$VITE_UMAMI_SCRIPT_URL
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ .
|
||||
|
|
|
|||
81
docs/umami-analytics.md
Normal file
81
docs/umami-analytics.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Umami analytics (BilHej app)
|
||||
|
||||
Privacy-friendly page analytics via self-hosted [Umami](https://umami.is/docs) on **`https://analytics.bilhej.se`**. Server install is live on the VPS; this doc is for **BilHej code and deploy config** only.
|
||||
|
||||
## Values (production)
|
||||
|
||||
| Item | Value |
|
||||
|------|--------|
|
||||
| Collector | `https://analytics.bilhej.se` |
|
||||
| Tracker script | `https://analytics.bilhej.se/script.js` |
|
||||
| Dashboard | `https://analytics.bilhej.se` (admin login) |
|
||||
| Website in Umami | Name **BilHej**, domain **`bilhej.se`** |
|
||||
| Website ID | `ce59614c-9f2a-4f99-8ba3-c5217f88c3f7` |
|
||||
|
||||
The Website ID is public in the browser (tracking snippet). Set it via **`VITE_UMAMI_WEBSITE_ID`** in production frontend build env — do not hardcode in source.
|
||||
|
||||
**Note:** Umami 3.1 on this server uses the default **`/script.js`** path. `TRACKER_SCRIPT_NAME` / custom `bilhej-stats.js` is not applied in this version.
|
||||
|
||||
### Example snippet (for reference)
|
||||
|
||||
```html
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.bilhej.se/script.js"
|
||||
data-website-id="ce59614c-9f2a-4f99-8ba3-c5217f88c3f7"
|
||||
></script>
|
||||
```
|
||||
|
||||
## Frontend env
|
||||
|
||||
Production builds read this from the **Forgejo Actions secret** `VITE_UMAMI_WEBSITE_ID`. The deploy workflow writes it into `.env` on the server, then `docker compose -f docker-compose.prod.yml build` bakes it into the frontend image.
|
||||
|
||||
**Forgejo → Repository → Settings → Actions → Secrets:**
|
||||
|
||||
| Secret | Value |
|
||||
|--------|--------|
|
||||
| `VITE_UMAMI_WEBSITE_ID` | `ce59614c-9f2a-4f99-8ba3-c5217f88c3f7` |
|
||||
|
||||
Not a high-risk secret (the same ID appears in the browser), but keeping it in Forgejo matches other deploy config.
|
||||
|
||||
Manual deploy on the server (without Forgejo) works the same way: put the line in the project `.env` before `docker compose ... up --build`.
|
||||
|
||||
Optional override (default matches production):
|
||||
|
||||
```bash
|
||||
# VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js
|
||||
```
|
||||
|
||||
Leave `VITE_UMAMI_WEBSITE_ID` unset in local dev unless you intentionally send traffic to production Umami.
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
- [x] Load `script.js` with `data-website-id` from `VITE_UMAMI_WEBSITE_ID` (only when set).
|
||||
- [x] Send **SPA pageviews** on Vue Router `afterEach` (`data-auto-track="false"`).
|
||||
- [x] Update **integritetspolicy** — analytics, country-level stats, no IP stored in BilHej DB.
|
||||
- [x] Admin link **Webbstatistik** → Umami dashboard (prod builds only).
|
||||
|
||||
Umami derives **country** from the visitor IP at ingest and does not show IP lists in the UI. BilHej must not store visitor IPs for analytics.
|
||||
|
||||
## Verify after deploy
|
||||
|
||||
1. Browse `https://bilhej.se` (several routes).
|
||||
2. Umami → **BilHej** → **Realtime** / **Countries**.
|
||||
|
||||
## Server layout (reference)
|
||||
|
||||
| Item | Actual on VPS |
|
||||
|------|----------------|
|
||||
| Compose project | `~/umami` (`/home/jocke/umami`) |
|
||||
| Public access | nginx → `http://umami:3000` on Docker network `web` (host port 3000 used by open-webui) |
|
||||
| Database | `umami-db` on internal network `umami-internal` only |
|
||||
|
||||
```bash
|
||||
cd ~/umami
|
||||
docker compose ps
|
||||
docker compose logs -f umami
|
||||
docker compose pull && docker compose up -d # updates — read release notes first
|
||||
docker exec umami-db pg_dump -U umami umami > ~/umami-backup-$(date +%F).sql
|
||||
```
|
||||
|
||||
Country stats require nginx to pass **`X-Forwarded-For`** (already configured for this vhost).
|
||||
|
|
@ -41,6 +41,16 @@ describe('PrivacyPolicyPage', () => {
|
|||
expect(wrapper.text()).toContain('varken vi eller obehöriga')
|
||||
})
|
||||
|
||||
it('describes web analytics', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(PrivacyPolicyPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Webbstatistik')
|
||||
expect(wrapper.text()).toContain('analytics.bilhej.se')
|
||||
expect(wrapper.text()).toContain('IP-adresser')
|
||||
})
|
||||
|
||||
it('links to contact email and contact page', () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(PrivacyPolicyPage, {
|
||||
|
|
|
|||
72
frontend/src/__tests__/umami.spec.ts
Normal file
72
frontend/src/__tests__/umami.spec.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import {
|
||||
getUmamiConfig,
|
||||
initUmamiAnalytics,
|
||||
trackUmamiPageview,
|
||||
} from '@/utils/umami'
|
||||
|
||||
describe('umami', () => {
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = ''
|
||||
delete window.umami
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('returns null when website id is unset', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '')
|
||||
expect(getUmamiConfig()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns config when website id is set', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '11111111-2222-3333-4444-555555555555')
|
||||
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', '')
|
||||
expect(getUmamiConfig()).toEqual({
|
||||
websiteId: '11111111-2222-3333-4444-555555555555',
|
||||
scriptUrl: 'https://analytics.bilhej.se/script.js',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses custom script url when provided', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'test-id')
|
||||
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://example.test/script.js')
|
||||
expect(getUmamiConfig()?.scriptUrl).toBe('https://example.test/script.js')
|
||||
})
|
||||
|
||||
it('does not inject script when website id is unset', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', '')
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
})
|
||||
initUmamiAnalytics(router)
|
||||
expect(document.querySelector('script[data-website-id]')).toBeNull()
|
||||
})
|
||||
|
||||
it('injects script with auto-track disabled when configured', () => {
|
||||
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'test-id')
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
})
|
||||
initUmamiAnalytics(router)
|
||||
const script = document.querySelector('script[data-website-id]')
|
||||
expect(script?.getAttribute('data-website-id')).toBe('test-id')
|
||||
expect(script?.getAttribute('data-auto-track')).toBe('false')
|
||||
expect(script?.getAttribute('src')).toContain('script.js')
|
||||
})
|
||||
|
||||
it('trackUmamiPageview forwards url to umami', () => {
|
||||
const track = vi.fn()
|
||||
window.umami = { track }
|
||||
trackUmamiPageview('/orders')
|
||||
expect(track).toHaveBeenCalledOnce()
|
||||
const mapper = track.mock.calls[0][0] as (
|
||||
props: Record<string, unknown>,
|
||||
) => Record<string, unknown>
|
||||
expect(mapper({ referrer: 'x' })).toEqual({ referrer: 'x', url: '/orders' })
|
||||
})
|
||||
})
|
||||
22
frontend/src/env.d.ts
vendored
Normal file
22
frontend/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string
|
||||
readonly VITE_UMAMI_WEBSITE_ID?: string
|
||||
readonly VITE_UMAMI_SCRIPT_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (
|
||||
input?:
|
||||
| string
|
||||
| Record<string, unknown>
|
||||
| ((props: Record<string, unknown>) => Record<string, unknown>),
|
||||
) => void
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,13 @@ import { createApp } from 'vue'
|
|||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { initUmamiAnalytics } from '@/utils/umami'
|
||||
import './assets/styles/base.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
initUmamiAnalytics(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ const {
|
|||
handleNotesSave,
|
||||
} = useAdminOrderActions(orders, replaceOrder)
|
||||
|
||||
const umamiDashboardUrl = import.meta.env.VITE_UMAMI_WEBSITE_ID
|
||||
? 'https://analytics.bilhej.se'
|
||||
: null
|
||||
|
||||
function handleModalKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && messageModalOrder.value) {
|
||||
closeMessageModal()
|
||||
|
|
@ -55,7 +59,18 @@ onUnmounted(() => {
|
|||
|
||||
<template>
|
||||
<div class="admin">
|
||||
<h1 class="admin__title">Administration</h1>
|
||||
<header class="admin__header">
|
||||
<h1 class="admin__title">Administration</h1>
|
||||
<a
|
||||
v-if="umamiDashboardUrl"
|
||||
class="admin__analytics-link"
|
||||
:href="umamiDashboardUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Webbstatistik
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<p
|
||||
v-if="loading"
|
||||
|
|
@ -137,12 +152,33 @@ onUnmounted(() => {
|
|||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.admin__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.admin__title {
|
||||
margin: 0 0 var(--space-xl) 0;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.admin__analytics-link {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.admin__analytics-link:hover {
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.admin__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,15 @@ const sections = [
|
|||
'Vi säljer inte personuppgifter och visar inte mottagarens identitet eller adress för dig som avsändare.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'webbanalys',
|
||||
title: 'Webbstatistik',
|
||||
paragraphs: [
|
||||
'Vi använder självhostad webbanalys (Umami) på analytics.bilhej.se för att förstå hur webbplatsen används, till exempel vilka sidor som besöks och ungefärlig geografisk fördelning på landsnivå.',
|
||||
'Analysen bygger på sidvisningar och teknisk information som webbläsaren skickar vid besök. Vi lagrar inte besökares IP-adresser i Bilhejs databas; Umami behandlar IP tillfälligt för att kunna visa land och tar inte emot personuppgifter som du skriver i brev eller konto.',
|
||||
'Du kan begränsa spårning med webbläsarens spärrlistor eller “Do Not Track”. Kontakta oss om du har frågor om webbanalys.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lagring',
|
||||
title: 'Hur länge sparar vi uppgifterna?',
|
||||
|
|
|
|||
45
frontend/src/utils/umami.ts
Normal file
45
frontend/src/utils/umami.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { Router } from 'vue-router'
|
||||
|
||||
const DEFAULT_SCRIPT_URL = 'https://analytics.bilhej.se/script.js'
|
||||
|
||||
export type UmamiConfig = {
|
||||
websiteId: string
|
||||
scriptUrl: string
|
||||
}
|
||||
|
||||
export function getUmamiConfig(): UmamiConfig | null {
|
||||
const websiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID?.trim()
|
||||
if (!websiteId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scriptUrl =
|
||||
import.meta.env.VITE_UMAMI_SCRIPT_URL?.trim() || DEFAULT_SCRIPT_URL
|
||||
|
||||
return { websiteId, scriptUrl }
|
||||
}
|
||||
|
||||
export function trackUmamiPageview(url: string): void {
|
||||
window.umami?.track((props) => ({ ...props, url }))
|
||||
}
|
||||
|
||||
export function initUmamiAnalytics(router: Router): void {
|
||||
const config = getUmamiConfig()
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.defer = true
|
||||
script.src = config.scriptUrl
|
||||
script.setAttribute('data-website-id', config.websiteId)
|
||||
script.setAttribute('data-auto-track', 'false')
|
||||
script.onload = () => {
|
||||
trackUmamiPageview(router.currentRoute.value.fullPath)
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
|
||||
router.afterEach((to) => {
|
||||
trackUmamiPageview(to.fullPath)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue