15 Commits

Author SHA1 Message Date
dependabot[bot]
64a190678e build(deps): bump the pip-minor-patch group in /backend with 2 updates (#42)
Bumps the pip-minor-patch group in /backend with 2 updates: [alembic](https://github.com/sqlalchemy/alembic) and [pytest](https://github.com/pytest-dev/pytest).


Updates `alembic` from 1.19.2 to 1.20.0
- [Release notes](https://github.com/sqlalchemy/alembic/releases)
- [Changelog](https://github.com/sqlalchemy/alembic/blob/main/CHANGES)
- [Commits](https://github.com/sqlalchemy/alembic/commits)

Updates `pytest` from 9.0.3 to 9.1.1
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.3...9.1.1)

---
updated-dependencies:
- dependency-name: alembic
  dependency-version: 1.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: pip-minor-patch
- dependency-name: pytest
  dependency-version: 9.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: pip-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZProger <81753080+Zproger@users.noreply.github.com>
2026-09-22 13:16:48 +03:00
dependabot[bot]
0cf72831a8 build(deps): bump the npm-minor-patch group in /frontend with 8 updates (#43)
Bumps the npm-minor-patch group in /frontend with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.101.4` | `5.102.8` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.31.0` | `1.45.0` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.8` | `19.3.0` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.18` | `19.3.0` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.8` | `19.3.0` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.7` | `19.3.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.4.1` | `26.5.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.2` | `8.3.0` |


Updates `@tanstack/react-query` from 5.101.4 to 5.102.8
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.102.8/packages/react-query)

Updates `lucide-react` from 1.31.0 to 1.45.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.45.0/packages/lucide-react)

Updates `react` from 19.2.8 to 19.3.0
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.0/packages/react)

Updates `@types/react` from 19.2.18 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-dom` from 19.2.8 to 19.3.0
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.0/packages/react-dom)

Updates `@types/react-dom` from 19.2.7 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `@types/node` from 26.4.1 to 26.5.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/react` from 19.2.18 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 19.2.7 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `vite` from 8.2.2 to 8.3.0
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.3.0/packages/vite)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.102.8
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: lucide-react
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: react
  dependency-version: 19.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@types/react"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: react-dom
  dependency-version: 19.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@types/react-dom"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@types/node"
  dependency-version: 26.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@types/react"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@types/react-dom"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: vite
  dependency-version: 8.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 13:12:15 +03:00
ZProger
848ff21665 feat: профили импорта CSV для Т-Банка, monobank и ПриватБанка, версия 1.1.8 2026-09-13 06:32:36 +03:00
ZProger
c867710f38 fix: перевести SEO-теги и document lang на английский 2026-09-13 00:42:51 +03:00
ZProger
3bd484b88e feat: добавить SEO-теги (description, keywords, Open Graph, Twitter Card) 2026-09-13 00:42:51 +03:00
ZProger
14948aff1d security: TLS, проверка Host, шифрованное запоминание входа и аудит-лог
- TLS через подключаемый overlay docker-compose.tls.yml + Caddyfile:
  Caddy сам получает и продлевает сертификат Let's Encrypt, HSTS
  добавляется на уровне прокси — намеренно там, а не в nginx.conf,
  иначе установки без TLS заперли бы сами себя;
- проверка заголовка Host против DNS rebinding: AURUM_ALLOWED_HOSTS,
  по умолчанию localhost и 127.0.0.1, чужой Host получает 421 с
  подсказкой, какую переменную поправить;
- «запомнить меня на 7 дней» вернулось в безопасном виде: пароль
  шифруется AES-GCM ключом с extractable: false в IndexedDB, вместо
  прежнего base64 в localStorage, который разворачивался обратно в
  пароль одним atob. Требует secure context, поэтому по обычному HTTP
  к LAN-адресу чекбокс не показывается, а не притворяется рабочим;
- аудит-лог необратимых операций (восстановление бэкапа, массовое
  создание транзакций, удаление счёта) в лог контейнера. Не в БД
  сознательно: таблица аудита исчезла бы ровно в том событии, которое
  должна объяснять;
- README: раздел про TLS и таблица двух переменных, решающих, кто
  может достучаться до инстанса.
2026-09-10 21:34:40 +03:00
ZProger
f9413aa6e1 security: устранить находки аудита, версия 1.1.7
Конфигурация и эксплуатационный слой:
- nginx: client_max_body_size 64m — восстановление из бэкапа не работало
  вовсе, дефолтный лимит в 1 МБ резал дампы (реальный экспорт ~3.4 МБ)
  с 413 ещё до backend;
- nginx: заголовки безопасности (nosniff, Referrer-Policy,
  Permissions-Policy) и полный Content-Security-Policy. Ради него
  inline-скрипт темы вынесен в public/theme-init.js;
- nginx: limit_req на /api/ — перебор пароля Basic Auth шёл со
  скоростью сети, ни блокировки, ни задержки не было;
- nginx: server_tokens off;
- порт web привязан к 127.0.0.1, адрес настраивается через
  AURUM_BIND_ADDRESS (раньше слушался на всех интерфейсах);
- backend работает от непривилегированного пользователя,
  no-new-privileges для backend и web;
- дефолты пароля Postgres сведены к одному значению в обоих сервисах,
  в .env.example описана ловушка с инициализацией тома.

Приложение:
- версия убрана из неавторизованного /api/health и отдаётся как
  app_version в GET /api/settings;
- /api/docs, /api/redoc и /api/openapi.json отключаются через
  AURUM_ENABLE_DOCS;
- текст исключения при неудачном восстановлении уходит в лог, а не
  клиенту;
- max_length для notes в схемах транзакций, активов и регулярных
  платежей (и в соответствующих полях UI);
- срок «запомнить меня» сокращён с 30 дней до 7;
- pytest обновлён до 9.0.3 (PYSEC-2026-1845), pytest-asyncio до 1.4.0;
- CI: permissions contents:read для GITHUB_TOKEN.
2026-09-10 21:12:31 +03:00
ZProger
eba8add92f fix: устранить баги в проверке дублей и смене кодировки при импорте CSV
- Кэш проверки дублей не сбрасывался после импорта: повторный импорт
  того же файла в течение 30 секунд (staleTime) сверялся со списком
  транзакций, полученным ДО первого импорта, и не находил ни одного
  дубля — ровно тот случай, ради которого проверка и делалась.
- Кнопку импорта можно было нажать, пока проверка дублей ещё шла или
  шёл фоновый рефетч: дубли проходили мимо фильтра. Теперь кнопка
  заблокирована на время запроса, а его ошибка больше не проглатывается
  молча, а показывается предупреждением.
- Ручная смена кодировки ломала уже выбранные колонки: заголовки
  перечитывались, старый выбор указывал на несуществующее имя,
  indexOf давал -1, и все строки молча отбрасывались как «не удалось
  распознать дату». Маппинг переугадывается при изменении состава
  заголовков.
- Текст «Они не будут импортированы» противоречил включённой галочке
  «Всё равно импортировать их».
2026-09-10 20:16:00 +03:00
ZProger
777e3af725 feat: улучшить импорт CSV — дедупликация, кодировка, парсинг сумм
Пять доработок импорта транзакций из CSV:
1. Проверка дублей при повторном импорте — сверка с уже существующими
   транзакциями на счёте за диапазон дат файла (по дате+типу+сумме+
   описанию), похожие исключаются из импорта по умолчанию с опцией
   всё равно их добавить.
2. Автоопределение кодировки файла (UTF-8 с откатом на Windows-1251)
   вместо жёстко зашитого UTF-8 — раньше кириллица из банковских
   выгрузок превращалась в кракозябры. Есть ручной выбор на случай
   неверного автоопределения.
3. Исправлен баг в parseAmount: суммы с несколькими разделителями
   тысяч и без дробной части (например "1,234,567") теряли цифры и
   отбрасывались как нераспознанные — теперь распознаются верно.
4. Добавлен явный выбор разделителя дробной части (точка/запятая) на
   случай, когда автоопределение не может разрешить неоднозначность.
5. Пример значения из файла показывается прямо под каждым выбором
   колонки при сопоставлении — не нужно доходить до превью, чтобы
   понять, что в колонке.
2026-09-10 20:06:41 +03:00
ZProger
78826db33e Revert "feat: автосоздавать .env из .env.example при первом запуске"
This reverts commit 47027c517d.
2026-09-10 19:48:44 +03:00
ZProger
47027c517d feat: автосоздавать .env из .env.example при первом запуске
Раньше при отсутствии .env docker compose тихо стартовал на дефолтах
из docker-compose.yml (без пароля, дефолтные креды Postgres), никак
не сигнализируя об этом. Добавлен start.sh — обёртка над
docker compose up -d --build, которая при отсутствии .env создаёт его
из .env.example и один раз громко предупреждает об используемых
дефолтах перед стартом контейнеров. README обновлён на использование
этого скрипта.
2026-09-10 19:45:16 +03:00
ZProger
6df4e57f08 feat: заменить баннер отсутствия auth на модальное окно с согласием
Дизайн-баннер был легко закрываем и появлялся заново каждую сессию.
Теперь показывается блокирующее модальное окно без кнопки закрытия/
Escape/клика по фону — пройти дальше можно только явным подтверждением
'Я понимаю риски'. Согласие сохраняется в localStorage и не запрашивается повторно.
2026-09-10 19:38:07 +03:00
ZProger
292ef1e410 feat: показывать баннер, если инстанс не защищён паролем
Раньше предупреждение об отсутствии AURUM_BASIC_AUTH_USER/PASSWORD
было видно только в логах docker-контейнера при старте и терялось,
если пользователь забыл настроить .env. Теперь LoginGate различает
'авторизован' и 'auth вообще не настроен' и во втором случае
показывает заметный баннер прямо в интерфейсе.
2026-09-10 19:34:25 +03:00
ZProger
19ca9c3158 fix: скрывать file input бэкапа без display:none
На Firefox (Linux) программный .click() по input[type=file] со стилем
display:none может не открывать нативный диалог выбора файла. Теперь
инпут скрыт визуально (opacity + нулевой размер), но остаётся в layout.
2026-09-10 19:29:39 +03:00
ZProger
cd2e4ae9f9 fix: заменить crypto.randomUUID() на fallback для HTTP без TLS
crypto.randomUUID() доступен только в secure context (HTTPS/localhost),
из-за чего при открытии приложения по обычному HTTP падал TypeError
при добавлении сплитов транзакции.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01769tkxLJooEWNjx3xc9aCv
2026-09-10 19:17:20 +03:00
49 changed files with 1740 additions and 352 deletions

View File

@@ -1,6 +1,17 @@
# Copy this file to .env and adjust values before running `docker compose up`.
# --- Postgres ---
# IMPORTANT, and easy to get wrong: Postgres only applies this password when
# it FIRST creates its data directory. Change it later and the running
# database keeps the old one — the value here and the value in the database
# drift apart, and the backend starts failing to connect with a password
# authentication error that looks like it came out of nowhere. So set it
# before the first `docker compose up`. Changing it afterwards means either
# ALTER USER inside the running database, or deleting the aurum_pgdata volume
# (which deletes all your data — export a backup from Settings first).
#
# The database is not published to your host machine (no ports mapping for the
# db service), so it's only reachable from inside the container network.
AURUM_POSTGRES_USER=aurum
AURUM_POSTGRES_PASSWORD=change-me
AURUM_POSTGRES_DB=aurum
@@ -23,6 +34,13 @@ AURUM_DEFAULT_CURRENCY=USD
# you to set this — nothing else in the app is affected.
AURUM_COINGECKO_API_KEY=
# Swagger UI / ReDoc / openapi.json at /api/docs. Left on: the REST API is a
# documented feature (see DOCS.md), and when basic auth is configured these
# sit behind it like every other /api/ route. Set it to false on an instance
# that's reachable from the internet without basic auth — an open
# openapi.json is a complete machine-readable map of every endpoint.
AURUM_ENABLE_DOCS=true
# --- Basic auth (STRONGLY recommended if this instance is reachable from
# anywhere other than your own machine) ---
# Aurum has no login system of its own — it's built for one person
@@ -36,3 +54,21 @@ AURUM_BASIC_AUTH_PASSWORD=
# --- Ports exposed on your host machine ---
AURUM_WEB_PORT=3000
# Which host interface Aurum listens on. The default, 127.0.0.1, means "this
# machine only" — nothing else on your network can reach it. Change it to
# 0.0.0.0 to open it up (phone on the same Wi-Fi, another PC, a reverse
# proxy), but set the basic auth variables above first: without them the app
# has no password, and this is the line that decides who can knock.
AURUM_BIND_ADDRESS=127.0.0.1
# Hostnames Aurum will answer to, space- or comma-separated. Anything else
# gets a 421 with a message telling you to edit this line. It exists to block
# DNS rebinding: a site you're visiting can point its own domain at
# 127.0.0.1 and then read your instance through your own browser — the Host
# header is what gives it away.
#
# If you opened AURUM_BIND_ADDRESS above, add whatever you type in the
# address bar here too: the LAN IP (192.168.1.50), your domain
# (aurum.example.com), a .local name. Set it to * to turn the check off.
AURUM_ALLOWED_HOSTS=localhost 127.0.0.1

View File

@@ -8,6 +8,12 @@ on:
push:
branches: [main]
# Least privilege for GITHUB_TOKEN: these jobs only ever read the checkout.
# Without this the token inherits the repository default, which can include
# write access — handed to every third-party action in the run.
permissions:
contents: read
jobs:
backend-tests:
runs-on: ubuntu-latest

23
Caddyfile Normal file
View File

@@ -0,0 +1,23 @@
# Used only by the TLS overlay (docker-compose.tls.yml). The hostname comes
# from AURUM_DOMAIN in .env — Caddy requests a Let's Encrypt certificate for
# it on first start and renews it on its own from then on.
#
# For a local trial without a public domain, set AURUM_DOMAIN=localhost and
# uncomment "tls internal" below: Caddy then issues its own certificate from
# a local CA. The browser won't trust it until you install that CA, so this
# is for checking the plumbing, not for daily use.
{$AURUM_DOMAIN} {
# tls internal
encode zstd gzip
# Aurum's own nginx already sets CSP, nosniff, Referrer-Policy and the
# rest — they pass through untouched. HSTS is added here instead of
# there on purpose: it tells browsers to refuse plain HTTP to this host
# for a year, which is only safe to promise once TLS actually terminates
# in front of the app. Setting it in nginx.conf would apply it to
# plain-HTTP installs too, and lock them out of their own instance.
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
reverse_proxy web:80
}

25
DOCS.md
View File

@@ -116,9 +116,13 @@ string.
### `GET /api/health`
No auth required. Returns `{"status": "ok", "version": "1.1.0"}` — `version` is the running app's
version, the same value shown in the UI under Settings. Use this to check the backend is up before
hitting anything else.
No auth required. Returns `{"status": "ok"}`. Use this to check the backend is up before hitting
anything else.
This response deliberately carries nothing else. It used to include the running app version, but
this is the one endpoint served without authentication — handing the exact release to anyone who
can reach the instance tells them which known issues it still has. The version now comes back as
`app_version` from `GET /settings`, which sits behind auth like everything else.
## Accounts
@@ -364,6 +368,16 @@ Used by the CSV import wizard, but callable directly for any bulk load (e.g. syn
export tool). All rows are validated **before** any is inserted — one bad row fails the whole
request with no partial import.
The wizard itself (`/transactions/import`) is bank-agnostic: it auto-detects delimiter and
encoding and lets you map columns by hand. On top of that it ships **bank profiles** that pre-fill
the mapping and formats when the header row matches a known export (or when you pick the bank
explicitly): **T-Bank** (semicolon CSV, `Сумма платежа`, rows with `Статус ≠ OK` skipped),
**monobank** (Ukrainian-language CSV, `Сума в валюті картки (…)`) and **PrivatBank**
(`Сума в валюті картки`, `Опис операції`). Profiles live in `frontend/src/lib/bankPresets.ts` —
adding a bank is one declarative entry (header matchers, date/amount format, optional row filter).
Banks that only export PDF/XLSX (Sber, Kaspi, Halyk, maib…) are imported via a third-party
PDF→CSV converter and the manual mapping.
```json
{ "items": [ /* 1–5000 TransactionCreate objects */ ] }
```
@@ -763,10 +777,13 @@ Single row, created automatically on first run — there's nothing to create, on
"net_worth_decline_threshold_months": 2,
"risky_allocation_threshold_percent": 20,
"idle_cash_threshold_amount": "1000.00",
"idle_cash_threshold_days": 60
"idle_cash_threshold_days": 60,
"app_version": "1.1.8"
}
```
- `app_version`: read-only, ignored on `PATCH` — the running app's release, shown in the UI under
Settings. It lives here rather than on the unauthenticated `/health`.
- `currency`: 3-letter uppercase code — display-only, no conversion.
- `negative_cash_flow_threshold_months` / `net_worth_decline_threshold_months`: consecutive months
before the corresponding alert fires (`1`–`24`).

View File

@@ -58,7 +58,7 @@ Aurum treats your financial life as a **system**, not a spreadsheet.
## 🧩 Core Features
### 💰 Cash Flow & Transactions
Log income, expenses, and transfers across as many accounts as you want, organized into categories (with one level of subcategories) plus free-form tags, and searchable. One purchase spanning multiple subcategories of the same parent (a grocery receipt part "Sweets", part "Alcohol") can be recorded as a single split transaction instead of several — every report and chart still counts each share under its own category. Already have history elsewhere? Import a bank's CSV export in a guided 3-step wizard instead of typing every line by hand. A dedicated Cash Flow view charts income vs. expense month by month.
Log income, expenses, and transfers across as many accounts as you want, organized into categories (with one level of subcategories) plus free-form tags, and searchable. One purchase spanning multiple subcategories of the same parent (a grocery receipt part "Sweets", part "Alcohol") can be recorded as a single split transaction instead of several — every report and chart still counts each share under its own category. Already have history elsewhere? Import a bank's CSV export in a guided 3-step wizard instead of typing every line by hand — with built-in profiles for T-Bank, monobank and PrivatBank exports (columns, date/amount formats and declined-payment rows handled automatically), and manual column mapping for everything else. A dedicated Cash Flow view charts income vs. expense month by month.
### 📈 Net Worth Engine
A live net worth timeline (30 days to all-time) aggregating cash and every manually tracked asset — investments, crypto, real estate, vehicles, precious metals — into one number, with a full breakdown by asset class, by how each asset behaves (income / neutral / drain), and by risk level.
@@ -173,9 +173,35 @@ or open `/api/docs` on your running instance for interactive Swagger docs.
**Aurum has no built-in login system.** It's built for one person to self-host one private instance of their own financial data — not as a multi-tenant service with per-user accounts. That's a deliberate trade-off, not an oversight, but it means:
- If you leave `AURUM_BASIC_AUTH_USER` / `AURUM_BASIC_AUTH_PASSWORD` unset in `.env`, **anyone who can reach the container can read, edit, and delete all of it — no password prompt at all.** This is fine if Aurum is only reachable from `localhost` or your own private network.
- If you leave `AURUM_BASIC_AUTH_USER` / `AURUM_BASIC_AUTH_PASSWORD` unset in `.env`, **anyone who can reach the container can read, edit, and delete all of it — no password prompt at all.** The app itself now says so on first load, with a warning you have to dismiss. This is fine if Aurum is only reachable from `localhost`.
- Set both variables before exposing your instance beyond your own machine (a VPS, a subdomain, a Tailscale/VPN endpoint someone else might share). This turns on an HTTP Basic Auth prompt in front of the entire app, UI and API alike.
- For anything beyond that — a reverse proxy with TLS (Caddy, Traefik, nginx + Let's Encrypt) is on you; Aurum doesn't terminate HTTPS itself.
### Two settings that decide who can reach you
Both default to "this machine only", so opening Aurum up is a deliberate act rather than something that happens by accident:
| Variable | Default | What it does |
|---|---|---|
| `AURUM_BIND_ADDRESS` | `127.0.0.1` | Which host interface the port is published on. The default means nothing else on your network can connect, even on the same Wi-Fi. Set `0.0.0.0` to open it. |
| `AURUM_ALLOWED_HOSTS` | `localhost 127.0.0.1` | Hostnames Aurum answers to; anything else gets a `421`. This blocks DNS rebinding, where a site you're browsing points its own domain at `127.0.0.1` and reads your instance through your own browser. |
If you open the bind address, add whatever you type in the address bar to `AURUM_ALLOWED_HOSTS` too — the LAN IP, your domain, a `.local` name. Forgetting the second one is the usual cause of a `421` on an otherwise working setup.
### Serving it over HTTPS
Aurum speaks plain HTTP by design — certificates belong to a dedicated terminator, not to the app's own nginx. Basic Auth sends your password on **every** request, so without TLS anyone who can see the traffic has it.
A ready-to-use Caddy setup ships with the repo (`docker-compose.tls.yml` + `Caddyfile`). It gets a Let's Encrypt certificate on first start and renews it by itself:
```bash
# in .env — a real domain pointing at this host
AURUM_DOMAIN=aurum.example.com
AURUM_ALLOWED_HOSTS=aurum.example.com
docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d --build
```
Caddy then owns ports 80 and 443 (80 stays open for the ACME challenge and the HTTPS redirect), the `web` container stops publishing a port of its own, and HSTS is added at the proxy — deliberately there and not in `nginx.conf`, since promising browsers "never use plain HTTP for this host again" is only safe once TLS is actually in front.
If you find a security issue, please open a private report via GitHub's Security tab rather than a public issue.

View File

@@ -14,6 +14,19 @@ RUN pip install --upgrade pip && pip install -r requirements.txt
COPY . .
# Drop root. The app never writes to disk (PYTHONDONTWRITEBYTECODE above, no
# uploads, no local cache), so it only needs read access to /app — which is
# why the source stays root-owned and is merely readable here. Anything that
# manages to execute code in this container now lands as an unprivileged user
# instead of root, which is the difference between a bad day and a container
# escape.
#
# Note for running the test suite (see tests/README.md): installing
# requirements-dev.txt inside the container now needs `--user`, or run the
# exec with `-u root`.
RUN useradd --create-home --uid 10001 aurum
USER aurum
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=5 \

View File

@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.core.audit import log_destructive
from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance
from app.services.account_service import create_account, delete_account, list_accounts, update_account
@@ -31,4 +32,7 @@ async def update_account_route(
@router.delete("/{account_id}", status_code=204)
async def delete_account_route(account_id: int, session: AsyncSession = Depends(get_session)) -> None:
# Takes every transaction on the account with it, so it's worth a line in
# the log even though the UI asks for confirmation first.
await delete_account(session, account_id)
log_destructive("account.deleted", account_id=account_id)

View File

@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.core.audit import log_destructive
from app.schemas.backup import BackupPayload
from app.services.backup_service import build_backup, restore_backup
@@ -15,5 +16,16 @@ async def export_backup(session: AsyncSession = Depends(get_session)) -> BackupP
@router.post("/import")
async def import_backup(payload: BackupPayload, session: AsyncSession = Depends(get_session)) -> dict[str, str]:
# Logged before the fact as well as after: if the restore dies partway
# (it rolls back, but the process could also be killed outright), the
# "started" line is the only evidence it was ever attempted.
log_destructive(
"backup.restore.started",
exported_at=payload.exported_at,
source_app_version=payload.app_version,
accounts=len(payload.accounts),
transactions=len(payload.transactions),
)
await restore_backup(session, payload)
log_destructive("backup.restore.completed", transactions=len(payload.transactions))
return {"status": "ok"}

View File

@@ -9,6 +9,9 @@ from app.services.settings_service import get_or_create_app_settings
router = APIRouter(prefix="/settings", tags=["settings"])
# Both routes still return the ORM row as-is: AppSettingsRead.app_version
# isn't a stored column, it defaults to APP_VERSION when FastAPI validates
# the response (see schemas/settings.py).
@router.get("", response_model=AppSettingsRead)
async def read_settings(session: AsyncSession = Depends(get_session)) -> AppSettings:
return await get_or_create_app_settings(session)

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.deps import get_session
from app.core.audit import log_destructive
from app.models.category import Category
from app.models.enums import CategoryKind, TransactionType
from app.models.tag import Tag
@@ -228,6 +229,9 @@ async def bulk_create_transactions(
session.add_all(transactions)
await session.commit()
# One request can add thousands of rows (CSV import) — worth a line so an
# unexpected pile of transactions can be dated later.
log_destructive("transactions.bulk_created", count=len(transactions))
return TransactionBulkCreateResult(created=len(transactions))

32
backend/app/core/audit.py Normal file
View File

@@ -0,0 +1,32 @@
"""Audit trail for the handful of operations that destroy or wholesale
replace data.
Aurum is single-user, so this isn't about "who did it" — there's only one
person. It's about *what happened and when*, after the fact. The operations
recorded here are the ones that can't be undone from the UI: restoring a
backup wipes every table before writing the new rows, deleting an account
takes its transactions with it, and a CSV import can add thousands of rows in
one request. When someone opens the app and finds it empty, the container log
is the only place that can say whether a restore ran at 02:14 or whether
something else went wrong.
Deliberately just the standard logger, no table of its own: an audit trail
that lives in the same database it's auditing disappears in exactly the event
it exists to explain (a restore truncates it too). Read it with
`docker compose logs backend`.
"""
import logging
logger = logging.getLogger("aurum.audit")
def log_destructive(action: str, **details: object) -> None:
"""Record one irreversible operation. `details` are rendered as
key=value; keep them to counts and identifiers — never transaction
descriptions, amounts or anything else that would spill the user's
finances into a log file that gets shared when debugging."""
if details:
rendered = " ".join(f"{key}={value}" for key, value in details.items())
logger.warning("%s %s", action, rendered)
else:
logger.warning("%s", action)

View File

@@ -4,10 +4,11 @@ from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
# Single source of truth for the running app's version — surfaced in the API
# title, /api/health (which the frontend reads to show it in Settings), and
# embedded in exported backups so an old file can be told apart from a
# current one.
APP_VERSION = "1.1.6"
# title, in GET /api/settings as app_version (which the frontend reads to show
# it in Settings; it deliberately does NOT ride on the unauthenticated
# /api/health), and embedded in exported backups so an old file can be told
# apart from a current one.
APP_VERSION = "1.1.8"
class Settings(BaseSettings):
@@ -35,6 +36,14 @@ class Settings(BaseSettings):
# rather than silently hitting CoinGecko's much stingier keyless tier.
coingecko_api_key: str = ""
# Swagger/ReDoc/openapi.json at /api/docs. On by default because the API
# is a documented feature of this app (see DOCS.md), and because when
# basic auth is configured these sit behind it like everything else under
# /api/. Turn it off on an instance that's reachable from the internet
# *without* basic auth: an open /api/openapi.json is a complete,
# machine-readable map of every endpoint and payload shape.
enable_docs: bool = True
@property
def database_url(self) -> str:
return (

View File

@@ -46,9 +46,12 @@ app = FastAPI(
# backend (see frontend/nginx.conf) — everything else falls through to
# the SPA's index.html, which is why the defaults (/docs, /openapi.json)
# would silently 404 through the reverse proxy.
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
# All three go away together when AURUM_ENABLE_DOCS=false — leaving
# openapi.json reachable would keep handing out the full API map even
# with the Swagger UI itself switched off.
docs_url="/api/docs" if settings.enable_docs else None,
redoc_url="/api/redoc" if settings.enable_docs else None,
openapi_url="/api/openapi.json" if settings.enable_docs else None,
)
# CORS stays off unless someone deliberately opens it, and credentials are
@@ -88,7 +91,10 @@ app.include_router(crypto.router, prefix="/api")
@app.get("/api/health")
async def health() -> dict[str, str]:
# version rides along so the frontend's Settings page can show which
# release is actually running without a separate authenticated endpoint —
# this route is already auth_basic-exempt for Docker's HEALTHCHECK.
return {"status": "ok", "version": APP_VERSION}
# Status only. This is the one route nginx serves without auth (Docker's
# HEALTHCHECK and uptime monitors need it — see frontend/nginx.conf), so
# anything returned here is world-readable. The running version used to
# ride along, which told an unauthenticated caller exactly which release
# they were looking at, and therefore which known issues it still has;
# it now comes back from GET /api/settings, which is behind auth.
return {"status": "ok"}

View File

@@ -23,7 +23,7 @@ class AssetBase(BaseModel):
name: str = Field(min_length=1, max_length=150)
asset_class: AssetClass
currency: str = Field(default="USD", min_length=3, max_length=3)
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
capital_role: CapitalRole = CapitalRole.NEUTRAL
# Rough self-reported monthly net cash flow — informational, not tracked
# transactions (see Asset.monthly_cash_flow).
@@ -40,7 +40,7 @@ class AssetUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=150)
asset_class: AssetClass | None = None
currency: str | None = Field(default=None, min_length=3, max_length=3)
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
capital_role: CapitalRole | None = None
monthly_cash_flow: Decimal | None = Field(default=None, max_digits=14, decimal_places=2)
risk_level: RiskLevel | None = None

View File

@@ -15,7 +15,7 @@ class RecurringTransactionCreate(BaseModel):
amount: Decimal = Field(gt=0)
description: str = Field(min_length=1, max_length=255)
merchant: str | None = None
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
frequency: RecurringFrequency
anchor_date: date_
@@ -35,7 +35,7 @@ class RecurringTransactionUpdate(BaseModel):
amount: Decimal | None = Field(default=None, gt=0)
description: str | None = Field(default=None, min_length=1, max_length=255)
merchant: str | None = None
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
frequency: RecurringFrequency | None = None
anchor_date: date_ | None = None
is_active: bool | None = None

View File

@@ -1,15 +1,25 @@
from decimal import Decimal
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from app.core.config import APP_VERSION
class AppSettingsRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
currency: str
negative_cash_flow_threshold_months: int
net_worth_decline_threshold_months: int
risky_allocation_threshold_percent: int
idle_cash_threshold_amount: Decimal
idle_cash_threshold_days: int
# Not a stored column: the default fills itself in when FastAPI validates
# the ORM row against this model, so neither route has to assemble it.
# It lives on this (authenticated) response rather than on /api/health,
# which is deliberately unauthenticated and so would hand the running
# version to anyone who can reach the instance.
app_version: str = APP_VERSION
class AppSettingsUpdate(BaseModel):

View File

@@ -86,7 +86,7 @@ class TransactionFields(BaseModel):
amount: Decimal = Field(gt=0, max_digits=14, decimal_places=2)
description: str = Field(min_length=1, max_length=255)
merchant: str | None = Field(default=None, max_length=150)
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
date: date_
# Auto-capitalizes "траты на продукты" -> "Траты на продукты" so mixed
@@ -152,7 +152,7 @@ class TransactionUpdate(BaseModel):
amount: Decimal | None = Field(default=None, gt=0, max_digits=14, decimal_places=2)
description: str | None = Field(default=None, min_length=1, max_length=255)
merchant: str | None = Field(default=None, max_length=150)
notes: str | None = None
notes: str | None = Field(default=None, max_length=2000)
date: date_ | None = None
# Omitted -> tags untouched; sent (even as []) -> replaces the full tag set.
tag_ids: list[int] | None = None

View File

@@ -9,6 +9,7 @@ run first, before any row is touched), and any failure during the swap rolls
the database back to exactly where it was, so a bad file never leaves the
app half-restored.
"""
import logging
from datetime import datetime, timezone
from fastapi import HTTPException
@@ -46,6 +47,8 @@ from app.schemas.backup import (
TransactionSplitBackup,
)
logger = logging.getLogger(__name__)
BACKUP_FORMAT_VERSION = 1
@@ -304,4 +307,9 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
raise
except Exception as exc:
await session.rollback()
raise HTTPException(400, f"Restore failed, no changes were made: {exc}") from exc
# The exception text is SQLAlchemy's, naming tables, columns and
# constraints — a free map of the schema for anyone probing the API.
# It goes to the container log, where the operator can actually read
# it; the client gets told only that nothing changed.
logger.exception("Backup restore failed")
raise HTTPException(400, "Restore failed, no changes were made") from exc

View File

@@ -3,6 +3,6 @@
# backend container (see backend/tests/README.md) rather than baking these
# into the Docker image.
-r requirements.txt
pytest==8.3.4
pytest-asyncio==0.25.0
pytest==9.1.1
pytest-asyncio==1.4.0
httpx==0.28.1

View File

@@ -2,7 +2,7 @@ fastapi==0.141.1
uvicorn[standard]==0.52.4
sqlalchemy==2.0.52
asyncpg==0.31.0
alembic==1.19.2
alembic==1.20.0
pydantic==2.13.5
pydantic-settings==2.15.0
python-dotenv==1.2.3

View File

@@ -14,12 +14,22 @@ lifetime, then run pytest inside the running `backend` container — it's the
only place with network access to Postgres:
```bash
docker compose exec backend pip install -r requirements-dev.txt
docker compose exec backend pytest -v
docker compose exec backend pip install --user -r requirements-dev.txt
docker compose exec backend python -m pytest -v
```
Re-run just `pytest -v` for subsequent runs; the `pip install` only needs
repeating after a container restart/rebuild.
Re-run just the `python -m pytest -v` line for subsequent runs; the
`pip install` only needs repeating after a container restart/rebuild.
Two details that both come from the container running as an unprivileged user
(`aurum`, uid 10001 — see backend/Dockerfile) rather than root:
- `--user` on the install: site-packages belongs to root and isn't writable.
The packages land in `/home/aurum/.local`, which isn't on `PATH`, hence
`python -m pytest` instead of a bare `pytest`.
- pytest prints a `PytestCacheWarning` about not being able to write
`/app/.pytest_cache`. Expected — the source tree is read-only to the app
user by design. Tests still pass; add `-p no:cacheprovider` to silence it.
## Adding a test

50
docker-compose.tls.yml Normal file
View File

@@ -0,0 +1,50 @@
# TLS overlay — opt in, not part of the default stack.
#
# docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d
#
# Aurum itself speaks plain HTTP and stays that way: certificate handling is
# a job for a dedicated terminator, not for the app's own nginx. Caddy is
# used here because it obtains and renews a Let's Encrypt certificate on its
# own, with no cron job and no certbot invocation to forget.
#
# What this changes:
# - Caddy takes ports 80 and 443 on the host. 80 has to stay open: it's
# both the ACME HTTP challenge and the redirect to HTTPS.
# - The web container stops publishing a port of its own — it's reachable
# only from inside the compose network, through Caddy.
#
# Required before the first run (see .env):
# AURUM_DOMAIN=aurum.example.com — a real name resolving to this host
# AURUM_ALLOWED_HOSTS=aurum.example.com
# ^ easy to miss: the app's own Host check (25-allowed-hosts.sh) still
# applies behind the proxy, and Caddy forwards the original Host, so
# leaving this at the default gets you a 421 through a perfectly good
# certificate.
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
environment:
AURUM_DOMAIN: ${AURUM_DOMAIN:?set AURUM_DOMAIN in .env to the hostname this instance answers on}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
# Certificates and ACME account keys. Keep this volume — losing it
# means re-issuing certificates, and Let's Encrypt rate-limits that.
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
web:
# Reachable only through Caddy now. The "!reset" is what actually removes
# the base file's published port instead of adding to it — a plain empty
# list would be merged, not substituted, and the port would stay open.
ports: !reset []
volumes:
caddy_data:
caddy_config:

View File

@@ -4,7 +4,14 @@ services:
restart: unless-stopped
environment:
POSTGRES_USER: ${AURUM_POSTGRES_USER:-aurum}
POSTGRES_PASSWORD: ${AURUM_POSTGRES_PASSWORD:-aurum}
# "change-me" so this matches .env.example exactly: with no .env at all
# the fallback here used to be "aurum" while the template said
# "change-me", i.e. "the default password" meant two different things
# depending on whether the file existed. Postgres isn't published to the
# host (no ports: below), so reaching it means already being on the
# container network — but see .env.example for the trap that makes this
# worth getting right the first time.
POSTGRES_PASSWORD: ${AURUM_POSTGRES_PASSWORD:-change-me}
POSTGRES_DB: ${AURUM_POSTGRES_DB:-aurum}
volumes:
- aurum_pgdata:/var/lib/postgresql/data
@@ -17,9 +24,17 @@ services:
backend:
build: ./backend
restart: unless-stopped
# Nothing here ever needs to gain privileges (no setuid binaries, no sudo),
# so forbid it outright — this is what stops a compromised process from
# escalating back to root inside the container.
security_opt:
- no-new-privileges:true
environment:
AURUM_POSTGRES_USER: ${AURUM_POSTGRES_USER:-aurum}
AURUM_POSTGRES_PASSWORD: ${AURUM_POSTGRES_PASSWORD:-aurum}
# Must stay identical to the db service's fallback above — if the two
# drift, an install without .env brings up a database with one password
# and a backend dialling it with another.
AURUM_POSTGRES_PASSWORD: ${AURUM_POSTGRES_PASSWORD:-change-me}
AURUM_POSTGRES_DB: ${AURUM_POSTGRES_DB:-aurum}
AURUM_POSTGRES_HOST: db
AURUM_POSTGRES_PORT: 5432
@@ -28,6 +43,7 @@ services:
# Free CoinGecko Demo key (coingecko.com/en/api/pricing) — the Crypto
# tab's price sync 400s with a clear message until this is set.
AURUM_COINGECKO_API_KEY: ${AURUM_COINGECKO_API_KEY:-}
AURUM_ENABLE_DOCS: ${AURUM_ENABLE_DOCS:-true}
depends_on:
db:
condition: service_healthy
@@ -35,12 +51,26 @@ services:
web:
build: ./frontend
restart: unless-stopped
# nginx's master still needs root to bind :80 and to write the htpasswd
# file at boot (docker-entrypoint.d/20-basic-auth.sh), so this one can't
# drop to a non-root user without reworking both — but it can still be
# barred from escalating any further.
security_opt:
- no-new-privileges:true
ports:
- "${AURUM_WEB_PORT:-3000}:80"
# Bound to loopback by default: published as "3000:80" the app answers
# on every interface, so anyone on the same Wi-Fi reaches it — and by
# default there is no password on it at all (see 20-basic-auth.sh).
# Set AURUM_BIND_ADDRESS=0.0.0.0 to reach it from other devices, but
# set AURUM_BASIC_AUTH_USER/PASSWORD before you do.
- "${AURUM_BIND_ADDRESS:-127.0.0.1}:${AURUM_WEB_PORT:-3000}:80"
environment:
# Both unset = no login screen at all. See frontend/docker-entrypoint.d/20-basic-auth.sh.
AURUM_BASIC_AUTH_USER: ${AURUM_BASIC_AUTH_USER:-}
AURUM_BASIC_AUTH_PASSWORD: ${AURUM_BASIC_AUTH_PASSWORD:-}
# Hostnames this instance answers to — anything else gets a 421.
# See frontend/docker-entrypoint.d/25-allowed-hosts.sh.
AURUM_ALLOWED_HOSTS: ${AURUM_ALLOWED_HOSTS:-localhost 127.0.0.1}
depends_on:
- backend

View File

@@ -12,13 +12,16 @@ RUN apk add --no-cache openssl
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.d/20-basic-auth.sh /docker-entrypoint.d/20-basic-auth.sh
COPY docker-entrypoint.d/25-allowed-hosts.sh /docker-entrypoint.d/25-allowed-hosts.sh
# sed strips any stray CR: cloned on Windows with core.autocrlf=true the
# script arrives as CRLF, the kernel reads the shebang as "/bin/sh\r", and
# scripts arrive as CRLF, the kernel reads the shebang as "/bin/sh\r", and
# nginx dies on boot with "not found" (exit 127). .gitattributes fixes this
# at the repository level; this line covers checkouts that predate it.
RUN sed -i 's/\r$//' /docker-entrypoint.d/20-basic-auth.sh \
&& chmod +x /docker-entrypoint.d/20-basic-auth.sh \
&& touch /etc/nginx/basic-auth.conf
# The empty .conf files matter: nginx.conf includes both unconditionally, and
# an include of a missing file is a startup error.
RUN sed -i 's/\r$//' /docker-entrypoint.d/20-basic-auth.sh /docker-entrypoint.d/25-allowed-hosts.sh \
&& chmod +x /docker-entrypoint.d/20-basic-auth.sh /docker-entrypoint.d/25-allowed-hosts.sh \
&& touch /etc/nginx/basic-auth.conf /etc/nginx/allowed-hosts.conf
EXPOSE 80

View File

@@ -0,0 +1,45 @@
#!/bin/sh
# Runs before nginx starts, same convention as 20-basic-auth.sh next door.
#
# Writes the Host allow-list that nginx.conf includes. This is what stops DNS
# rebinding: a page on the open internet can point its own hostname at
# 127.0.0.1, wait for the DNS TTL to expire, and then have the victim's own
# browser issue same-origin requests to whatever is listening there. The
# browser's origin checks don't help — as far as it's concerned the request
# really is same-origin — but the Host header still carries the attacker's
# domain, so refusing hostnames we don't expect closes it.
#
# AURUM_ALLOWED_HOSTS is a space- or comma-separated list. Default covers
# local access only; add your own hostname when you serve Aurum under one
# (see .env.example). "*" disables the check entirely.
set -eu
FRAGMENT=/etc/nginx/allowed-hosts.conf
HOSTS="${AURUM_ALLOWED_HOSTS:-localhost 127.0.0.1}"
if [ "$HOSTS" = "*" ]; then
: > "$FRAGMENT"
echo "[aurum] Host header check disabled (AURUM_ALLOWED_HOSTS=*)." >&2
return 0 2>/dev/null || exit 0
fi
# "a,b c" -> "a|b|c", with regex metacharacters in each entry escaped so an
# IP's dots can't match any character.
PATTERN="$(printf '%s' "$HOSTS" \
| tr ',' ' ' \
| tr -s ' ' '\n' \
| sed '/^$/d' \
| sed 's/[].[^$*\/+?(){}|\\]/\\&/g' \
| paste -sd '|' -)"
cat > "$FRAGMENT" <<EOF
# Generated at container start by docker-entrypoint.d/25-allowed-hosts.sh.
# \$host is the Host header without the port, so entries here are bare
# hostnames. "if" is normally best avoided in nginx, but a bare "return"
# inside it is one of the two documented-safe uses.
if (\$host !~* '^(${PATTERN})\$') {
return 421 "Aurum: unexpected Host header '\$host'. Add it to AURUM_ALLOWED_HOSTS in .env (see .env.example), or set that variable to * to disable this check.\n";
}
EOF
echo "[aurum] Host header allow-list: ${HOSTS}" >&2

View File

@@ -1,29 +1,39 @@
<!doctype html>
<html lang="ru">
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b0b0b" />
<title>Aurum — Личные финансы</title>
<script>
// Applies the saved theme (light/dark/system) to <html> before the
// first paint, so an explicit override never flashes the wrong theme
// for a frame while React/lib/theme.ts loads. "system" needs no
// attribute at all — index.css's prefers-color-scheme media query
// already handles that case with zero JS.
(function () {
var stored = localStorage.getItem("aurum:theme");
if (stored === "light" || stored === "dark") {
document.documentElement.setAttribute("data-theme", stored);
}
var isDark =
stored === "dark" || (stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
var meta = document.querySelector('meta[name="theme-color"]');
// Kept in sync by hand with index.css's --surface-0 light/dark values.
if (meta) meta.setAttribute("content", isDark ? "#0d0d0d" : "#f9f9f7");
})();
</script>
<title>Aurum — Personal Finance</title>
<meta
name="description"
content="Aurum is a self-hosted personal finance operating system: cash flow, net worth, budgets, subscriptions, assets, and every income stream in one place."
/>
<meta
name="keywords"
content="personal finance, finance tracker, budgeting, self-hosted, cash flow, net worth, expense tracker, subscription tracker, crypto tracker, family budget"
/>
<meta name="author" content="Aurum" />
<!-- Self-hosted app without a fixed public domain, so og:url/og:image
are left out — each deployment would need its own values. -->
<meta property="og:title" content="Aurum — Personal Finance" />
<meta
property="og:description"
content="A self-hosted personal finance operating system: cash flow, net worth, budgets, subscriptions, and assets."
/>
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Aurum — Personal Finance" />
<meta
name="twitter:description"
content="A self-hosted personal finance operating system: cash flow, net worth, budgets, subscriptions, and assets."
/>
<!-- Runs before the first paint to avoid a wrong-theme flash. Kept in its
own file (public/theme-init.js), not inline, so the CSP in
frontend/nginx.conf can forbid inline scripts entirely. -->
<script src="/theme-init.js"></script>
</head>
<body>
<div id="root"></div>

View File

@@ -1,9 +1,58 @@
# Rate limit shared by every /api/ request. This file is included from inside
# nginx's http{} block, which is the only context where a zone can be declared.
# Basic Auth has no lockout, no captcha and no delay of its own, so without
# this the single instance password can be guessed at network speed. The rate
# is deliberately well above what the UI needs (a dashboard load fires a
# dozen or so queries at once, and CSV import pages through history) and still
# cuts a brute-force attempt by orders of magnitude.
limit_req_zone $binary_remote_addr zone=aurum_api:10m rate=20r/s;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Don't advertise the exact nginx version — it turns "is this instance
# vulnerable to X" into a lookup instead of a probe.
server_tokens off;
# Rejects requests whose Host header isn't one we expect (DNS rebinding —
# see docker-entrypoint.d/25-allowed-hosts.sh, which generates this and
# explains the attack). Empty file when the check is switched off.
include /etc/nginx/allowed-hosts.conf;
# Security headers, applied at server level so every location inherits
# them. Note for whoever edits this next: adding an add_header inside a
# location block drops ALL of these for that location — nginx does not
# merge them.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# Enforced. This started out as Content-Security-Policy-Report-Only, but
# Report-Only without a report-uri/report-to endpoint is inert in Firefox
# — it says so itself in the console: "CSP will not block and cannot
# report violations of this policy". So an empty console proved nothing,
# and the choice was between standing up a report-collecting endpoint for
# a single self-hosted app or enforcing and watching. Enforcing wins: a
# violation then shows up in every browser's console *and* as a visibly
# broken element, and rolling back is renaming this one header.
#
# Why each part is the way it is:
# script-src 'self' — verified: the built bundle contains no eval/
# new Function, and the theme script was moved
# out of index.html into /theme-init.js.
# style-src 'unsafe-inline' — unavoidable for now: 27 components use
# style={{...}} and Recharts injects inline
# styles of its own.
# img-src ... coingecko — coin icons come straight from CoinGecko's
# CDN (thumb_url on a holding); both the
# current and the older asset host are listed.
# img-src blob: — the chart/download paths build blob URLs.
# connect-src 'self' — the API is same-origin; nothing else is called.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://coin-images.coingecko.com https://assets.coingecko.com; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# Always open, no auth — this is what Docker's own HEALTHCHECK and any
# external uptime monitor hit, and it returns nothing but {"status":"ok"}.
# docker-entrypoint.d/20-basic-auth.sh writes basic-auth.conf; without an
@@ -11,6 +60,11 @@ server {
# sets below and healthchecks would start failing with 401.
location = /api/health {
auth_basic off;
# Rate limited too: this is the one unauthenticated route, so it's the
# one an attacker can hammer for free. Docker's HEALTHCHECK hits it
# once every 10s, nowhere near the limit.
limit_req zone=aurum_api burst=40 nodelay;
limit_req_status 429;
proxy_pass http://backend:8000/api/health;
proxy_set_header Host $host;
}
@@ -20,6 +74,21 @@ server {
# aren't set, so this is a no-op by default) — see docker-entrypoint.d/.
location /api/ {
include /etc/nginx/basic-auth.conf;
limit_req zone=aurum_api burst=40 nodelay;
limit_req_status 429;
# Backup restore posts the whole snapshot as one JSON body, and a
# real one runs to several MB — nginx's 1m default silently rejected
# those with a 413 before they ever reached the backend, which meant
# backups could be taken but not restored. Bounded on purpose rather
# than disabled (0), so an oversized upload still can't exhaust memory.
client_max_body_size 64m;
client_body_timeout 60s;
# A large restore rewrites every table in one transaction; the default
# 60s read timeout can cut that off midway.
proxy_read_timeout 120s;
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -1,32 +1,32 @@
{
"name": "aurum-frontend",
"version": "1.1.6",
"version": "1.1.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aurum-frontend",
"version": "1.1.6",
"version": "1.1.8",
"dependencies": {
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query": "^5.102.8",
"clsx": "^2.1.1",
"lucide-react": "^1.31.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"lucide-react": "^1.45.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router-dom": "^7.18.3",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.4.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@types/node": "^26.5.1",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.0.1",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.2.2",
"vite": "^8.3.0",
"vitest": "^5.0.0"
}
},
@@ -285,13 +285,13 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
"integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
"version": "0.149.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@reduxjs/toolkit": {
@@ -320,10 +320,27 @@
}
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
"integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
"cpu": [
"arm64"
],
@@ -338,9 +355,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
"cpu": [
"arm64"
],
@@ -355,9 +372,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
"integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
"cpu": [
"x64"
],
@@ -372,9 +389,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
"integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
"cpu": [
"x64"
],
@@ -389,9 +406,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
"integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
"cpu": [
"arm"
],
@@ -406,9 +423,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
"integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
"cpu": [
"arm64"
],
@@ -426,9 +443,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
"integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
"cpu": [
"arm64"
],
@@ -446,9 +463,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
"integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
"cpu": [
"ppc64"
],
@@ -466,9 +483,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
"integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
"cpu": [
"s390x"
],
@@ -486,9 +503,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
"integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
"cpu": [
"x64"
],
@@ -506,9 +523,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
"integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
"cpu": [
"x64"
],
@@ -526,9 +543,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
"integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
"cpu": [
"arm64"
],
@@ -543,9 +560,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
"integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
"cpu": [
"arm64"
],
@@ -560,9 +577,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
"integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
"cpu": [
"x64"
],
@@ -946,9 +963,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.101.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
"version": "5.102.8",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz",
"integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -956,12 +973,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
"version": "5.102.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz",
"integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.4"
"@tanstack/query-core": "5.102.8"
},
"funding": {
"type": "github",
@@ -1060,19 +1077,19 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.4.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
"integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
"version": "26.5.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
"undici-types": "~8.9.0"
}
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -1080,13 +1097,13 @@
}
},
"node_modules/@types/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
"@types/react": "^19.3.0"
}
},
"node_modules/@types/use-sync-external-store": {
@@ -2244,9 +2261,9 @@
}
},
"node_modules/lucide-react": {
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz",
"integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==",
"version": "1.45.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.45.0.tgz",
"integrity": "sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -2270,9 +2287,9 @@
"license": "CC0-1.0"
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"dev": true,
"funding": [
{
@@ -2336,9 +2353,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"dev": true,
"funding": [
{
@@ -2356,7 +2373,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.17",
"nanoid": "^3.3.18",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -2375,24 +2392,24 @@
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
"scheduler": "^0.28.0"
},
"peerDependencies": {
"react": "^19.2.8"
"react": "^19.3.0"
}
},
"node_modules/react-is": {
@@ -2525,13 +2542,13 @@
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
"integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.144.0",
"@oxc-project/types": "=0.149.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -2541,20 +2558,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.2.4",
"@rolldown/binding-darwin-arm64": "1.2.4",
"@rolldown/binding-darwin-x64": "1.2.4",
"@rolldown/binding-freebsd-x64": "1.2.4",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
"@rolldown/binding-linux-arm64-gnu": "1.2.4",
"@rolldown/binding-linux-arm64-musl": "1.2.4",
"@rolldown/binding-linux-ppc64-gnu": "1.2.4",
"@rolldown/binding-linux-s390x-gnu": "1.2.4",
"@rolldown/binding-linux-x64-gnu": "1.2.4",
"@rolldown/binding-linux-x64-musl": "1.2.4",
"@rolldown/binding-openharmony-arm64": "1.2.4",
"@rolldown/binding-win32-arm64-msvc": "1.2.4",
"@rolldown/binding-win32-x64-msvc": "1.2.4"
"@rolldown/binding-android-arm-eabi": "1.2.8",
"@rolldown/binding-android-arm64": "1.2.8",
"@rolldown/binding-darwin-arm64": "1.2.8",
"@rolldown/binding-darwin-x64": "1.2.8",
"@rolldown/binding-freebsd-x64": "1.2.8",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
"@rolldown/binding-linux-arm64-gnu": "1.2.8",
"@rolldown/binding-linux-arm64-musl": "1.2.8",
"@rolldown/binding-linux-ppc64-gnu": "1.2.8",
"@rolldown/binding-linux-s390x-gnu": "1.2.8",
"@rolldown/binding-linux-x64-gnu": "1.2.8",
"@rolldown/binding-linux-x64-musl": "1.2.8",
"@rolldown/binding-openharmony-arm64": "1.2.8",
"@rolldown/binding-win32-arm64-msvc": "1.2.8",
"@rolldown/binding-win32-x64-msvc": "1.2.8"
}
},
"node_modules/saxes": {
@@ -2571,9 +2589,9 @@
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT"
},
"node_modules/set-cookie-parser": {
@@ -2786,9 +2804,9 @@
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
@@ -2824,16 +2842,16 @@
}
},
"node_modules/vite": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.26",
"rolldown": "~1.2.4",
"picomatch": "^4.0.7",
"postcss": "^8.5.28",
"rolldown": "~1.2.6",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -2850,7 +2868,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
"@vitejs/devtools": "^0.7.1",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",

View File

@@ -1,7 +1,7 @@
{
"name": "aurum-frontend",
"private": true,
"version": "1.1.6",
"version": "1.1.8",
"type": "module",
"scripts": {
"dev": "vite",
@@ -11,25 +11,25 @@
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query": "^5.102.8",
"clsx": "^2.1.1",
"lucide-react": "^1.31.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"lucide-react": "^1.45.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router-dom": "^7.18.3",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.4.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@types/node": "^26.5.1",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.0.1",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.2.2",
"vite": "^8.3.0",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1,24 @@
// Applies the saved theme (light/dark/system) to <html> before the first
// paint, so an explicit override never flashes the wrong theme for a frame
// while React/lib/theme.ts loads. "system" needs no attribute at all —
// index.css's prefers-color-scheme media query already handles that case
// with zero JS.
//
// Lives in public/ as its own file rather than inline in index.html so the
// Content-Security-Policy in frontend/nginx.conf can forbid inline scripts
// outright — an inline block would need either 'unsafe-inline' (which gives
// up most of what CSP buys) or a hash that has to be regenerated by hand on
// every edit to these lines. Loaded as a plain synchronous <script src> in
// <head>, which still blocks the first paint, so the no-flash behaviour is
// unchanged.
(function () {
var stored = localStorage.getItem("aurum:theme");
if (stored === "light" || stored === "dark") {
document.documentElement.setAttribute("data-theme", stored);
}
var isDark =
stored === "dark" || (stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
var meta = document.querySelector('meta[name="theme-color"]');
// Kept in sync by hand with index.css's --surface-0 light/dark values.
if (meta) meta.setAttribute("content", isDark ? "#0d0d0d" : "#f9f9f7");
})();

View File

@@ -1,6 +0,0 @@
import { api } from "@/api/client";
import type { HealthStatus } from "@/types";
export function fetchHealth() {
return api.get<HealthStatus>("/health");
}

View File

@@ -26,6 +26,25 @@ export function fetchTransactions(filters: TransactionFilters = {}) {
return api.get<TransactionPage>(`/transactions?${params.toString()}`);
}
// Used by CSV import's duplicate check (see pages/CsvImportPage.tsx) — needs
// every transaction in the imported date range for one account, not just a
// page of them, to know what's already there. Capped at 25 pages (5000 rows
// at the API's max page_size) so a huge date range can't turn one import
// into an unbounded fetch loop; beyond that, duplicate detection just
// silently covers less of the range instead of hanging.
const MAX_DUPLICATE_CHECK_PAGES = 25;
export async function fetchAllTransactionsInRange(filters: TransactionFilters): Promise<Transaction[]> {
const pageSize = 200;
const items: Transaction[] = [];
for (let page = 1; page <= MAX_DUPLICATE_CHECK_PAGES; page++) {
const result = await fetchTransactions({ ...filters, page, page_size: pageSize });
items.push(...result.items);
if (items.length >= result.total || result.items.length < pageSize) break;
}
return items;
}
/** Full range of years to offer in the year picker, from the earliest
* transaction through the current year (see backend for the "gap year"
* rationale). */

View File

@@ -1,9 +1,17 @@
import { type ReactNode, useEffect, useState } from "react";
import { Logo } from "@/components/layout/Logo";
import { LoginScreen } from "@/components/auth/LoginScreen";
import { checkCredentials, useAuthHeader } from "@/lib/auth";
import { NoAuthModal } from "@/components/auth/NoAuthModal";
import { checkCredentials, useAuthHeader, whenAuthRestored } from "@/lib/auth";
type Probe = "checking" | "required" | "not-required";
// "not-configured" and "authenticated" both render `children` the same way,
// but they must stay distinct: only "not-configured" means this instance has
// no AURUM_BASIC_AUTH_USER/PASSWORD at all (nginx never sent 401 to our
// deliberately-wrong probe header), which is what triggers NoAuthModal.
// Collapsing them back into one "not-required" state would make an
// authenticated session with valid stored credentials show the "this
// instance has no password" warning too.
type Probe = "checking" | "required" | "not-configured" | "authenticated";
/** Wraps the whole app. Renders the login screen only when this instance
* actually has Basic Auth turned on (AURUM_BASIC_AUTH_USER/PASSWORD in
@@ -14,21 +22,28 @@ type Probe = "checking" | "required" | "not-required";
* reactively, and this falls back to the login screen on its own. */
export function LoginGate({ children }: { children: ReactNode }) {
const authHeader = useAuthHeader();
const [probe, setProbe] = useState<Probe>(authHeader ? "not-required" : "checking");
const [probe, setProbe] = useState<Probe>(authHeader ? "authenticated" : "checking");
useEffect(() => {
if (authHeader) {
setProbe("not-required");
setProbe("authenticated");
return;
}
let cancelled = false;
checkCredentials(null).then((result) => {
if (cancelled) return;
// A network/backend error here isn't an auth problem — don't block
// the user behind a login screen for an unrelated outage, let the
// app's own per-page error states (e.g. dashboard.errorLoading) explain it.
setProbe(result === "unauthorized" ? "required" : "not-required");
});
// Wait for the encrypted "remember me" credential to be read back before
// concluding anything: it arrives asynchronously (IndexedDB + WebCrypto),
// and probing first would show the login screen for a moment to someone
// who is, in fact, already logged in. When it does arrive it sets the
// header, which re-runs this effect down the authenticated path above.
void whenAuthRestored()
.then(() => (cancelled ? null : checkCredentials(null)))
.then((result) => {
if (cancelled || result === null) return;
// A network/backend error here isn't an auth problem — don't block
// the user behind a login screen for an unrelated outage, let the
// app's own per-page error states (e.g. dashboard.errorLoading) explain it.
setProbe(result === "unauthorized" ? "required" : "not-configured");
});
return () => {
cancelled = true;
};
@@ -46,5 +61,10 @@ export function LoginGate({ children }: { children: ReactNode }) {
return <LoginScreen />;
}
return <>{children}</>;
return (
<>
{probe === "not-configured" && <NoAuthModal />}
{children}
</>
);
}

View File

@@ -3,7 +3,7 @@ import { Logo } from "@/components/layout/Logo";
import { Button } from "@/components/ui/Button";
import { Card, CardContent } from "@/components/ui/Card";
import { Input, Label } from "@/components/ui/Input";
import { buildBasicAuthHeader, checkCredentials, setCredentials } from "@/lib/auth";
import { buildBasicAuthHeader, checkCredentials, isRememberSupported, setCredentials } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n";
type Status = "idle" | "submitting" | "invalid" | "unreachable";
@@ -19,13 +19,18 @@ export function LoginScreen() {
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(false);
const [status, setStatus] = useState<Status>("idle");
// Remembering encrypts the credential with WebCrypto, which browsers only
// expose in a secure context — over plain HTTP to a LAN address there's no
// safe way to persist it, so the option is hidden rather than offered and
// quietly ignored. See lib/credentialVault.ts.
const canRemember = isRememberSupported();
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setStatus("submitting");
const result = await checkCredentials(buildBasicAuthHeader(username, password));
if (result === "ok") {
setCredentials(username, password, remember);
setCredentials(username, password, canRemember && remember);
return;
}
setStatus(result === "unauthorized" ? "invalid" : "unreachable");
@@ -67,15 +72,19 @@ export function LoginScreen() {
/>
</div>
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
checked={remember}
onChange={(event) => setRemember(event.target.checked)}
className="h-3.5 w-3.5 accent-text-primary"
/>
{t("auth.rememberMe")}
</label>
{canRemember ? (
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
checked={remember}
onChange={(event) => setRemember(event.target.checked)}
className="h-3.5 w-3.5 accent-text-primary"
/>
{t("auth.rememberMe")}
</label>
) : (
<p className="text-xs text-text-muted">{t("auth.sessionOnlyHint")}</p>
)}
{status === "invalid" && <p className="text-sm text-danger">{t("auth.errorInvalidCredentials")}</p>}
{status === "unreachable" && <p className="text-sm text-danger">{t("auth.errorUnreachable")}</p>}

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { ShieldAlert } from "lucide-react";
import { useTranslation } from "@/lib/i18n";
const ACK_KEY = "aurum:noAuthAcknowledged";
/** Shown by LoginGate once (ever, per browser) when this instance has no
* AURUM_BASIC_AUTH_USER/PASSWORD configured (see frontend/docker-entrypoint.d/
* 20-basic-auth.sh) — that state is otherwise only logged to the container's
* stderr at startup, which most self-hosters never look at. Unlike a normal
* Dialog, there is deliberately no backdrop-click, Escape, or X-button way
* out — the only way past it is the explicit "I understand the risk" button,
* whose click is what persists the acknowledgement. */
export function NoAuthModal() {
const { t } = useTranslation();
const [acknowledged, setAcknowledged] = useState(() => {
try {
return localStorage.getItem(ACK_KEY) === "1";
} catch {
return false;
}
});
if (acknowledged) return null;
function handleAccept() {
setAcknowledged(true);
try {
localStorage.setItem(ACK_KEY, "1");
} catch {
// storage unavailable — modal just reappears next reload, harmless
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-sm rounded-2xl border border-danger/30 bg-surface-1 p-6 text-center shadow-xl">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-danger/10 text-danger">
<ShieldAlert size={24} />
</div>
<h2 className="mb-2 text-base font-semibold text-text-primary">{t("auth.noAuthModalTitle")}</h2>
<p className="mb-6 text-sm text-text-secondary">{t("auth.noAuthModalBody")}</p>
<button
type="button"
onClick={handleAccept}
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-danger px-4 text-sm font-medium text-white transition-colors hover:opacity-90"
>
{t("auth.noAuthModalAccept")}
</button>
</div>
</div>
);
}

View File

@@ -208,6 +208,7 @@ export function AssetFormModal({ open, onClose, asset }: AssetFormModalProps) {
<Label htmlFor="asset-notes">{t("netWorth.form.notesLabel")}</Label>
<Input
id="asset-notes"
maxLength={2000}
value={form.notes}
onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))}
/>

View File

@@ -80,12 +80,16 @@ export function BackupCard() {
<Upload size={16} />
{isImporting ? t("backup.importing") : t("backup.importButton")}
</Button>
{/* Visually hidden via opacity/size, not `display:none` — Firefox on
Linux can silently fail to open the native file dialog when
.click() targets a file input removed from layout entirely. */}
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleFileSelected}
className="hidden"
className="absolute h-px w-px overflow-hidden opacity-0"
tabIndex={-1}
/>
</div>

View File

@@ -41,8 +41,19 @@ interface SplitRowState {
note: string;
}
// crypto.randomUUID() only exists in secure contexts (HTTPS/localhost) — on plain
// HTTP (e.g. accessing the app by LAN IP) it's undefined and throws a TypeError.
// This key is only a local React list key, never sent to the backend, so a
// Math.random()-based fallback is fine when the Web Crypto API isn't available.
function generateRowKey(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `split-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function emptySplitRow(): SplitRowState {
return { key: crypto.randomUUID(), category_id: "", amount: "", note: "" };
return { key: generateRowKey(), category_id: "", amount: "", note: "" };
}
// Cents, not floats — a plain Number sum of "0.10" + "0.20" style amounts can
@@ -474,8 +485,11 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
<div>
<Label htmlFor="notes">{t("transactions.form.notesLabel")}</Label>
{/* Matches the server's max_length on notes — without it an overlong
note only fails on save, as an untranslated 422. */}
<Input
id="notes"
maxLength={2000}
value={form.notes}
onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))}
/>

View File

@@ -1,8 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { fetchHealth } from "@/api/health";
// The running version only changes on a deploy (a full page reload), so
// there's no need to ever refetch within a session.
export function useHealth() {
return useQuery({ queryKey: ["health"], queryFn: fetchHealth, staleTime: Infinity });
}

View File

@@ -3,6 +3,7 @@ import {
bulkCreateTransactions,
createTransaction,
deleteTransaction,
fetchAllTransactionsInRange,
fetchTransactions,
fetchTransactionYears,
updateTransaction,
@@ -14,6 +15,11 @@ function useInvalidateAfterTransactionChange() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: ["transactions"] });
// Without this, a second CSV import of the same date range within
// staleTime (see main.tsx) would check for duplicates against a cached
// list from *before* the first import finished — and find none, which
// is exactly the case the duplicate check exists to catch.
queryClient.invalidateQueries({ queryKey: ["transactions-duplicate-check"] });
queryClient.invalidateQueries({ queryKey: ["transaction-years"] });
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
@@ -33,6 +39,21 @@ export function useTransactions(filters: TransactionFilters) {
});
}
/** CSV import's duplicate check (see pages/CsvImportPage.tsx) — every
* existing transaction for one account within the imported date range, so
* re-importing an overlapping bank export can be told apart from new rows. */
export function useTransactionsForDuplicateCheck(
accountId: number | null,
startDate: string | null,
endDate: string | null
) {
return useQuery({
queryKey: ["transactions-duplicate-check", accountId, startDate, endDate],
queryFn: () => fetchAllTransactionsInRange({ account_id: accountId!, start_date: startDate!, end_date: endDate! }),
enabled: accountId !== null && startDate !== null && endDate !== null,
});
}
export function useTransactionYears() {
return useQuery({
queryKey: ["transaction-years"],

View File

@@ -1,5 +1,7 @@
import { useSyncExternalStore } from "react";
import { forgetCredential, isVaultAvailable, recallCredential, rememberCredential } from "@/lib/credentialVault";
/**
* Client-side mirror of the HTTP Basic Auth credentials Aurum's own login
* screen collects (see components/auth/LoginScreen.tsx), so every fetch can
@@ -9,41 +11,31 @@ import { useSyncExternalStore } from "react";
* this only avoids ever triggering that native prompt, by never letting an
* unauthenticated request happen without us attaching the header ourselves.
*
* Two storage tiers, chosen at login time by the "remember me" checkbox:
* - sessionStorage (default): gone as soon as the tab closes.
* - localStorage, with an explicit expiry stamped into the stored value:
* survives closing the tab/browser, but only for REMEMBER_DAYS — an
* unbounded "stay logged in forever" is too much for a finance app.
* The expiry is only checked when this module loads (i.e. on page load/
* reload) — a tab left open across the expiry moment without reloading
* keeps working until its next reload or a 401 forces a fresh check.
* Two tiers, chosen by the "remember me" checkbox:
* - sessionStorage (default): plain, but gone the moment the browser closes.
* - the encrypted vault (lib/credentialVault.ts): survives a restart for
* REMEMBER_DAYS. It used to be plain localStorage, which meant the
* instance password sat on disk recoverable with a single atob() — the
* vault encrypts it under a key the browser refuses to hand back.
*
* The vault needs a secure context, so on plain HTTP to anything but
* localhost the remember tier is simply unavailable (isRememberSupported()) —
* the login screen hides the checkbox rather than silently not remembering.
*/
const SESSION_KEY = "aurum:basicAuth";
const REMEMBER_KEY = "aurum:basicAuth:remember";
const REMEMBER_DAYS = 30;
// Where the pre-vault "remember me" tier kept its plaintext copy. Read never,
// deleted always: an install upgrading past that version would otherwise
// leave the password sitting on disk for up to a week with nothing left to
// clear it.
const LEGACY_REMEMBER_KEY = "aurum:basicAuth:remember";
const REMEMBER_DAYS = 7;
const REMEMBER_MS = REMEMBER_DAYS * 24 * 60 * 60 * 1000;
interface RememberedEntry {
header: string;
expiresAt: number; // epoch ms
}
function readRemembered(): string | null {
function forgetLegacyRememberedCredentials(): void {
try {
const raw = localStorage.getItem(REMEMBER_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<RememberedEntry>;
if (typeof parsed.header !== "string" || typeof parsed.expiresAt !== "number") {
localStorage.removeItem(REMEMBER_KEY);
return null;
}
if (Date.now() >= parsed.expiresAt) {
localStorage.removeItem(REMEMBER_KEY);
return null;
}
return parsed.header;
localStorage.removeItem(LEGACY_REMEMBER_KEY);
} catch {
return null;
// storage unavailable — nothing was stored there either
}
}
@@ -55,11 +47,13 @@ function readSession(): string | null {
}
}
function readStored(): string | null {
return readRemembered() ?? readSession();
export function isRememberSupported(): boolean {
return isVaultAvailable();
}
let currentHeader: string | null = readStored();
forgetLegacyRememberedCredentials();
let currentHeader: string | null = readSession();
const listeners = new Set<() => void>();
function notify(): void {
@@ -70,6 +64,25 @@ export function getAuthHeader(): string | null {
return currentHeader;
}
/** Reading the vault is async (IndexedDB + WebCrypto), unlike the
* sessionStorage tier that's already resolved by the time this module
* finishes loading. LoginGate awaits this before deciding anything, so a
* remembered login doesn't flash the login screen on its way in. Started
* eagerly at module load rather than on demand, so the work overlaps with
* React mounting instead of following it. */
const restored: Promise<void> = (async () => {
if (currentHeader !== null) return;
const remembered = await recallCredential();
// A login that happened while the vault was being read wins — it's newer.
if (remembered === null || currentHeader !== null) return;
currentHeader = remembered;
notify();
})();
export function whenAuthRestored(): Promise<void> {
return restored;
}
// btoa() only handles Latin1 — the UI is bilingual RU/EN, so a Cyrillic
// password has to survive this, not just ASCII ones.
function encodeUtf8Base64(value: string): string {
@@ -85,22 +98,22 @@ export function buildBasicAuthHeader(username: string, password: string): string
return `Basic ${encodeUtf8Base64(`${username}:${password}`)}`;
}
export function setCredentials(username: string, password: string, remember: boolean): void {
export function setCredentials(username: string, password: string, remember = false): void {
currentHeader = buildBasicAuthHeader(username, password);
try {
if (remember) {
const entry: RememberedEntry = { header: currentHeader, expiresAt: Date.now() + REMEMBER_MS };
localStorage.setItem(REMEMBER_KEY, JSON.stringify(entry));
sessionStorage.removeItem(SESSION_KEY);
} else {
sessionStorage.setItem(SESSION_KEY, currentHeader);
localStorage.removeItem(REMEMBER_KEY);
}
sessionStorage.setItem(SESSION_KEY, currentHeader);
} catch {
// storage unavailable (private browsing, storage disabled) — the header
// still works for the rest of this tab's life via the in-memory
// variable above, it just won't survive a refresh.
}
// Fire-and-forget: nothing downstream waits on the write, and a vault that
// refuses to store only costs this login its persistence.
if (remember) {
void rememberCredential(currentHeader, REMEMBER_MS);
} else {
void forgetCredential();
}
notify();
}
@@ -112,10 +125,14 @@ export function clearCredentials(): void {
currentHeader = null;
try {
sessionStorage.removeItem(SESSION_KEY);
localStorage.removeItem(REMEMBER_KEY);
forgetLegacyRememberedCredentials();
} catch {
// ignore — nothing to clean up if storage was never usable
}
// A 401 means the stored credential is wrong or revoked, so the remembered
// copy is wrong too — leaving it would restore the same rejected header on
// the next reload and loop the user straight back to the login screen.
void forgetCredential();
notify();
}

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { applyPresetMapping, detectPreset, findPreset, normalizeHeader, rowFilterRejection, type ImportMapping } from "@/lib/bankPresets";
import { parseAmount, parseCsv, parseDateWithFormat } from "@/lib/csv";
const EMPTY: ImportMapping = { date: "", amount: "", description: "", merchant: "", notes: "", category: "" };
// Header rows as the banks actually emit them (see the per-preset comments in
// lib/bankPresets.ts for where each export comes from).
const TBANK_CSV = [
'"Дата операции";"Дата платежа";"Номер карты";"Статус";"Сумма операции";"Валюта операции";"Сумма платежа";"Валюта платежа";"Кэшбэк";"Категория";"MCC";"Описание";"Бонусы (включая кэшбэк)"',
'"24.10.2018 17:04:25";"24.10.2018";"*8305";"OK";"-882,04";"RUB";"-882,04";"RUB";"";"Переводы/иб";"";"Пополнение счета Тинькофф Брокер";"0,00"',
'"25.10.2018 09:12:00";"25.10.2018";"*8305";"FAILED";"-1500,00";"RUB";"-1500,00";"RUB";"";"Супермаркеты";"5411";"Пятёрочка";"0,00"',
].join("\n");
const MONOBANK_CSV = [
// Latin "i" in "Дата i час" is deliberate — that's what the real file has.
'"Дата i час операції","Деталі операції",MCC,"Сума в валюті картки (UAH)","Сума в валюті операції",Валюта,Курс,"Сума комісій (UAH)","Сума кешбеку (UAH)","Залишок після операції"',
'"01.03.2024 12:30:45","Сільпо",5411,-250.50,-250.50,UAH,—,—,2.50,10000.00',
].join("\n");
const PRIVAT_CSV = [
"Дата;Час;Категорія;Картка;Опис операції;Сума в валюті картки;Валюта картки;Сума в валюті транзакції;Валюта транзакції;Залишок на кінець періоду;Валюта залишку",
"05.02.2024;14:05;Продукти;5168****1234;АТБ;-320,00;UAH;-320,00;UAH;1500,00;UAH",
].join("\n");
describe("normalizeHeader", () => {
it("folds case, whitespace, BOM and the Latin i", () => {
expect(normalizeHeader(" Дата i час ")).toBe("дата і час");
});
});
describe("detectPreset", () => {
it("recognises T-Bank, monobank and PrivatBank header rows", () => {
expect(detectPreset(parseCsv(TBANK_CSV)[0])?.id).toBe("tbank");
expect(detectPreset(parseCsv(MONOBANK_CSV)[0])?.id).toBe("monobank");
expect(detectPreset(parseCsv(PRIVAT_CSV)[0])?.id).toBe("privatbank");
});
it("returns null for a generic export", () => {
expect(detectPreset(["Date", "Amount", "Description"])).toBeNull();
});
});
describe("applyPresetMapping", () => {
it("maps T-Bank onto the card-currency amount and keeps the category", () => {
const headers = parseCsv(TBANK_CSV)[0];
const mapping = applyPresetMapping(findPreset("tbank")!, headers, EMPTY);
expect(mapping).toMatchObject({ date: "Дата операции", amount: "Сумма платежа", description: "Описание", category: "Категория" });
});
it("matches monobank's currency-suffixed amount header by prefix", () => {
const headers = parseCsv(MONOBANK_CSV)[0];
const mapping = applyPresetMapping(findPreset("monobank")!, headers, EMPTY);
expect(mapping.amount).toBe("Сума в валюті картки (UAH)");
expect(mapping.date).toBe("Дата i час операції");
});
it("leaves fields the preset doesn't cover on the fallback", () => {
const headers = parseCsv(PRIVAT_CSV)[0];
const mapping = applyPresetMapping(findPreset("privatbank")!, headers, { ...EMPTY, notes: "Картка" });
expect(mapping.notes).toBe("Картка");
expect(mapping.category).toBe("Категорія");
});
});
describe("rowFilterRejection", () => {
it("drops T-Bank rows whose status is not OK and reports the status", () => {
const [headers, ok, failed] = parseCsv(TBANK_CSV);
const preset = findPreset("tbank")!;
expect(rowFilterRejection(preset, headers, ok)).toBeNull();
expect(rowFilterRejection(preset, headers, failed)).toBe("FAILED");
});
it("keeps everything when the preset has no filter or the column is absent", () => {
const [headers, row] = parseCsv(MONOBANK_CSV);
expect(rowFilterRejection(findPreset("monobank")!, headers, row)).toBeNull();
expect(rowFilterRejection(findPreset("tbank")!, ["Дата"], ["01.01.2024"])).toBeNull();
expect(rowFilterRejection(null, headers, row)).toBeNull();
});
});
describe("preset formats against real sample rows", () => {
it("parses T-Bank's timestamped date and comma-decimal amount", () => {
const [, row] = parseCsv(TBANK_CSV);
const preset = findPreset("tbank")!;
expect(parseDateWithFormat(row[0], preset.dateFormat)).toBe("2018-10-24");
expect(parseAmount(row[6], preset.amountFormat)).toBe(-882.04);
});
it("parses monobank's timestamped date and dot-decimal amount", () => {
const [, row] = parseCsv(MONOBANK_CSV);
const preset = findPreset("monobank")!;
expect(parseDateWithFormat(row[0], preset.dateFormat)).toBe("2024-03-01");
expect(parseAmount(row[3], preset.amountFormat)).toBe(-250.5);
// monobank writes empty cells as an em dash — must not parse as 0.
expect(parseAmount(row[6], preset.amountFormat)).toBeNull();
});
it("parses PrivatBank's plain date and comma amount", () => {
const [, row] = parseCsv(PRIVAT_CSV);
const preset = findPreset("privatbank")!;
expect(parseDateWithFormat(row[0], preset.dateFormat)).toBe("2024-02-05");
expect(parseAmount(row[5], preset.amountFormat)).toBe(-320);
});
});
describe("parseDateWithFormat with a time suffix", () => {
it("accepts an optional trailing time in every format", () => {
expect(parseDateWithFormat("2024-01-05T09:30", "YYYY-MM-DD")).toBe("2024-01-05");
expect(parseDateWithFormat("05/01/2024 09:30:15", "DD/MM/YYYY")).toBe("2024-01-05");
expect(parseDateWithFormat("01/05/2024 9:30", "MM/DD/YYYY")).toBe("2024-01-05");
expect(parseDateWithFormat("05-01-2024 09:30", "DD-MM-YYYY")).toBe("2024-01-05");
});
it("still rejects trailing garbage that isn't a time", () => {
expect(parseDateWithFormat("05.01.2024 abc", "DD.MM.YYYY")).toBeNull();
expect(parseDateWithFormat("05.01.2024 09", "DD.MM.YYYY")).toBeNull();
});
});

View File

@@ -0,0 +1,152 @@
// Bank-specific import presets for the CSV wizard (pages/CsvImportPage.tsx).
//
// A preset is nothing more than a pre-filled answer to the wizard's "map"
// step: which column is the date, which is the amount, what the date/amount
// formats are, and — the one thing the generic mapping can't express — which
// rows to drop (a declined card payment still shows up in the export). The
// wizard itself stays bank-agnostic: presets only feed it defaults, and the
// user can still override any of them on the map screen.
//
// Only banks whose personal-account CSV export has a known, stable header
// layout are listed. Banks that export PDF/XLSX only (Sber, Kaspi, Halyk,
// maib…) go through the generic mapping via a third-party converter instead.
import type { AmountFormat, DateFormat } from "@/lib/csv";
/** Column mapping as the wizard understands it — header *names*, not
* indexes, so re-decoding the file under another encoding can re-resolve
* them. Empty string = not mapped. */
export interface ImportMapping {
date: string;
amount: string;
description: string;
merchant: string;
notes: string;
category: string;
}
/** Matches a header cell by normalised prefix — see normalizeHeader(). A
* prefix, not equality, because some banks put the account currency in the
* header itself ("Сума в валюті картки (UAH)") and it changes per card. */
export type HeaderMatcher = string[];
export interface BankPreset {
id: string;
/** Proper name, shown as-is — bank names aren't translated. */
label: string;
/** Every one of these must be present for detectPreset() to pick this
* preset. Keep it to the columns the mapping actually needs, so a bank
* adding a trailing column doesn't break detection. */
detect: HeaderMatcher[];
mapping: Partial<Record<keyof ImportMapping, HeaderMatcher>>;
dateFormat: DateFormat;
amountFormat: AmountFormat;
/** Rows whose `column` value fails `accept` are skipped before parsing —
* e.g. a "Статус" column that reads FAILED for a declined payment. */
rowFilter?: { column: HeaderMatcher; accept: (value: string) => boolean };
}
/** Lower-cased, trimmed, BOM-stripped, whitespace-collapsed header text.
* Also folds the Latin "i" into the Cyrillic "і": monobank's Ukrainian header
* literally reads "Дата i час операції" with a Latin i in the middle, and
* nobody typing a matcher by hand would guess that. */
export function normalizeHeader(header: string): string {
return header
.replace(/^\uFEFF/, "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/i/g, "і");
}
function findHeader(headers: string[], matcher: HeaderMatcher): string {
const candidates = matcher.map(normalizeHeader);
return headers.find((header) => candidates.some((candidate) => normalizeHeader(header).startsWith(candidate))) ?? "";
}
export const BANK_PRESETS: readonly BankPreset[] = [
{
// Web version → Операции → «Выгрузка операций» → CSV. Semicolon-separated,
// every field quoted, comma decimal mark, dates carry a time of day.
// "Сумма платежа" is the amount in the card's own currency (a foreign
// purchase shows the original amount in "Сумма операции"), which is what
// the account balance actually moved by.
id: "tbank",
label: "Т-Банк",
detect: [["дата операции"], ["сумма платежа"], ["описание"], ["статус"]],
mapping: {
date: ["дата операции"],
amount: ["сумма платежа"],
description: ["описание"],
category: ["категория"],
},
dateFormat: "DD.MM.YYYY",
amountFormat: "comma-decimal",
rowFilter: { column: ["статус"], accept: (value) => value.trim().toUpperCase() === "OK" },
},
{
// App → card → Виписка → CSV, Ukrainian UI language. Comma-separated,
// UTF-8, dot decimal mark, empty cells written as an em dash ("—").
// The amount header embeds the card currency, hence the prefix match.
id: "monobank",
label: "monobank",
detect: [["дата і час операції"], ["деталі операції"], ["сума в валюті картки"]],
mapping: {
date: ["дата і час операції"],
amount: ["сума в валюті картки"],
description: ["деталі операції"],
},
dateFormat: "DD.MM.YYYY",
amountFormat: "dot-decimal",
},
{
// next.privat24.ua → card → Виписки → CSV. Date and time come in
// separate columns, so the plain date parses as-is; the amount in the
// card's currency is what the balance moved by.
id: "privatbank",
label: "ПриватБанк",
detect: [["дата"], ["опис операції"], ["сума в валюті картки"]],
mapping: {
date: ["дата"],
amount: ["сума в валюті картки"],
description: ["опис операції"],
category: ["категорія"],
},
dateFormat: "DD.MM.YYYY",
amountFormat: "auto",
},
];
export function findPreset(id: string): BankPreset | null {
return BANK_PRESETS.find((preset) => preset.id === id) ?? null;
}
/** The first preset whose every `detect` matcher finds a header. Order in
* BANK_PRESETS matters only if two banks ever share a header set. */
export function detectPreset(headers: string[]): BankPreset | null {
return BANK_PRESETS.find((preset) => preset.detect.every((matcher) => findHeader(headers, matcher) !== "")) ?? null;
}
/** Resolves a preset's matchers against the actual header row. Fields the
* preset doesn't cover fall back to `fallback` (the wizard's generic
* header guesses), so a preset only has to describe what it knows. */
export function applyPresetMapping(preset: BankPreset, headers: string[], fallback: ImportMapping): ImportMapping {
const resolved = { ...fallback };
(Object.keys(preset.mapping) as (keyof ImportMapping)[]).forEach((field) => {
const matcher = preset.mapping[field];
if (matcher) resolved[field] = findHeader(headers, matcher);
});
return resolved;
}
/** When `preset.rowFilter` says this row is not a real transaction, returns
* the offending cell value (for the "skipped because…" message); null means
* keep the row. Also null when the preset has no filter or its column is
* missing from the header row — an absent status column is not a reason
* to drop data. */
export function rowFilterRejection(preset: BankPreset | null, headers: string[], cells: string[]): string | null {
if (!preset?.rowFilter) return null;
const idx = headers.indexOf(findHeader(headers, preset.rowFilter.column));
if (idx < 0) return null;
const value = cells[idx] ?? "";
return preset.rowFilter.accept(value) ? null : value.trim();
}

View File

@@ -0,0 +1,135 @@
/**
* Encrypted at-rest storage for the Basic Auth header, so "remember me" can
* survive a browser restart without leaving the instance password readable on
* disk.
*
* How it works: an AES-GCM key is generated with `extractable: false` and
* kept in IndexedDB. The browser will happily store and hand back that
* CryptoKey, but `crypto.subtle.exportKey` refuses to reveal its bytes — so
* page code can decrypt with it and still cannot read it. Alongside it sits
* the ciphertext of the header plus an expiry.
*
* What this actually buys, stated honestly:
* - Someone who copies the profile directory, restores a disk backup, reads
* synced storage, or just opens DevTools gets ciphertext. Previously they
* got `Basic dXNlcjpwYXNz`, which is the password in one `atob()`.
* - It does NOT stop code executing *inside* the page: an XSS can simply
* call decrypt() the same way the app does. That vector is what the
* Content-Security-Policy in frontend/nginx.conf is there to close;
* this and that are two halves of the same answer.
*
* Requires a secure context — `crypto.subtle` is undefined on plain HTTP to
* anything but localhost. That's a real configuration here, not a hypothetical
* (AURUM_BIND_ADDRESS makes LAN access over http:// possible), so callers must
* check isVaultAvailable() and fall back to session-only storage.
*/
const DB_NAME = "aurum-auth";
const DB_VERSION = 1;
const STORE = "vault";
const KEY_ID = "wrapping-key";
const BLOB_ID = "remembered-credential";
interface StoredBlob {
iv: Uint8Array;
ciphertext: ArrayBuffer;
expiresAt: number; // epoch ms
}
export function isVaultAvailable(): boolean {
return (
typeof indexedDB !== "undefined" &&
typeof crypto !== "undefined" &&
typeof crypto.subtle !== "undefined" &&
typeof crypto.subtle.generateKey === "function"
);
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE)) {
request.result.createObjectStore(STORE);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function runRequest<T>(store: IDBObjectStore, request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
store.transaction.onabort = () => reject(store.transaction.error);
});
}
async function withStore<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => Promise<T>): Promise<T> {
const db = await openDb();
try {
return await run(db.transaction(STORE, mode).objectStore(STORE));
} finally {
db.close();
}
}
/** Reuses the existing key when there is one: regenerating it on every login
* would strand any ciphertext already written under the old key. */
async function getOrCreateKey(): Promise<CryptoKey> {
const existing = await withStore("readonly", (store) => runRequest<CryptoKey | undefined>(store, store.get(KEY_ID)));
if (existing) return existing;
// extractable: false is the whole point — see the module comment.
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
await withStore("readwrite", (store) => runRequest(store, store.put(key, KEY_ID)));
return key;
}
export async function rememberCredential(header: string, ttlMs: number): Promise<void> {
if (!isVaultAvailable()) return;
try {
const key = await getOrCreateKey();
// A fresh IV per encryption: reusing one with AES-GCM under the same key
// is what breaks the mode outright.
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(header));
const blob: StoredBlob = { iv, ciphertext, expiresAt: Date.now() + ttlMs };
await withStore("readwrite", (store) => runRequest(store, store.put(blob, BLOB_ID)));
} catch {
// Storage denied or unavailable (private window, quota, disabled) —
// login still works for this session, it just won't be remembered.
}
}
export async function recallCredential(): Promise<string | null> {
if (!isVaultAvailable()) return null;
try {
const blob = await withStore("readonly", (store) => runRequest<StoredBlob | undefined>(store, store.get(BLOB_ID)));
if (!blob) return null;
if (Date.now() >= blob.expiresAt) {
await forgetCredential();
return null;
}
// Copied into a fresh view: structured clone hands the IV back typed over
// ArrayBufferLike, which doesn't satisfy WebCrypto's BufferSource.
const iv = new Uint8Array(blob.iv);
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, await getOrCreateKey(), blob.ciphertext);
return new TextDecoder().decode(plaintext);
} catch {
// Tampered ciphertext, a key that no longer matches, or storage trouble.
// Nothing recoverable either way — drop it and fall back to the login
// screen rather than leaving a blob around that can never be read.
await forgetCredential();
return null;
}
}
export async function forgetCredential(): Promise<void> {
if (typeof indexedDB === "undefined") return;
try {
await withStore("readwrite", (store) => runRequest(store, store.delete(BLOB_ID)));
} catch {
// ignore — nothing usable to clean up
}
}

View File

@@ -57,12 +57,17 @@ export function parseCsv(text: string): string[][] {
export const DATE_FORMATS = ["YYYY-MM-DD", "DD.MM.YYYY", "DD/MM/YYYY", "MM/DD/YYYY", "DD-MM-YYYY"] as const;
export type DateFormat = (typeof DATE_FORMATS)[number];
// Every pattern tolerates an optional trailing time of day ("24.10.2018
// 17:04:25", "2024-01-05T09:30") — bank exports routinely timestamp each
// operation (T-Bank, monobank), and the wizard only stores the calendar
// date anyway.
const TIME_SUFFIX = /(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?$/.source;
const DATE_PATTERNS: Record<DateFormat, { regex: RegExp; order: ["y" | "m" | "d", "y" | "m" | "d", "y" | "m" | "d"] }> = {
"YYYY-MM-DD": { regex: /^(\d{4})-(\d{1,2})-(\d{1,2})$/, order: ["y", "m", "d"] },
"DD.MM.YYYY": { regex: /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/, order: ["d", "m", "y"] },
"DD/MM/YYYY": { regex: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, order: ["d", "m", "y"] },
"MM/DD/YYYY": { regex: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, order: ["m", "d", "y"] },
"DD-MM-YYYY": { regex: /^(\d{1,2})-(\d{1,2})-(\d{4})$/, order: ["d", "m", "y"] },
"YYYY-MM-DD": { regex: new RegExp(/^(\d{4})-(\d{1,2})-(\d{1,2})/.source + TIME_SUFFIX), order: ["y", "m", "d"] },
"DD.MM.YYYY": { regex: new RegExp(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/.source + TIME_SUFFIX), order: ["d", "m", "y"] },
"DD/MM/YYYY": { regex: new RegExp(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/.source + TIME_SUFFIX), order: ["d", "m", "y"] },
"MM/DD/YYYY": { regex: new RegExp(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/.source + TIME_SUFFIX), order: ["m", "d", "y"] },
"DD-MM-YYYY": { regex: new RegExp(/^(\d{1,2})-(\d{1,2})-(\d{4})/.source + TIME_SUFFIX), order: ["d", "m", "y"] },
};
/** Parses `raw` per `format`, returning an ISO "YYYY-MM-DD" string, or null
@@ -86,21 +91,53 @@ export function parseDateWithFormat(raw: string, format: DateFormat): string | n
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
/** Parses a bank-export amount string — handles thousands separators and
* either "," or "." as the decimal mark by assuming whichever comes last is
* the decimal separator (e.g. "1.234,56" and "1,234.56" both work). */
export function parseAmount(raw: string): number | null {
export const AMOUNT_FORMATS = ["auto", "dot-decimal", "comma-decimal"] as const;
export type AmountFormat = (typeof AMOUNT_FORMATS)[number];
/** Parses a bank-export amount string. `format` lets the user resolve what
* "auto" can't: a single "," or "." with nothing else in the string is
* genuinely ambiguous (thousands separator vs. decimal mark) — there's no
* way to tell "1,234" (one thousand two hundred thirty-four) from "1,234"
* (one point two three four) without knowing the exporting bank's locale.
* "auto" keeps the historical last-separator-wins heuristic for that case
* (e.g. "1.234,56" and "1,234.56" both resolve correctly since both
* separators are present), but no longer corrupts amounts that have
* *multiple* thousands separators and no decimal part at all (e.g.
* "1,234,567" or "1.234.567") — a number can have at most one decimal
* point, so 2+ occurrences of the same separator can only be thousands
* grouping. */
export function parseAmount(raw: string, format: AmountFormat = "auto"): number | null {
let value = raw.trim().replace(/[\s ]/g, "");
if (!value) return null;
const hasComma = value.includes(",");
const hasDot = value.includes(".");
if (hasComma && hasDot) {
value = value.lastIndexOf(",") > value.lastIndexOf(".") ? value.replace(/\./g, "").replace(",", ".") : value.replace(/,/g, "");
} else if (hasComma) {
value = value.replace(",", ".");
if (format === "dot-decimal") {
value = value.replace(/,/g, "");
} else if (format === "comma-decimal") {
value = value.replace(/\./g, "").replace(",", ".");
} else {
const commaCount = (value.match(/,/g) ?? []).length;
const dotCount = (value.match(/\./g) ?? []).length;
if (commaCount > 0 && dotCount > 0) {
value = value.lastIndexOf(",") > value.lastIndexOf(".") ? value.replace(/\./g, "").replace(",", ".") : value.replace(/,/g, "");
} else if (commaCount > 1) {
value = value.replace(/,/g, "");
} else if (commaCount === 1) {
value = value.replace(",", ".");
} else if (dotCount > 1) {
value = value.replace(/\./g, "");
}
}
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
/** Stable key for spotting a re-imported bank export that overlaps a
* previous one — see pages/CsvImportPage.tsx's duplicate check. Matches on
* date + type + amount (2dp) + description, the same fields the API
* actually stores, so it works whether the "existing" side comes from a
* Transaction or the "candidate" side from a freshly parsed CSV row. */
export function transactionDedupeKey(date: string, type: string, amount: number | string, description: string): string {
const normalizedAmount = typeof amount === "number" ? amount : Number(amount);
return `${date}|${type}|${normalizedAmount.toFixed(2)}|${description.trim().toLowerCase()}`;
}

View File

@@ -49,11 +49,15 @@ const ru = {
"auth.subtitle": "Личные финансы",
"auth.usernameLabel": "Логин",
"auth.passwordLabel": "Пароль",
"auth.rememberMe": "Запомнить меня на 30 дней",
"auth.rememberMe": "Запомнить меня на 7 дней",
"auth.sessionOnlyHint": "Вход сохраняется до закрытия браузера. Запоминание надолго требует HTTPS или localhost.",
"auth.submitButton": "Войти",
"auth.submitting": "Вход…",
"auth.errorInvalidCredentials": "Неверный логин или пароль",
"auth.errorUnreachable": "Не удалось подключиться к серверу. Проверьте соединение и попробуйте снова.",
"auth.noAuthModalTitle": "Авторизация не настроена",
"auth.noAuthModalBody": "Этот экземпляр Aurum не защищён паролем — все данные доступны любому, кто до него достучится. Задайте AURUM_BASIC_AUTH_USER и AURUM_BASIC_AUTH_PASSWORD в .env, чтобы включить вход.",
"auth.noAuthModalAccept": "Я понимаю риски и хочу продолжить",
"dashboard.errorLoading": "Не удалось загрузить данные дашборда. Проверьте, что backend запущен.",
"dashboard.statRealIncomeLabel": "Реальный доход",
@@ -249,6 +253,23 @@ const ru = {
"transactions.import.errorBadDate": "не удалось распознать дату «{{value}}»",
"transactions.import.errorBadAmount": "не удалось распознать сумму «{{value}}»",
"transactions.import.errorNoDescription": "нет описания",
"transactions.import.encodingLabel": "Кодировка файла",
"transactions.import.encodingHint": "Определяется автоматически; если вместо русских букв — кракозябры, попробуйте выбрать вручную.",
"transactions.import.sampleValueLabel": "Пример из файла: {{value}}",
"transactions.import.amountFormatLabel": "Разделитель дробной части",
"transactions.import.amountFormat.auto": "Автоматически",
"transactions.import.amountFormat.dot-decimal": "Точка (1234.56), запятая — тысячи",
"transactions.import.amountFormat.comma-decimal": "Запятая (1234,56), точка — тысячи",
"transactions.import.duplicateCheckLoading": "Проверяем, нет ли уже таких транзакций…",
"transactions.import.duplicateCheckFailed": "Не удалось проверить, есть ли уже такие транзакции на счёте — при импорте возможны дубли.",
"transactions.import.duplicatesFound": "Найдено похожих на уже существующие: {{count}}. По умолчанию они не импортируются.",
"transactions.import.includeDuplicates": "Всё равно импортировать их",
"transactions.import.presetLabel": "Банк / формат файла",
"transactions.import.preset.auto": "Определить автоматически",
"transactions.import.presetHint": "Готовые профили: Т-Банк, monobank, ПриватБанк — колонки и форматы подставятся сами. Выгрузки других банков импортируются через ручное сопоставление колонок.",
"transactions.import.presetDetected": "Профиль банка: {{bank}}. Колонки и форматы подставлены автоматически — проверьте и при необходимости поправьте.",
"transactions.import.presetNotDetected": "Формат банка не распознан — сопоставьте колонки вручную или выберите банк из списка.",
"transactions.import.errorRowFiltered": "операция не проведена (статус «{{value}}»)",
"transactions.allTypes": "Все типы",
"transactions.expense": "Расходы",
"transactions.income": "Доходы",
@@ -553,11 +574,15 @@ const en: Record<keyof typeof ru, string> = {
"auth.subtitle": "Personal finance",
"auth.usernameLabel": "Username",
"auth.passwordLabel": "Password",
"auth.rememberMe": "Remember me for 30 days",
"auth.rememberMe": "Remember me for 7 days",
"auth.sessionOnlyHint": "You stay signed in until you close the browser. Staying signed in for longer needs HTTPS or localhost.",
"auth.submitButton": "Sign in",
"auth.submitting": "Signing in…",
"auth.errorInvalidCredentials": "Incorrect username or password",
"auth.errorUnreachable": "Couldn't reach the server. Check your connection and try again.",
"auth.noAuthModalTitle": "Authentication not configured",
"auth.noAuthModalBody": "This Aurum instance has no password protection — all data is accessible to anyone who can reach it. Set AURUM_BASIC_AUTH_USER and AURUM_BASIC_AUTH_PASSWORD in .env to enable login.",
"auth.noAuthModalAccept": "I understand the risk and want to continue",
"dashboard.errorLoading": "Failed to load dashboard data. Check that the backend is running.",
"dashboard.statRealIncomeLabel": "Real income",
@@ -753,6 +778,23 @@ const en: Record<keyof typeof ru, string> = {
"transactions.import.errorBadDate": "couldn't parse the date \"{{value}}\"",
"transactions.import.errorBadAmount": "couldn't parse the amount \"{{value}}\"",
"transactions.import.errorNoDescription": "no description",
"transactions.import.encodingLabel": "File encoding",
"transactions.import.encodingHint": "Auto-detected; if you see garbled characters instead of letters, try picking one manually.",
"transactions.import.sampleValueLabel": "Example from file: {{value}}",
"transactions.import.amountFormatLabel": "Decimal separator",
"transactions.import.amountFormat.auto": "Automatic",
"transactions.import.amountFormat.dot-decimal": "Dot (1234.56), comma is thousands",
"transactions.import.amountFormat.comma-decimal": "Comma (1234,56), dot is thousands",
"transactions.import.duplicateCheckLoading": "Checking for transactions already on this account…",
"transactions.import.duplicateCheckFailed": "Couldn't check what's already on this account — importing may create duplicates.",
"transactions.import.duplicatesFound": "Found {{count}} that look like duplicates of existing transactions. They're left out by default.",
"transactions.import.includeDuplicates": "Import them anyway",
"transactions.import.presetLabel": "Bank / file format",
"transactions.import.preset.auto": "Detect automatically",
"transactions.import.presetHint": "Built-in profiles: T-Bank, monobank, PrivatBank — columns and formats are pre-filled. Exports from other banks go through manual column mapping.",
"transactions.import.presetDetected": "Bank profile: {{bank}}. Columns and formats were pre-filled — review and adjust if needed.",
"transactions.import.presetNotDetected": "Bank format not recognised — map the columns by hand or pick a bank from the list.",
"transactions.import.errorRowFiltered": "operation was not completed (status “{{value}}”)",
"transactions.allTypes": "All types",
"transactions.expense": "Expenses",
"transactions.income": "Income",

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ArrowLeft, Upload } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
@@ -6,9 +6,27 @@ import { Button } from "@/components/ui/Button";
import { Label, Select } from "@/components/ui/Input";
import { useAccounts } from "@/hooks/useAccounts";
import { useCategories } from "@/hooks/useCategories";
import { useBulkCreateTransactions } from "@/hooks/useTransactions";
import { useBulkCreateTransactions, useTransactionsForDuplicateCheck } from "@/hooks/useTransactions";
import { translateCategoryName } from "@/lib/categoryLabels";
import { DATE_FORMATS, parseAmount, parseCsv, parseDateWithFormat, type DateFormat } from "@/lib/csv";
import {
BANK_PRESETS,
applyPresetMapping,
detectPreset,
findPreset,
rowFilterRejection,
type BankPreset,
type ImportMapping,
} from "@/lib/bankPresets";
import {
AMOUNT_FORMATS,
DATE_FORMATS,
parseAmount,
parseCsv,
parseDateWithFormat,
transactionDedupeKey,
type AmountFormat,
type DateFormat,
} from "@/lib/csv";
import { formatCurrency } from "@/lib/format";
import { ApiError } from "@/api/client";
import { useTranslation } from "@/lib/i18n";
@@ -18,20 +36,69 @@ type Step = "upload" | "map" | "preview";
const NONE = "";
interface Mapping {
date: string;
amount: string;
description: string;
merchant: string;
notes: string;
category: string;
}
// WHATWG encoding labels TextDecoder understands. UTF-8 covers most modern
// exports; the rest are here because bank CSVs — especially older or
// Russian-bank ones — are routinely still in a legacy Windows codepage, and
// decoding those as UTF-8 silently turns every non-ASCII character into
// mojibake instead of failing loudly.
const ENCODINGS = ["utf-8", "windows-1251", "windows-1252", "koi8-r"] as const;
type Encoding = (typeof ENCODINGS)[number];
// Header names, not indexes — see lib/bankPresets.ts's ImportMapping.
type Mapping = ImportMapping;
// "auto" lets detectPreset() pick a bank profile from the header row; a
// preset id pins one regardless of what the headers look like.
const PRESET_AUTO = "auto";
interface SkippedRow {
row: number;
reason: string;
}
// Best-effort auto-mapping by common header names — whatever it gets wrong,
// the user corrects on the map screen.
const HEADER_GUESSES: Record<keyof Mapping, string[]> = {
date: ["date", "дата"],
amount: ["amount", "сумма"],
description: ["description", "описание", "назначение платежа"],
merchant: ["merchant", "payee", "получатель"],
notes: ["notes", "заметка", "примечание"],
category: ["category", "категория"],
};
function guessMapping(headerRow: string[]): Mapping {
const guess = (candidates: string[]) => headerRow.find((h) => candidates.includes(h.trim().toLowerCase())) ?? "";
return {
date: guess(HEADER_GUESSES.date),
amount: guess(HEADER_GUESSES.amount),
description: guess(HEADER_GUESSES.description),
merchant: guess(HEADER_GUESSES.merchant),
notes: guess(HEADER_GUESSES.notes),
category: guess(HEADER_GUESSES.category),
};
}
/** The bank profile in force for a given header row: the pinned one when
* the user picked a bank, otherwise whatever the headers match (or null —
* plain generic mapping). Pure, so both the file handler and the
* header-change effect below resolve it the same way. */
function resolvePreset(headers: string[], presetChoice: string): BankPreset | null {
return presetChoice === PRESET_AUTO ? detectPreset(headers) : findPreset(presetChoice);
}
/** Map-step defaults for a header row: the preset's answers where it has
* them, the generic header guesses everywhere else. */
function resolveDefaults(headers: string[], preset: BankPreset | null): { mapping: Mapping; dateFormat: DateFormat; amountFormat: AmountFormat } {
const guessed = guessMapping(headers);
if (!preset) return { mapping: guessed, dateFormat: "YYYY-MM-DD", amountFormat: "auto" };
return {
mapping: applyPresetMapping(preset, headers, guessed),
dateFormat: preset.dateFormat,
amountFormat: preset.amountFormat,
};
}
export function CsvImportPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -43,14 +110,61 @@ export function CsvImportPage() {
const [accountId, setAccountId] = useState("");
const [step, setStep] = useState<Step>("upload");
const [fileName, setFileName] = useState("");
const [headers, setHeaders] = useState<string[]>([]);
const [dataRows, setDataRows] = useState<string[][]>([]);
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
const [encoding, setEncoding] = useState<Encoding>("utf-8");
const [presetChoice, setPresetChoice] = useState<string>(PRESET_AUTO);
const [mapping, setMapping] = useState<Mapping>({ date: "", amount: "", description: "", merchant: "", notes: "", category: "" });
const [dateFormat, setDateFormat] = useState<DateFormat>("YYYY-MM-DD");
const [amountFormat, setAmountFormat] = useState<AmountFormat>("auto");
const [includeDuplicates, setIncludeDuplicates] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [createdCount, setCreatedCount] = useState<number | null>(null);
// Re-decoded whenever `encoding` changes, so picking a different encoding
// on the map screen (because the auto-detected guess came out wrong)
// re-parses the same upload without asking the user to re-select the file.
const { headers, dataRows } = useMemo(() => {
if (!fileBuffer) return { headers: [] as string[], dataRows: [] as string[][] };
const text = new TextDecoder(encoding).decode(fileBuffer);
const rows = parseCsv(text);
if (rows.length < 2) return { headers: [] as string[], dataRows: [] as string[][] };
const [headerRow, ...rest] = rows;
return { headers: headerRow, dataRows: rest };
}, [fileBuffer, encoding]);
const activePreset = useMemo(() => resolvePreset(headers, presetChoice), [headers, presetChoice]);
// Owns mapping initialisation, keyed on the header row's *contents* and
// the chosen bank profile.
// Changing the encoding re-decodes that row, and a column picked under the
// previous encoding then names a header that no longer exists:
// headers.indexOf() returns -1, the dropdown renders blank while
// mappingComplete still reports "ready", and every row gets skipped as an
// unparseable date. Re-guessing against the new names is the only sane
// reading of a mapping whose columns are all gone. When the decoded
// headers come out identical (an all-ASCII header row survives every
// encoding here), the key doesn't change, this doesn't run, and manual
// column picks stay put. Switching the bank profile re-runs it on
// purpose: the whole point of picking a bank is to replace the mapping.
// JSON.stringify, not join(): joining on a separator would read
// ["ab","c"] and ["a","bc"] as one and the same header row.
const headersKey = JSON.stringify(headers);
useEffect(() => {
if (headers.length === 0) return;
const preset = resolvePreset(headers, presetChoice);
const defaults = resolveDefaults(headers, preset);
setMapping(defaults.mapping);
// Formats are only overwritten when a bank profile dictates them; with
// no profile, a date format the user picked by hand survives an
// encoding switch (the fresh-file reset lives in handleFileSelected).
if (preset) {
setDateFormat(defaults.dateFormat);
setAmountFormat(defaults.amountFormat);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [headersKey, presetChoice]);
const categoryLookup = useMemo(() => {
const map: Record<"income" | "expense", Map<string, number>> = { income: new Map(), expense: new Map() };
for (const category of categories ?? []) {
@@ -64,8 +178,54 @@ export function CsvImportPage() {
return amount < 0 ? "expense" : "income";
}
const { valid, skipped } = useMemo(() => {
if (step !== "preview") return { valid: [] as TransactionInput[], skipped: [] as SkippedRow[] };
/** First non-empty raw value under `headerName`, shown next to a mapping
* dropdown so the user can confirm they picked the right column before
* committing to a full preview — a bank's own header names (or a header
* row that's just "Column1", "Column2"…) aren't always self-explanatory. */
function sampleValue(headerName: string): string {
if (!headerName) return "";
const idx = headers.indexOf(headerName);
if (idx < 0) return "";
const row = dataRows.find((cells) => (cells[idx] ?? "").trim() !== "");
return (row?.[idx] ?? "").trim();
}
// Bounds the duplicate-check query (see useTransactionsForDuplicateCheck
// below) to the date range actually present in the file, instead of every
// transaction ever recorded on the account.
const dateBounds = useMemo(() => {
if (step !== "preview" || !mapping.date) return null;
const dateIdx = headers.indexOf(mapping.date);
if (dateIdx < 0) return null;
let min: string | null = null;
let max: string | null = null;
for (const cells of dataRows) {
const iso = parseDateWithFormat(cells[dateIdx] ?? "", dateFormat);
if (!iso) continue;
if (min === null || iso < min) min = iso;
if (max === null || iso > max) max = iso;
}
return min && max ? { min, max } : null;
}, [step, headers, dataRows, mapping.date, dateFormat]);
const existingTransactions = useTransactionsForDuplicateCheck(
accountId ? Number(accountId) : null,
dateBounds?.min ?? null,
dateBounds?.max ?? null
);
// Keyed on date+type+amount+description — see lib/csv.ts's
// transactionDedupeKey doc comment for why those fields and not an ID.
const existingKeys = useMemo(() => {
const keys = new Set<string>();
for (const tx of existingTransactions.data ?? []) {
keys.add(transactionDedupeKey(tx.date, tx.type, tx.amount, tx.description));
}
return keys;
}, [existingTransactions.data]);
const { valid, skipped, duplicateCount } = useMemo(() => {
if (step !== "preview") return { valid: [] as TransactionInput[], skipped: [] as SkippedRow[], duplicateCount: 0 };
const dateIdx = headers.indexOf(mapping.date);
const amountIdx = headers.indexOf(mapping.amount);
@@ -74,7 +234,8 @@ export function CsvImportPage() {
const notesIdx = mapping.notes ? headers.indexOf(mapping.notes) : -1;
const categoryIdx = mapping.category ? headers.indexOf(mapping.category) : -1;
const validRows: TransactionInput[] = [];
const freshRows: TransactionInput[] = [];
const duplicateRows: TransactionInput[] = [];
const skippedRows: SkippedRow[] = [];
dataRows.forEach((cells, index) => {
@@ -86,12 +247,21 @@ export function CsvImportPage() {
const rawNotes = notesIdx >= 0 ? (cells[notesIdx] ?? "").trim() : "";
const rawCategory = categoryIdx >= 0 ? (cells[categoryIdx] ?? "").trim() : "";
// Bank-profile row filter first (e.g. T-Bank's "Статус" = FAILED):
// a declined payment parses perfectly well as a date + amount, and
// nothing downstream could tell it apart from a real expense.
const rejectedStatus = rowFilterRejection(activePreset, headers, cells);
if (rejectedStatus !== null) {
skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorRowFiltered", { value: rejectedStatus || "—" }) });
return;
}
const isoDate = parseDateWithFormat(rawDate, dateFormat);
if (!isoDate) {
skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadDate", { value: rawDate || "—" }) });
return;
}
const amount = parseAmount(rawAmount);
const amount = parseAmount(rawAmount, amountFormat);
if (amount === null || amount === 0) {
skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadAmount", { value: rawAmount || "—" }) });
return;
@@ -105,7 +275,7 @@ export function CsvImportPage() {
const type = resolveType(amount);
const categoryId = rawCategory ? categoryLookup[type].get(rawCategory.toLowerCase()) ?? null : null;
validRows.push({
const candidate: TransactionInput = {
account_id: Number(accountId),
category_id: categoryId,
transfer_account_id: null,
@@ -115,53 +285,67 @@ export function CsvImportPage() {
merchant: rawMerchant || null,
notes: rawNotes || null,
date: isoDate,
});
};
const key = transactionDedupeKey(candidate.date, candidate.type, candidate.amount, candidate.description);
if (existingKeys.has(key)) {
duplicateRows.push(candidate);
} else {
freshRows.push(candidate);
}
});
return { valid: validRows, skipped: skippedRows };
return {
valid: includeDuplicates ? [...freshRows, ...duplicateRows] : freshRows,
skipped: skippedRows,
duplicateCount: duplicateRows.length,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step, headers, dataRows, mapping, dateFormat, categoryLookup, accountId, t]);
}, [step, headers, dataRows, mapping, dateFormat, amountFormat, categoryLookup, accountId, existingKeys, includeDuplicates, activePreset, t]);
function handleFileSelected(event: React.ChangeEvent<HTMLInputElement>) {
async function handleFileSelected(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setParseError(null);
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result ?? "");
const rows = parseCsv(text);
if (rows.length < 2) {
setParseError(t("transactions.import.errorEmptyFile"));
return;
}
const [headerRow, ...rest] = rows;
setFileName(file.name);
setHeaders(headerRow);
setDataRows(rest);
// Best-effort auto-mapping by common header names — the user can
// still correct any of these on the next screen.
const guess = (...candidates: string[]) =>
headerRow.find((h) => candidates.includes(h.trim().toLowerCase())) ?? "";
setMapping({
date: guess("date", "дата"),
amount: guess("amount", "сумма"),
description: guess("description", "описание", "назначение платежа"),
merchant: guess("merchant", "payee", "получатель"),
notes: guess("notes", "заметка", "примечание"),
category: guess("category", "категория"),
});
setStep("map");
};
reader.readAsText(file);
const buffer = await file.arrayBuffer();
// Invalid-UTF-8 byte sequences almost always mean a Cyrillic bank export
// in the classic Windows codepage — a far better first guess than
// staying on UTF-8 and showing every letter as mojibake. The user can
// still override this from the dropdown on the next screen either way.
let detectedEncoding: Encoding = "utf-8";
try {
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
} catch {
detectedEncoding = "windows-1251";
}
const rows = parseCsv(new TextDecoder(detectedEncoding).decode(buffer));
if (rows.length < 2) {
setParseError(t("transactions.import.errorEmptyFile"));
return;
}
const [headerRow] = rows;
// Full reset for a fresh file — the header-change effect above skips
// files whose header row is byte-identical to the previous one, so
// formats from the last import can't be left over here.
const defaults = resolveDefaults(headerRow, resolvePreset(headerRow, presetChoice));
setFileName(file.name);
setFileBuffer(buffer);
setEncoding(detectedEncoding);
setMapping(defaults.mapping);
setDateFormat(defaults.dateFormat);
setAmountFormat(defaults.amountFormat);
setIncludeDuplicates(false);
setStep("map");
}
function startOver() {
setStep("upload");
setFileName("");
setHeaders([]);
setDataRows([]);
setFileBuffer(null);
setEncoding("utf-8");
setCreatedCount(null);
setImportError(null);
}
@@ -178,6 +362,23 @@ export function CsvImportPage() {
const mappingComplete = Boolean(mapping.date && mapping.amount && mapping.description);
// Same control on the upload and map steps: picking the bank up front is
// the "I know what this file is" path, changing it on the map step is the
// "auto-detect guessed wrong" path.
const presetSelect = (id: string) => (
<div>
<Label htmlFor={id}>{t("transactions.import.presetLabel")}</Label>
<Select id={id} value={presetChoice} onChange={(event) => setPresetChoice(event.target.value)} className="sm:w-72">
<option value={PRESET_AUTO}>{t("transactions.import.preset.auto")}</option>
{BANK_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</Select>
</div>
);
return (
<div className="space-y-5">
<div className="flex items-center gap-2">
@@ -217,6 +418,8 @@ export function CsvImportPage() {
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-text-secondary">{t("transactions.import.uploadHint")}</p>
{presetSelect("upload-preset")}
<p className="text-xs text-text-muted">{t("transactions.import.presetHint")}</p>
<Button onClick={() => fileInputRef.current?.click()} disabled={!accountId}>
<Upload size={16} />
{t("transactions.import.chooseFile")}
@@ -235,6 +438,25 @@ export function CsvImportPage() {
<span className="text-xs text-text-muted">{fileName}</span>
</CardHeader>
<CardContent className="space-y-3">
{presetSelect("map-preset")}
<p className={`text-xs ${activePreset ? "text-success" : "text-text-muted"}`}>
{activePreset
? t("transactions.import.presetDetected", { bank: activePreset.label })
: t("transactions.import.presetNotDetected")}
</p>
<div>
<Label htmlFor="map-encoding">{t("transactions.import.encodingLabel")}</Label>
<Select id="map-encoding" value={encoding} onChange={(event) => setEncoding(event.target.value as Encoding)} className="sm:w-56">
{ENCODINGS.map((enc) => (
<option key={enc} value={enc}>
{enc.toUpperCase()}
</option>
))}
</Select>
<p className="mt-1 text-xs text-text-muted">{t("transactions.import.encodingHint")}</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<Label htmlFor="map-date">{t("transactions.import.dateColumnLabel")}</Label>
@@ -248,6 +470,11 @@ export function CsvImportPage() {
</option>
))}
</Select>
{mapping.date && (
<p className="mt-1 text-xs text-text-muted">
{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.date) || "—" })}
</p>
)}
</div>
<div>
<Label htmlFor="map-date-format">{t("transactions.import.dateFormatLabel")}</Label>
@@ -272,6 +499,21 @@ export function CsvImportPage() {
))}
</Select>
<p className="mt-1 text-xs text-text-muted">{t("transactions.import.amountHint")}</p>
{mapping.amount && (
<p className="text-xs text-text-muted">
{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.amount) || "—" })}
</p>
)}
</div>
<div>
<Label htmlFor="map-amount-format">{t("transactions.import.amountFormatLabel")}</Label>
<Select id="map-amount-format" value={amountFormat} onChange={(event) => setAmountFormat(event.target.value as AmountFormat)}>
{AMOUNT_FORMATS.map((format) => (
<option key={format} value={format}>
{t(`transactions.import.amountFormat.${format}`)}
</option>
))}
</Select>
</div>
<div>
<Label htmlFor="map-description">{t("transactions.import.descriptionColumnLabel")}</Label>
@@ -289,6 +531,11 @@ export function CsvImportPage() {
</option>
))}
</Select>
{mapping.description && (
<p className="mt-1 text-xs text-text-muted">
{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.description) || "—" })}
</p>
)}
</div>
<div>
<Label htmlFor="map-merchant">{t("transactions.form.merchantLabel")}</Label>
@@ -300,6 +547,11 @@ export function CsvImportPage() {
</option>
))}
</Select>
{mapping.merchant && (
<p className="mt-1 text-xs text-text-muted">
{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.merchant) || "—" })}
</p>
)}
</div>
<div>
<Label htmlFor="map-notes">{t("transactions.form.notesLabel")}</Label>
@@ -311,6 +563,11 @@ export function CsvImportPage() {
</option>
))}
</Select>
{mapping.notes && (
<p className="mt-1 text-xs text-text-muted">
{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.notes) || "—" })}
</p>
)}
</div>
<div>
<Label htmlFor="map-category">{t("transactions.form.categoryLabel")}</Label>
@@ -323,6 +580,11 @@ export function CsvImportPage() {
))}
</Select>
<p className="mt-1 text-xs text-text-muted">{t("transactions.import.categoryHint")}</p>
{mapping.category && (
<p className="text-xs text-text-muted">
{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.category) || "—" })}
</p>
)}
</div>
</div>
@@ -355,6 +617,31 @@ export function CsvImportPage() {
{t("transactions.import.summary", { valid: valid.length, skipped: skipped.length })}
</p>
{existingTransactions.isFetching && (
<p className="text-xs text-text-muted">{t("transactions.import.duplicateCheckLoading")}</p>
)}
{existingTransactions.isError && (
<p className="text-sm text-danger">{t("transactions.import.duplicateCheckFailed")}</p>
)}
{duplicateCount > 0 && (
<div className="flex items-start gap-2 rounded-lg border border-border bg-surface-2 p-3 text-xs text-text-muted">
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={includeDuplicates}
onChange={(event) => setIncludeDuplicates(event.target.checked)}
className="mt-0.5 h-3.5 w-3.5 accent-text-primary"
/>
<span>
{t("transactions.import.duplicatesFound", { count: duplicateCount })}{" "}
{t("transactions.import.includeDuplicates")}
</span>
</label>
</div>
)}
{valid.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left text-sm">
@@ -410,7 +697,13 @@ export function CsvImportPage() {
<Button variant="ghost" onClick={() => setStep("map")}>
{t("common.back")}
</Button>
<Button disabled={valid.length === 0 || bulkCreate.isPending} onClick={() => void handleImport()}>
{/* isFetching, not isLoading: a background refetch of the
duplicate check still serves stale data, and importing
against that would wave duplicates straight through. */}
<Button
disabled={valid.length === 0 || bulkCreate.isPending || existingTransactions.isFetching}
onClick={() => void handleImport()}
>
{bulkCreate.isPending
? t("common.saving")
: t("transactions.import.importButton", { count: valid.length })}

View File

@@ -2,14 +2,14 @@ import { AlertThresholdsCard } from "@/components/settings/AlertThresholdsCard";
import { BackupCard } from "@/components/settings/BackupCard";
import { CurrencyCard } from "@/components/settings/CurrencyCard";
import { PreferencesCard } from "@/components/settings/PreferencesCard";
import { useHealth } from "@/hooks/useHealth";
import { useAppSettings } from "@/hooks/useSettings";
import { t } from "@/lib/i18n";
export function SettingsPage() {
// Purely informational — if /api/health hasn't answered yet (or is
// Purely informational — if the settings request hasn't answered yet (or is
// unreachable), just show nothing rather than a loading/error state for
// one line of fine print.
const { data: health } = useHealth();
const { data: appSettings } = useAppSettings();
return (
<div className="space-y-5">
@@ -17,9 +17,9 @@ export function SettingsPage() {
<CurrencyCard />
<AlertThresholdsCard />
<BackupCard />
{health?.version && (
{appSettings?.app_version && (
<p className="text-center text-xs text-text-muted">
{t("settings.version", { version: health.version })}
{t("settings.version", { version: appSettings.app_version })}
</p>
)}
</div>

View File

@@ -565,10 +565,8 @@ export interface AppSettings {
risky_allocation_threshold_percent: number;
idle_cash_threshold_amount: string;
idle_cash_threshold_days: number;
}
export interface HealthStatus {
status: string;
version: string;
/** Read-only: the running backend release. Comes back here rather than
* from /api/health, which is served without auth. */
app_version: string;
}