mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-14 03:26:40 +07:00
Compare commits
15 Commits
aac72f36f2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64a190678e | ||
|
|
0cf72831a8 | ||
|
|
848ff21665 | ||
|
|
c867710f38 | ||
|
|
3bd484b88e | ||
|
|
14948aff1d | ||
|
|
f9413aa6e1 | ||
|
|
eba8add92f | ||
|
|
777e3af725 | ||
|
|
78826db33e | ||
|
|
47027c517d | ||
|
|
6df4e57f08 | ||
|
|
292ef1e410 | ||
|
|
19ca9c3158 | ||
|
|
cd2e4ae9f9 |
36
.env.example
36
.env.example
@@ -1,6 +1,17 @@
|
|||||||
# Copy this file to .env and adjust values before running `docker compose up`.
|
# Copy this file to .env and adjust values before running `docker compose up`.
|
||||||
|
|
||||||
# --- Postgres ---
|
# --- 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_USER=aurum
|
||||||
AURUM_POSTGRES_PASSWORD=change-me
|
AURUM_POSTGRES_PASSWORD=change-me
|
||||||
AURUM_POSTGRES_DB=aurum
|
AURUM_POSTGRES_DB=aurum
|
||||||
@@ -23,6 +34,13 @@ AURUM_DEFAULT_CURRENCY=USD
|
|||||||
# you to set this — nothing else in the app is affected.
|
# you to set this — nothing else in the app is affected.
|
||||||
AURUM_COINGECKO_API_KEY=
|
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
|
# --- Basic auth (STRONGLY recommended if this instance is reachable from
|
||||||
# anywhere other than your own machine) ---
|
# anywhere other than your own machine) ---
|
||||||
# Aurum has no login system of its own — it's built for one person
|
# 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 ---
|
# --- Ports exposed on your host machine ---
|
||||||
AURUM_WEB_PORT=3000
|
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
|
||||||
|
|||||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -8,6 +8,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
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:
|
jobs:
|
||||||
backend-tests:
|
backend-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
23
Caddyfile
Normal file
23
Caddyfile
Normal 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
25
DOCS.md
@@ -116,9 +116,13 @@ string.
|
|||||||
|
|
||||||
### `GET /api/health`
|
### `GET /api/health`
|
||||||
|
|
||||||
No auth required. Returns `{"status": "ok", "version": "1.1.0"}` — `version` is the running app's
|
No auth required. Returns `{"status": "ok"}`. Use this to check the backend is up before hitting
|
||||||
version, the same value shown in the UI under Settings. Use this to check the backend is up before
|
anything else.
|
||||||
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
|
## 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
|
export tool). All rows are validated **before** any is inserted — one bad row fails the whole
|
||||||
request with no partial import.
|
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
|
```json
|
||||||
{ "items": [ /* 1–5000 TransactionCreate objects */ ] }
|
{ "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,
|
"net_worth_decline_threshold_months": 2,
|
||||||
"risky_allocation_threshold_percent": 20,
|
"risky_allocation_threshold_percent": 20,
|
||||||
"idle_cash_threshold_amount": "1000.00",
|
"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.
|
- `currency`: 3-letter uppercase code — display-only, no conversion.
|
||||||
- `negative_cash_flow_threshold_months` / `net_worth_decline_threshold_months`: consecutive months
|
- `negative_cash_flow_threshold_months` / `net_worth_decline_threshold_months`: consecutive months
|
||||||
before the corresponding alert fires (`1`–`24`).
|
before the corresponding alert fires (`1`–`24`).
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -58,7 +58,7 @@ Aurum treats your financial life as a **system**, not a spreadsheet.
|
|||||||
## 🧩 Core Features
|
## 🧩 Core Features
|
||||||
|
|
||||||
### 💰 Cash Flow & Transactions
|
### 💰 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
|
### 📈 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.
|
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:
|
**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.
|
- 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.
|
If you find a security issue, please open a private report via GitHub's Security tab rather than a public issue.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ RUN pip install --upgrade pip && pip install -r requirements.txt
|
|||||||
|
|
||||||
COPY . .
|
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
|
EXPOSE 8000
|
||||||
|
|
||||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=5 \
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=5 \
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
|
from app.core.audit import log_destructive
|
||||||
from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance
|
from app.schemas.account import AccountCreate, AccountUpdate, AccountWithBalance
|
||||||
from app.services.account_service import create_account, delete_account, list_accounts, update_account
|
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)
|
@router.delete("/{account_id}", status_code=204)
|
||||||
async def delete_account_route(account_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
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)
|
await delete_account(session, account_id)
|
||||||
|
log_destructive("account.deleted", account_id=account_id)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
|
from app.core.audit import log_destructive
|
||||||
from app.schemas.backup import BackupPayload
|
from app.schemas.backup import BackupPayload
|
||||||
from app.services.backup_service import build_backup, restore_backup
|
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")
|
@router.post("/import")
|
||||||
async def import_backup(payload: BackupPayload, session: AsyncSession = Depends(get_session)) -> dict[str, str]:
|
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)
|
await restore_backup(session, payload)
|
||||||
|
log_destructive("backup.restore.completed", transactions=len(payload.transactions))
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ from app.services.settings_service import get_or_create_app_settings
|
|||||||
router = APIRouter(prefix="/settings", tags=["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)
|
@router.get("", response_model=AppSettingsRead)
|
||||||
async def read_settings(session: AsyncSession = Depends(get_session)) -> AppSettings:
|
async def read_settings(session: AsyncSession = Depends(get_session)) -> AppSettings:
|
||||||
return await get_or_create_app_settings(session)
|
return await get_or_create_app_settings(session)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
|
from app.core.audit import log_destructive
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.enums import CategoryKind, TransactionType
|
from app.models.enums import CategoryKind, TransactionType
|
||||||
from app.models.tag import Tag
|
from app.models.tag import Tag
|
||||||
@@ -228,6 +229,9 @@ async def bulk_create_transactions(
|
|||||||
|
|
||||||
session.add_all(transactions)
|
session.add_all(transactions)
|
||||||
await session.commit()
|
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))
|
return TransactionBulkCreateResult(created=len(transactions))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
32
backend/app/core/audit.py
Normal file
32
backend/app/core/audit.py
Normal 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)
|
||||||
@@ -4,10 +4,11 @@ from functools import lru_cache
|
|||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
# Single source of truth for the running app's version — surfaced in the API
|
# 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
|
# title, in GET /api/settings as app_version (which the frontend reads to show
|
||||||
# embedded in exported backups so an old file can be told apart from a
|
# it in Settings; it deliberately does NOT ride on the unauthenticated
|
||||||
# current one.
|
# /api/health), and embedded in exported backups so an old file can be told
|
||||||
APP_VERSION = "1.1.6"
|
# apart from a current one.
|
||||||
|
APP_VERSION = "1.1.8"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -35,6 +36,14 @@ class Settings(BaseSettings):
|
|||||||
# rather than silently hitting CoinGecko's much stingier keyless tier.
|
# rather than silently hitting CoinGecko's much stingier keyless tier.
|
||||||
coingecko_api_key: str = ""
|
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
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -46,9 +46,12 @@ app = FastAPI(
|
|||||||
# backend (see frontend/nginx.conf) — everything else falls through to
|
# backend (see frontend/nginx.conf) — everything else falls through to
|
||||||
# the SPA's index.html, which is why the defaults (/docs, /openapi.json)
|
# the SPA's index.html, which is why the defaults (/docs, /openapi.json)
|
||||||
# would silently 404 through the reverse proxy.
|
# would silently 404 through the reverse proxy.
|
||||||
docs_url="/api/docs",
|
# All three go away together when AURUM_ENABLE_DOCS=false — leaving
|
||||||
redoc_url="/api/redoc",
|
# openapi.json reachable would keep handing out the full API map even
|
||||||
openapi_url="/api/openapi.json",
|
# 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
|
# 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")
|
@app.get("/api/health")
|
||||||
async def health() -> dict[str, str]:
|
async def health() -> dict[str, str]:
|
||||||
# version rides along so the frontend's Settings page can show which
|
# Status only. This is the one route nginx serves without auth (Docker's
|
||||||
# release is actually running without a separate authenticated endpoint —
|
# HEALTHCHECK and uptime monitors need it — see frontend/nginx.conf), so
|
||||||
# this route is already auth_basic-exempt for Docker's HEALTHCHECK.
|
# anything returned here is world-readable. The running version used to
|
||||||
return {"status": "ok", "version": APP_VERSION}
|
# 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"}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class AssetBase(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=150)
|
name: str = Field(min_length=1, max_length=150)
|
||||||
asset_class: AssetClass
|
asset_class: AssetClass
|
||||||
currency: str = Field(default="USD", min_length=3, max_length=3)
|
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
|
capital_role: CapitalRole = CapitalRole.NEUTRAL
|
||||||
# Rough self-reported monthly net cash flow — informational, not tracked
|
# Rough self-reported monthly net cash flow — informational, not tracked
|
||||||
# transactions (see Asset.monthly_cash_flow).
|
# 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)
|
name: str | None = Field(default=None, min_length=1, max_length=150)
|
||||||
asset_class: AssetClass | None = None
|
asset_class: AssetClass | None = None
|
||||||
currency: str | None = Field(default=None, min_length=3, max_length=3)
|
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
|
capital_role: CapitalRole | None = None
|
||||||
monthly_cash_flow: Decimal | None = Field(default=None, max_digits=14, decimal_places=2)
|
monthly_cash_flow: Decimal | None = Field(default=None, max_digits=14, decimal_places=2)
|
||||||
risk_level: RiskLevel | None = None
|
risk_level: RiskLevel | None = None
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class RecurringTransactionCreate(BaseModel):
|
|||||||
amount: Decimal = Field(gt=0)
|
amount: Decimal = Field(gt=0)
|
||||||
description: str = Field(min_length=1, max_length=255)
|
description: str = Field(min_length=1, max_length=255)
|
||||||
merchant: str | None = None
|
merchant: str | None = None
|
||||||
notes: str | None = None
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
frequency: RecurringFrequency
|
frequency: RecurringFrequency
|
||||||
anchor_date: date_
|
anchor_date: date_
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ class RecurringTransactionUpdate(BaseModel):
|
|||||||
amount: Decimal | None = Field(default=None, gt=0)
|
amount: Decimal | None = Field(default=None, gt=0)
|
||||||
description: str | None = Field(default=None, min_length=1, max_length=255)
|
description: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
merchant: str | None = None
|
merchant: str | None = None
|
||||||
notes: str | None = None
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
frequency: RecurringFrequency | None = None
|
frequency: RecurringFrequency | None = None
|
||||||
anchor_date: date_ | None = None
|
anchor_date: date_ | None = None
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
from decimal import Decimal
|
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):
|
class AppSettingsRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
currency: str
|
currency: str
|
||||||
negative_cash_flow_threshold_months: int
|
negative_cash_flow_threshold_months: int
|
||||||
net_worth_decline_threshold_months: int
|
net_worth_decline_threshold_months: int
|
||||||
risky_allocation_threshold_percent: int
|
risky_allocation_threshold_percent: int
|
||||||
idle_cash_threshold_amount: Decimal
|
idle_cash_threshold_amount: Decimal
|
||||||
idle_cash_threshold_days: int
|
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):
|
class AppSettingsUpdate(BaseModel):
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class TransactionFields(BaseModel):
|
|||||||
amount: Decimal = Field(gt=0, max_digits=14, decimal_places=2)
|
amount: Decimal = Field(gt=0, max_digits=14, decimal_places=2)
|
||||||
description: str = Field(min_length=1, max_length=255)
|
description: str = Field(min_length=1, max_length=255)
|
||||||
merchant: str | None = Field(default=None, max_length=150)
|
merchant: str | None = Field(default=None, max_length=150)
|
||||||
notes: str | None = None
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
date: date_
|
date: date_
|
||||||
|
|
||||||
# Auto-capitalizes "траты на продукты" -> "Траты на продукты" so mixed
|
# 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)
|
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)
|
description: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
merchant: str | None = Field(default=None, max_length=150)
|
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
|
date: date_ | None = None
|
||||||
# Omitted -> tags untouched; sent (even as []) -> replaces the full tag set.
|
# Omitted -> tags untouched; sent (even as []) -> replaces the full tag set.
|
||||||
tag_ids: list[int] | None = None
|
tag_ids: list[int] | None = None
|
||||||
|
|||||||
@@ -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
|
the database back to exactly where it was, so a bad file never leaves the
|
||||||
app half-restored.
|
app half-restored.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -46,6 +47,8 @@ from app.schemas.backup import (
|
|||||||
TransactionSplitBackup,
|
TransactionSplitBackup,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
BACKUP_FORMAT_VERSION = 1
|
BACKUP_FORMAT_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
@@ -304,4 +307,9 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
|||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await session.rollback()
|
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
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
# backend container (see backend/tests/README.md) rather than baking these
|
# backend container (see backend/tests/README.md) rather than baking these
|
||||||
# into the Docker image.
|
# into the Docker image.
|
||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
pytest==8.3.4
|
pytest==9.1.1
|
||||||
pytest-asyncio==0.25.0
|
pytest-asyncio==1.4.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ fastapi==0.141.1
|
|||||||
uvicorn[standard]==0.52.4
|
uvicorn[standard]==0.52.4
|
||||||
sqlalchemy==2.0.52
|
sqlalchemy==2.0.52
|
||||||
asyncpg==0.31.0
|
asyncpg==0.31.0
|
||||||
alembic==1.19.2
|
alembic==1.20.0
|
||||||
pydantic==2.13.5
|
pydantic==2.13.5
|
||||||
pydantic-settings==2.15.0
|
pydantic-settings==2.15.0
|
||||||
python-dotenv==1.2.3
|
python-dotenv==1.2.3
|
||||||
|
|||||||
@@ -14,12 +14,22 @@ lifetime, then run pytest inside the running `backend` container — it's the
|
|||||||
only place with network access to Postgres:
|
only place with network access to Postgres:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec backend pip install -r requirements-dev.txt
|
docker compose exec backend pip install --user -r requirements-dev.txt
|
||||||
docker compose exec backend pytest -v
|
docker compose exec backend python -m pytest -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Re-run just `pytest -v` for subsequent runs; the `pip install` only needs
|
Re-run just the `python -m pytest -v` line for subsequent runs; the
|
||||||
repeating after a container restart/rebuild.
|
`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
|
## Adding a test
|
||||||
|
|
||||||
|
|||||||
50
docker-compose.tls.yml
Normal file
50
docker-compose.tls.yml
Normal 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:
|
||||||
@@ -4,7 +4,14 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${AURUM_POSTGRES_USER:-aurum}
|
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}
|
POSTGRES_DB: ${AURUM_POSTGRES_DB:-aurum}
|
||||||
volumes:
|
volumes:
|
||||||
- aurum_pgdata:/var/lib/postgresql/data
|
- aurum_pgdata:/var/lib/postgresql/data
|
||||||
@@ -17,9 +24,17 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
restart: unless-stopped
|
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:
|
environment:
|
||||||
AURUM_POSTGRES_USER: ${AURUM_POSTGRES_USER:-aurum}
|
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_DB: ${AURUM_POSTGRES_DB:-aurum}
|
||||||
AURUM_POSTGRES_HOST: db
|
AURUM_POSTGRES_HOST: db
|
||||||
AURUM_POSTGRES_PORT: 5432
|
AURUM_POSTGRES_PORT: 5432
|
||||||
@@ -28,6 +43,7 @@ services:
|
|||||||
# Free CoinGecko Demo key (coingecko.com/en/api/pricing) — the Crypto
|
# Free CoinGecko Demo key (coingecko.com/en/api/pricing) — the Crypto
|
||||||
# tab's price sync 400s with a clear message until this is set.
|
# tab's price sync 400s with a clear message until this is set.
|
||||||
AURUM_COINGECKO_API_KEY: ${AURUM_COINGECKO_API_KEY:-}
|
AURUM_COINGECKO_API_KEY: ${AURUM_COINGECKO_API_KEY:-}
|
||||||
|
AURUM_ENABLE_DOCS: ${AURUM_ENABLE_DOCS:-true}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -35,12 +51,26 @@ services:
|
|||||||
web:
|
web:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
restart: unless-stopped
|
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:
|
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:
|
environment:
|
||||||
# Both unset = no login screen at all. See frontend/docker-entrypoint.d/20-basic-auth.sh.
|
# 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_USER: ${AURUM_BASIC_AUTH_USER:-}
|
||||||
AURUM_BASIC_AUTH_PASSWORD: ${AURUM_BASIC_AUTH_PASSWORD:-}
|
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:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ RUN apk add --no-cache openssl
|
|||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
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/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
|
# 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
|
# nginx dies on boot with "not found" (exit 127). .gitattributes fixes this
|
||||||
# at the repository level; this line covers checkouts that predate it.
|
# at the repository level; this line covers checkouts that predate it.
|
||||||
RUN sed -i 's/\r$//' /docker-entrypoint.d/20-basic-auth.sh \
|
# The empty .conf files matter: nginx.conf includes both unconditionally, and
|
||||||
&& chmod +x /docker-entrypoint.d/20-basic-auth.sh \
|
# an include of a missing file is a startup error.
|
||||||
&& touch /etc/nginx/basic-auth.conf
|
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
|
EXPOSE 80
|
||||||
|
|
||||||
|
|||||||
45
frontend/docker-entrypoint.d/25-allowed-hosts.sh
Normal file
45
frontend/docker-entrypoint.d/25-allowed-hosts.sh
Normal 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
|
||||||
@@ -1,29 +1,39 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<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="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#0b0b0b" />
|
<meta name="theme-color" content="#0b0b0b" />
|
||||||
<title>Aurum — Личные финансы</title>
|
<title>Aurum — Personal Finance</title>
|
||||||
<script>
|
<meta
|
||||||
// Applies the saved theme (light/dark/system) to <html> before the
|
name="description"
|
||||||
// first paint, so an explicit override never flashes the wrong theme
|
content="Aurum is a self-hosted personal finance operating system: cash flow, net worth, budgets, subscriptions, assets, and every income stream in one place."
|
||||||
// for a frame while React/lib/theme.ts loads. "system" needs no
|
/>
|
||||||
// attribute at all — index.css's prefers-color-scheme media query
|
<meta
|
||||||
// already handles that case with zero JS.
|
name="keywords"
|
||||||
(function () {
|
content="personal finance, finance tracker, budgeting, self-hosted, cash flow, net worth, expense tracker, subscription tracker, crypto tracker, family budget"
|
||||||
var stored = localStorage.getItem("aurum:theme");
|
/>
|
||||||
if (stored === "light" || stored === "dark") {
|
<meta name="author" content="Aurum" />
|
||||||
document.documentElement.setAttribute("data-theme", stored);
|
<!-- Self-hosted app without a fixed public domain, so og:url/og:image
|
||||||
}
|
are left out — each deployment would need its own values. -->
|
||||||
var isDark =
|
<meta property="og:title" content="Aurum — Personal Finance" />
|
||||||
stored === "dark" || (stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
<meta
|
||||||
var meta = document.querySelector('meta[name="theme-color"]');
|
property="og:description"
|
||||||
// Kept in sync by hand with index.css's --surface-0 light/dark values.
|
content="A self-hosted personal finance operating system: cash flow, net worth, budgets, subscriptions, and assets."
|
||||||
if (meta) meta.setAttribute("content", isDark ? "#0d0d0d" : "#f9f9f7");
|
/>
|
||||||
})();
|
<meta property="og:type" content="website" />
|
||||||
</script>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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
|
# Always open, no auth — this is what Docker's own HEALTHCHECK and any
|
||||||
# external uptime monitor hit, and it returns nothing but {"status":"ok"}.
|
# external uptime monitor hit, and it returns nothing but {"status":"ok"}.
|
||||||
# docker-entrypoint.d/20-basic-auth.sh writes basic-auth.conf; without an
|
# 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.
|
# sets below and healthchecks would start failing with 401.
|
||||||
location = /api/health {
|
location = /api/health {
|
||||||
auth_basic off;
|
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_pass http://backend:8000/api/health;
|
||||||
proxy_set_header Host $host;
|
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/.
|
# aren't set, so this is a no-op by default) — see docker-entrypoint.d/.
|
||||||
location /api/ {
|
location /api/ {
|
||||||
include /etc/nginx/basic-auth.conf;
|
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_pass http://backend:8000/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
264
frontend/package-lock.json
generated
264
frontend/package-lock.json
generated
@@ -1,32 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "aurum-frontend",
|
"name": "aurum-frontend",
|
||||||
"version": "1.1.6",
|
"version": "1.1.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aurum-frontend",
|
"name": "aurum-frontend",
|
||||||
"version": "1.1.6",
|
"version": "1.1.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.102.8",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.31.0",
|
"lucide-react": "^1.45.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.3.0",
|
||||||
"react-router-dom": "^7.18.3",
|
"react-router-dom": "^7.18.3",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/node": "^26.4.1",
|
"@types/node": "^26.5.1",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.2.7",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^8.2.2",
|
"vite": "^8.3.0",
|
||||||
"vitest": "^5.0.0"
|
"vitest": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -285,13 +285,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.144.0",
|
"version": "0.149.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
|
||||||
"integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
|
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/oxc-project"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
"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": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
|
||||||
"integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
|
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -338,9 +355,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
|
||||||
"integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
|
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -355,9 +372,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
|
||||||
"integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
|
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -372,9 +389,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
|
||||||
"integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
|
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -389,9 +406,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
|
||||||
"integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
|
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -406,9 +423,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
|
||||||
"integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
|
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -426,9 +443,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
|
||||||
"integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
|
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -446,9 +463,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
|
||||||
"integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
|
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -466,9 +483,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
|
||||||
"integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
|
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -486,9 +503,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
|
||||||
"integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
|
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -506,9 +523,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
|
||||||
"integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
|
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -526,9 +543,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
|
||||||
"integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
|
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -543,9 +560,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
|
||||||
"integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
|
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -560,9 +577,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
|
||||||
"integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
|
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -946,9 +963,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.101.4",
|
"version": "5.102.8",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz",
|
||||||
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
|
"integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -956,12 +973,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.101.4",
|
"version": "5.102.8",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz",
|
||||||
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
|
"integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.101.4"
|
"@tanstack/query-core": "5.102.8"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1060,19 +1077,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "26.4.1",
|
"version": "26.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
|
||||||
"integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
|
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~8.3.0"
|
"undici-types": "~8.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.18",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1080,13 +1097,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.2.7",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
"integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==",
|
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
@@ -2244,9 +2261,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.31.0",
|
"version": "1.45.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.45.0.tgz",
|
||||||
"integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==",
|
"integrity": "sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
@@ -2270,9 +2287,9 @@
|
|||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.18",
|
"version": "3.3.19",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2336,9 +2353,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.26",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2356,7 +2373,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.17",
|
"nanoid": "^3.3.18",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -2375,24 +2392,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.8",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.8",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.28.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.2.8"
|
"react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
@@ -2525,13 +2542,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
|
||||||
"integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
|
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.144.0",
|
"@oxc-project/types": "=0.149.0",
|
||||||
"@rolldown/pluginutils": "^1.0.0"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2541,20 +2558,21 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.2.4",
|
"@rolldown/binding-android-arm-eabi": "1.2.8",
|
||||||
"@rolldown/binding-darwin-arm64": "1.2.4",
|
"@rolldown/binding-android-arm64": "1.2.8",
|
||||||
"@rolldown/binding-darwin-x64": "1.2.4",
|
"@rolldown/binding-darwin-arm64": "1.2.8",
|
||||||
"@rolldown/binding-freebsd-x64": "1.2.4",
|
"@rolldown/binding-darwin-x64": "1.2.8",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
|
"@rolldown/binding-freebsd-x64": "1.2.8",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.2.4",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.2.4",
|
"@rolldown/binding-linux-arm64-gnu": "1.2.8",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.4",
|
"@rolldown/binding-linux-arm64-musl": "1.2.8",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.2.4",
|
"@rolldown/binding-linux-ppc64-gnu": "1.2.8",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.2.4",
|
"@rolldown/binding-linux-s390x-gnu": "1.2.8",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.2.4",
|
"@rolldown/binding-linux-x64-gnu": "1.2.8",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.2.4",
|
"@rolldown/binding-linux-x64-musl": "1.2.8",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.2.4",
|
"@rolldown/binding-openharmony-arm64": "1.2.8",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.2.4"
|
"@rolldown/binding-win32-arm64-msvc": "1.2.8",
|
||||||
|
"@rolldown/binding-win32-x64-msvc": "1.2.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/saxes": {
|
"node_modules/saxes": {
|
||||||
@@ -2571,9 +2589,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
@@ -2786,9 +2804,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "8.3.0",
|
"version": "8.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -2824,16 +2842,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.2.2",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||||
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
|
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.33.0",
|
"lightningcss": "^1.33.0",
|
||||||
"picomatch": "^4.0.5",
|
"picomatch": "^4.0.7",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "^8.5.28",
|
||||||
"rolldown": "~1.2.4",
|
"rolldown": "~1.2.6",
|
||||||
"tinyglobby": "^0.2.17"
|
"tinyglobby": "^0.2.17"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2850,7 +2868,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@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",
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aurum-frontend",
|
"name": "aurum-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.6",
|
"version": "1.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -11,25 +11,25 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.102.8",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.31.0",
|
"lucide-react": "^1.45.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.3.0",
|
||||||
"react-router-dom": "^7.18.3",
|
"react-router-dom": "^7.18.3",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/node": "^26.4.1",
|
"@types/node": "^26.5.1",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.2.7",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^8.2.2",
|
"vite": "^8.3.0",
|
||||||
"vitest": "^5.0.0"
|
"vitest": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
frontend/public/theme-init.js
Normal file
24
frontend/public/theme-init.js
Normal 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");
|
||||||
|
})();
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { api } from "@/api/client";
|
|
||||||
import type { HealthStatus } from "@/types";
|
|
||||||
|
|
||||||
export function fetchHealth() {
|
|
||||||
return api.get<HealthStatus>("/health");
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,25 @@ export function fetchTransactions(filters: TransactionFilters = {}) {
|
|||||||
return api.get<TransactionPage>(`/transactions?${params.toString()}`);
|
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
|
/** Full range of years to offer in the year picker, from the earliest
|
||||||
* transaction through the current year (see backend for the "gap year"
|
* transaction through the current year (see backend for the "gap year"
|
||||||
* rationale). */
|
* rationale). */
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { type ReactNode, useEffect, useState } from "react";
|
import { type ReactNode, useEffect, useState } from "react";
|
||||||
import { Logo } from "@/components/layout/Logo";
|
import { Logo } from "@/components/layout/Logo";
|
||||||
import { LoginScreen } from "@/components/auth/LoginScreen";
|
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
|
/** Wraps the whole app. Renders the login screen only when this instance
|
||||||
* actually has Basic Auth turned on (AURUM_BASIC_AUTH_USER/PASSWORD in
|
* 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. */
|
* reactively, and this falls back to the login screen on its own. */
|
||||||
export function LoginGate({ children }: { children: ReactNode }) {
|
export function LoginGate({ children }: { children: ReactNode }) {
|
||||||
const authHeader = useAuthHeader();
|
const authHeader = useAuthHeader();
|
||||||
const [probe, setProbe] = useState<Probe>(authHeader ? "not-required" : "checking");
|
const [probe, setProbe] = useState<Probe>(authHeader ? "authenticated" : "checking");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authHeader) {
|
if (authHeader) {
|
||||||
setProbe("not-required");
|
setProbe("authenticated");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
checkCredentials(null).then((result) => {
|
// Wait for the encrypted "remember me" credential to be read back before
|
||||||
if (cancelled) return;
|
// concluding anything: it arrives asynchronously (IndexedDB + WebCrypto),
|
||||||
// A network/backend error here isn't an auth problem — don't block
|
// and probing first would show the login screen for a moment to someone
|
||||||
// the user behind a login screen for an unrelated outage, let the
|
// who is, in fact, already logged in. When it does arrive it sets the
|
||||||
// app's own per-page error states (e.g. dashboard.errorLoading) explain it.
|
// header, which re-runs this effect down the authenticated path above.
|
||||||
setProbe(result === "unauthorized" ? "required" : "not-required");
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
@@ -46,5 +61,10 @@ export function LoginGate({ children }: { children: ReactNode }) {
|
|||||||
return <LoginScreen />;
|
return <LoginScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return (
|
||||||
|
<>
|
||||||
|
{probe === "not-configured" && <NoAuthModal />}
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Logo } from "@/components/layout/Logo";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card, CardContent } from "@/components/ui/Card";
|
import { Card, CardContent } from "@/components/ui/Card";
|
||||||
import { Input, Label } from "@/components/ui/Input";
|
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";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
type Status = "idle" | "submitting" | "invalid" | "unreachable";
|
type Status = "idle" | "submitting" | "invalid" | "unreachable";
|
||||||
@@ -19,13 +19,18 @@ export function LoginScreen() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [remember, setRemember] = useState(false);
|
const [remember, setRemember] = useState(false);
|
||||||
const [status, setStatus] = useState<Status>("idle");
|
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) => {
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setStatus("submitting");
|
setStatus("submitting");
|
||||||
const result = await checkCredentials(buildBasicAuthHeader(username, password));
|
const result = await checkCredentials(buildBasicAuthHeader(username, password));
|
||||||
if (result === "ok") {
|
if (result === "ok") {
|
||||||
setCredentials(username, password, remember);
|
setCredentials(username, password, canRemember && remember);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus(result === "unauthorized" ? "invalid" : "unreachable");
|
setStatus(result === "unauthorized" ? "invalid" : "unreachable");
|
||||||
@@ -67,15 +72,19 @@ export function LoginScreen() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-xs text-text-muted">
|
{canRemember ? (
|
||||||
<input
|
<label className="flex items-center gap-2 text-xs text-text-muted">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={remember}
|
type="checkbox"
|
||||||
onChange={(event) => setRemember(event.target.checked)}
|
checked={remember}
|
||||||
className="h-3.5 w-3.5 accent-text-primary"
|
onChange={(event) => setRemember(event.target.checked)}
|
||||||
/>
|
className="h-3.5 w-3.5 accent-text-primary"
|
||||||
{t("auth.rememberMe")}
|
/>
|
||||||
</label>
|
{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 === "invalid" && <p className="text-sm text-danger">{t("auth.errorInvalidCredentials")}</p>}
|
||||||
{status === "unreachable" && <p className="text-sm text-danger">{t("auth.errorUnreachable")}</p>}
|
{status === "unreachable" && <p className="text-sm text-danger">{t("auth.errorUnreachable")}</p>}
|
||||||
|
|||||||
53
frontend/src/components/auth/NoAuthModal.tsx
Normal file
53
frontend/src/components/auth/NoAuthModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -208,6 +208,7 @@ export function AssetFormModal({ open, onClose, asset }: AssetFormModalProps) {
|
|||||||
<Label htmlFor="asset-notes">{t("netWorth.form.notesLabel")}</Label>
|
<Label htmlFor="asset-notes">{t("netWorth.form.notesLabel")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="asset-notes"
|
id="asset-notes"
|
||||||
|
maxLength={2000}
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))}
|
onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -80,12 +80,16 @@ export function BackupCard() {
|
|||||||
<Upload size={16} />
|
<Upload size={16} />
|
||||||
{isImporting ? t("backup.importing") : t("backup.importButton")}
|
{isImporting ? t("backup.importing") : t("backup.importButton")}
|
||||||
</Button>
|
</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
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="application/json,.json"
|
accept="application/json,.json"
|
||||||
onChange={handleFileSelected}
|
onChange={handleFileSelected}
|
||||||
className="hidden"
|
className="absolute h-px w-px overflow-hidden opacity-0"
|
||||||
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,19 @@ interface SplitRowState {
|
|||||||
note: string;
|
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 {
|
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
|
// 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>
|
<div>
|
||||||
<Label htmlFor="notes">{t("transactions.form.notesLabel")}</Label>
|
<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
|
<Input
|
||||||
id="notes"
|
id="notes"
|
||||||
|
maxLength={2000}
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))}
|
onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
bulkCreateTransactions,
|
bulkCreateTransactions,
|
||||||
createTransaction,
|
createTransaction,
|
||||||
deleteTransaction,
|
deleteTransaction,
|
||||||
|
fetchAllTransactionsInRange,
|
||||||
fetchTransactions,
|
fetchTransactions,
|
||||||
fetchTransactionYears,
|
fetchTransactionYears,
|
||||||
updateTransaction,
|
updateTransaction,
|
||||||
@@ -14,6 +15,11 @@ function useInvalidateAfterTransactionChange() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return () => {
|
return () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
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: ["transaction-years"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["net-worth-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() {
|
export function useTransactionYears() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["transaction-years"],
|
queryKey: ["transaction-years"],
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
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
|
* Client-side mirror of the HTTP Basic Auth credentials Aurum's own login
|
||||||
* screen collects (see components/auth/LoginScreen.tsx), so every fetch can
|
* 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
|
* this only avoids ever triggering that native prompt, by never letting an
|
||||||
* unauthenticated request happen without us attaching the header ourselves.
|
* unauthenticated request happen without us attaching the header ourselves.
|
||||||
*
|
*
|
||||||
* Two storage tiers, chosen at login time by the "remember me" checkbox:
|
* Two tiers, chosen by the "remember me" checkbox:
|
||||||
* - sessionStorage (default): gone as soon as the tab closes.
|
* - sessionStorage (default): plain, but gone the moment the browser closes.
|
||||||
* - localStorage, with an explicit expiry stamped into the stored value:
|
* - the encrypted vault (lib/credentialVault.ts): survives a restart for
|
||||||
* survives closing the tab/browser, but only for REMEMBER_DAYS — an
|
* REMEMBER_DAYS. It used to be plain localStorage, which meant the
|
||||||
* unbounded "stay logged in forever" is too much for a finance app.
|
* instance password sat on disk recoverable with a single atob() — the
|
||||||
* The expiry is only checked when this module loads (i.e. on page load/
|
* vault encrypts it under a key the browser refuses to hand back.
|
||||||
* reload) — a tab left open across the expiry moment without reloading
|
*
|
||||||
* keeps working until its next reload or a 401 forces a fresh check.
|
* 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 SESSION_KEY = "aurum:basicAuth";
|
||||||
const REMEMBER_KEY = "aurum:basicAuth:remember";
|
// Where the pre-vault "remember me" tier kept its plaintext copy. Read never,
|
||||||
const REMEMBER_DAYS = 30;
|
// 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;
|
const REMEMBER_MS = REMEMBER_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
interface RememberedEntry {
|
function forgetLegacyRememberedCredentials(): void {
|
||||||
header: string;
|
|
||||||
expiresAt: number; // epoch ms
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRemembered(): string | null {
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(REMEMBER_KEY);
|
localStorage.removeItem(LEGACY_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;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
// storage unavailable — nothing was stored there either
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +47,13 @@ function readSession(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStored(): string | null {
|
export function isRememberSupported(): boolean {
|
||||||
return readRemembered() ?? readSession();
|
return isVaultAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentHeader: string | null = readStored();
|
forgetLegacyRememberedCredentials();
|
||||||
|
|
||||||
|
let currentHeader: string | null = readSession();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
function notify(): void {
|
function notify(): void {
|
||||||
@@ -70,6 +64,25 @@ export function getAuthHeader(): string | null {
|
|||||||
return currentHeader;
|
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
|
// btoa() only handles Latin1 — the UI is bilingual RU/EN, so a Cyrillic
|
||||||
// password has to survive this, not just ASCII ones.
|
// password has to survive this, not just ASCII ones.
|
||||||
function encodeUtf8Base64(value: string): string {
|
function encodeUtf8Base64(value: string): string {
|
||||||
@@ -85,22 +98,22 @@ export function buildBasicAuthHeader(username: string, password: string): string
|
|||||||
return `Basic ${encodeUtf8Base64(`${username}:${password}`)}`;
|
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);
|
currentHeader = buildBasicAuthHeader(username, password);
|
||||||
try {
|
try {
|
||||||
if (remember) {
|
sessionStorage.setItem(SESSION_KEY, currentHeader);
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// storage unavailable (private browsing, storage disabled) — the header
|
// storage unavailable (private browsing, storage disabled) — the header
|
||||||
// still works for the rest of this tab's life via the in-memory
|
// still works for the rest of this tab's life via the in-memory
|
||||||
// variable above, it just won't survive a refresh.
|
// 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();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,10 +125,14 @@ export function clearCredentials(): void {
|
|||||||
currentHeader = null;
|
currentHeader = null;
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(SESSION_KEY);
|
sessionStorage.removeItem(SESSION_KEY);
|
||||||
localStorage.removeItem(REMEMBER_KEY);
|
forgetLegacyRememberedCredentials();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — nothing to clean up if storage was never usable
|
// 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();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
119
frontend/src/lib/bankPresets.test.ts
Normal file
119
frontend/src/lib/bankPresets.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
152
frontend/src/lib/bankPresets.ts
Normal file
152
frontend/src/lib/bankPresets.ts
Normal 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();
|
||||||
|
}
|
||||||
135
frontend/src/lib/credentialVault.ts
Normal file
135
frontend/src/lib/credentialVault.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 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];
|
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"] }> = {
|
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"] },
|
"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: /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/, 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"] },
|
||||||
"DD/MM/YYYY": { regex: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, 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: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, order: ["m", "d", "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: /^(\d{1,2})-(\d{1,2})-(\d{4})$/, 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"] },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Parses `raw` per `format`, returning an ISO "YYYY-MM-DD" string, or null
|
/** 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")}`;
|
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
|
export const AMOUNT_FORMATS = ["auto", "dot-decimal", "comma-decimal"] as const;
|
||||||
* either "," or "." as the decimal mark by assuming whichever comes last is
|
export type AmountFormat = (typeof AMOUNT_FORMATS)[number];
|
||||||
* the decimal separator (e.g. "1.234,56" and "1,234.56" both work). */
|
|
||||||
export function parseAmount(raw: string): number | null {
|
/** 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, "");
|
let value = raw.trim().replace(/[\s ]/g, "");
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
const hasComma = value.includes(",");
|
if (format === "dot-decimal") {
|
||||||
const hasDot = value.includes(".");
|
value = value.replace(/,/g, "");
|
||||||
if (hasComma && hasDot) {
|
} else if (format === "comma-decimal") {
|
||||||
value = value.lastIndexOf(",") > value.lastIndexOf(".") ? value.replace(/\./g, "").replace(",", ".") : value.replace(/,/g, "");
|
value = value.replace(/\./g, "").replace(",", ".");
|
||||||
} else if (hasComma) {
|
} else {
|
||||||
value = value.replace(",", ".");
|
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);
|
const num = Number(value);
|
||||||
return Number.isFinite(num) ? num : null;
|
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()}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,11 +49,15 @@ const ru = {
|
|||||||
"auth.subtitle": "Личные финансы",
|
"auth.subtitle": "Личные финансы",
|
||||||
"auth.usernameLabel": "Логин",
|
"auth.usernameLabel": "Логин",
|
||||||
"auth.passwordLabel": "Пароль",
|
"auth.passwordLabel": "Пароль",
|
||||||
"auth.rememberMe": "Запомнить меня на 30 дней",
|
"auth.rememberMe": "Запомнить меня на 7 дней",
|
||||||
|
"auth.sessionOnlyHint": "Вход сохраняется до закрытия браузера. Запоминание надолго требует HTTPS или localhost.",
|
||||||
"auth.submitButton": "Войти",
|
"auth.submitButton": "Войти",
|
||||||
"auth.submitting": "Вход…",
|
"auth.submitting": "Вход…",
|
||||||
"auth.errorInvalidCredentials": "Неверный логин или пароль",
|
"auth.errorInvalidCredentials": "Неверный логин или пароль",
|
||||||
"auth.errorUnreachable": "Не удалось подключиться к серверу. Проверьте соединение и попробуйте снова.",
|
"auth.errorUnreachable": "Не удалось подключиться к серверу. Проверьте соединение и попробуйте снова.",
|
||||||
|
"auth.noAuthModalTitle": "Авторизация не настроена",
|
||||||
|
"auth.noAuthModalBody": "Этот экземпляр Aurum не защищён паролем — все данные доступны любому, кто до него достучится. Задайте AURUM_BASIC_AUTH_USER и AURUM_BASIC_AUTH_PASSWORD в .env, чтобы включить вход.",
|
||||||
|
"auth.noAuthModalAccept": "Я понимаю риски и хочу продолжить",
|
||||||
|
|
||||||
"dashboard.errorLoading": "Не удалось загрузить данные дашборда. Проверьте, что backend запущен.",
|
"dashboard.errorLoading": "Не удалось загрузить данные дашборда. Проверьте, что backend запущен.",
|
||||||
"dashboard.statRealIncomeLabel": "Реальный доход",
|
"dashboard.statRealIncomeLabel": "Реальный доход",
|
||||||
@@ -249,6 +253,23 @@ const ru = {
|
|||||||
"transactions.import.errorBadDate": "не удалось распознать дату «{{value}}»",
|
"transactions.import.errorBadDate": "не удалось распознать дату «{{value}}»",
|
||||||
"transactions.import.errorBadAmount": "не удалось распознать сумму «{{value}}»",
|
"transactions.import.errorBadAmount": "не удалось распознать сумму «{{value}}»",
|
||||||
"transactions.import.errorNoDescription": "нет описания",
|
"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.allTypes": "Все типы",
|
||||||
"transactions.expense": "Расходы",
|
"transactions.expense": "Расходы",
|
||||||
"transactions.income": "Доходы",
|
"transactions.income": "Доходы",
|
||||||
@@ -553,11 +574,15 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"auth.subtitle": "Personal finance",
|
"auth.subtitle": "Personal finance",
|
||||||
"auth.usernameLabel": "Username",
|
"auth.usernameLabel": "Username",
|
||||||
"auth.passwordLabel": "Password",
|
"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.submitButton": "Sign in",
|
||||||
"auth.submitting": "Signing in…",
|
"auth.submitting": "Signing in…",
|
||||||
"auth.errorInvalidCredentials": "Incorrect username or password",
|
"auth.errorInvalidCredentials": "Incorrect username or password",
|
||||||
"auth.errorUnreachable": "Couldn't reach the server. Check your connection and try again.",
|
"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.errorLoading": "Failed to load dashboard data. Check that the backend is running.",
|
||||||
"dashboard.statRealIncomeLabel": "Real income",
|
"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.errorBadDate": "couldn't parse the date \"{{value}}\"",
|
||||||
"transactions.import.errorBadAmount": "couldn't parse the amount \"{{value}}\"",
|
"transactions.import.errorBadAmount": "couldn't parse the amount \"{{value}}\"",
|
||||||
"transactions.import.errorNoDescription": "no description",
|
"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.allTypes": "All types",
|
||||||
"transactions.expense": "Expenses",
|
"transactions.expense": "Expenses",
|
||||||
"transactions.income": "Income",
|
"transactions.income": "Income",
|
||||||
|
|||||||
@@ -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 { Link, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft, Upload } from "lucide-react";
|
import { ArrowLeft, Upload } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
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 { Label, Select } from "@/components/ui/Input";
|
||||||
import { useAccounts } from "@/hooks/useAccounts";
|
import { useAccounts } from "@/hooks/useAccounts";
|
||||||
import { useCategories } from "@/hooks/useCategories";
|
import { useCategories } from "@/hooks/useCategories";
|
||||||
import { useBulkCreateTransactions } from "@/hooks/useTransactions";
|
import { useBulkCreateTransactions, useTransactionsForDuplicateCheck } from "@/hooks/useTransactions";
|
||||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
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 { formatCurrency } from "@/lib/format";
|
||||||
import { ApiError } from "@/api/client";
|
import { ApiError } from "@/api/client";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
@@ -18,20 +36,69 @@ type Step = "upload" | "map" | "preview";
|
|||||||
|
|
||||||
const NONE = "";
|
const NONE = "";
|
||||||
|
|
||||||
interface Mapping {
|
// WHATWG encoding labels TextDecoder understands. UTF-8 covers most modern
|
||||||
date: string;
|
// exports; the rest are here because bank CSVs — especially older or
|
||||||
amount: string;
|
// Russian-bank ones — are routinely still in a legacy Windows codepage, and
|
||||||
description: string;
|
// decoding those as UTF-8 silently turns every non-ASCII character into
|
||||||
merchant: string;
|
// mojibake instead of failing loudly.
|
||||||
notes: string;
|
const ENCODINGS = ["utf-8", "windows-1251", "windows-1252", "koi8-r"] as const;
|
||||||
category: string;
|
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 {
|
interface SkippedRow {
|
||||||
row: number;
|
row: number;
|
||||||
reason: string;
|
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() {
|
export function CsvImportPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -43,14 +110,61 @@ export function CsvImportPage() {
|
|||||||
const [accountId, setAccountId] = useState("");
|
const [accountId, setAccountId] = useState("");
|
||||||
const [step, setStep] = useState<Step>("upload");
|
const [step, setStep] = useState<Step>("upload");
|
||||||
const [fileName, setFileName] = useState("");
|
const [fileName, setFileName] = useState("");
|
||||||
const [headers, setHeaders] = useState<string[]>([]);
|
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
|
||||||
const [dataRows, setDataRows] = useState<string[][]>([]);
|
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 [mapping, setMapping] = useState<Mapping>({ date: "", amount: "", description: "", merchant: "", notes: "", category: "" });
|
||||||
const [dateFormat, setDateFormat] = useState<DateFormat>("YYYY-MM-DD");
|
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 [parseError, setParseError] = useState<string | null>(null);
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
const [createdCount, setCreatedCount] = useState<number | 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 categoryLookup = useMemo(() => {
|
||||||
const map: Record<"income" | "expense", Map<string, number>> = { income: new Map(), expense: new Map() };
|
const map: Record<"income" | "expense", Map<string, number>> = { income: new Map(), expense: new Map() };
|
||||||
for (const category of categories ?? []) {
|
for (const category of categories ?? []) {
|
||||||
@@ -64,8 +178,54 @@ export function CsvImportPage() {
|
|||||||
return amount < 0 ? "expense" : "income";
|
return amount < 0 ? "expense" : "income";
|
||||||
}
|
}
|
||||||
|
|
||||||
const { valid, skipped } = useMemo(() => {
|
/** First non-empty raw value under `headerName`, shown next to a mapping
|
||||||
if (step !== "preview") return { valid: [] as TransactionInput[], skipped: [] as SkippedRow[] };
|
* 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 dateIdx = headers.indexOf(mapping.date);
|
||||||
const amountIdx = headers.indexOf(mapping.amount);
|
const amountIdx = headers.indexOf(mapping.amount);
|
||||||
@@ -74,7 +234,8 @@ export function CsvImportPage() {
|
|||||||
const notesIdx = mapping.notes ? headers.indexOf(mapping.notes) : -1;
|
const notesIdx = mapping.notes ? headers.indexOf(mapping.notes) : -1;
|
||||||
const categoryIdx = mapping.category ? headers.indexOf(mapping.category) : -1;
|
const categoryIdx = mapping.category ? headers.indexOf(mapping.category) : -1;
|
||||||
|
|
||||||
const validRows: TransactionInput[] = [];
|
const freshRows: TransactionInput[] = [];
|
||||||
|
const duplicateRows: TransactionInput[] = [];
|
||||||
const skippedRows: SkippedRow[] = [];
|
const skippedRows: SkippedRow[] = [];
|
||||||
|
|
||||||
dataRows.forEach((cells, index) => {
|
dataRows.forEach((cells, index) => {
|
||||||
@@ -86,12 +247,21 @@ export function CsvImportPage() {
|
|||||||
const rawNotes = notesIdx >= 0 ? (cells[notesIdx] ?? "").trim() : "";
|
const rawNotes = notesIdx >= 0 ? (cells[notesIdx] ?? "").trim() : "";
|
||||||
const rawCategory = categoryIdx >= 0 ? (cells[categoryIdx] ?? "").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);
|
const isoDate = parseDateWithFormat(rawDate, dateFormat);
|
||||||
if (!isoDate) {
|
if (!isoDate) {
|
||||||
skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadDate", { value: rawDate || "—" }) });
|
skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadDate", { value: rawDate || "—" }) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const amount = parseAmount(rawAmount);
|
const amount = parseAmount(rawAmount, amountFormat);
|
||||||
if (amount === null || amount === 0) {
|
if (amount === null || amount === 0) {
|
||||||
skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadAmount", { value: rawAmount || "—" }) });
|
skippedRows.push({ row: rowNumber, reason: t("transactions.import.errorBadAmount", { value: rawAmount || "—" }) });
|
||||||
return;
|
return;
|
||||||
@@ -105,7 +275,7 @@ export function CsvImportPage() {
|
|||||||
const type = resolveType(amount);
|
const type = resolveType(amount);
|
||||||
const categoryId = rawCategory ? categoryLookup[type].get(rawCategory.toLowerCase()) ?? null : null;
|
const categoryId = rawCategory ? categoryLookup[type].get(rawCategory.toLowerCase()) ?? null : null;
|
||||||
|
|
||||||
validRows.push({
|
const candidate: TransactionInput = {
|
||||||
account_id: Number(accountId),
|
account_id: Number(accountId),
|
||||||
category_id: categoryId,
|
category_id: categoryId,
|
||||||
transfer_account_id: null,
|
transfer_account_id: null,
|
||||||
@@ -115,53 +285,67 @@ export function CsvImportPage() {
|
|||||||
merchant: rawMerchant || null,
|
merchant: rawMerchant || null,
|
||||||
notes: rawNotes || null,
|
notes: rawNotes || null,
|
||||||
date: isoDate,
|
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
|
// 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];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setParseError(null);
|
setParseError(null);
|
||||||
|
|
||||||
const reader = new FileReader();
|
const buffer = await file.arrayBuffer();
|
||||||
reader.onload = () => {
|
// Invalid-UTF-8 byte sequences almost always mean a Cyrillic bank export
|
||||||
const text = String(reader.result ?? "");
|
// in the classic Windows codepage — a far better first guess than
|
||||||
const rows = parseCsv(text);
|
// staying on UTF-8 and showing every letter as mojibake. The user can
|
||||||
if (rows.length < 2) {
|
// still override this from the dropdown on the next screen either way.
|
||||||
setParseError(t("transactions.import.errorEmptyFile"));
|
let detectedEncoding: Encoding = "utf-8";
|
||||||
return;
|
try {
|
||||||
}
|
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
||||||
const [headerRow, ...rest] = rows;
|
} catch {
|
||||||
setFileName(file.name);
|
detectedEncoding = "windows-1251";
|
||||||
setHeaders(headerRow);
|
}
|
||||||
setDataRows(rest);
|
|
||||||
// Best-effort auto-mapping by common header names — the user can
|
const rows = parseCsv(new TextDecoder(detectedEncoding).decode(buffer));
|
||||||
// still correct any of these on the next screen.
|
if (rows.length < 2) {
|
||||||
const guess = (...candidates: string[]) =>
|
setParseError(t("transactions.import.errorEmptyFile"));
|
||||||
headerRow.find((h) => candidates.includes(h.trim().toLowerCase())) ?? "";
|
return;
|
||||||
setMapping({
|
}
|
||||||
date: guess("date", "дата"),
|
const [headerRow] = rows;
|
||||||
amount: guess("amount", "сумма"),
|
// Full reset for a fresh file — the header-change effect above skips
|
||||||
description: guess("description", "описание", "назначение платежа"),
|
// files whose header row is byte-identical to the previous one, so
|
||||||
merchant: guess("merchant", "payee", "получатель"),
|
// formats from the last import can't be left over here.
|
||||||
notes: guess("notes", "заметка", "примечание"),
|
const defaults = resolveDefaults(headerRow, resolvePreset(headerRow, presetChoice));
|
||||||
category: guess("category", "категория"),
|
setFileName(file.name);
|
||||||
});
|
setFileBuffer(buffer);
|
||||||
setStep("map");
|
setEncoding(detectedEncoding);
|
||||||
};
|
setMapping(defaults.mapping);
|
||||||
reader.readAsText(file);
|
setDateFormat(defaults.dateFormat);
|
||||||
|
setAmountFormat(defaults.amountFormat);
|
||||||
|
setIncludeDuplicates(false);
|
||||||
|
setStep("map");
|
||||||
}
|
}
|
||||||
|
|
||||||
function startOver() {
|
function startOver() {
|
||||||
setStep("upload");
|
setStep("upload");
|
||||||
setFileName("");
|
setFileName("");
|
||||||
setHeaders([]);
|
setFileBuffer(null);
|
||||||
setDataRows([]);
|
setEncoding("utf-8");
|
||||||
setCreatedCount(null);
|
setCreatedCount(null);
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
}
|
}
|
||||||
@@ -178,6 +362,23 @@ export function CsvImportPage() {
|
|||||||
|
|
||||||
const mappingComplete = Boolean(mapping.date && mapping.amount && mapping.description);
|
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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -217,6 +418,8 @@ export function CsvImportPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<p className="text-sm text-text-secondary">{t("transactions.import.uploadHint")}</p>
|
<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}>
|
<Button onClick={() => fileInputRef.current?.click()} disabled={!accountId}>
|
||||||
<Upload size={16} />
|
<Upload size={16} />
|
||||||
{t("transactions.import.chooseFile")}
|
{t("transactions.import.chooseFile")}
|
||||||
@@ -235,6 +438,25 @@ export function CsvImportPage() {
|
|||||||
<span className="text-xs text-text-muted">{fileName}</span>
|
<span className="text-xs text-text-muted">{fileName}</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<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 className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="map-date">{t("transactions.import.dateColumnLabel")}</Label>
|
<Label htmlFor="map-date">{t("transactions.import.dateColumnLabel")}</Label>
|
||||||
@@ -248,6 +470,11 @@ export function CsvImportPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
{mapping.date && (
|
||||||
|
<p className="mt-1 text-xs text-text-muted">
|
||||||
|
{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.date) || "—" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="map-date-format">{t("transactions.import.dateFormatLabel")}</Label>
|
<Label htmlFor="map-date-format">{t("transactions.import.dateFormatLabel")}</Label>
|
||||||
@@ -272,6 +499,21 @@ export function CsvImportPage() {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<p className="mt-1 text-xs text-text-muted">{t("transactions.import.amountHint")}</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="map-description">{t("transactions.import.descriptionColumnLabel")}</Label>
|
<Label htmlFor="map-description">{t("transactions.import.descriptionColumnLabel")}</Label>
|
||||||
@@ -289,6 +531,11 @@ export function CsvImportPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
{mapping.description && (
|
||||||
|
<p className="mt-1 text-xs text-text-muted">
|
||||||
|
{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.description) || "—" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="map-merchant">{t("transactions.form.merchantLabel")}</Label>
|
<Label htmlFor="map-merchant">{t("transactions.form.merchantLabel")}</Label>
|
||||||
@@ -300,6 +547,11 @@ export function CsvImportPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
{mapping.merchant && (
|
||||||
|
<p className="mt-1 text-xs text-text-muted">
|
||||||
|
{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.merchant) || "—" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="map-notes">{t("transactions.form.notesLabel")}</Label>
|
<Label htmlFor="map-notes">{t("transactions.form.notesLabel")}</Label>
|
||||||
@@ -311,6 +563,11 @@ export function CsvImportPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
{mapping.notes && (
|
||||||
|
<p className="mt-1 text-xs text-text-muted">
|
||||||
|
{t("transactions.import.sampleValueLabel", { value: sampleValue(mapping.notes) || "—" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="map-category">{t("transactions.form.categoryLabel")}</Label>
|
<Label htmlFor="map-category">{t("transactions.form.categoryLabel")}</Label>
|
||||||
@@ -323,6 +580,11 @@ export function CsvImportPage() {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<p className="mt-1 text-xs text-text-muted">{t("transactions.import.categoryHint")}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -355,6 +617,31 @@ export function CsvImportPage() {
|
|||||||
{t("transactions.import.summary", { valid: valid.length, skipped: skipped.length })}
|
{t("transactions.import.summary", { valid: valid.length, skipped: skipped.length })}
|
||||||
</p>
|
</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 && (
|
{valid.length > 0 && (
|
||||||
<div className="overflow-x-auto rounded-lg border border-border">
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
@@ -410,7 +697,13 @@ export function CsvImportPage() {
|
|||||||
<Button variant="ghost" onClick={() => setStep("map")}>
|
<Button variant="ghost" onClick={() => setStep("map")}>
|
||||||
{t("common.back")}
|
{t("common.back")}
|
||||||
</Button>
|
</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
|
{bulkCreate.isPending
|
||||||
? t("common.saving")
|
? t("common.saving")
|
||||||
: t("transactions.import.importButton", { count: valid.length })}
|
: t("transactions.import.importButton", { count: valid.length })}
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { AlertThresholdsCard } from "@/components/settings/AlertThresholdsCard";
|
|||||||
import { BackupCard } from "@/components/settings/BackupCard";
|
import { BackupCard } from "@/components/settings/BackupCard";
|
||||||
import { CurrencyCard } from "@/components/settings/CurrencyCard";
|
import { CurrencyCard } from "@/components/settings/CurrencyCard";
|
||||||
import { PreferencesCard } from "@/components/settings/PreferencesCard";
|
import { PreferencesCard } from "@/components/settings/PreferencesCard";
|
||||||
import { useHealth } from "@/hooks/useHealth";
|
import { useAppSettings } from "@/hooks/useSettings";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function SettingsPage() {
|
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
|
// unreachable), just show nothing rather than a loading/error state for
|
||||||
// one line of fine print.
|
// one line of fine print.
|
||||||
const { data: health } = useHealth();
|
const { data: appSettings } = useAppSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -17,9 +17,9 @@ export function SettingsPage() {
|
|||||||
<CurrencyCard />
|
<CurrencyCard />
|
||||||
<AlertThresholdsCard />
|
<AlertThresholdsCard />
|
||||||
<BackupCard />
|
<BackupCard />
|
||||||
{health?.version && (
|
{appSettings?.app_version && (
|
||||||
<p className="text-center text-xs text-text-muted">
|
<p className="text-center text-xs text-text-muted">
|
||||||
{t("settings.version", { version: health.version })}
|
{t("settings.version", { version: appSettings.app_version })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -565,10 +565,8 @@ export interface AppSettings {
|
|||||||
risky_allocation_threshold_percent: number;
|
risky_allocation_threshold_percent: number;
|
||||||
idle_cash_threshold_amount: string;
|
idle_cash_threshold_amount: string;
|
||||||
idle_cash_threshold_days: number;
|
idle_cash_threshold_days: number;
|
||||||
}
|
/** Read-only: the running backend release. Comes back here rather than
|
||||||
|
* from /api/health, which is served without auth. */
|
||||||
export interface HealthStatus {
|
app_version: string;
|
||||||
status: string;
|
|
||||||
version: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user