Compare commits

...

19 Commits

Author SHA1 Message Date
Elisiário Couto
9fee74e2a9 chore(ci): Bump version to 2025.10.2 2025-10-06 01:02:19 +01:00
Elisiário Couto
7c06a1d8b9 fix(frontend): Include default mime types. 2025-10-06 01:02:15 +01:00
Elisiário Couto
d78f481192 fix(frontend): Improve nginx config. 2025-10-06 00:57:27 +01:00
Elisiário Couto
b32853e8fd chore(ci): Bump version to 2025.10.1 2025-10-06 00:06:07 +01:00
Elisiário Couto
0750c41b7b docs: Improve documentation, add gif showing web app. 2025-10-06 00:02:56 +01:00
Elisiário Couto
1cd63731a3 fix(frontend): Fix PWA caching system, remove prompts. 2025-10-05 23:15:29 +01:00
Elisiário Couto
38fddeb281 refactor(frontend): Standardize button styling using shadcn Button component.
Replace inconsistent native HTML button elements with shadcn/ui Button
component across all components for consistent styling and behavior.

Changes:
- TransactionsTable: Use Button with ghost variant for Raw action buttons
- Settings: Standardize edit, save, cancel, and delete buttons with icon variant
- AccountsOverview: Apply consistent Button component for account actions
- AccountSettings: Update account editing buttons to use Button component
- NotificationFiltersDrawer: Convert filter removal buttons to Button component

Benefits:
- Consistent design system throughout the app
- Better accessibility and keyboard navigation
- Proper theme support and state handling
- Reduced custom CSS and improved maintainability
2025-10-05 23:14:19 +01:00
Elisiário Couto
0205e5be0d chore(ci): Bump version to 2025.10.0 2025-10-01 11:30:48 +01:00
Elisiário Couto
ca7968cc3c fix(gocardless): Increase timeout to 30 seconds, some requests take some time. 2025-10-01 11:05:52 +01:00
Elisiário Couto
e6da6ee9ab chore(ci): Bump version to 2025.9.26 2025-09-30 14:09:57 +01:00
Elisiário Couto
8802d24789 debug: Log different sets of GoCardless rate limits. 2025-09-30 14:07:10 +01:00
Elisiário Couto
d3954f079b chore(ci): Bump version to 2025.9.25 2025-09-30 10:49:00 +01:00
Elisiário Couto
0b68038739 Lock dependencies before commiting next version. 2025-09-30 10:48:49 +01:00
Elisiário Couto
d36568da54 chore: Log more rate limit headers. 2025-09-30 10:46:13 +01:00
Elisiário Couto
473f126d3e feat(frontend): Add ability to list backups and create a backup on demand. 2025-09-28 23:23:44 +01:00
Elisiário Couto
222bb2ec64 Lint and reformat. 2025-09-28 23:23:44 +01:00
Elisiário Couto
22ec0e36b1 fix(api): Fix S3 backup path-style configuration and improve UX.
- Fix critical S3 client configuration bug for path-style addressing
- Add toast notifications for better user feedback on S3 config operations
- Set up Toaster component in root layout for app-wide notifications
- Clean up unused imports in test files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 23:23:44 +01:00
copilot-swe-agent[bot]
0122913052 feat(frontend): Add S3 backup UI and complete backup functionality
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-28 23:23:44 +01:00
copilot-swe-agent[bot]
7f2a4634c5 feat(api): Add S3 backup functionality to backend
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-28 23:23:44 +01:00
33 changed files with 2046 additions and 627 deletions

View File

@@ -10,7 +10,6 @@ repos:
- id: trailing-whitespace
exclude: ".*\\.md$"
- id: end-of-file-fixer
- id: check-added-large-files
- repo: local
hooks:
- id: mypy

View File

@@ -41,7 +41,7 @@ The command outputs instructions for setting the required environment variable t
uv run leggen server
```
- For development mode with auto-reload: `uv run leggen server --reload`
- API will be available at `http://localhost:8000` with docs at `http://localhost:8000/docs`
- API will be available at `http://localhost:8000` with docs at `http://localhost:8000/api/v1/docs`
### Start the Frontend
1. Navigate to the frontend directory: `cd frontend`

View File

@@ -1,4 +1,67 @@
## 2025.10.2 (2025/10/06)
### Bug Fixes
- **frontend:** Improve nginx config. ([d78f4811](https://github.com/elisiariocouto/leggen/commit/d78f4811922df7e637abe65b1d0b1157dd331c3c))
- **frontend:** Include default mime types. ([7c06a1d8](https://github.com/elisiariocouto/leggen/commit/7c06a1d8b9bca3da2c481d9e89e7564cfffe32a3))
## 2025.10.1 (2025/10/05)
### Bug Fixes
- **frontend:** Fix PWA caching system, remove prompts. ([1cd63731](https://github.com/elisiariocouto/leggen/commit/1cd63731a35a1c77a59d7ae1a898ad8f22e362e4))
### Documentation
- Improve documentation, add gif showing web app. ([0750c41b](https://github.com/elisiariocouto/leggen/commit/0750c41b7b6634900ec19b1701d58b06346028e3))
### Refactor
- **frontend:** Standardize button styling using shadcn Button component. ([38fddeb2](https://github.com/elisiariocouto/leggen/commit/38fddeb281588de41d8ff6292c1dd48443a059a4))
## 2025.10.0 (2025/10/01)
### Bug Fixes
- **gocardless:** Increase timeout to 30 seconds, some requests take some time. ([ca7968cc](https://github.com/elisiariocouto/leggen/commit/ca7968cc3c625e243fe2d75590a9e56f3100072b))
## 2025.9.26 (2025/09/30)
### Debug
- Log different sets of GoCardless rate limits. ([8802d247](https://github.com/elisiariocouto/leggen/commit/8802d24789cbb8e854d857a0d7cc89a25a26f378))
## 2025.9.25 (2025/09/30)
### Bug Fixes
- **api:** Fix S3 backup path-style configuration and improve UX. ([22ec0e36](https://github.com/elisiariocouto/leggen/commit/22ec0e36b11e5b017075bee51de0423a53ec4648))
### Features
- **api:** Add S3 backup functionality to backend ([7f2a4634](https://github.com/elisiariocouto/leggen/commit/7f2a4634c51814b6785433a25ce42d20aea0558c))
- **frontend:** Add S3 backup UI and complete backup functionality ([01229130](https://github.com/elisiariocouto/leggen/commit/0122913052793bcbf011cb557ef182be21c5de93))
- **frontend:** Add ability to list backups and create a backup on demand. ([473f126d](https://github.com/elisiariocouto/leggen/commit/473f126d3e699521172539f2ca0bff0579ccee51))
### Miscellaneous Tasks
- Log more rate limit headers. ([d36568da](https://github.com/elisiariocouto/leggen/commit/d36568da540d4fb4ae1fa10b322a3fa77dcc5360))
## 2025.9.24 (2025/09/25)
### Features

280
README.md
View File

@@ -1,13 +1,21 @@
# 💲 leggen
An Open Banking CLI and API service for managing bank connections and transactions.
This tool provides a **unified command-line interface** (`leggen`) with both CLI commands and an integrated **FastAPI backend service**, plus a **React Web Interface** to connect to banks using the GoCardless Open Banking API.
A self hosted Open Banking Dashboard, API and CLI for managing bank connections and transactions.
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
![Leggen demo](docs/leggen_demo.gif)
## 🛠️ Technologies
### Frontend
- [React](https://reactjs.org/): Modern web interface with TypeScript
- [Vite](https://vitejs.dev/): Fast build tool and development server
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
- [shadcn/ui](https://ui.shadcn.com/): Modern component system built on Radix UI
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
### 🔌 API & Backend
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (integrated into `leggen server`)
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
@@ -16,12 +24,6 @@ Having your bank data accessible through both CLI and REST API gives you the pow
### 📦 Storage
- [SQLite](https://www.sqlite.org): for storing transactions, simple and easy to use
### Frontend
- [React](https://reactjs.org/): Modern web interface with TypeScript
- [Vite](https://vitejs.dev/): Fast build tool and development server
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
- [shadcn/ui](https://ui.shadcn.com/): Modern component system built on Radix UI
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
## ✨ Features
@@ -54,10 +56,9 @@ Having your bank data accessible through both CLI and REST API gives you the pow
1. Create a GoCardless account at [https://gocardless.com/bank-account-data/](https://gocardless.com/bank-account-data/)
2. Get your API credentials (key and secret)
### Installation Options
### Installation
#### Option 1: Docker Compose (Recommended)
The easiest way to get started is with Docker Compose, which includes both the React frontend and FastAPI backend:
#### Docker Compose (Recommended)
```bash
# Clone the repository
@@ -68,50 +69,11 @@ cd leggen
mkdir -p data && cp config.example.toml data/config.toml
# Edit data/config.toml with your GoCardless credentials
# Start all services (frontend + backend)
# Start all services
docker compose up -d
# Access the web interface at http://localhost:3000
# API is available at http://localhost:8000
```
#### Production Deployment
For production deployment using published Docker images:
```bash
# Clone the repository
git clone https://github.com/elisiariocouto/leggen.git
cd leggen
# Create your configuration
mkdir -p data && cp config.example.toml data/config.toml
# Edit data/config.toml with your GoCardless credentials
# Start production services
docker compose up -d
# Access the web interface at http://localhost:3000
# API is available at http://localhost:8000
```
### Development vs Production
- **Development**: Use `docker compose -f compose.dev.yml up -d` (builds from source)
- **Production**: Use `docker compose up -d` (uses published images)
#### Option 2: Local Development
For development or local installation:
```bash
# Install with uv (recommended) or pip
uv sync # or pip install -e .
# Start the API service
uv run leggen server --reload # Development mode with auto-reload
# Use the CLI (in another terminal)
uv run leggen --help
# API documentation at http://localhost:3000/api/v1/docs
```
### Configuration
@@ -153,214 +115,22 @@ case_sensitive = ["SpecificStore"]
## 📖 Usage
### API Service (`leggen server`)
### Web Interface
Access the React web interface at `http://localhost:3000` after starting the services.
Start the FastAPI backend service:
### API Service
Visit `http://localhost:3000/api/v1/docs` for interactive API documentation.
### CLI Commands
```bash
# Production mode
leggen server
# Development mode with auto-reload
leggen server --reload
# Custom host and port
leggen server --host 127.0.0.1 --port 8080
leggen status # Check connection status
leggen bank add # Connect to a new bank
leggen balances # View account balances
leggen transactions # List transactions
leggen sync # Trigger background sync
```
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
### CLI Commands (`leggen`)
#### Basic Commands
```bash
# Check connection status
leggen status
# Connect to a new bank
leggen bank add
# View account balances
leggen balances
# List recent transactions
leggen transactions --limit 20
# View detailed transactions
leggen transactions --full
```
#### Sync Operations
```bash
# Start background sync
leggen sync
# Synchronous sync (wait for completion)
leggen sync --wait
# Force sync (override running sync)
leggen sync --force --wait
```
#### API Integration
```bash
# Use custom API URL
leggen --api-url http://localhost:8080 status
# Set via environment variable
export LEGGEN_API_URL=http://localhost:8080
leggen status
```
### Docker Usage
#### Development (build from source)
```bash
# Start development services
docker compose -f compose.dev.yml up -d
# View service status
docker compose -f compose.dev.yml ps
# Check logs
docker compose -f compose.dev.yml logs frontend
docker compose -f compose.dev.yml logs leggen-server
# Stop development services
docker compose -f compose.dev.yml down
```
#### Production (use published images)
```bash
# Start production services
docker compose up -d
# View service status
docker compose ps
# Check logs
docker compose logs frontend
docker compose logs leggen-server
# Access the web interface at http://localhost:3000
# API documentation at http://localhost:8000/docs
# Stop production services
docker compose down
```
## 🔌 API Endpoints
The FastAPI backend provides comprehensive REST endpoints:
### Banks & Connections
- `GET /api/v1/banks/institutions?country=PT` - List available banks
- `POST /api/v1/banks/connect` - Create bank connection
- `GET /api/v1/banks/status` - Connection status
- `GET /api/v1/banks/countries` - Supported countries
### Accounts & Balances
- `GET /api/v1/accounts` - List all accounts
- `GET /api/v1/accounts/{id}` - Account details
- `GET /api/v1/accounts/{id}/balances` - Account balances
- `GET /api/v1/accounts/{id}/transactions` - Account transactions
### Transactions
- `GET /api/v1/transactions` - All transactions with filtering
- `GET /api/v1/transactions/stats` - Transaction statistics
### Sync & Scheduling
- `POST /api/v1/sync` - Trigger background sync
- `POST /api/v1/sync/now` - Synchronous sync
- `GET /api/v1/sync/status` - Sync status
- `GET/PUT /api/v1/sync/scheduler` - Scheduler configuration
### Notifications
- `GET/PUT /api/v1/notifications/settings` - Manage notifications
- `POST /api/v1/notifications/test` - Test notifications
## 🛠️ Development
### Local Development Setup
```bash
# Clone and setup
git clone https://github.com/elisiariocouto/leggen.git
cd leggen
# Install dependencies
uv sync
# Start API service with auto-reload
uv run leggen server --reload
# Use CLI commands
uv run leggen status
```
### Testing
Run the comprehensive test suite with:
```bash
# Run all tests
uv run pytest
# Run unit tests only
uv run pytest tests/unit/
# Run with verbose output
uv run pytest tests/unit/ -v
# Run specific test files
uv run pytest tests/unit/test_config.py -v
uv run pytest tests/unit/test_scheduler.py -v
uv run pytest tests/unit/test_api_banks.py -v
# Run tests by markers
uv run pytest -m unit # Unit tests
uv run pytest -m api # API endpoint tests
uv run pytest -m cli # CLI tests
```
The test suite includes:
- **Configuration management tests** - TOML config loading/saving
- **API endpoint tests** - FastAPI route testing with mocked dependencies
- **CLI API client tests** - HTTP client integration testing
- **Background scheduler tests** - APScheduler job management
- **Mock data and fixtures** - Realistic test data for banks, accounts, transactions
### Code Structure
```
leggen/ # CLI application
├── commands/ # CLI command implementations
├── utils/ # Shared utilities
├── api/ # FastAPI API routes and models
├── services/ # Business logic
├── background/ # Background job scheduler
└── api_client.py # API client for server communication
tests/ # Test suite
├── conftest.py # Shared test fixtures
└── unit/ # Unit tests
├── test_config.py # Configuration tests
├── test_scheduler.py # Background scheduler tests
├── test_api_banks.py # Banks API tests
├── test_api_accounts.py # Accounts API tests
└── test_api_client.py # CLI API client tests
```
### Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes with tests
4. Submit a pull request
The repository uses GitHub Actions for CI/CD:
- **CI**: Runs Python tests (`uv run pytest`) and frontend linting/build on every push
- **Release**: Creates GitHub releases with changelog when tags are pushed
For more options, run `leggen --help` or `leggen <command> --help`.
## ⚠️ Notes
- This project is in active development
- GoCardless API rate limits apply
- Some banks may require additional authorization steps
- Docker images are automatically built and published on releases

View File

@@ -28,3 +28,13 @@ enabled = true
[filters]
case_insensitive = ["salary", "utility"]
case_sensitive = ["SpecificStore"]
# Optional: S3 backup configuration
[backup.s3]
access_key_id = "your-s3-access-key"
secret_access_key = "your-s3-secret-key"
bucket_name = "your-bucket-name"
region = "us-east-1"
# endpoint_url = "https://custom-s3-endpoint.com" # Optional: for custom S3-compatible endpoints
path_style = false # Set to true for path-style addressing
enabled = true

BIN
docs/leggen_demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

View File

@@ -1,33 +1,102 @@
server {
# MIME types for PWA
include mime.types;
types {
application/manifest+json webmanifest;
}
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Trust proxy headers from Caddy/upstream proxy
set_real_ip_from 0.0.0.0/0;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json application/manifest+json image/svg+xml;
# Handle client-side routing
# Service worker - no cache, must revalidate
location ~ ^/(sw\.js|workbox-.*\.js)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
add_header Service-Worker-Allowed "/" always;
types {
application/javascript js;
}
}
# PWA manifest - short cache with revalidation
location ~ ^/manifest\.webmanifest$ {
add_header Cache-Control "public, max-age=3600, must-revalidate" always;
types {
application/manifest+json webmanifest;
}
}
# Handle client-side routing (SPA)
location / {
try_files $uri $uri/ /index.html;
autoindex off;
expires off;
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
try_files $uri $uri/ /index.html =404;
}
# API proxy to backend (configurable via API_BACKEND_URL env var)
location /api/ {
proxy_pass ${API_BACKEND_URL};
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host $http_x_forwarded_host;
proxy_redirect off;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
# Cache static assets with immutable flag
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Cache-Control "public, immutable" always;
access_log off;
}
# Cache images and icons
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
access_log off;
}
# Cache fonts (if any are added later)
location ~* \.(woff|woff2|ttf|eot|otf)$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
add_header Access-Control-Allow-Origin "*" always;
access_log off;
}
# Other static files
location ~* \.(xml|txt)$ {
expires 1d;
add_header Cache-Control "public, must-revalidate" always;
}
}

View File

@@ -234,24 +234,28 @@ export default function AccountSettings() {
}}
autoFocus
/>
<button
<Button
onClick={handleEditSave}
disabled={
!editingName.trim() ||
updateAccountMutation.isPending
}
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
size="icon"
variant="ghost"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100"
title="Save changes"
>
<Check className="h-4 w-4" />
</button>
<button
</Button>
<Button
onClick={handleEditCancel}
className="p-1 text-gray-600 hover:text-gray-700"
size="icon"
variant="ghost"
className="h-8 w-8"
title="Cancel editing"
>
<X className="h-4 w-4" />
</button>
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
@@ -265,13 +269,15 @@ export default function AccountSettings() {
account.name ||
"Unnamed Account"}
</h4>
<button
<Button
onClick={() => handleEditStart(account)}
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
size="icon"
variant="ghost"
className="h-7 w-7 flex-shrink-0"
title="Edit account name"
>
<Edit2 className="h-4 w-4" />
</button>
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}

View File

@@ -273,24 +273,28 @@ export default function AccountsOverview() {
}}
autoFocus
/>
<button
<Button
onClick={handleEditSave}
disabled={
!editingName.trim() ||
updateAccountMutation.isPending
}
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
size="icon"
variant="ghost"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100"
title="Save changes"
>
<Check className="h-4 w-4" />
</button>
<button
</Button>
<Button
onClick={handleEditCancel}
className="p-1 text-gray-600 hover:text-gray-700"
size="icon"
variant="ghost"
className="h-8 w-8"
title="Cancel editing"
>
<X className="h-4 w-4" />
</button>
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
@@ -304,13 +308,15 @@ export default function AccountsOverview() {
account.name ||
"Unnamed Account"}
</h4>
<button
<Button
onClick={() => handleEditStart(account)}
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
size="icon"
variant="ghost"
className="h-7 w-7 flex-shrink-0"
title="Edit account name"
>
<Edit2 className="h-4 w-4" />
</button>
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}

View File

@@ -166,13 +166,15 @@ export default function NotificationFiltersDrawer({
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
>
<span>{filter}</span>
<button
<Button
type="button"
onClick={() => removeCaseInsensitiveFilter(index)}
className="text-secondary-foreground hover:text-foreground"
variant="ghost"
size="icon"
className="h-5 w-5 hover:bg-secondary-foreground/10"
>
<X className="h-3 w-3" />
</button>
</Button>
</div>
))
) : (
@@ -222,13 +224,15 @@ export default function NotificationFiltersDrawer({
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
>
<span>{filter}</span>
<button
<Button
type="button"
onClick={() => removeCaseSensitiveFilter(index)}
className="text-secondary-foreground hover:text-foreground"
variant="ghost"
size="icon"
className="h-5 w-5 hover:bg-secondary-foreground/10"
>
<X className="h-3 w-3" />
</button>
</Button>
</div>
))
) : (

View File

@@ -1,160 +0,0 @@
import { useEffect, useState } from "react";
import { X, Download, RotateCcw } from "lucide-react";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
interface PWAPromptProps {
onInstall?: () => void;
}
export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowPrompt(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
try {
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
onInstall?.();
}
setDeferredPrompt(null);
setShowPrompt(false);
} catch (error) {
console.error("Error installing PWA:", error);
}
};
const handleDismiss = () => {
setShowPrompt(false);
setDeferredPrompt(null);
};
if (!showPrompt || !deferredPrompt) return null;
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<Download className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
Install Leggen
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Add to your home screen for quick access
</p>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex gap-2">
<button
onClick={handleInstall}
className="flex-1 bg-blue-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Install
</button>
<button
onClick={handleDismiss}
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Not now
</button>
</div>
</div>
);
}
interface PWAUpdatePromptProps {
updateAvailable: boolean;
onUpdate: () => void;
}
export function PWAUpdatePrompt({
updateAvailable,
onUpdate,
}: PWAUpdatePromptProps) {
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
if (updateAvailable) {
setShowPrompt(true);
}
}, [updateAvailable]);
const handleUpdate = () => {
onUpdate();
setShowPrompt(false);
};
const handleDismiss = () => {
setShowPrompt(false);
};
if (!showPrompt || !updateAvailable) return null;
return (
<div className="fixed top-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-4 z-50">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<RotateCcw className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
Update Available
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
A new version of Leggen is ready to install
</p>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex gap-2">
<button
onClick={handleUpdate}
className="flex-1 bg-green-600 text-white text-sm font-medium px-3 py-2 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
>
Update Now
</button>
<button
onClick={handleDismiss}
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
>
Later
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,273 @@
import { useState, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Cloud, TestTube } from "lucide-react";
import { toast } from "sonner";
import { apiClient } from "../lib/api";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import { EditButton } from "./ui/edit-button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
import type { BackupSettings, S3Config } from "../types/api";
interface S3BackupConfigDrawerProps {
settings?: BackupSettings;
trigger?: React.ReactNode;
}
export default function S3BackupConfigDrawer({
settings,
trigger,
}: S3BackupConfigDrawerProps) {
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<S3Config>({
access_key_id: "",
secret_access_key: "",
bucket_name: "",
region: "us-east-1",
endpoint_url: "",
path_style: false,
enabled: true,
});
const queryClient = useQueryClient();
useEffect(() => {
if (settings?.s3) {
setConfig({ ...settings.s3 });
}
}, [settings]);
const updateMutation = useMutation({
mutationFn: (s3Config: S3Config) =>
apiClient.updateBackupSettings({
s3: s3Config,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["backupSettings"] });
setOpen(false);
toast.success("S3 backup configuration saved successfully");
},
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
console.error("Failed to update S3 backup configuration:", error);
const message =
error?.response?.data?.detail ||
"Failed to save S3 configuration. Please check your settings and try again.";
toast.error(message);
},
});
const testMutation = useMutation({
mutationFn: () =>
apiClient.testBackupConnection({
service: "s3",
config: config,
}),
onSuccess: (response) => {
if (response.success) {
console.log("S3 connection test successful");
toast.success(
"S3 connection test successful! Your configuration is working correctly.",
);
} else {
console.error("S3 connection test failed:", response.message);
toast.error(response.message || "S3 connection test failed. Please verify your credentials and settings.");
}
},
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
console.error("Failed to test S3 connection:", error);
const message =
error?.response?.data?.detail ||
"S3 connection test failed. Please verify your credentials and settings.";
toast.error(message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate(config);
};
const handleTest = () => {
testMutation.mutate();
};
const isConfigValid =
config.access_key_id.trim().length > 0 &&
config.secret_access_key.trim().length > 0 &&
config.bucket_name.trim().length > 0;
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle className="flex items-center space-x-2">
<Cloud className="h-5 w-5 text-primary" />
<span>S3 Backup Configuration</span>
</DrawerTitle>
<DrawerDescription>
Configure S3 settings for automatic database backups
</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit} className="px-4 space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="enabled"
checked={config.enabled}
onCheckedChange={(checked) =>
setConfig({ ...config, enabled: checked })
}
/>
<Label htmlFor="enabled">Enable S3 backups</Label>
</div>
{config.enabled && (
<>
<div className="space-y-2">
<Label htmlFor="access_key_id">Access Key ID</Label>
<Input
id="access_key_id"
type="text"
value={config.access_key_id}
onChange={(e) =>
setConfig({ ...config, access_key_id: e.target.value })
}
placeholder="Your AWS Access Key ID"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="secret_access_key">Secret Access Key</Label>
<Input
id="secret_access_key"
type="password"
value={config.secret_access_key}
onChange={(e) =>
setConfig({
...config,
secret_access_key: e.target.value,
})
}
placeholder="Your AWS Secret Access Key"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="bucket_name">Bucket Name</Label>
<Input
id="bucket_name"
type="text"
value={config.bucket_name}
onChange={(e) =>
setConfig({ ...config, bucket_name: e.target.value })
}
placeholder="my-backup-bucket"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="region">Region</Label>
<Input
id="region"
type="text"
value={config.region}
onChange={(e) =>
setConfig({ ...config, region: e.target.value })
}
placeholder="us-east-1"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="endpoint_url">
Custom Endpoint URL (Optional)
</Label>
<Input
id="endpoint_url"
type="url"
value={config.endpoint_url || ""}
onChange={(e) =>
setConfig({ ...config, endpoint_url: e.target.value })
}
placeholder="https://custom-s3-endpoint.com"
/>
<p className="text-xs text-muted-foreground">
For S3-compatible services like MinIO or DigitalOcean Spaces
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="path_style"
checked={config.path_style}
onCheckedChange={(checked) =>
setConfig({ ...config, path_style: checked })
}
/>
<Label htmlFor="path_style">Use path-style addressing</Label>
</div>
<p className="text-xs text-muted-foreground">
Enable for older S3 implementations or certain S3-compatible
services
</p>
</>
)}
<DrawerFooter className="px-0">
<div className="flex space-x-2">
<Button
type="submit"
disabled={updateMutation.isPending || !config.enabled}
>
{updateMutation.isPending
? "Saving..."
: "Save Configuration"}
</Button>
{config.enabled && isConfigValid && (
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={testMutation.isPending}
>
{testMutation.isPending ? (
<>
<TestTube className="h-4 w-4 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<TestTube className="h-4 w-4 mr-2" />
Test
</>
)}
</Button>
)}
</div>
<DrawerClose asChild>
<Button variant="ghost">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</form>
</div>
</DrawerContent>
</Drawer>
);
}

View File

@@ -16,7 +16,11 @@ import {
Trash2,
User,
Filter,
Cloud,
Archive,
Eye,
} from "lucide-react";
import { toast } from "sonner";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import {
@@ -35,11 +39,14 @@ import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
import DiscordConfigDrawer from "./DiscordConfigDrawer";
import TelegramConfigDrawer from "./TelegramConfigDrawer";
import AddBankAccountDrawer from "./AddBankAccountDrawer";
import S3BackupConfigDrawer from "./S3BackupConfigDrawer";
import type {
Account,
Balance,
NotificationSettings,
NotificationService,
BackupSettings,
BackupInfo,
} from "../types/api";
// Helper function to get status indicator color and styles
@@ -80,6 +87,7 @@ export default function Settings() {
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
const [showBackups, setShowBackups] = useState(false);
const queryClient = useQueryClient();
@@ -125,6 +133,28 @@ export default function Settings() {
queryFn: apiClient.getBankConnectionsStatus,
});
// Backup queries
const {
data: backupSettings,
isLoading: backupLoading,
error: backupError,
refetch: refetchBackup,
} = useQuery<BackupSettings>({
queryKey: ["backupSettings"],
queryFn: apiClient.getBackupSettings,
});
const {
data: backups,
isLoading: backupsLoading,
error: backupsError,
refetch: refetchBackups,
} = useQuery<BackupInfo[]>({
queryKey: ["backups"],
queryFn: apiClient.listBackups,
enabled: showBackups,
});
// Account mutations
const updateAccountMutation = useMutation({
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
@@ -158,6 +188,26 @@ export default function Settings() {
},
});
// Backup mutations
const createBackupMutation = useMutation({
mutationFn: () => apiClient.performBackupOperation({ operation: "backup" }),
onSuccess: (response) => {
if (response.success) {
toast.success(response.message || "Backup created successfully!");
queryClient.invalidateQueries({ queryKey: ["backups"] });
} else {
toast.error(response.message || "Failed to create backup.");
}
},
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
console.error("Failed to create backup:", error);
const message =
error?.response?.data?.detail ||
"Failed to create backup. Please check your S3 configuration.";
toast.error(message);
},
});
// Account handlers
const handleEditStart = (account: Account) => {
setEditingAccountId(account.id);
@@ -189,8 +239,27 @@ export default function Settings() {
}
};
const isLoading = accountsLoading || settingsLoading || servicesLoading;
const hasError = accountsError || settingsError || servicesError;
// Backup handlers
const handleCreateBackup = () => {
if (!backupSettings?.s3?.enabled) {
toast.error("S3 backup is not enabled. Please configure and enable S3 backup first.");
return;
}
createBackupMutation.mutate();
};
const handleViewBackups = () => {
if (!backupSettings?.s3?.enabled) {
toast.error("S3 backup is not enabled. Please configure and enable S3 backup first.");
return;
}
setShowBackups(true);
};
const isLoading =
accountsLoading || settingsLoading || servicesLoading || backupLoading;
const hasError =
accountsError || settingsError || servicesError || backupError;
if (isLoading) {
return <AccountsSkeleton />;
@@ -211,6 +280,7 @@ export default function Settings() {
refetchAccounts();
refetchSettings();
refetchServices();
refetchBackup();
}}
variant="outline"
size="sm"
@@ -226,7 +296,7 @@ export default function Settings() {
return (
<div className="space-y-6">
<Tabs defaultValue="accounts" className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="accounts" className="flex items-center space-x-2">
<User className="h-4 w-4" />
<span>Accounts</span>
@@ -238,6 +308,10 @@ export default function Settings() {
<Bell className="h-4 w-4" />
<span>Notifications</span>
</TabsTrigger>
<TabsTrigger value="backup" className="flex items-center space-x-2">
<Cloud className="h-4 w-4" />
<span>Backup</span>
</TabsTrigger>
</TabsList>
<TabsContent value="accounts" className="space-y-6">
@@ -329,24 +403,28 @@ export default function Settings() {
}}
autoFocus
/>
<button
<Button
onClick={handleEditSave}
disabled={
!editingName.trim() ||
updateAccountMutation.isPending
}
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
size="icon"
variant="ghost"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100"
title="Save changes"
>
<Check className="h-4 w-4" />
</button>
<button
</Button>
<Button
onClick={handleEditCancel}
className="p-1 text-gray-600 hover:text-gray-700"
size="icon"
variant="ghost"
className="h-8 w-8"
title="Cancel editing"
>
<X className="h-4 w-4" />
</button>
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
@@ -360,13 +438,15 @@ export default function Settings() {
account.name ||
"Unnamed Account"}
</h4>
<button
<Button
onClick={() => handleEditStart(account)}
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
size="icon"
variant="ghost"
className="h-7 w-7 flex-shrink-0"
title="Edit account name"
>
<Edit2 className="h-4 w-4" />
</button>
</Button>
</div>
<p className="text-sm text-muted-foreground truncate">
{account.institution_id}
@@ -505,7 +585,7 @@ export default function Settings() {
Created {formatDate(connection.created_at)}
</p>
</div>
<button
<Button
onClick={() => {
const isWorking =
connection.status.toLowerCase() === "ln";
@@ -520,11 +600,13 @@ export default function Settings() {
}
}}
disabled={deleteBankConnectionMutation.isPending}
className="p-1 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
title="Delete connection"
>
<Trash2 className="h-4 w-4" />
</button>
</Button>
</div>
</div>
</div>
@@ -728,6 +810,174 @@ export default function Settings() {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="backup" className="space-y-6">
{/* S3 Backup Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Cloud className="h-5 w-5 text-primary" />
<span>S3 Backup Configuration</span>
</CardTitle>
<CardDescription>
Configure automatic database backups to Amazon S3 or
S3-compatible storage
</CardDescription>
</CardHeader>
<CardContent>
{!backupSettings?.s3 ? (
<div className="text-center py-8">
<Cloud className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No S3 backup configured
</h3>
<p className="text-muted-foreground mb-4">
Set up S3 backup to automatically backup your database to
the cloud.
</p>
<S3BackupConfigDrawer settings={backupSettings} />
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center space-x-4">
<div className="p-3 bg-muted rounded-full">
<Cloud className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<div className="flex items-center space-x-3">
<h4 className="text-lg font-medium text-foreground">
S3 Backup
</h4>
<div className="flex items-center space-x-2">
<div
className={`w-2 h-2 rounded-full ${
backupSettings.s3.enabled
? "bg-green-500"
: "bg-muted-foreground"
}`}
/>
<span className="text-sm text-muted-foreground">
{backupSettings.s3.enabled
? "Enabled"
: "Disabled"}
</span>
</div>
</div>
<div className="mt-2 space-y-1">
<p className="text-sm text-muted-foreground">
<span className="font-medium">Bucket:</span>{" "}
{backupSettings.s3.bucket_name}
</p>
<p className="text-sm text-muted-foreground">
<span className="font-medium">Region:</span>{" "}
{backupSettings.s3.region}
</p>
{backupSettings.s3.endpoint_url && (
<p className="text-sm text-muted-foreground">
<span className="font-medium">Endpoint:</span>{" "}
{backupSettings.s3.endpoint_url}
</p>
)}
</div>
</div>
</div>
<S3BackupConfigDrawer settings={backupSettings} />
</div>
<div className="p-4 bg-muted rounded-lg">
<h5 className="font-medium mb-2">Backup Information</h5>
<p className="text-sm text-muted-foreground mb-3">
Database backups are stored in the "leggen_backups/"
folder in your S3 bucket. Backups include the complete
SQLite database file.
</p>
<div className="flex space-x-2">
<Button
size="sm"
variant="outline"
onClick={handleCreateBackup}
disabled={createBackupMutation.isPending}
>
{createBackupMutation.isPending ? (
<>
<Archive className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Create Backup Now
</>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleViewBackups}
>
<Eye className="h-4 w-4 mr-2" />
View Backups
</Button>
</div>
</div>
{/* Backup List Modal/View */}
{showBackups && (
<div className="mt-6 p-4 border rounded-lg bg-background">
<div className="flex items-center justify-between mb-4">
<h5 className="font-medium">Available Backups</h5>
<Button
size="sm"
variant="ghost"
onClick={() => setShowBackups(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{backupsLoading ? (
<p className="text-sm text-muted-foreground">Loading backups...</p>
) : backupsError ? (
<div className="space-y-2">
<p className="text-sm text-destructive">Failed to load backups</p>
<Button
size="sm"
variant="outline"
onClick={() => refetchBackups()}
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
) : !backups || backups.length === 0 ? (
<p className="text-sm text-muted-foreground">No backups found</p>
) : (
<div className="space-y-2">
{backups.map((backup, index) => (
<div
key={backup.key || index}
className="flex items-center justify-between p-3 border rounded bg-muted/50"
>
<div>
<p className="text-sm font-medium">{backup.key}</p>
<div className="flex items-center space-x-4 text-xs text-muted-foreground mt-1">
<span>Modified: {formatDate(backup.last_modified)}</span>
<span>Size: {(backup.size / 1024 / 1024).toFixed(2)} MB</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);

View File

@@ -259,14 +259,15 @@ export default function TransactionsTable() {
cell: ({ row }) => {
const transaction = row.original;
return (
<button
<Button
onClick={() => handleViewRaw(transaction)}
className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-accent transition-colors"
variant="ghost"
size="sm"
title="View raw transaction data"
>
<Eye className="h-3 w-3 mr-1" />
Raw
</button>
</Button>
);
},
},
@@ -530,14 +531,15 @@ export default function TransactionsTable() {
transaction.transaction_currency,
)}
</p>
<button
<Button
onClick={() => handleViewRaw(transaction)}
className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-accent transition-colors"
variant="ghost"
size="sm"
title="View raw transaction data"
>
<Eye className="h-3 w-3 mr-1" />
Raw
</button>
</Button>
</div>
</div>
</div>

View File

@@ -1,72 +0,0 @@
import { useEffect, useState } from "react";
interface PWAUpdate {
updateAvailable: boolean;
updateSW: () => Promise<void>;
forceReload: () => Promise<void>;
}
export function usePWA(): PWAUpdate {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateSW, setUpdateSW] = useState<() => Promise<void>>(
() => async () => {},
);
const forceReload = async (): Promise<void> => {
try {
// Clear all caches
if ("caches" in window) {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map((cacheName) => caches.delete(cacheName)),
);
console.log("All caches cleared");
}
// Unregister service worker
if ("serviceWorker" in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(
registrations.map((registration) => registration.unregister()),
);
console.log("All service workers unregistered");
}
// Force reload
window.location.reload();
} catch (error) {
console.error("Error during force reload:", error);
// Fallback: just reload the page
window.location.reload();
}
};
useEffect(() => {
// Check if SW registration is available
if ("serviceWorker" in navigator) {
// Import the registerSW function
import("virtual:pwa-register")
.then(({ registerSW }) => {
const updateSWFunction = registerSW({
onNeedRefresh() {
setUpdateAvailable(true);
setUpdateSW(() => updateSWFunction);
},
onOfflineReady() {
console.log("App ready to work offline");
},
});
})
.catch(() => {
// PWA not available in development mode or when disabled
console.log("PWA registration not available");
});
}
}, []);
return {
updateAvailable,
updateSW,
forceReload,
};
}

View File

@@ -1,39 +0,0 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
const VERSION_STORAGE_KEY = "leggen_app_version";
export function useVersionCheck(forceReload: () => Promise<void>) {
const { data: healthStatus, isSuccess: healthSuccess } = useQuery({
queryKey: ["health"],
queryFn: apiClient.getHealth,
refetchInterval: 30000,
retry: false,
staleTime: 0, // Always consider data stale to ensure fresh version checks
});
useEffect(() => {
if (healthSuccess && healthStatus?.version) {
const currentVersion = healthStatus.version;
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
if (storedVersion && storedVersion !== currentVersion) {
console.log(
`Version mismatch detected: stored=${storedVersion}, current=${currentVersion}`,
);
console.log("Clearing cache and reloading...");
// Update stored version first
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
// Force reload to clear cache
forceReload();
} else if (!storedVersion) {
// First time loading, store the version
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
console.log(`Version stored: ${currentVersion}`);
}
}
}, [healthSuccess, healthStatus?.version, forceReload]);
}

View File

@@ -17,6 +17,10 @@ import type {
BankConnectionStatus,
BankRequisition,
Country,
BackupSettings,
BackupTest,
BackupInfo,
BackupOperation,
} from "../types/api";
// Use VITE_API_URL for development, relative URLs for production
@@ -274,6 +278,38 @@ export const apiClient = {
const response = await api.get<ApiResponse<Country[]>>("/banks/countries");
return response.data.data;
},
// Backup endpoints
getBackupSettings: async (): Promise<BackupSettings> => {
const response =
await api.get<ApiResponse<BackupSettings>>("/backup/settings");
return response.data.data;
},
updateBackupSettings: async (
settings: BackupSettings,
): Promise<BackupSettings> => {
const response = await api.put<ApiResponse<BackupSettings>>(
"/backup/settings",
settings,
);
return response.data.data;
},
testBackupConnection: async (test: BackupTest): Promise<ApiResponse<{ connected?: boolean }>> => {
const response = await api.post<ApiResponse<{ connected?: boolean }>>("/backup/test", test);
return response.data;
},
listBackups: async (): Promise<BackupInfo[]> => {
const response = await api.get<ApiResponse<BackupInfo[]>>("/backup/list");
return response.data.data;
},
performBackupOperation: async (operation: BackupOperation): Promise<ApiResponse<{ operation: string; completed: boolean }>> => {
const response = await api.post<ApiResponse<{ operation: string; completed: boolean }>>("/backup/operation", operation);
return response.data;
},
};
export default apiClient;

View File

@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "./contexts/ThemeContext";
import "./index.css";
import { routeTree } from "./routeTree.gen";
import { registerSW } from "virtual:pwa-register";
const router = createRouter({ routeTree });
@@ -17,6 +18,57 @@ const queryClient = new QueryClient({
},
});
const intervalMS = 60 * 60 * 1000;
registerSW({
onRegisteredSW(swUrl, r) {
console.log("[PWA] Service worker registered successfully");
if (r) {
setInterval(async () => {
console.log("[PWA] Checking for updates...");
if (r.installing) {
console.log("[PWA] Update already installing, skipping check");
return;
}
if (!navigator) {
console.log("[PWA] Navigator not available, skipping check");
return;
}
if ("connection" in navigator && !navigator.onLine) {
console.log("[PWA] Device is offline, skipping check");
return;
}
try {
const resp = await fetch(swUrl, {
cache: "no-store",
headers: {
cache: "no-store",
"cache-control": "no-cache",
},
});
if (resp?.status === 200) {
console.log("[PWA] Update check successful, triggering update");
await r.update();
} else {
console.log(`[PWA] Update check returned status: ${resp?.status}`);
}
} catch (error) {
console.error("[PWA] Error checking for updates:", error);
}
}, intervalMS);
}
},
onOfflineReady() {
console.log("[PWA] App ready to work offline");
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>

View File

@@ -1,30 +1,10 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { AppSidebar } from "../components/AppSidebar";
import { SiteHeader } from "../components/SiteHeader";
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA";
import { useVersionCheck } from "../hooks/useVersionCheck";
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
import { Toaster } from "../components/ui/sonner";
function RootLayout() {
const { updateAvailable, updateSW, forceReload } = usePWA();
// Check for version mismatches and force reload if needed
useVersionCheck(forceReload);
const handlePWAInstall = () => {
console.log("PWA installed successfully");
};
const handlePWAUpdate = async () => {
try {
await updateSW();
console.log("PWA updated successfully");
} catch (error) {
console.error("Error updating PWA:", error);
}
};
return (
<SidebarProvider
style={
@@ -42,12 +22,8 @@ function RootLayout() {
</main>
</SidebarInset>
{/* PWA Prompts */}
<PWAInstallPrompt onInstall={handlePWAInstall} />
<PWAUpdatePrompt
updateAvailable={updateAvailable}
onUpdate={handlePWAUpdate}
/>
{/* Toast Notifications */}
<Toaster />
</SidebarProvider>
);
}

View File

@@ -277,3 +277,34 @@ export interface Country {
code: string;
name: string;
}
// Backup types
export interface S3Config {
access_key_id: string;
secret_access_key: string;
bucket_name: string;
region: string;
endpoint_url?: string;
path_style: boolean;
enabled: boolean;
}
export interface BackupSettings {
s3?: S3Config;
}
export interface BackupTest {
service: string;
config: S3Config;
}
export interface BackupInfo {
key: string;
last_modified: string;
size: number;
}
export interface BackupOperation {
operation: string;
backup_key?: string;
}

View File

@@ -11,10 +11,7 @@ export default defineConfig({
VitePWA({
registerType: "autoUpdate",
includeAssets: [
"favicon.ico",
"apple-touch-icon-180x180.png",
"maskable-icon-512x512.png",
"robots.txt",
"robots.txt"
],
manifest: {
name: "Leggen",

View File

@@ -0,0 +1,49 @@
"""API models for backup endpoints."""
from typing import Optional
from pydantic import BaseModel, Field
class S3Config(BaseModel):
"""S3 backup configuration model for API."""
access_key_id: str = Field(..., description="AWS S3 access key ID")
secret_access_key: str = Field(..., description="AWS S3 secret access key")
bucket_name: str = Field(..., description="S3 bucket name")
region: str = Field(default="us-east-1", description="AWS S3 region")
endpoint_url: Optional[str] = Field(
default=None, description="Custom S3 endpoint URL"
)
path_style: bool = Field(default=False, description="Use path-style addressing")
enabled: bool = Field(default=True, description="Enable S3 backups")
class BackupSettings(BaseModel):
"""Backup settings model for API."""
s3: Optional[S3Config] = None
class BackupTest(BaseModel):
"""Backup connection test request model."""
service: str = Field(..., description="Backup service type (s3)")
config: S3Config = Field(..., description="S3 configuration to test")
class BackupInfo(BaseModel):
"""Backup file information model."""
key: str = Field(..., description="S3 object key")
last_modified: str = Field(..., description="Last modified timestamp (ISO format)")
size: int = Field(..., description="File size in bytes")
class BackupOperation(BaseModel):
"""Backup operation request model."""
operation: str = Field(..., description="Operation type (backup, restore)")
backup_key: Optional[str] = Field(
default=None, description="Backup key for restore operations"
)

264
leggen/api/routes/backup.py Normal file
View File

@@ -0,0 +1,264 @@
"""API routes for backup management."""
from fastapi import APIRouter, HTTPException
from loguru import logger
from leggen.api.models.backup import (
BackupOperation,
BackupSettings,
BackupTest,
S3Config,
)
from leggen.api.models.common import APIResponse
from leggen.models.config import S3BackupConfig
from leggen.services.backup_service import BackupService
from leggen.utils.config import config
from leggen.utils.paths import path_manager
router = APIRouter()
@router.get("/backup/settings", response_model=APIResponse)
async def get_backup_settings() -> APIResponse:
"""Get current backup settings."""
try:
backup_config = config.backup_config
# Build response safely without exposing secrets
s3_config = backup_config.get("s3", {})
settings = BackupSettings(
s3=S3Config(
access_key_id="***" if s3_config.get("access_key_id") else "",
secret_access_key="***" if s3_config.get("secret_access_key") else "",
bucket_name=s3_config.get("bucket_name", ""),
region=s3_config.get("region", "us-east-1"),
endpoint_url=s3_config.get("endpoint_url"),
path_style=s3_config.get("path_style", False),
enabled=s3_config.get("enabled", True),
)
if s3_config.get("bucket_name")
else None,
)
return APIResponse(
success=True,
data=settings,
message="Backup settings retrieved successfully",
)
except Exception as e:
logger.error(f"Failed to get backup settings: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to get backup settings: {str(e)}"
) from e
@router.put("/backup/settings", response_model=APIResponse)
async def update_backup_settings(settings: BackupSettings) -> APIResponse:
"""Update backup settings."""
try:
# First test the connection if S3 config is provided
if settings.s3:
# Convert API model to config model
s3_config = S3BackupConfig(
access_key_id=settings.s3.access_key_id,
secret_access_key=settings.s3.secret_access_key,
bucket_name=settings.s3.bucket_name,
region=settings.s3.region,
endpoint_url=settings.s3.endpoint_url,
path_style=settings.s3.path_style,
enabled=settings.s3.enabled,
)
# Test connection
backup_service = BackupService()
connection_success = await backup_service.test_connection(s3_config)
if not connection_success:
raise HTTPException(
status_code=400,
detail="S3 connection test failed. Please check your configuration.",
)
# Update backup config
backup_config = {}
if settings.s3:
backup_config["s3"] = {
"access_key_id": settings.s3.access_key_id,
"secret_access_key": settings.s3.secret_access_key,
"bucket_name": settings.s3.bucket_name,
"region": settings.s3.region,
"endpoint_url": settings.s3.endpoint_url,
"path_style": settings.s3.path_style,
"enabled": settings.s3.enabled,
}
# Save to config
if backup_config:
config.update_section("backup", backup_config)
return APIResponse(
success=True,
data={"updated": True},
message="Backup settings updated successfully",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update backup settings: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to update backup settings: {str(e)}"
) from e
@router.post("/backup/test", response_model=APIResponse)
async def test_backup_connection(test_request: BackupTest) -> APIResponse:
"""Test backup connection."""
try:
if test_request.service != "s3":
raise HTTPException(
status_code=400, detail="Only 's3' service is supported"
)
# Convert API model to config model
s3_config = S3BackupConfig(
access_key_id=test_request.config.access_key_id,
secret_access_key=test_request.config.secret_access_key,
bucket_name=test_request.config.bucket_name,
region=test_request.config.region,
endpoint_url=test_request.config.endpoint_url,
path_style=test_request.config.path_style,
enabled=test_request.config.enabled,
)
backup_service = BackupService()
success = await backup_service.test_connection(s3_config)
if success:
return APIResponse(
success=True,
data={"connected": True},
message="S3 connection test successful",
)
else:
return APIResponse(
success=False,
message="S3 connection test failed",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to test backup connection: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to test backup connection: {str(e)}"
) from e
@router.get("/backup/list", response_model=APIResponse)
async def list_backups() -> APIResponse:
"""List available backups."""
try:
backup_config = config.backup_config.get("s3", {})
if not backup_config.get("bucket_name"):
return APIResponse(
success=True,
data=[],
message="No S3 backup configuration found",
)
# Convert config to model
s3_config = S3BackupConfig(**backup_config)
backup_service = BackupService(s3_config)
backups = await backup_service.list_backups()
return APIResponse(
success=True,
data=backups,
message=f"Found {len(backups)} backups",
)
except Exception as e:
logger.error(f"Failed to list backups: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to list backups: {str(e)}"
) from e
@router.post("/backup/operation", response_model=APIResponse)
async def backup_operation(operation_request: BackupOperation) -> APIResponse:
"""Perform backup operation (backup or restore)."""
try:
backup_config = config.backup_config.get("s3", {})
if not backup_config.get("bucket_name"):
raise HTTPException(status_code=400, detail="S3 backup is not configured")
# Convert config to model with validation
try:
s3_config = S3BackupConfig(**backup_config)
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Invalid S3 configuration: {str(e)}"
) from e
backup_service = BackupService(s3_config)
if operation_request.operation == "backup":
# Backup database
database_path = path_manager.get_database_path()
success = await backup_service.backup_database(database_path)
if success:
return APIResponse(
success=True,
data={"operation": "backup", "completed": True},
message="Database backup completed successfully",
)
else:
return APIResponse(
success=False,
message="Database backup failed",
)
elif operation_request.operation == "restore":
if not operation_request.backup_key:
raise HTTPException(
status_code=400,
detail="backup_key is required for restore operation",
)
# Restore database
database_path = path_manager.get_database_path()
success = await backup_service.restore_database(
operation_request.backup_key, database_path
)
if success:
return APIResponse(
success=True,
data={"operation": "restore", "completed": True},
message="Database restore completed successfully",
)
else:
return APIResponse(
success=False,
message="Database restore failed",
)
else:
raise HTTPException(
status_code=400, detail="Invalid operation. Use 'backup' or 'restore'"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to perform backup operation: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to perform backup operation: {str(e)}"
) from e

View File

@@ -7,7 +7,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from leggen.api.routes import accounts, banks, notifications, sync, transactions
from leggen.api.routes import accounts, backup, banks, notifications, sync, transactions
from leggen.background.scheduler import scheduler
from leggen.utils.config import config
from leggen.utils.paths import path_manager
@@ -60,6 +60,8 @@ def create_app() -> FastAPI:
description="Open Banking API for Leggen",
version=version,
lifespan=lifespan,
docs_url="/api/v1/docs",
openapi_url="/api/v1/openapi.json",
)
# Add CORS middleware
@@ -81,6 +83,7 @@ def create_app() -> FastAPI:
app.include_router(transactions.router, prefix="/api/v1", tags=["transactions"])
app.include_router(sync.router, prefix="/api/v1", tags=["sync"])
app.include_router(notifications.router, prefix="/api/v1", tags=["notifications"])
app.include_router(backup.router, prefix="/api/v1", tags=["backup"])
@app.get("/api/v1/health")
async def health():

View File

@@ -32,6 +32,22 @@ class NotificationConfig(BaseModel):
telegram: Optional[TelegramNotificationConfig] = None
class S3BackupConfig(BaseModel):
access_key_id: str = Field(..., description="AWS S3 access key ID")
secret_access_key: str = Field(..., description="AWS S3 secret access key")
bucket_name: str = Field(..., description="S3 bucket name")
region: str = Field(default="us-east-1", description="AWS S3 region")
endpoint_url: Optional[str] = Field(
default=None, description="Custom S3 endpoint URL"
)
path_style: bool = Field(default=False, description="Use path-style addressing")
enabled: bool = Field(default=True, description="Enable S3 backups")
class BackupConfig(BaseModel):
s3: Optional[S3BackupConfig] = None
class FilterConfig(BaseModel):
case_insensitive: Optional[List[str]] = Field(default_factory=list)
case_sensitive: Optional[List[str]] = Field(default_factory=list)
@@ -56,3 +72,4 @@ class Config(BaseModel):
notifications: Optional[NotificationConfig] = None
filters: Optional[FilterConfig] = None
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
backup: Optional[BackupConfig] = None

View File

@@ -0,0 +1,192 @@
"""Backup service for S3 storage."""
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Optional
import boto3
from botocore.exceptions import ClientError, NoCredentialsError
from loguru import logger
from leggen.models.config import S3BackupConfig
class BackupService:
"""Service for managing S3 backups."""
def __init__(self, s3_config: Optional[S3BackupConfig] = None):
"""Initialize backup service with S3 configuration."""
self.s3_config = s3_config
self._s3_client = None
def _get_s3_client(self, config: Optional[S3BackupConfig] = None):
"""Get or create S3 client with current configuration."""
current_config = config or self.s3_config
if not current_config:
raise ValueError("S3 configuration is required")
# Create S3 client with configuration
session = boto3.Session(
aws_access_key_id=current_config.access_key_id,
aws_secret_access_key=current_config.secret_access_key,
region_name=current_config.region,
)
s3_kwargs = {}
if current_config.endpoint_url:
s3_kwargs["endpoint_url"] = current_config.endpoint_url
if current_config.path_style:
from botocore.config import Config
s3_kwargs["config"] = Config(s3={"addressing_style": "path"})
return session.client("s3", **s3_kwargs)
async def test_connection(self, config: S3BackupConfig) -> bool:
"""Test S3 connection with provided configuration.
Args:
config: S3 configuration to test
Returns:
True if connection successful, False otherwise
"""
try:
s3_client = self._get_s3_client(config)
# Try to list objects in the bucket (limited to 1 to minimize cost)
s3_client.list_objects_v2(Bucket=config.bucket_name, MaxKeys=1)
logger.info(
f"S3 connection test successful for bucket: {config.bucket_name}"
)
return True
except NoCredentialsError:
logger.error("S3 credentials not found or invalid")
return False
except ClientError as e:
error_code = e.response["Error"]["Code"]
logger.error(
f"S3 connection test failed: {error_code} - {e.response['Error']['Message']}"
)
return False
except Exception as e:
logger.error(f"Unexpected error during S3 connection test: {str(e)}")
return False
async def backup_database(self, database_path: Path) -> bool:
"""Backup database file to S3.
Args:
database_path: Path to the SQLite database file
Returns:
True if backup successful, False otherwise
"""
if not self.s3_config or not self.s3_config.enabled:
logger.warning("S3 backup is not configured or disabled")
return False
if not database_path.exists():
logger.error(f"Database file not found: {database_path}")
return False
try:
s3_client = self._get_s3_client()
# Generate backup filename with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_key = f"leggen_backups/database_backup_{timestamp}.db"
# Upload database file
logger.info(f"Starting database backup to S3: {backup_key}")
s3_client.upload_file(
str(database_path), self.s3_config.bucket_name, backup_key
)
logger.info(f"Database backup completed successfully: {backup_key}")
return True
except Exception as e:
logger.error(f"Database backup failed: {str(e)}")
return False
async def list_backups(self) -> list[dict]:
"""List available backups in S3.
Returns:
List of backup metadata dictionaries
"""
if not self.s3_config or not self.s3_config.enabled:
logger.warning("S3 backup is not configured or disabled")
return []
try:
s3_client = self._get_s3_client()
# List objects with backup prefix
response = s3_client.list_objects_v2(
Bucket=self.s3_config.bucket_name, Prefix="leggen_backups/"
)
backups = []
for obj in response.get("Contents", []):
backups.append(
{
"key": obj["Key"],
"last_modified": obj["LastModified"].isoformat(),
"size": obj["Size"],
}
)
# Sort by last modified (newest first)
backups.sort(key=lambda x: x["last_modified"], reverse=True)
return backups
except Exception as e:
logger.error(f"Failed to list backups: {str(e)}")
return []
async def restore_database(self, backup_key: str, restore_path: Path) -> bool:
"""Restore database from S3 backup.
Args:
backup_key: S3 key of the backup to restore
restore_path: Path where to restore the database
Returns:
True if restore successful, False otherwise
"""
if not self.s3_config or not self.s3_config.enabled:
logger.warning("S3 backup is not configured or disabled")
return False
try:
s3_client = self._get_s3_client()
# Download backup file
logger.info(f"Starting database restore from S3: {backup_key}")
# Create parent directory if it doesn't exist
restore_path.parent.mkdir(parents=True, exist_ok=True)
# Download to temporary file first, then move to final location
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
s3_client.download_file(
self.s3_config.bucket_name, backup_key, temp_file.name
)
# Move temp file to final location
temp_path = Path(temp_file.name)
temp_path.replace(restore_path)
logger.info(f"Database restore completed successfully: {restore_path}")
return True
except Exception as e:
logger.error(f"Database restore failed: {str(e)}")
return False

View File

@@ -9,16 +9,21 @@ from leggen.utils.config import config
from leggen.utils.paths import path_manager
def _log_rate_limits(response):
def _log_rate_limits(response, method, url):
"""Log GoCardless API rate limit headers"""
limit = response.headers.get("http_x_ratelimit_limit")
remaining = response.headers.get("http_x_ratelimit_remaining")
reset = response.headers.get("http_x_ratelimit_reset")
if limit or remaining or reset:
logger.info(
f"GoCardless rate limits - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s"
)
account_limit = response.headers.get("http_x_ratelimit_account_success_limit")
account_remaining = response.headers.get(
"http_x_ratelimit_account_success_remaining"
)
account_reset = response.headers.get("http_x_ratelimit_account_success_reset")
logger.debug(
f"{method} {url} Limit/Remaining/Reset (Global: {limit}/{remaining}/{reset}s) (Account: {account_limit}/{account_remaining}/{account_reset}s)"
)
class GoCardlessService:
@@ -36,16 +41,20 @@ class GoCardlessService:
headers = await self._get_auth_headers()
async with httpx.AsyncClient() as client:
response = await client.request(method, url, headers=headers, **kwargs)
_log_rate_limits(response)
response = await client.request(
method, url, headers=headers, timeout=30, **kwargs
)
_log_rate_limits(response, method, url)
# If we get 401, clear token cache and retry once
if response.status_code == 401:
logger.warning("Got 401, clearing token cache and retrying")
self._token = None
headers = await self._get_auth_headers()
response = await client.request(method, url, headers=headers, **kwargs)
_log_rate_limits(response)
response = await client.request(
method, url, headers=headers, timeout=30, **kwargs
)
_log_rate_limits(response, method, url)
response.raise_for_status()
return response.json()
@@ -76,7 +85,9 @@ class GoCardlessService:
f"{self.base_url}/token/refresh/",
json={"refresh": auth["refresh"]},
)
_log_rate_limits(response)
_log_rate_limits(
response, "POST", f"{self.base_url}/token/refresh/"
)
response.raise_for_status()
auth.update(response.json())
self._save_auth(auth)
@@ -104,7 +115,7 @@ class GoCardlessService:
"secret_key": self.config["secret"],
},
)
_log_rate_limits(response)
_log_rate_limits(response, "POST", f"{self.base_url}/token/new/")
response.raise_for_status()
auth = response.json()
self._save_auth(auth)

View File

@@ -162,6 +162,11 @@ class Config:
}
return self.config.get("scheduler", default_schedule)
@property
def backup_config(self) -> Dict[str, Any]:
"""Get backup configuration"""
return self.config.get("backup", {})
def load_config(ctx: click.Context, _, filename):
try:

View File

@@ -1,6 +1,6 @@
[project]
name = "leggen"
version = "2025.9.24"
version = "2025.10.2"
description = "An Open Banking CLI"
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
requires-python = "~=3.13.0"
@@ -35,6 +35,7 @@ dependencies = [
"tomli-w>=1.0.0,<2",
"httpx>=0.28.1",
"pydantic>=2.0.0,<3",
"boto3>=1.35.0,<2",
]
[project.urls]
@@ -88,5 +89,5 @@ markers = [
]
[[tool.mypy.overrides]]
module = ["apscheduler.*", "discord_webhook.*"]
module = ["apscheduler.*", "discord_webhook.*", "botocore.*", "boto3.*"]
ignore_missing_imports = true

View File

@@ -42,6 +42,9 @@ echo " > Version bumped to $NEXT_VERSION"
echo "Updating CHANGELOG.md"
git-cliff --unreleased --tag "$NEXT_VERSION" --prepend CHANGELOG.md > /dev/null
echo "Locking dependencies"
uv lock
echo " > Commiting changes and adding git tag"
git add pyproject.toml CHANGELOG.md uv.lock
git commit -m "chore(ci): Bump version to $NEXT_VERSION"

View File

@@ -0,0 +1,303 @@
"""Tests for backup API endpoints."""
from unittest.mock import patch
import pytest
@pytest.mark.api
class TestBackupAPI:
"""Test backup-related API endpoints."""
def test_get_backup_settings_no_config(self, api_client, mock_config):
"""Test getting backup settings with no configuration."""
# Mock empty backup config by updating the config dict
mock_config._config["backup"] = {}
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/backup/settings")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["s3"] is None
def test_get_backup_settings_with_s3_config(self, api_client, mock_config):
"""Test getting backup settings with S3 configuration."""
# Mock S3 backup config (with masked credentials)
mock_config._config["backup"] = {
"s3": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"endpoint_url": None,
"path_style": False,
"enabled": True,
}
}
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/backup/settings")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["s3"] is not None
s3_config = data["data"]["s3"]
assert s3_config["access_key_id"] == "***" # Masked
assert s3_config["secret_access_key"] == "***" # Masked
assert s3_config["bucket_name"] == "test-bucket"
assert s3_config["region"] == "us-east-1"
assert s3_config["enabled"] is True
@patch("leggen.services.backup_service.BackupService.test_connection")
def test_update_backup_settings_success(
self, mock_test_connection, api_client, mock_config
):
"""Test successful backup settings update."""
mock_test_connection.return_value = True
mock_config._config["backup"] = {}
request_data = {
"s3": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"endpoint_url": None,
"path_style": False,
"enabled": True,
}
}
with patch("leggen.utils.config.config", mock_config):
response = api_client.put("/api/v1/backup/settings", json=request_data)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["updated"] is True
# Verify connection test was called
mock_test_connection.assert_called_once()
@patch("leggen.services.backup_service.BackupService.test_connection")
def test_update_backup_settings_connection_failure(
self, mock_test_connection, api_client, mock_config
):
"""Test backup settings update with connection test failure."""
mock_test_connection.return_value = False
mock_config._config["backup"] = {}
request_data = {
"s3": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "invalid-bucket",
"region": "us-east-1",
"endpoint_url": None,
"path_style": False,
"enabled": True,
}
}
with patch("leggen.utils.config.config", mock_config):
response = api_client.put("/api/v1/backup/settings", json=request_data)
assert response.status_code == 400
data = response.json()
assert "S3 connection test failed" in data["detail"]
@patch("leggen.services.backup_service.BackupService.test_connection")
def test_test_backup_connection_success(self, mock_test_connection, api_client):
"""Test successful backup connection test."""
mock_test_connection.return_value = True
request_data = {
"service": "s3",
"config": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"endpoint_url": None,
"path_style": False,
"enabled": True,
},
}
response = api_client.post("/api/v1/backup/test", json=request_data)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["connected"] is True
# Verify connection test was called
mock_test_connection.assert_called_once()
@patch("leggen.services.backup_service.BackupService.test_connection")
def test_test_backup_connection_failure(self, mock_test_connection, api_client):
"""Test failed backup connection test."""
mock_test_connection.return_value = False
request_data = {
"service": "s3",
"config": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "invalid-bucket",
"region": "us-east-1",
"endpoint_url": None,
"path_style": False,
"enabled": True,
},
}
response = api_client.post("/api/v1/backup/test", json=request_data)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
def test_test_backup_connection_invalid_service(self, api_client):
"""Test backup connection test with invalid service."""
request_data = {
"service": "invalid",
"config": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"endpoint_url": None,
"path_style": False,
"enabled": True,
},
}
response = api_client.post("/api/v1/backup/test", json=request_data)
assert response.status_code == 400
data = response.json()
assert "Only 's3' service is supported" in data["detail"]
@patch("leggen.services.backup_service.BackupService.list_backups")
def test_list_backups_success(self, mock_list_backups, api_client, mock_config):
"""Test successful backup listing."""
mock_list_backups.return_value = [
{
"key": "leggen_backups/database_backup_20250101_120000.db",
"last_modified": "2025-01-01T12:00:00",
"size": 1024,
},
{
"key": "leggen_backups/database_backup_20250101_110000.db",
"last_modified": "2025-01-01T11:00:00",
"size": 512,
},
]
mock_config._config["backup"] = {
"s3": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"enabled": True,
}
}
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/backup/list")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
assert (
data["data"][0]["key"]
== "leggen_backups/database_backup_20250101_120000.db"
)
def test_list_backups_no_config(self, api_client, mock_config):
"""Test backup listing with no configuration."""
mock_config._config["backup"] = {}
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/backup/list")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"] == []
@patch("leggen.services.backup_service.BackupService.backup_database")
@patch("leggen.utils.paths.path_manager.get_database_path")
def test_backup_operation_success(
self, mock_get_db_path, mock_backup_db, api_client, mock_config
):
"""Test successful backup operation."""
from pathlib import Path
mock_get_db_path.return_value = Path("/test/database.db")
mock_backup_db.return_value = True
mock_config._config["backup"] = {
"s3": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"enabled": True,
}
}
request_data = {"operation": "backup"}
with patch("leggen.utils.config.config", mock_config):
response = api_client.post("/api/v1/backup/operation", json=request_data)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["operation"] == "backup"
assert data["data"]["completed"] is True
# Verify backup was called
mock_backup_db.assert_called_once()
def test_backup_operation_no_config(self, api_client, mock_config):
"""Test backup operation with no configuration."""
mock_config._config["backup"] = {}
request_data = {"operation": "backup"}
with patch("leggen.utils.config.config", mock_config):
response = api_client.post("/api/v1/backup/operation", json=request_data)
assert response.status_code == 400
data = response.json()
assert "S3 backup is not configured" in data["detail"]
def test_backup_operation_invalid_operation(self, api_client, mock_config):
"""Test backup operation with invalid operation type."""
mock_config._config["backup"] = {
"s3": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"enabled": True,
}
}
request_data = {"operation": "invalid"}
with patch("leggen.utils.config.config", mock_config):
response = api_client.post("/api/v1/backup/operation", json=request_data)
assert response.status_code == 400
data = response.json()
assert "Invalid operation" in data["detail"]

View File

@@ -0,0 +1,226 @@
"""Tests for backup service functionality."""
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from botocore.exceptions import ClientError, NoCredentialsError
from leggen.models.config import S3BackupConfig
from leggen.services.backup_service import BackupService
@pytest.mark.unit
class TestBackupService:
"""Test backup service functionality."""
def test_backup_service_initialization(self):
"""Test backup service can be initialized."""
service = BackupService()
assert service.s3_config is None
assert service._s3_client is None
def test_backup_service_with_config(self):
"""Test backup service initialization with config."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService(s3_config)
assert service.s3_config == s3_config
@pytest.mark.asyncio
async def test_test_connection_success(self):
"""Test successful S3 connection test."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService()
# Mock S3 client
with patch("boto3.Session") as mock_session:
mock_client = MagicMock()
mock_session.return_value.client.return_value = mock_client
# Mock successful list_objects_v2 call
mock_client.list_objects_v2.return_value = {"Contents": []}
result = await service.test_connection(s3_config)
assert result is True
# Verify the client was called correctly
mock_client.list_objects_v2.assert_called_once_with(
Bucket="test-bucket", MaxKeys=1
)
@pytest.mark.asyncio
async def test_test_connection_no_credentials(self):
"""Test S3 connection test with no credentials."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService()
# Mock S3 client to raise NoCredentialsError
with patch("boto3.Session") as mock_session:
mock_client = MagicMock()
mock_session.return_value.client.return_value = mock_client
mock_client.list_objects_v2.side_effect = NoCredentialsError()
result = await service.test_connection(s3_config)
assert result is False
@pytest.mark.asyncio
async def test_test_connection_client_error(self):
"""Test S3 connection test with client error."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService()
# Mock S3 client to raise ClientError
with patch("boto3.Session") as mock_session:
mock_client = MagicMock()
mock_session.return_value.client.return_value = mock_client
error_response = {
"Error": {"Code": "NoSuchBucket", "Message": "Bucket not found"}
}
mock_client.list_objects_v2.side_effect = ClientError(
error_response, "ListObjectsV2"
)
result = await service.test_connection(s3_config)
assert result is False
@pytest.mark.asyncio
async def test_backup_database_no_config(self):
"""Test backup database with no configuration."""
service = BackupService()
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
db_path.write_text("test database content")
result = await service.backup_database(db_path)
assert result is False
@pytest.mark.asyncio
async def test_backup_database_disabled(self):
"""Test backup database with disabled configuration."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
enabled=False,
)
service = BackupService(s3_config)
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
db_path.write_text("test database content")
result = await service.backup_database(db_path)
assert result is False
@pytest.mark.asyncio
async def test_backup_database_file_not_found(self):
"""Test backup database with non-existent file."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService(s3_config)
non_existent_path = Path("/non/existent/path.db")
result = await service.backup_database(non_existent_path)
assert result is False
@pytest.mark.asyncio
async def test_backup_database_success(self):
"""Test successful database backup."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService(s3_config)
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
db_path.write_text("test database content")
# Mock S3 client
with patch("boto3.Session") as mock_session:
mock_client = MagicMock()
mock_session.return_value.client.return_value = mock_client
result = await service.backup_database(db_path)
assert result is True
# Verify upload_file was called
mock_client.upload_file.assert_called_once()
args = mock_client.upload_file.call_args[0]
assert args[0] == str(db_path) # source file
assert args[1] == "test-bucket" # bucket name
assert args[2].startswith("leggen_backups/database_backup_") # key
@pytest.mark.asyncio
async def test_list_backups_success(self):
"""Test successful backup listing."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService(s3_config)
# Mock S3 client response
with patch("boto3.Session") as mock_session:
mock_client = MagicMock()
mock_session.return_value.client.return_value = mock_client
from datetime import datetime
mock_response = {
"Contents": [
{
"Key": "leggen_backups/database_backup_20250101_120000.db",
"LastModified": datetime(2025, 1, 1, 12, 0, 0),
"Size": 1024,
},
{
"Key": "leggen_backups/database_backup_20250101_130000.db",
"LastModified": datetime(2025, 1, 1, 13, 0, 0),
"Size": 2048,
},
]
}
mock_client.list_objects_v2.return_value = mock_response
backups = await service.list_backups()
assert len(backups) == 2
# Check that backups are sorted by last modified (newest first)
assert backups[0]["last_modified"] > backups[1]["last_modified"]
assert backups[0]["size"] == 2048
assert backups[1]["size"] == 1024

74
uv.lock generated
View File

@@ -36,6 +36,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" },
]
[[package]]
name = "boto3"
version = "1.40.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8d/21/7bc857b155e8264c92b6fa8e0860a67dc01a19cbe6ba4342500299f2ae5b/boto3-1.40.36.tar.gz", hash = "sha256:bfc1f3d5c4f5d12b8458406b8972f8794ac57e2da1ee441469e143bc0440a5c3", size = 111552, upload-time = "2025-09-22T19:26:17.357Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/4c/428b728d5cf9003f83f735d10dd522945ab20c7d67e6c987909f29be12a0/boto3-1.40.36-py3-none-any.whl", hash = "sha256:d7c1fe033f491f560cd26022a9dcf28baf877ae854f33bc64fffd0df3b9c98be", size = 139345, upload-time = "2025-09-22T19:26:15.194Z" },
]
[[package]]
name = "botocore"
version = "1.40.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/30/75fdc75933d3bc1c8dd7fbaee771438328b518936906b411075b1eacac93/botocore-1.40.36.tar.gz", hash = "sha256:93386a8dc54173267ddfc6cd8636c9171e021f7c032aa1df3af7de816e3df616", size = 14349583, upload-time = "2025-09-22T19:26:05.957Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/51/95c0324ac20b5bbafad4c89dd610c8e0dd6cbadbb2c8ca66dc95ccde98b8/botocore-1.40.36-py3-none-any.whl", hash = "sha256:d6edf75875e4013cb7078875a1d6c289afb4cc6675d99d80700c692d8d8e0b72", size = 14020478, upload-time = "2025-09-22T19:26:02.054Z" },
]
[[package]]
name = "certifi"
version = "2025.8.3"
@@ -218,12 +246,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "jmespath"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" },
]
[[package]]
name = "leggen"
version = "2025.9.24"
version = "2025.10.2"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },
{ name = "boto3" },
{ name = "click" },
{ name = "discord-webhook" },
{ name = "fastapi" },
@@ -253,6 +291,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "apscheduler", specifier = ">=3.10.0,<4" },
{ name = "boto3", specifier = ">=1.35.0,<2" },
{ name = "click", specifier = ">=8.1.7,<9" },
{ name = "discord-webhook", specifier = ">=1.3.1,<2" },
{ name = "fastapi", specifier = ">=0.104.0,<1" },
@@ -474,6 +513,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/b3/7fefc43fb706380144bcd293cc6e446e6f637ddfa8b83f48d1734156b529/pytest_mock-3.15.0-py3-none-any.whl", hash = "sha256:ef2219485fb1bd256b00e7ad7466ce26729b30eadfc7cbcdb4fa9a92ca68db6f", size = 10050, upload-time = "2025-09-04T20:57:47.274Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.1"
@@ -565,6 +616,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357, upload-time = "2025-09-10T16:25:35.595Z" },
]
[[package]]
name = "s3transfer"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"