diff --git a/.env.example b/.env.example index c9a9fc0..ceb4463 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 0ff5be1..4be9e8f 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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 diff --git a/README.md b/README.md index 2d4d3d2..1e149e3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a851d5c..9c14588 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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" diff --git a/docker/frontend.prod.Dockerfile b/docker/frontend.prod.Dockerfile index 23450d7..636ce5d 100644 --- a/docker/frontend.prod.Dockerfile +++ b/docker/frontend.prod.Dockerfile @@ -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/ . diff --git a/docs/umami-analytics.md b/docs/umami-analytics.md new file mode 100644 index 0000000..b1b8cc9 --- /dev/null +++ b/docs/umami-analytics.md @@ -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 + +``` + +## 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). diff --git a/frontend/src/__tests__/PrivacyPolicyPage.spec.ts b/frontend/src/__tests__/PrivacyPolicyPage.spec.ts index f7f0d2c..1719bb8 100644 --- a/frontend/src/__tests__/PrivacyPolicyPage.spec.ts +++ b/frontend/src/__tests__/PrivacyPolicyPage.spec.ts @@ -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, { diff --git a/frontend/src/__tests__/umami.spec.ts b/frontend/src/__tests__/umami.spec.ts new file mode 100644 index 0000000..08e6523 --- /dev/null +++ b/frontend/src/__tests__/umami.spec.ts @@ -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: '
' } }], + }) + 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: '
' } }], + }) + 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, + ) => Record + expect(mapper({ referrer: 'x' })).toEqual({ referrer: 'x', url: '/orders' }) + }) +}) diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..33214d2 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,22 @@ +/// + +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 + | ((props: Record) => Record), + ) => void + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 7e0569d..10b4445 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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') diff --git a/frontend/src/pages/AdminPage.vue b/frontend/src/pages/AdminPage.vue index 0a64c96..f5a2824 100644 --- a/frontend/src/pages/AdminPage.vue +++ b/frontend/src/pages/AdminPage.vue @@ -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(() => {