436 lines
19 KiB
Markdown
436 lines
19 KiB
Markdown
<div align="center">
|
||
|
||
# 💜 VibeFinance
|
||
|
||
**Persoonlijk vermogensdashboard**
|
||
|
||
Eigen vermogen · Schulden · Voortgang · Gebruikersbeheer
|
||
|
||

|
||

|
||

|
||

|
||

|
||

|
||
|
||
<img src="docs/screenshot-dashboard.png" alt="VibeFinance dashboard" width="820">
|
||
|
||
</div>
|
||
|
||
---
|
||
|
||
## 🤖 Gebouwd met Claude.ai
|
||
|
||
Deze applicatie is volledig ontworpen en ontwikkeld met behulp van [Claude.ai](https://claude.ai) — de AI-assistent van Anthropic. Van architectuur tot gebruikersinterface: alles is stap voor stap tot stand gekomen in samenwerking met Claude.
|
||
|
||
---
|
||
|
||
## ⚡ Quick Start
|
||
|
||
> Vereisten: Docker Engine 24+ en Docker Compose v2+
|
||
|
||
De app draait in **2 containers**: `app` (Express API + gebouwde frontend) en
|
||
`postgres` (database).
|
||
|
||
**1. De bestanden op de server zetten.** `docker compose` leest
|
||
`docker-compose.yml` uit de map waarin je het commando draait, dus dat bestand
|
||
moet er eerst staan. De organisatie in Gitea is publiek, dus dit kan anoniem:
|
||
|
||
```bash
|
||
git clone https://vibehoogie.duckdns.org/Vibe/VibeFinance.git
|
||
cd VibeFinance
|
||
```
|
||
|
||
Wil je de repo niet op je server, dan zijn deze twee bestanden genoeg:
|
||
|
||
```bash
|
||
mkdir -p vibefinance && cd vibefinance
|
||
base=https://vibehoogie.duckdns.org/Vibe/VibeFinance/raw/branch/main
|
||
curl -fsSLO $base/docker-compose.yml
|
||
curl -fsSL $base/.env.example -o .env.example
|
||
```
|
||
|
||
**2. `.env` aanmaken** — kopieer `.env.example` en vul in:
|
||
|
||
```env
|
||
POSTGRES_USER=vibefinance
|
||
POSTGRES_PASSWORD=kies_een_sterk_wachtwoord
|
||
JWT_SECRET=minimaal_64_willekeurige_tekens
|
||
APP_PORT=3400
|
||
```
|
||
|
||
```bash
|
||
# Genereer een veilige JWT_SECRET
|
||
openssl rand -hex 64
|
||
```
|
||
|
||
**3. Starten:**
|
||
|
||
```bash
|
||
cp .env.example .env # en vul de waarden in
|
||
|
||
docker compose pull && docker compose up -d
|
||
```
|
||
|
||
Twee foutmeldingen en wat ze betekenen:
|
||
|
||
- **`no configuration file provided: not found`** — je staat in een map zonder
|
||
`docker-compose.yml`. Controleer met `ls docker-compose.yml`, of geef het
|
||
bestand expliciet mee: `docker compose -f /pad/naar/docker-compose.yml up -d`.
|
||
- **`401 Unauthorized`** of **"pull access denied"** — de organisatie staat niet
|
||
meer op publiek. Dan alsnog inloggen:
|
||
|
||
```bash
|
||
docker login vibehoogie.duckdns.org # wachtwoord = Gitea-token met read:package
|
||
```
|
||
|
||
Navigeer naar `http://<server-ip>:3400` en registreer je account. De **eerste** geregistreerde gebruiker krijgt automatisch de Admin-rol.
|
||
|
||
De container luistert intern op poort 3001 en serveert daar zowel de frontend
|
||
(op `/`) als de API (op `/api`). `APP_PORT` bepaalt de poort op de host.
|
||
|
||
### Development (hot reload)
|
||
|
||
```powershell
|
||
.\dev.ps1
|
||
```
|
||
|
||
Eén dev-container draait de API (`node --watch`) én de Vite dev-server samen:
|
||
|
||
| Adres | Wat |
|
||
|---|---|
|
||
| http://localhost | de app in de browser (Vite, hot reload) |
|
||
| http://localhost:3001/api/health | de API rechtstreeks |
|
||
|
||
Vite proxyt `/api` naar de API in dezelfde container, dus je hebt geen CORS-
|
||
uitzonderingen nodig. Handige acties:
|
||
|
||
```powershell
|
||
.\dev.ps1 up -Target app # alleen de app herbouwen en herstarten
|
||
.\dev.ps1 logs # logs van de app volgen
|
||
.\dev.ps1 down # stoppen én volumes wissen (dev-database weg)
|
||
.\dev.ps1 test # testsuite draaien
|
||
```
|
||
|
||
> `.\dev.ps1 up` laat de dev-database staan; alleen `.\dev.ps1 down` wist de volumes.
|
||
|
||
### Testen
|
||
|
||
```powershell
|
||
.\test.ps1 # deps + backend-tests + build + imagescan + smoketest
|
||
.\test.ps1 -Unit # dependency-check + backend-tests
|
||
.\test.ps1 -NoSmoke # zonder de stack te starten (imagescan draait wél)
|
||
.\test.ps1 -Strict # faal op achterlopende deps; scan ook op MEDIUM/LOW
|
||
.\test.ps1 -SkipDeps # dependency-check overslaan (bijv. offline)
|
||
.\test.ps1 -SkipScan # imagescan overslaan (bijv. offline)
|
||
```
|
||
|
||
Alles loopt in Docker — Node op de host is niet nodig. De backend-tests draaien
|
||
zonder database via het `test`-target in de `Dockerfile`.
|
||
|
||
Stap 1 vergelijkt elke dependency in `frontend/` en `backend/package.json` met
|
||
de laatste versie in het npm-register, zodat je bij elke testronde ziet of er
|
||
updates open staan. Pakketten die bewust achterblijven staan met reden in
|
||
[`docker/update-exceptions.json`](docker/update-exceptions.json) en laten de
|
||
check niet falen:
|
||
|
||
```
|
||
frontend/package.json (9 dependencies)
|
||
[vast ] eslint ^9.39.5 → 10.9.1 eslint-plugin-react ondersteunt nog geen ESLint 10
|
||
actueel op de vastgezette versies na
|
||
backend/package.json (15 dependencies)
|
||
alles actueel
|
||
```
|
||
|
||
Stap 4 scant het gebouwde image met [Trivy](https://trivy.dev) en blokkeert bij
|
||
CRITICAL of HIGH waarvoor een fix bestaat. Dat is iets anders dan stap 1: die
|
||
kijkt naar de `package.json`'s, deze naar wat er daadwerkelijk in het image zit
|
||
— de Alpine-pakketten en alles wat het base-image meelevert. Dat onderscheid is
|
||
niet theoretisch. Bij de scan van 1.0.10 kwamen alle 39 bevindingen daarvandaan
|
||
en geen enkele uit de applicatie zelf:
|
||
|
||
| Bron | Aantal | Waar het vandaan kwam |
|
||
|---|---|---|
|
||
| `libssl3` + `libcrypto3` | 20 | Alpine in het node-image liep achter op 3.5.8-r0 |
|
||
| npm's eigen dependencies | 19 | `tar`, `pacote`, `sigstore`, `brace-expansion`, `picomatch`, `ip-address` |
|
||
|
||
Een dependency-check had er nul van gezien. Het productie-image doet daarom aan
|
||
het eind van de build een `apk upgrade` en gooit npm, npx, corepack en yarn
|
||
eruit: de container start met `node src/server.js` en gebruikt nooit een
|
||
pakketbeheerder. Dat bracht de scan op nul, en houdt hem daar — npm's
|
||
dependencies zijn een terugkerende bron die nu simpelweg niet meer in het image
|
||
zit. `release.ps1` draait dezelfde scan vóór het pushen, dus een image met
|
||
openstaande CVE's komt de registry niet in.
|
||
|
||
### Productie release
|
||
|
||
```powershell
|
||
# token: staat in .env.release, of eenmalig in de shell
|
||
# één Gitea-token met write:package (push) én write:repository (release)
|
||
$env:GITEA_TOKEN = "<token met write:package + write:repository>"
|
||
.\release.ps1
|
||
```
|
||
|
||
Draait de tests, bouwt het app-image voor `amd64` + `arm64` (Raspberry Pi),
|
||
pusht naar de registry, zet de Git-tag en publiceert de Gitea-release.
|
||
|
||
Het script regelt ook het bijwerken eromheen: de changelog-kop
|
||
`## [Niet vrijgegeven]` wordt `## [x.x.x] - datum`, de versie in
|
||
`frontend/package.json` en de badge hierboven gaan mee, en ná het publiceren
|
||
zet het de root `package.json` op `x.x.(x+1)-dev` voor de volgende cyclus.
|
||
Beschrijft de changelog niets over deze versie, dan stopt het script vóór de
|
||
build — anders zou de release met een placeholder als notes online komen.
|
||
|
||
---
|
||
|
||
## 🗂️ Functionaliteit
|
||
|
||
| Scherm | Wat het doet |
|
||
|---|---|
|
||
| 📊 **Dashboard** | KPI's over inleg, vermogen, winst en ROI, de voortgang naar je doel, en de grafiek met vermogen tegen inleg |
|
||
| 💰 **Eigen vermogen** | Vermogensposten per categorie beheren + geïnvesteerde fiat historiek |
|
||
| 📉 **Schuld** | Schulden bijhouden per blok met afbetalingsoverzicht |
|
||
| 📈 **Voortgang Schuld & EV** | Grafieken en trends van EV en schuld over tijd (apart bij te houden) |
|
||
| 👥 **Gebruikers** | Accounts aanmaken, bewerken en beheren (Admin-rol vereist) |
|
||
| ⚙️ **Instellingen** | Kleurthema, licht/donker, backup downloaden, backup terugzetten en export naar HTML |
|
||
|
||
De grafiek "Ontwikkeling van Eigen Vermogen" op het dashboard tekent twee lijnen
|
||
op één as: je netto waarde per voortgangsmoment, en daaronder de cumulatief
|
||
geïnvesteerde fiat. Het verschil tussen de twee is je rendement; de tooltip zet
|
||
vermogen, inleg en resultaat per datum onder elkaar. Het laatste punt van de
|
||
inleglijn is per definitie gelijk aan de KPI "Totaal geïnvesteerde fiat" — beide
|
||
rekenen het oudste voortgangsmoment als startinleg en tellen daar de stortingen
|
||
per rij bij op.
|
||
|
||
De x-as noemt maanden (`sep '26`), niet losse datums: de grafiek rekent uit
|
||
hoeveel labels er naast elkaar passen en toont er zoveel, terugtellend vanaf het
|
||
laatste punt zodat dat er altijd bij staat. Bij een langere reeks dunt hij
|
||
vanzelf verder uit. De exacte dag lees je in de tooltip. De vermogenslijn is een
|
||
Catmull-Rom spline; de inleglijn krijgt bewust rechte segmenten, omdat een
|
||
spline tussen twee punten iets buiten hun bereik uitschiet en dat bij een reeks
|
||
die nooit terugloopt een daling zou tekenen die er niet is. Beide lijnen delen
|
||
de tekenhulp in
|
||
[`frontend/src/utils/grafiek.js`](frontend/src/utils/grafiek.js).
|
||
|
||
Onder **Instellingen** kies je een van de negen kleurthema's. Thema en
|
||
licht/donker staan los van elkaar: het thema levert het accent, de schakelaar de
|
||
ondergrond. De paletten staan in
|
||
[`frontend/src/constants/themas.js`](frontend/src/constants/themas.js);
|
||
`useTheme(themaId, darkMode)` leidt daar randen, invoervelden en schaduwen uit af.
|
||
Groen, rood en goud veranderen bewust nooit mee — die betekenen winst, verlies en
|
||
inleg, en betekenis hoort niet van een kleurkeuze af te hangen. Thema en modus
|
||
worden per browser bewaard.
|
||
|
||
De kolommen in de tabellen op Eigen vermogen en Voortgang zijn versleepbaar: pak
|
||
de rechterrand van een kolomkop. De breedtes worden per tabel in de browser
|
||
bewaard; dubbelklik op de greep voor de standaardbreedte.
|
||
|
||
Backup en restore lopen via **Instellingen** in de navigatiebalk: "Backup maken"
|
||
downloadt een ZIP (`data.json` + `meta.json`) met je eigen financiële data,
|
||
"Backup terugzetten" leest zo'n ZIP weer in en schrijft hem via `/api/data` weg.
|
||
Dat is de gebruikersvariant; een volledige database-dump staat in `BEHEER.md`.
|
||
|
||
Daarnaast staat er **"Exporteren naar HTML"**: één zelfdragend bestand met al je
|
||
gegevens, bedoeld als laatste vangnet. Draait de app niet meer, is de database
|
||
weg en ligt er geen backup, dan opent dit bestand nog steeds — in elke browser,
|
||
op een computer zonder internet. Er zit niets van buiten in: de opmaak staat
|
||
inline, er worden geen lettertypen, afbeeldingen of scripts geladen. Je ziet een
|
||
samenvatting met de KPI's, eigen vermogen en schulden per categorie met
|
||
subtotalen, de initiële schuld met wat er sinds de start is afgelost, de
|
||
voortgangstabel en de historie.
|
||
|
||
Alles wat je ooit hebt ingevoerd komt mee, ook wat de app zelf niet meer toont:
|
||
stortingen op een inmiddels verwijderde categorie krijgen een eigen kolom
|
||
(`Verwijderd (c9)`). De voortgangstabel is bij zes categorieën twaalf kolommen
|
||
breed en scrollt horizontaal in plaats van kolommen af te kappen; de datumkolom
|
||
blijft daarbij staan. Op print vervalt de scrollbalk en wordt de tabel kleiner
|
||
gezet, zodat alle kolommen op papier alsnog passen.
|
||
|
||
Het bestand is tegelijk een backup: onderin staat de complete ruwe data in een
|
||
`<script type="application/json">`-blok dat de browser niet uitvoert en niet
|
||
toont. "Backup herstellen" accepteert daarom naast de ZIP ook zo'n HTML-bestand.
|
||
Eén bestand dat je zowel kunt lézen als kunt terugzetten — zie
|
||
[`frontend/src/utils/htmlExport.js`](frontend/src/utils/htmlExport.js).
|
||
|
||
---
|
||
|
||
## 🏗️ Architectuur
|
||
|
||
```
|
||
vibefinance/
|
||
├── Dockerfile # Multi-stage: frontend build → app-image
|
||
├── .dockerignore
|
||
├── docker-compose.yml # Productie: app + postgres
|
||
├── docker-compose.dev.yml # Dev: app (API + Vite) + postgres
|
||
├── .env.example # Kopieer naar .env en vul in
|
||
├── package.json # Versienummer — single source of truth
|
||
├── dev.ps1 / dev.sh # Dev script (bouwen, starten, logs, pushen)
|
||
├── test.ps1 / test.sh # Testsuite (unit + build + smoketest)
|
||
├── release.ps1 / release.sh # Release (test, push, tag, Gitea-release)
|
||
│
|
||
├── docker/
|
||
│ ├── dev-entrypoint.sh # Start API + Vite samen in de dev-container
|
||
│ ├── check-updates.mjs # Dependency-check tegen het npm-register
|
||
│ └── update-exceptions.json # Pakketten die bewust achterblijven, met reden
|
||
│
|
||
├── docs/
|
||
│ └── screenshot-dashboard.png # Screenshot bovenaan deze README
|
||
│
|
||
├── frontend/ # React / Vite SPA (build → dist)
|
||
│ ├── vite.config.js # /api proxy naar de API in dezelfde container
|
||
│ ├── index.html # Vite-entry, verwijst naar /favicon.svg
|
||
│ ├── public/ # Ongebundeld: favicon.svg + icons/
|
||
│ └── src/
|
||
│ ├── App.jsx # Root – wiring + idle-logout
|
||
│ ├── App.css # Reset + losse klassen (o.a. .kolom-grip)
|
||
│ ├── main.jsx
|
||
│ ├── version.js # Draaiende versie (__APP_VERSION__ uit de build)
|
||
│ ├── constants/ # index.js (kleuren, tabs, begindata), themas.js
|
||
│ ├── utils/ # format.js (bedragen/datums), storage.js,
|
||
│ │ # grafiek.js (SVG), htmlExport.js (export)
|
||
│ ├── hooks/ # useTheme, useUpdateCheck, useKolomBreedtes
|
||
│ ├── context/ # AppContext (global state + API sync + backup)
|
||
│ ├── components/
|
||
│ │ ├── ui/ # Primitieve UI (VCard, DonutChart…)
|
||
│ │ ├── LoginPage.jsx
|
||
│ │ ├── LoginBackground.jsx
|
||
│ │ ├── NavBar.jsx
|
||
│ │ ├── ProfielPopup.jsx
|
||
│ │ ├── InstellingenModal.jsx # Thema + backup/restore + export
|
||
│ │ └── GebruikersBeheer.jsx
|
||
│ └── pages/
|
||
│ ├── DashboardTab.jsx
|
||
│ ├── EigenVermogenTab.jsx
|
||
│ ├── SchuldTab.jsx
|
||
│ └── VoortgangTab.jsx
|
||
│
|
||
└── backend/ # Express REST API + statische frontend
|
||
├── test/ # node --test integratietests
|
||
└── src/
|
||
├── server.js # Entry point: API op /api, SPA op /
|
||
├── db.js # PostgreSQL pool + initDb() schema-migraties
|
||
├── logger.js # pino (pino-pretty buiten productie)
|
||
├── config/ # Omgevingsvariabelen (incl. STATIC_DIR)
|
||
├── constants/ # Begindata (initialData.js)
|
||
├── middleware/ # auth.js, errorHandler.js
|
||
├── routes/ # auth, data, users, health, update
|
||
└── stores/ # userStore, dataStore
|
||
```
|
||
|
||
**Stack:** React 19 · Vite 8 · Node.js 22 · Express 5 · PostgreSQL 18 · JWT · bcrypt · Docker Compose v2
|
||
|
||
### Waarom 2 containers
|
||
|
||
De frontend werd eerder door een eigen nginx-container geserveerd. Voor
|
||
self-hosting op een Raspberry Pi is dat een container te veel: de Express-app
|
||
serveert de statische build nu zelf, dus er is geen aparte webserver en geen
|
||
interne proxy meer nodig. Dat betekent ook: één origin, dus geen CORS tussen
|
||
frontend en API.
|
||
|
||
| Verzoek | Wat de app doet |
|
||
|---|---|
|
||
| `/api/...` | REST API (rate limited, `Cache-Control: no-store`) |
|
||
| `/assets/...` | Gehashte build-bestanden, 1 jaar `immutable` gecached |
|
||
| `/favicon.svg`, `/icons/...` | Vaste namen, dus `max-age=0, must-revalidate` |
|
||
| pad mét extensie, geen bestand | echte 404 (geen HTML waar een icoon wordt verwacht) |
|
||
| al het overige | `index.html` — fallback voor React-routing, niet gecached |
|
||
|
||
---
|
||
|
||
## 🔌 API
|
||
|
||
Beveiligde endpoints vereisen `Authorization: Bearer <token>`. Rate limiting: 20 req/15 min op auth-routes, 200 req/15 min op de rest van `/api` (statische bestanden zijn uitgezonderd).
|
||
|
||
| Route | Methoden | Omschrijving |
|
||
|---|---|---|
|
||
| `/api/auth/register` | POST | Account aanmaken (eerste user = Admin) |
|
||
| `/api/auth/login` | POST | Inloggen, JWT token terug |
|
||
| `/api/auth/me` | GET, PATCH | Profiel ophalen en bijwerken |
|
||
| `/api/auth/me/password` | POST | Wachtwoord wijzigen |
|
||
| `/api/data` | GET, PUT, DELETE | Financiële data per gebruiker |
|
||
| `/api/users` | GET, POST, PATCH, DELETE | Gebruikersbeheer (Admin only) |
|
||
| `/api/health` | GET | Status + uptime |
|
||
| `/api/update-check` | GET | Laatste release (voor de update-melding) |
|
||
|
||
---
|
||
|
||
## 🗄️ Database
|
||
|
||
| Tabel | Omschrijving |
|
||
|---|---|
|
||
| `users` | Accounts met bcrypt hash, rol, actief-vlag en aanmaakdatum |
|
||
| `user_data` | Financiële data per gebruiker (JSON) |
|
||
|
||
| Omgeving | Volume | Mount pad |
|
||
|---|---|---|
|
||
| Productie | `vibefinance_pgdata` | `/var/lib/postgresql` |
|
||
| Development | `postgres_dev_data` | `/var/lib/postgresql` |
|
||
|
||
> PostgreSQL 18 gebruikt `PGDATA=/var/lib/postgresql/18/docker` — het volume is daarom gemount op `/var/lib/postgresql` (niet `/data`).
|
||
|
||
Het schema wordt bij het starten van de app aangemaakt/bijgewerkt via
|
||
`initDb()` in `backend/src/db.js`.
|
||
|
||
---
|
||
|
||
## ⚙️ Omgevingsvariabelen
|
||
|
||
| Variabele | Standaard | Omschrijving |
|
||
|---|---|---|
|
||
| `POSTGRES_PASSWORD` | — | **Verplicht** |
|
||
| `JWT_SECRET` | — | **Verplicht** — minimaal 64 tekens |
|
||
| `POSTGRES_USER` | `vibefinance` | Database gebruikersnaam |
|
||
| `POSTGRES_DB` | `vibefinance` | Database naam |
|
||
| `DATABASE_URL` | — | Connectiestring; `docker-compose.yml` stelt hem samen uit de drie `POSTGRES_*` waarden, dus zelf zetten hoeft alleen buiten Compose |
|
||
| `JWT_EXPIRES` | `7d` | Geldigheidsduur token |
|
||
| `CORS_ORIGIN` | `http://localhost` | Externe domeinnaam (één origin, dus zelden nodig) |
|
||
| `APP_PORT` | `3400` | Poort op de host (container luistert op 3001) |
|
||
| `PORT` | `3001` | Poort binnen de container |
|
||
| `STATIC_DIR` | `/app/public` | Map met de frontend-build |
|
||
| `UPDATE_CHECK_URL` | Gitea releases-API | Bron update-melding (leeg = uit) |
|
||
| `UPDATE_CHECK_TOKEN` | — | Optioneel: de Gitea-repo is publiek, dus de check werkt anoniem. Alleen nodig als de organisatie weer privé wordt; scope `read:repository` |
|
||
|
||
---
|
||
|
||
## 🔐 Beveiliging
|
||
|
||
- Wachtwoorden gehasht met bcrypt (cost factor 12)
|
||
- JWT authenticatie met instelbare vervaltijd
|
||
- Data is per gebruiker: `/api/data` leest en schrijft alleen de rij van de
|
||
ingelogde gebruiker (`sub` uit het token)
|
||
- Van de rollen wordt alleen **Admin** afgedwongen, op `/api/users` — zie de
|
||
sectie Rollen in `BEHEER.md`
|
||
- Frontend en API op één origin — geen interne proxy, geen CORS-uitzonderingen
|
||
- Content-Security-Policy via Helmet, toegespitst op wat de app laadt
|
||
- Database bereikbaar alleen binnen het interne Docker netwerk
|
||
- App-container draait als non-root
|
||
- Rate limiting op `/api` (statische bestanden uitgezonderd)
|
||
|
||
---
|
||
|
||
## 🧪 Testen
|
||
|
||
| Laag | Wat | Commando |
|
||
|---|---|---|
|
||
| Dependencies | Staan `package.json`'s op de laatste versies | `.\test.ps1 -Unit` |
|
||
| Backend | API-routes, SPA-fallback, cache- en security-headers | `.\test.ps1 -Unit` |
|
||
| Build | Vite-build + productie-image | `.\test.ps1 -NoSmoke` |
|
||
| Integratie | Smoketest op de echte app + postgres stack | `.\test.ps1` |
|
||
|
||
---
|
||
|
||
## 🛠️ Beheer
|
||
|
||
Zie `BEHEER.md` voor commando's voor lokale ontwikkeling, releases, logs en
|
||
database-toegang — en voor de sectie **Tokens**: waarom één Gitea-token op het
|
||
werkstation genoeg is, welke scopes het nodig heeft, en wat een 401, 403 of 404
|
||
van Gitea betekent.
|
||
|
||
---
|
||
|
||
<div align="center">
|
||
|
||
Privé gebruik · gebouwd met [Claude.ai](https://claude.ai)
|
||
|
||
</div>
|