Compare commits
	
		
			80 Commits
		
	
	
		
			1.0.0
			...
			fdade90266
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| fdade90266 | |||
| baf62aa2a5 | |||
| c19d46a50f | |||
| f001c618cd | |||
| f9d46e46a5 | |||
| 96f1bf589c | |||
| 467393dad0 | |||
| f619f26e93 | |||
| cc4ce19af2 | |||
| 192dc5827b | |||
| 37674a6229 | |||
| ef86667029 | |||
| 07f63a96fa | |||
| fa88a3c9ed | |||
| 85c6a0b955 | |||
| 21ee97b8a4 | |||
| 119f026a21 | |||
| d72acfac9b | |||
| 77c8866bb8 | |||
| 133f235639 | |||
| 7ef0499abf | |||
| 1383da4483 | |||
| 4f1a9d0865 | |||
| 31803feaa9 | |||
| aad32f9c25 | |||
| 1ea2bd6acf | |||
| a085116018 | |||
| 952a66042c | |||
| 6cf6ab5a37 | |||
| 1781318fdf | |||
| 2560962684 | |||
| dc3704f13b | |||
| af4c3c4a8f | |||
| a268e1bc4d | |||
| 77fc3699d6 | |||
| 96b3f6d4dc | |||
| 9a74c6c951 | |||
| c64fa838c9 | |||
| 8f45109e87 | |||
| d0c20aa68b | |||
| b84305428c | |||
| 560e415cb1 | |||
| 4863b1f4af | |||
| 6fa87a67d6 | |||
| e7219d2e44 | |||
| c9af4e4fc9 | |||
| 6cb4bcf4d4 | |||
| 447b6d89fb | |||
| a2a7d3d94a | |||
| 05a5da7527 | |||
| 43f62802dc | |||
| 60f6b94b41 | |||
| 5aaefafb48 | |||
| 63af408224 | |||
| a5966c8d53 | |||
| 196cb0b5c9 | |||
| ba06cd04ff | |||
| dc4f41f31c | |||
| 9ba928102c | |||
| 7d7ad0ce89 | |||
| d0ff90701f | |||
| f38482757e | |||
| 7e976d903d | |||
| 737d7f76d4 | |||
| cf0e42ff0e | |||
| d2fa9bf9ee | |||
| fb5a85311c | |||
| 288d334615 | |||
| 87f017fc42 | |||
| 43fb8dcda6 | |||
| a3b9c7cdb1 | |||
| c42b8b1bda | |||
| bdcbe94c97 | |||
| caeff985c2 | |||
| bed538793d | |||
| 602f20ad18 | |||
| 457c96b37e | |||
| a3f2b77548 | |||
| 3c5c82371a | |||
| fb46626cff | 
							
								
								
									
										10
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								.drone.yml
									
									
									
									
									
								
							| @@ -38,6 +38,7 @@ steps: | |||||||
|     - cd moneymgr_backend |     - cd moneymgr_backend | ||||||
|     - rustup component add clippy |     - rustup component add clippy | ||||||
|     - cargo clippy -- -D warnings |     - cargo clippy -- -D warnings | ||||||
|  |     - cargo clippy --example api_curl -- -D warnings | ||||||
|  |  | ||||||
| - name: backend_test | - name: backend_test | ||||||
|   image: rust |   image: rust | ||||||
| @@ -51,7 +52,7 @@ steps: | |||||||
|     - cargo test |     - cargo test | ||||||
|  |  | ||||||
|  |  | ||||||
| - name: backend_compile | - name: backend_build | ||||||
|   image: rust |   image: rust | ||||||
|   volumes: |   volumes: | ||||||
|   - name: rust_registry |   - name: rust_registry | ||||||
| @@ -67,15 +68,16 @@ steps: | |||||||
|   - cd moneymgr_backend |   - cd moneymgr_backend | ||||||
|   - mv /tmp/web_build/dist static |   - mv /tmp/web_build/dist static | ||||||
|   - cargo build --release |   - cargo build --release | ||||||
|   - ls -lah target/release/moneymgr_backend |   - cargo build --release --example api_curl | ||||||
|   - cp target/release/moneymgr_backend /tmp/release |   - ls -lah target/release/moneymgr_backend target/release/examples/api_curl | ||||||
|  |   - cp target/release/moneymgr_backend target/release/examples/api_curl /tmp/release | ||||||
|  |  | ||||||
|  |  | ||||||
| # Release | # Release | ||||||
| - name: gitea_release | - name: gitea_release | ||||||
|   image: plugins/gitea-release |   image: plugins/gitea-release | ||||||
|   depends_on: |   depends_on: | ||||||
|   - backend_compile |   - backend_build | ||||||
|   when: |   when: | ||||||
|     event: |     event: | ||||||
|       - tag |       - tag | ||||||
|   | |||||||
							
								
								
									
										38
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										38
									
								
								README.md
									
									
									
									
									
								
							| @@ -3,10 +3,46 @@ | |||||||
|  |  | ||||||
| Open Source web-based personal expenses tool. | Open Source web-based personal expenses tool. | ||||||
|  |  | ||||||
|  | **Note :** This project does not handle authentication itself. Instead, it relies on OpenID to achieve users authentication. | ||||||
|  |  | ||||||
|  | ## Setup prod env | ||||||
|  | 1. Install prerequisites: | ||||||
|  |    1. docker | ||||||
|  |    2. docker compose | ||||||
|  |    3. git | ||||||
|  |  | ||||||
|  | 2. Clone this git repository: | ||||||
|  | ```bash | ||||||
|  | git clone https://gitea.communiquons.org/pierre/MoneyMgr | ||||||
|  | cd MoneyMgr/docker_prod | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 3. Copy and adapt env values | ||||||
|  | ```bash | ||||||
|  | cp .env.sample .env | ||||||
|  | nano .env | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 4. Create required directories: | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | mkdir -p storage/{db,redis-data,redis-conf,minio} | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 5. Start containers | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | docker compose up | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | 6. Checkout http://localhost:8000/ | ||||||
|  |  | ||||||
|  | > The default credentials are `admin` / `admin` | ||||||
|  |  | ||||||
| ## Setup dev env | ## Setup dev env | ||||||
| 1. Install prerequisites: | 1. Install prerequisites: | ||||||
|    1. docker |    1. docker | ||||||
|    2. docker-compose |    2. docker compose | ||||||
|    3. rust |    3. rust | ||||||
|    4. node |    4. node | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								docker_prod/.env.sample
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								docker_prod/.env.sample
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | MINIO_ROOT_USER=rootuser | ||||||
|  | MINIO_ROOT_PASSWORD=rootpassword | ||||||
|  | DB_USER=db_user | ||||||
|  | DB_PASSWORD=db_password | ||||||
|  | REDIS_PASS=redis_password | ||||||
|  | WEBSITE_ORIGIN=http://localhost:8000 | ||||||
|  | APP_SECRET=secretsecretsecretsecretsecretsecretsecretsecretsecretsecretsecret | ||||||
|  | AUTH_SECRET_KEY=secretsecretsecretsecretsecretsecretsecretsecretsecretsecretsecret | ||||||
|  | OIDC_CLIENT_ID=bar | ||||||
|  | OIDC_CLIENT_SECRET=foo | ||||||
							
								
								
									
										3
									
								
								docker_prod/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								docker_prod/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | .env | ||||||
|  | storage | ||||||
|  | auth/users.json | ||||||
							
								
								
									
										5
									
								
								docker_prod/auth/clients.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								docker_prod/auth/clients.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | - id: ${OIDC_CLIENT_ID} | ||||||
|  |   name: MoneyMgr | ||||||
|  |   description: Money management tool | ||||||
|  |   secret: ${OIDC_CLIENT_SECRET} | ||||||
|  |   redirect_uri: ${APP_ORIGIN}/oidc_cb | ||||||
							
								
								
									
										79
									
								
								docker_prod/docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								docker_prod/docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | |||||||
|  | services: | ||||||
|  |   minio: | ||||||
|  |     image: minio/minio | ||||||
|  |     user: "1000" | ||||||
|  |     environment: | ||||||
|  |       - MINIO_ROOT_USER=$MINIO_ROOT_USER | ||||||
|  |       - MINIO_ROOT_PASSWORD=$MINIO_ROOT_PASSWORD | ||||||
|  |     volumes: | ||||||
|  |       - ./storage/minio:/data | ||||||
|  |     command: [ "minio", "server", "/data", "--console-address", ":9090" ] | ||||||
|  |     ports: | ||||||
|  |       - 9000:9000 | ||||||
|  |       - 9090:9090 | ||||||
|  |     expose: | ||||||
|  |       - 9000 | ||||||
|  |  | ||||||
|  |   db: | ||||||
|  |     image: postgres | ||||||
|  |     user: "1000" | ||||||
|  |     ports: | ||||||
|  |       - "5432:5432" | ||||||
|  |     expose: | ||||||
|  |       - 5432 | ||||||
|  |     environment: | ||||||
|  |       - POSTGRES_USER=$DB_USER | ||||||
|  |       - POSTGRES_PASSWORD=$DB_PASSWORD | ||||||
|  |       - POSTGRES_DB=moneymgr | ||||||
|  |     volumes: | ||||||
|  |       - ./storage/db:/var/lib/postgresql/data | ||||||
|  |  | ||||||
|  |   oidc: | ||||||
|  |     image: pierre42100/basic_oidc | ||||||
|  |     user: "1000" | ||||||
|  |     environment: | ||||||
|  |       - LISTEN_ADDRESS=0.0.0.0:9001 | ||||||
|  |       - STORAGE_PATH=/storage | ||||||
|  |       - TOKEN_KEY=$AUTH_SECRET_KEY | ||||||
|  |       - WEBSITE_ORIGIN=http://localhost:9001 | ||||||
|  |       - OIDC_CLIENT_ID=$OIDC_CLIENT_ID | ||||||
|  |       - OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET | ||||||
|  |       - APP_ORIGIN=$WEBSITE_ORIGIN | ||||||
|  |     expose: | ||||||
|  |       - 9001 | ||||||
|  |     ports: | ||||||
|  |       - 9001:9001 | ||||||
|  |     volumes: | ||||||
|  |       - ./auth:/storage | ||||||
|  |  | ||||||
|  |   redis: | ||||||
|  |     image: redis:alpine | ||||||
|  |     user: "1000" | ||||||
|  |     command: redis-server --requirepass ${REDIS_PASS:-secretredis} | ||||||
|  |     expose: | ||||||
|  |       - 6379 | ||||||
|  |     volumes: | ||||||
|  |       - ./storage/redis-data:/data | ||||||
|  |       - ./storage/redis-conf:/usr/local/etc/redis/redis.conf | ||||||
|  |  | ||||||
|  |   moneymgr: | ||||||
|  |     image: pierre42100/moneymgr_backend | ||||||
|  |     user: "1000" | ||||||
|  |     ports: | ||||||
|  |       - 8000:8000 | ||||||
|  |     environment: | ||||||
|  |       - WEBSITE_ORIGIN=${WEBSITE_ORIGIN} | ||||||
|  |       - SECRET=${APP_SECRET} | ||||||
|  |       - DB_HOST=db | ||||||
|  |       - DB_USERNAME=$DB_USER | ||||||
|  |       - DB_PASSWORD=$DB_PASSWORD | ||||||
|  |       - DB_NAME=moneymgr | ||||||
|  |       - OIDC_CONFIGURATION_URL=http://oidc:9001/.well-known/openid-configuration | ||||||
|  |       - OIDC_PROVIDER_NAME=OIDC | ||||||
|  |       - OIDC_CLIENT_ID=$OIDC_CLIENT_ID | ||||||
|  |       - OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET | ||||||
|  |       - S3_ENDPOINT=http://minio:9000 | ||||||
|  |       - S3_ACCESS_KEY=$MINIO_ROOT_USER | ||||||
|  |       - S3_SECRET_KEY=$MINIO_ROOT_PASSWORD | ||||||
|  |       - REDIS_HOSTNAME=redis | ||||||
|  |       - REDIS_PASSWORD=${REDIS_PASS:-secretredis} | ||||||
							
								
								
									
										665
									
								
								moneymgr_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										665
									
								
								moneymgr_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -6,33 +6,34 @@ edition = "2024" | |||||||
| [dependencies] | [dependencies] | ||||||
| env_logger = "0.11.8" | env_logger = "0.11.8" | ||||||
| log = "0.4.27" | log = "0.4.27" | ||||||
| diesel = { version = "2.2.0", features = ["postgres", "r2d2"] } | diesel = { version = "2.2.12", features = ["postgres", "r2d2"] } | ||||||
| diesel_migrations = "2.1.0" | diesel_migrations = "2.2.0" | ||||||
| clap = { version = "4.5.35", features = ["env", "derive"] } | clap = { version = "4.5.41", features = ["env", "derive"] } | ||||||
| actix-web = "4" | actix-web = "4.11.0" | ||||||
| actix-cors = "0.7.0" | actix-cors = "0.7.1" | ||||||
| actix-multipart = "0.7.0" | actix-multipart = "0.7.2" | ||||||
| actix-remote-ip = "0.1.0" | actix-remote-ip = "0.1.0" | ||||||
| actix-session = { version = "0.10.0", features = ["redis-session"] } | actix-session = { version = "0.10.1", features = ["redis-session"] } | ||||||
| actix-files = "0.6.6" | actix-files = "0.6.6" | ||||||
| lazy_static = "1.5.0" | lazy_static = "1.5.0" | ||||||
| anyhow = "1.0.97" | anyhow = "1.0.98" | ||||||
| serde = { version = "1.0.219", features = ["derive"] } | serde = { version = "1.0.219", features = ["derive"] } | ||||||
| rust-s3 = "0.36.0-beta.2" | rust-s3 = "0.36.0-beta.2" | ||||||
| thiserror = "2.0.12" | thiserror = "2.0.12" | ||||||
| tokio = "1.44.1" | tokio = "1.45.1" | ||||||
| futures-util = "0.3.31" | futures-util = "0.3.31" | ||||||
| serde_json = "1.0.140" | serde_json = "1.0.140" | ||||||
| light-openid = "1.0.4" | light-openid = "1.0.4" | ||||||
| rand = "0.9.0" | rand = "0.9.1" | ||||||
| ipnet = { version = "2.11.0", features = ["serde"] } | ipnet = { version = "2.11.0", features = ["serde"] } | ||||||
| lazy-regex = "3.4.1" | lazy-regex = "3.4.1" | ||||||
| jwt-simple = { version = "0.12.11", default-features = false, features = ["pure-rust"] } | jwt-simple = { version = "0.12.12", default-features = false, features = ["pure-rust"] } | ||||||
| mime_guess = "2.0.5" | mime_guess = "2.0.5" | ||||||
| rust-embed = { version = "8.6.0" } | rust-embed = { version = "8.7.2" } | ||||||
| sha2 = "0.10.8" | sha2 = "0.11.0-rc.0" | ||||||
|  | base16ct = "0.2.0" | ||||||
| httpdate = "1.0.3" | httpdate = "1.0.3" | ||||||
| chrono = "0.4.41" | chrono = "0.4.41" | ||||||
| tempfile = "3.19.1" | tempfile = "3.20.0" | ||||||
| zip = "2.6.1" | zip = "3.0.0" | ||||||
| rust_xlsxwriter = "0.86.1" | rust_xlsxwriter = "0.87.0" | ||||||
| @@ -14,7 +14,7 @@ use std::process::Command; | |||||||
| struct Args { | struct Args { | ||||||
|     /// URL to Money manager API |     /// URL to Money manager API | ||||||
|     #[arg(short('U'), long, env, default_value = "http://localhost:8000/api")] |     #[arg(short('U'), long, env, default_value = "http://localhost:8000/api")] | ||||||
|     matrix_gw_url: String, |     moneymgr_url: String, | ||||||
|  |  | ||||||
|     /// Token ID |     /// Token ID | ||||||
|     #[arg(short('i'), long, env)] |     #[arg(short('i'), long, env)] | ||||||
| @@ -39,7 +39,8 @@ struct Args { | |||||||
| fn main() { | fn main() { | ||||||
|     let args: Args = Args::parse(); |     let args: Args = Args::parse(); | ||||||
|  |  | ||||||
|     let full_url = format!("{}{}", args.matrix_gw_url, args.uri); |     let full_url = format!("{}{}", args.moneymgr_url, args.uri); | ||||||
|  |  | ||||||
|     log::debug!("Full URL: {full_url}"); |     log::debug!("Full URL: {full_url}"); | ||||||
|  |  | ||||||
|     let key = HS256Key::from_bytes(args.token_secret.as_bytes()); |     let key = HS256Key::from_bytes(args.token_secret.as_bytes()); | ||||||
|   | |||||||
| @@ -30,7 +30,7 @@ pub async fn create_bucket_if_required() -> anyhow::Result<()> { | |||||||
|             log::warn!("The bucket does not seem to exists, trying to create it!") |             log::warn!("The bucket does not seem to exists, trying to create it!") | ||||||
|         } |         } | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             log::error!("Got unexpected error when querying bucket info: {}", e); |             log::error!("Got unexpected error when querying bucket info: {e}"); | ||||||
|             return Err(BucketServiceError::FailedFetchBucketInfo.into()); |             return Err(BucketServiceError::FailedFetchBucketInfo.into()); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -78,12 +78,15 @@ pub async fn finances_manager_import(auth: AuthExtractor, file: FileExtractor) - | |||||||
|  |  | ||||||
| /// Export data to a [FinancesManager](https://gitlab.com/pierre42100/cpp-financesmanager) file | /// Export data to a [FinancesManager](https://gitlab.com/pierre42100/cpp-financesmanager) file | ||||||
| pub async fn finances_manager_export(auth: AuthExtractor) -> HttpResult { | pub async fn finances_manager_export(auth: AuthExtractor) -> HttpResult { | ||||||
|     let accounts = accounts_service::get_list_user(auth.user_id()).await?; |     let mut accounts = accounts_service::get_list_user(auth.user_id()).await?; | ||||||
|  |     accounts.sort_by_key(|a| a.id()); | ||||||
|  |  | ||||||
|     let mut out = FinancesManagerFile { accounts: vec![] }; |     let mut out = FinancesManagerFile { accounts: vec![] }; | ||||||
|  |  | ||||||
|     for account in accounts { |     for account in accounts { | ||||||
|         let movements = movements_service::get_list_account(account.id()).await?; |         let mut movements = movements_service::get_list_account(account.id()).await?; | ||||||
|  |         movements.sort_by(|a, b| b.time.cmp(&a.time)); | ||||||
|  |  | ||||||
|         let mut file_account = FinancesManagerAccount { |         let mut file_account = FinancesManagerAccount { | ||||||
|             name: account.name, |             name: account.name, | ||||||
|             movements: Vec::with_capacity(movements.len()), |             movements: Vec::with_capacity(movements.len()), | ||||||
|   | |||||||
| @@ -50,7 +50,7 @@ impl FromRequest for AccountInPath { | |||||||
|             Self::load_account_from_path(&auth, account_id) |             Self::load_account_from_path(&auth, account_id) | ||||||
|                 .await |                 .await | ||||||
|                 .map_err(|e| { |                 .map_err(|e| { | ||||||
|                     log::error!("Failed to extract account ID from URL! {}", e); |                     log::error!("Failed to extract account ID from URL! {e}"); | ||||||
|                     actix_web::error::ErrorNotFound("Could not fetch account information!") |                     actix_web::error::ErrorNotFound("Could not fetch account information!") | ||||||
|                 }) |                 }) | ||||||
|         }) |         }) | ||||||
|   | |||||||
| @@ -56,7 +56,7 @@ impl FromRequest for AuthExtractor { | |||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         Box::pin(async move { |         Box::pin(async move { | ||||||
|             // Check for authentication using OpenID |             // Check for authentication using API token | ||||||
|             if let Some(token) = req.headers().get(constants::API_TOKEN_HEADER) { |             if let Some(token) = req.headers().get(constants::API_TOKEN_HEADER) { | ||||||
|                 let Ok(jwt_token) = token.to_str() else { |                 let Ok(jwt_token) = token.to_str() else { | ||||||
|                     return Err(actix_web::error::ErrorBadRequest( |                     return Err(actix_web::error::ErrorBadRequest( | ||||||
| @@ -165,13 +165,13 @@ impl FromRequest for AuthExtractor { | |||||||
|                 // Update last use (if needed) |                 // Update last use (if needed) | ||||||
|                 if token.shall_update_time_used() { |                 if token.shall_update_time_used() { | ||||||
|                     if let Err(e) = tokens_service::update_time_used(&token).await { |                     if let Err(e) = tokens_service::update_time_used(&token).await { | ||||||
|                         log::error!("Failed to refresh last usage of token! {}", e); |                         log::error!("Failed to refresh last usage of token! {e}"); | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 // Handle tokens expiration |                 // Handle tokens expiration | ||||||
|                 if token.is_expired() { |                 if token.is_expired() { | ||||||
|                     log::error!("Attempted to use expired token! {:?}", token); |                     log::error!("Attempted to use expired token! {token:?}"); | ||||||
|                     return Err(actix_web::error::ErrorBadRequest("Token has expired!")); |                     return Err(actix_web::error::ErrorBadRequest("Token has expired!")); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -47,7 +47,7 @@ impl FromRequest for FileIdExtractor { | |||||||
|             Self::load_file_from_path(&auth, file_id) |             Self::load_file_from_path(&auth, file_id) | ||||||
|                 .await |                 .await | ||||||
|                 .map_err(|e| { |                 .map_err(|e| { | ||||||
|                     log::error!("Failed to extract file ID from URL! {}", e); |                     log::error!("Failed to extract file ID from URL! {e}"); | ||||||
|                     actix_web::error::ErrorNotFound("Could not fetch file information!") |                     actix_web::error::ErrorNotFound("Could not fetch file information!") | ||||||
|                 }) |                 }) | ||||||
|         }) |         }) | ||||||
|   | |||||||
| @@ -50,7 +50,7 @@ impl FromRequest for InboxEntryInPath { | |||||||
|             Self::load_inbox_entry_from_path(&auth, entry_id) |             Self::load_inbox_entry_from_path(&auth, entry_id) | ||||||
|                 .await |                 .await | ||||||
|                 .map_err(|e| { |                 .map_err(|e| { | ||||||
|                     log::error!("Failed to extract inbox entry ID from URL! {}", e); |                     log::error!("Failed to extract inbox entry ID from URL! {e}"); | ||||||
|                     actix_web::error::ErrorNotFound("Could not fetch inbox entry information!") |                     actix_web::error::ErrorNotFound("Could not fetch inbox entry information!") | ||||||
|                 }) |                 }) | ||||||
|         }) |         }) | ||||||
|   | |||||||
| @@ -57,7 +57,7 @@ impl FromRequest for MovementInPath { | |||||||
|             Self::load_movement_from_path(&auth, account_id) |             Self::load_movement_from_path(&auth, account_id) | ||||||
|                 .await |                 .await | ||||||
|                 .map_err(|e| { |                 .map_err(|e| { | ||||||
|                     log::error!("Failed to extract movement ID from URL! {}", e); |                     log::error!("Failed to extract movement ID from URL! {e}"); | ||||||
|                     actix_web::error::ErrorNotFound("Could not fetch movement information!") |                     actix_web::error::ErrorNotFound("Could not fetch movement information!") | ||||||
|                 }) |                 }) | ||||||
|         }) |         }) | ||||||
|   | |||||||
| @@ -5,5 +5,5 @@ pub fn sha512(bytes: &[u8]) -> String { | |||||||
|     let mut hasher = Sha512::new(); |     let mut hasher = Sha512::new(); | ||||||
|     hasher.update(bytes); |     hasher.update(bytes); | ||||||
|     let h = hasher.finalize(); |     let h = hasher.finalize(); | ||||||
|     format!("{:x}", h) |     base16ct::lower::encode_string(h.as_slice()) | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										3812
									
								
								moneymgr_web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3812
									
								
								moneymgr_web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -11,39 +11,39 @@ | |||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@emotion/react": "^11.14.0", |     "@emotion/react": "^11.14.0", | ||||||
|     "@emotion/styled": "^11.14.0", |     "@emotion/styled": "^11.14.1", | ||||||
|     "@fontsource/roboto": "^5.2.5", |     "@fontsource/roboto": "^5.2.6", | ||||||
|     "@jsonjoy.com/base64": "^1.1.2", |     "@jsonjoy.com/base64": "^1.1.2", | ||||||
|     "@mdi/js": "^7.4.47", |     "@mdi/js": "^7.4.47", | ||||||
|     "@mdi/react": "^1.6.1", |     "@mdi/react": "^1.6.1", | ||||||
|     "@mui/icons-material": "^7.0.1", |     "@mui/icons-material": "^7.1.2", | ||||||
|     "@mui/material": "^7.0.1", |     "@mui/material": "^7.1.2", | ||||||
|     "@mui/x-charts": "^8.2.0", |     "@mui/x-charts": "^8.8.0", | ||||||
|     "@mui/x-data-grid": "^7.28.3", |     "@mui/x-data-grid": "^8.8.0", | ||||||
|     "@mui/x-date-pickers": "^8.0.0-beta.3", |     "@mui/x-date-pickers": "^8.7.0", | ||||||
|     "date-and-time": "^3.6.0", |     "date-and-time": "^3.6.0", | ||||||
|     "dayjs": "^1.11.13", |     "dayjs": "^1.11.13", | ||||||
|     "filesize": "^10.1.6", |     "filesize": "^10.1.6", | ||||||
|     "qrcode.react": "^4.2.0", |     "qrcode.react": "^4.2.0", | ||||||
|     "react": "^19.1.0", |     "react": "^19.1.0", | ||||||
|     "react-dom": "^19.1.0", |     "react-dom": "^19.1.0", | ||||||
|     "react-router": "^7.4.1", |     "react-router": "^7.6.3", | ||||||
|     "react-router-dom": "^7.4.1", |     "react-router-dom": "^7.6.3", | ||||||
|     "ts-pattern": "^5.7.0" |     "ts-pattern": "^5.7.1" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@eslint/js": "^9.23.0", |     "@eslint/js": "^9.31.0", | ||||||
|     "@types/react": "^19.1.0", |     "@types/react": "^19.1.8", | ||||||
|     "@types/react-dom": "^19.1.1", |     "@types/react-dom": "^19.1.6", | ||||||
|     "@vitejs/plugin-react": "^4.3.4", |     "@vitejs/plugin-react": "^4.6.0", | ||||||
|     "eslint": "^9.23.0", |     "eslint": "^9.26.0", | ||||||
|     "eslint-plugin-react-dom": "^1.40.2", |     "eslint-plugin-react-dom": "^1.52.3", | ||||||
|     "eslint-plugin-react-hooks": "^5.1.0", |     "eslint-plugin-react-hooks": "^5.2.0", | ||||||
|     "eslint-plugin-react-refresh": "^0.4.19", |     "eslint-plugin-react-refresh": "^00.4.20", | ||||||
|     "eslint-plugin-react-x": "^1.40.2", |     "eslint-plugin-react-x": "^1.52.3", | ||||||
|     "globals": "^16.0.0", |     "globals": "^16.2.0", | ||||||
|     "typescript": "~5.8.2", |     "typescript": "~5.8.3", | ||||||
|     "typescript-eslint": "^8.29.0", |     "typescript-eslint": "^8.32.1", | ||||||
|     "vite": "^6.2.5" |     "vite": "^6.3.5" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -31,12 +31,21 @@ export class APIClient { | |||||||
|     return URL; |     return URL; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Get the full URL at which the backend can be contacted | ||||||
|  |    */ | ||||||
|  |   static ActualBackendURL(): string { | ||||||
|  |     const backendURL = this.backendURL(); | ||||||
|  |     if (backendURL.startsWith("/")) return `${location.origin}${backendURL}`; | ||||||
|  |     else return backendURL; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * Check out whether the backend is accessed through |    * Check out whether the backend is accessed through | ||||||
|    * HTTPS or not |    * HTTPS or not | ||||||
|    */ |    */ | ||||||
|   static IsBackendSecure(): boolean { |   static IsBackendSecure(): boolean { | ||||||
|     return this.backendURL().startsWith("https"); |     return this.ActualBackendURL().startsWith("https"); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|   | |||||||
| @@ -2,9 +2,11 @@ import DeleteIcon from "@mui/icons-material/DeleteOutlined"; | |||||||
| import DriveFileMoveOutlineIcon from "@mui/icons-material/DriveFileMoveOutline"; | import DriveFileMoveOutlineIcon from "@mui/icons-material/DriveFileMoveOutline"; | ||||||
| import LinkOffIcon from "@mui/icons-material/LinkOff"; | import LinkOffIcon from "@mui/icons-material/LinkOff"; | ||||||
| import MoreVertIcon from "@mui/icons-material/MoreVert"; | import MoreVertIcon from "@mui/icons-material/MoreVert"; | ||||||
|  | import ClearIcon from "@mui/icons-material/Clear"; | ||||||
| import RefreshIcon from "@mui/icons-material/Refresh"; | import RefreshIcon from "@mui/icons-material/Refresh"; | ||||||
| import { | import { | ||||||
|   IconButton, |   IconButton, | ||||||
|  |   InputAdornment, | ||||||
|   ListItemIcon, |   ListItemIcon, | ||||||
|   ListItemText, |   ListItemText, | ||||||
|   TextField, |   TextField, | ||||||
| @@ -121,16 +123,18 @@ function MovementsTable(p: { | |||||||
|  |  | ||||||
|   const chooseAccount = useSelectAccount(); |   const chooseAccount = useSelectAccount(); | ||||||
|  |  | ||||||
|   const [labelFilter, setLabelFilter] = React.useState(""); |   const [filter, setFilter] = React.useState(""); | ||||||
|  |  | ||||||
|   const filteredList = React.useMemo(() => { |   const filteredList = React.useMemo(() => { | ||||||
|     return p.movements.filter((m) => |     return p.movements.filter( | ||||||
|       m.label.toLowerCase().includes(labelFilter.toLowerCase()) |       (m) => | ||||||
|  |         m.label.toLowerCase().includes(filter.toLowerCase()) || | ||||||
|  |         m.amount.toString().includes(filter) | ||||||
|     ); |     ); | ||||||
|   }, [p.movements, labelFilter]); |   }, [p.movements, filter]); | ||||||
|  |  | ||||||
|   const [rowSelectionModel, setRowSelectionModel] = |   const [rowSelectionModel, setRowSelectionModel] = | ||||||
|     React.useState<GridRowSelectionModel>([]); |     React.useState<GridRowSelectionModel>({ type: "include", ids: new Set() }); | ||||||
|  |  | ||||||
|   // Set uploaded file |   // Set uploaded file | ||||||
|   const setUploadedFile = async ( |   const setUploadedFile = async ( | ||||||
| @@ -216,7 +220,7 @@ function MovementsTable(p: { | |||||||
|   const moveMultiple = async () => { |   const moveMultiple = async () => { | ||||||
|     try { |     try { | ||||||
|       const movements = p.movements.filter((m) => |       const movements = p.movements.filter((m) => | ||||||
|         rowSelectionModel.includes(m.id) |         rowSelectionModel.ids.has(m.id) | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
|       const targetAccount = await chooseAccount( |       const targetAccount = await chooseAccount( | ||||||
| @@ -260,7 +264,7 @@ function MovementsTable(p: { | |||||||
|   const deleteMultiple = async () => { |   const deleteMultiple = async () => { | ||||||
|     try { |     try { | ||||||
|       const movements = p.movements.filter((m) => |       const movements = p.movements.filter((m) => | ||||||
|         rowSelectionModel.includes(m.id) |         rowSelectionModel.ids.has(m.id) | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
|       if ( |       if ( | ||||||
| @@ -382,26 +386,43 @@ function MovementsTable(p: { | |||||||
|     <> |     <> | ||||||
|       <div style={{ display: "flex" }}> |       <div style={{ display: "flex" }}> | ||||||
|         <TextField |         <TextField | ||||||
|           placeholder="Filter by label" |           placeholder="Filter by label or amount" | ||||||
|           variant="standard" |           variant="standard" | ||||||
|           size="small" |           size="small" | ||||||
|           value={labelFilter} |           value={filter} | ||||||
|           onChange={(e) => { |           onChange={(e) => { | ||||||
|             setLabelFilter(e.target.value); |             setFilter(e.target.value); | ||||||
|           }} |           }} | ||||||
|           style={{ padding: "0px", flex: 1 }} |           style={{ padding: "0px", flex: 1 }} | ||||||
|  |           slotProps={{ | ||||||
|  |             input: { | ||||||
|  |               endAdornment: filter.length > 0 && ( | ||||||
|  |                 <InputAdornment position="end"> | ||||||
|  |                   <Tooltip title="Clear current filter"> | ||||||
|  |                     <IconButton size="small" onClick={() => { setFilter(""); }}> | ||||||
|  |                       <ClearIcon /> | ||||||
|  |                     </IconButton> | ||||||
|  |                   </Tooltip> | ||||||
|  |                 </InputAdornment> | ||||||
|  |               ), | ||||||
|  |             }, | ||||||
|  |           }} | ||||||
|         /> |         /> | ||||||
|         <span style={{ flex: 1 }}></span> |         <span style={{ flex: 1 }}></span> | ||||||
|         <Tooltip title="Refresh table"> |         <Tooltip title="Refresh table"> | ||||||
|           <IconButton onClick={() => { p.needReload(false); }}> |           <IconButton | ||||||
|  |             onClick={() => { | ||||||
|  |               p.needReload(false); | ||||||
|  |             }} | ||||||
|  |           > | ||||||
|             <RefreshIcon /> |             <RefreshIcon /> | ||||||
|           </IconButton> |           </IconButton> | ||||||
|         </Tooltip> |         </Tooltip> | ||||||
|         <Tooltip title="Move all the selected entries to another account"> |         <Tooltip title="Move all the selected entries to another account"> | ||||||
|           <IconButton |           <IconButton | ||||||
|             disabled={ |             disabled={ | ||||||
|               rowSelectionModel.length === 0 || |               rowSelectionModel.ids.size === 0 || | ||||||
|               rowSelectionModel.length === p.movements.length |               rowSelectionModel.ids.size === p.movements.length | ||||||
|             } |             } | ||||||
|             onClick={moveMultiple} |             onClick={moveMultiple} | ||||||
|           > |           > | ||||||
| @@ -411,8 +432,8 @@ function MovementsTable(p: { | |||||||
|         <Tooltip title="Delete all the selected entries"> |         <Tooltip title="Delete all the selected entries"> | ||||||
|           <IconButton |           <IconButton | ||||||
|             disabled={ |             disabled={ | ||||||
|               rowSelectionModel.length === 0 || |               rowSelectionModel.ids.size === 0 || | ||||||
|               rowSelectionModel.length === p.movements.length |               rowSelectionModel.ids.size === p.movements.length | ||||||
|             } |             } | ||||||
|             onClick={deleteMultiple} |             onClick={deleteMultiple} | ||||||
|           > |           > | ||||||
|   | |||||||
| @@ -158,7 +158,7 @@ function ImportExportModal(p: { | |||||||
|         </CardContent>{" "} |         </CardContent>{" "} | ||||||
|         <CardActions> |         <CardActions> | ||||||
|           <span style={{ flex: 1 }}> |           <span style={{ flex: 1 }}> | ||||||
|             <RouterLink to={p.exportURL}> |             <RouterLink to={p.exportURL} target="_blank"> | ||||||
|               <Button |               <Button | ||||||
|                 startIcon={<DownloadIcon />} |                 startIcon={<DownloadIcon />} | ||||||
|                 variant="outlined" |                 variant="outlined" | ||||||
|   | |||||||
| @@ -133,7 +133,7 @@ function InboxTable(p: { | |||||||
|   }, [p.entries, labelFilter]); |   }, [p.entries, labelFilter]); | ||||||
|  |  | ||||||
|   const [rowSelectionModel, setRowSelectionModel] = |   const [rowSelectionModel, setRowSelectionModel] = | ||||||
|     React.useState<GridRowSelectionModel>([]); |     React.useState<GridRowSelectionModel>({ type: "include", ids: new Set() }); | ||||||
|  |  | ||||||
|   const [attaching, setAttaching] = React.useState<InboxEntry | undefined>(); |   const [attaching, setAttaching] = React.useState<InboxEntry | undefined>(); | ||||||
|  |  | ||||||
| @@ -244,7 +244,7 @@ function InboxTable(p: { | |||||||
|  |  | ||||||
|       // Find the entry to map |       // Find the entry to map | ||||||
|       const entries = p.entries.filter( |       const entries = p.entries.filter( | ||||||
|         (m) => rowSelectionModel.includes(m.id) && !m.movement_id |         (m) => rowSelectionModel.ids.has(m.id) && !m.movement_id | ||||||
|       ); |       ); | ||||||
|       const movements: Movement[][] = []; |       const movements: Movement[][] = []; | ||||||
|  |  | ||||||
| @@ -324,7 +324,7 @@ function InboxTable(p: { | |||||||
|   const deleteMultiple = async () => { |   const deleteMultiple = async () => { | ||||||
|     try { |     try { | ||||||
|       const deletedEntries = p.entries.filter((m) => |       const deletedEntries = p.entries.filter((m) => | ||||||
|         rowSelectionModel.includes(m.id) |         rowSelectionModel.ids.has(m.id) | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
|       if ( |       if ( | ||||||
| @@ -437,7 +437,9 @@ function InboxTable(p: { | |||||||
|               icon={<SearchIcon />} |               icon={<SearchIcon />} | ||||||
|               label="Attach entry to movement" |               label="Attach entry to movement" | ||||||
|               color="inherit" |               color="inherit" | ||||||
|               onClick={() => { handleAttachClick(params.row); }} |               onClick={() => { | ||||||
|  |                 handleAttachClick(params.row); | ||||||
|  |               }} | ||||||
|               disabled={!!params.row.movement_id} |               disabled={!!params.row.movement_id} | ||||||
|             /> |             /> | ||||||
|           </Tooltip>, |           </Tooltip>, | ||||||
| @@ -496,7 +498,7 @@ function InboxTable(p: { | |||||||
|         </Tooltip> |         </Tooltip> | ||||||
|         <Tooltip title="Attach all the selected inbox entries to movements"> |         <Tooltip title="Attach all the selected inbox entries to movements"> | ||||||
|           <IconButton |           <IconButton | ||||||
|             disabled={rowSelectionModel.length === 0} |             disabled={rowSelectionModel.ids.size === 0} | ||||||
|             onClick={attachMultiple} |             onClick={attachMultiple} | ||||||
|           > |           > | ||||||
|             <SearchIcon /> |             <SearchIcon /> | ||||||
| @@ -505,8 +507,8 @@ function InboxTable(p: { | |||||||
|         <Tooltip title="Delete all the selected inbox entries"> |         <Tooltip title="Delete all the selected inbox entries"> | ||||||
|           <IconButton |           <IconButton | ||||||
|             disabled={ |             disabled={ | ||||||
|               rowSelectionModel.length === 0 || |               rowSelectionModel.ids.size === 0 || | ||||||
|               rowSelectionModel.length === p.entries.length |               rowSelectionModel.ids.size === p.entries.length | ||||||
|             } |             } | ||||||
|             onClick={deleteMultiple} |             onClick={deleteMultiple} | ||||||
|           > |           > | ||||||
|   | |||||||
| @@ -268,7 +268,7 @@ function CreatedToken(p: { token: TokenWithSecret }): React.ReactElement { | |||||||
|           <div style={{ padding: "15px", backgroundColor: "white" }}> |           <div style={{ padding: "15px", backgroundColor: "white" }}> | ||||||
|             <QRCodeCanvas |             <QRCodeCanvas | ||||||
|               value={`moneymgr://api=${encodeURIComponent( |               value={`moneymgr://api=${encodeURIComponent( | ||||||
|                 APIClient.backendURL() |                 APIClient.ActualBackendURL() | ||||||
|               )}&id=${p.token.id}&secret=${p.token.token}`} |               )}&id=${p.token.id}&secret=${p.token.token}`} | ||||||
|             /> |             /> | ||||||
|           </div> |           </div> | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { DatePicker } from "@mui/x-date-pickers"; | import { DateField } from "@mui/x-date-pickers"; | ||||||
| import { dateToTime, timeToDate } from "../../utils/DateUtils"; | import { dateToTime, timeToDate } from "../../utils/DateUtils"; | ||||||
| import { TextFieldVariants } from "@mui/material"; | import { TextFieldVariants } from "@mui/material"; | ||||||
|  |  | ||||||
| @@ -13,13 +13,16 @@ export function DateInput(p: { | |||||||
|   variant?: TextFieldVariants; |   variant?: TextFieldVariants; | ||||||
| }): React.ReactElement { | }): React.ReactElement { | ||||||
|   return ( |   return ( | ||||||
|     <DatePicker |     <DateField | ||||||
|       autoFocus={p.autoFocus} |       autoFocus={p.autoFocus} | ||||||
|       readOnly={p.editable === false} |       readOnly={p.editable === false} | ||||||
|       label={p.label} |       label={p.label} | ||||||
|       slotProps={{ |       slotProps={{ | ||||||
|         field: { ref: p.ref }, |         textField: { | ||||||
|         textField: { variant: p.variant ?? "standard", style: p.style }, |           ref: p.ref, | ||||||
|  |           variant: p.variant ?? "standard", | ||||||
|  |           style: p.style, | ||||||
|  |         }, | ||||||
|       }} |       }} | ||||||
|       value={timeToDate(p.value)} |       value={timeToDate(p.value)} | ||||||
|       onChange={(v) => { |       onChange={(v) => { | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user