14 Commits

Author SHA1 Message Date
60288b9b3a Merge pull request 'drag & drop' (#39) from update-categories into master
All checks were successful
Build and Publish Images / build-and-push (push) Successful in 21s
Reviewed-on: #39
2026-05-16 17:12:39 +03:00
arrelin
19abdd0e88 drag & drop 2026-05-16 17:10:10 +03:00
061bc18df7 Merge pull request 'ci/cd fix' (#38) from feature/change-categories into master
All checks were successful
Build and Publish Images / build-and-push (push) Successful in 3m34s
Reviewed-on: #38
2026-05-16 16:34:25 +03:00
arrelin
ecf0240ba9 ci/cd fix 2026-05-16 16:33:51 +03:00
90127d1e0d Merge pull request 'update category' (#37) from feature/change-categories into master
Some checks failed
Build and Publish Images / build-and-push (push) Failing after 30s
Reviewed-on: #37
2026-05-16 16:30:57 +03:00
arrelin
8daea3ea47 update category 2026-05-16 16:30:38 +03:00
318e2144f0 Merge pull request 'mobile update' (#36) from bugfix/iro4ka into master
All checks were successful
Build and Publish Images / build-and-push (push) Successful in 35s
Reviewed-on: http://192.168.31.100:3847/Arrelin/family_budget/pulls/36
2026-03-10 14:59:16 +03:00
arrelin
7c352d9e82 mobile update 2026-03-10 14:59:04 +03:00
fe1de2bbf9 Merge pull request 'mobile update' (#35) from bugfix/iro4ka into master
All checks were successful
Build and Publish Images / build-and-push (push) Successful in 2m12s
Reviewed-on: http://192.168.31.100:3847/Arrelin/family_budget/pulls/35
2026-03-10 14:45:19 +03:00
arrelin
035e6b20c7 mobile update 2026-03-10 14:45:08 +03:00
7b7554c84b Merge pull request 'mobile update' (#34) from bugfix/iro4ka into master
All checks were successful
Build and Publish Images / build-and-push (push) Successful in 2m8s
Reviewed-on: http://192.168.31.100:3847/Arrelin/family_budget/pulls/34
2026-03-10 14:28:58 +03:00
arrelin
1832997ebe mobile update 2026-03-10 14:28:24 +03:00
c884bf812c Merge pull request 'mobile update' (#33) from bugfix/iro4ka into master
All checks were successful
Build and Publish Images / build-and-push (push) Successful in 43s
Reviewed-on: http://192.168.31.100:3847/Arrelin/family_budget/pulls/33
2026-03-10 14:11:49 +03:00
arrelin
45eefeb1f5 mobile update 2026-03-10 14:11:35 +03:00
19 changed files with 493 additions and 113 deletions

View File

@@ -13,22 +13,22 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Login to Gitea Registry - name: Login to Gitea Registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 192.168.31.100:3847 -u ${{ secrets.REGISTRY_USER }} --password-stdin run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login gitea.arreliny.dedyn.io -u ${{ secrets.REGISTRY_USER }} --password-stdin
- name: Build and push backend image - name: Build and push backend image
run: | run: |
docker build -t 192.168.31.100:3847/arrelin/family_budget-backend:latest -t 192.168.31.100:3847/arrelin/family_budget-backend:${{ gitea.sha }} ./backend docker build -t gitea.arreliny.dedyn.io/arrelin/family_budget-backend:latest -t gitea.arreliny.dedyn.io/arrelin/family_budget-backend:${{ gitea.sha }} ./backend
docker push 192.168.31.100:3847/arrelin/family_budget-backend:latest docker push gitea.arreliny.dedyn.io/arrelin/family_budget-backend:latest
docker push 192.168.31.100:3847/arrelin/family_budget-backend:${{ gitea.sha }} docker push gitea.arreliny.dedyn.io/arrelin/family_budget-backend:${{ gitea.sha }}
- name: Build and push frontend image - name: Build and push frontend image
run: | run: |
docker build -t 192.168.31.100:3847/arrelin/family_budget-frontend:latest -t 192.168.31.100:3847/arrelin/family_budget-frontend:${{ gitea.sha }} ./frontend docker build -t gitea.arreliny.dedyn.io/arrelin/family_budget-frontend:latest -t gitea.arreliny.dedyn.io/arrelin/family_budget-frontend:${{ gitea.sha }} ./frontend
docker push 192.168.31.100:3847/arrelin/family_budget-frontend:latest docker push gitea.arreliny.dedyn.io/arrelin/family_budget-frontend:latest
docker push 192.168.31.100:3847/arrelin/family_budget-frontend:${{ gitea.sha }} docker push gitea.arreliny.dedyn.io/arrelin/family_budget-frontend:${{ gitea.sha }}
- name: Logout - name: Logout
run: docker logout 192.168.31.100:3847 run: docker logout gitea.arreliny.dedyn.io
- name: Trigger Coolify redeploy - name: Trigger Coolify redeploy
run: | run: |

View File

@@ -26,3 +26,7 @@ oauth2 = { version = "5.0.0", features = ["reqwest"] }
reqwest = { version = "0.13.1", features = ["json"] } reqwest = { version = "0.13.1", features = ["json"] }
rand = "0.9.2" rand = "0.9.2"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"
hex = "0.4"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -1,14 +1,10 @@
use axum::{ use axum::{
routing::{get, post, put, delete}, routing::{get, post, put, delete},
Router, middleware as axum_middleware, Router, middleware as axum_middleware,
Extension,
}; };
use sea_orm::{sqlx, Database, DatabaseConnection, DbErr}; use sea_orm::{sqlx, Database, DatabaseConnection, DbErr};
use sea_orm_migration::prelude::*; use sea_orm_migration::prelude::*;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use utoipa::OpenApi; use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi; use utoipa_swagger_ui::SwaggerUi;
use tower_sessions::{Expiry, SessionManagerLayer, cookie::SameSite}; use tower_sessions::{Expiry, SessionManagerLayer, cookie::SameSite};
@@ -18,7 +14,6 @@ use time::Duration;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use axum::http::{Method, HeaderValue}; use axum::http::{Method, HeaderValue};
pub type MobileTokenStore = Arc<Mutex<HashMap<String, (i32, Instant)>>>;
pub mod models; pub mod models;
pub mod services; pub mod services;
@@ -174,13 +169,10 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
.layer(auth_layer.clone()) .layer(auth_layer.clone())
.with_state(db.clone()); .with_state(db.clone());
let mobile_token_store: MobileTokenStore = Arc::new(Mutex::new(HashMap::new()));
let oauth_routes = Router::new() let oauth_routes = Router::new()
.route("/auth/google", get(routes::oauth::google_auth)) .route("/auth/google", get(routes::oauth::google_auth))
.route("/auth/google/callback", get(routes::oauth::google_callback)) .route("/auth/google/callback", get(routes::oauth::google_callback))
.route("/auth/mobile-callback", get(routes::oauth::mobile_callback)) .route("/auth/mobile-callback", get(routes::oauth::mobile_callback))
.layer(Extension(mobile_token_store))
.layer(auth_layer.clone()) .layer(auth_layer.clone())
.with_state(db.clone()); .with_state(db.clone());
@@ -239,7 +231,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
.url("/api-docs/openapi.json", ApiDoc::openapi()); .url("/api-docs/openapi.json", ApiDoc::openapi());
let allowed_origins = std::env::var("ALLOWED_ORIGINS") let allowed_origins = std::env::var("ALLOWED_ORIGINS")
.unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:8080".to_string()); .unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:8080,http://localhost:1420,http://tauri.localhost,https://tauri.localhost".to_string());
let origins: Vec<HeaderValue> = allowed_origins let origins: Vec<HeaderValue> = allowed_origins
.split(',') .split(',')

View File

@@ -3,6 +3,13 @@ use sea_orm::DbErr;
use sea_orm_migration::prelude::*; use sea_orm_migration::prelude::*;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), DbErr> { async fn main() -> Result<(), DbErr> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "family_budget=debug,info".parse().unwrap()),
)
.init();
let db = establish_connection().await?; let db = establish_connection().await?;
println!("Successfully connected to database!"); println!("Successfully connected to database!");

View File

@@ -2,23 +2,82 @@ use axum::{
extract::{Query, State}, extract::{Query, State},
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
Extension,
Json, Json,
}; };
use axum_login::AuthSession; use axum_login::AuthSession;
use sea_orm::{DatabaseConnection, EntityTrait}; use sea_orm::{DatabaseConnection, EntityTrait};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tower_sessions::Session; use tower_sessions::Session;
use tracing::{info, warn};
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::auth::AuthBackend; use crate::auth::AuthBackend;
use crate::models::User; use crate::models::User;
use crate::services::OAuthService; use crate::services::OAuthService;
use crate::MobileTokenStore;
const CSRF_TOKEN_KEY: &str = "oauth_csrf_token"; const CSRF_TOKEN_KEY: &str = "oauth_csrf_token";
const FRONTEND_URL_KEY: &str = "oauth_frontend_url"; const FRONTEND_URL_KEY: &str = "oauth_frontend_url";
fn mobile_secret() -> String {
std::env::var("MOBILE_SECRET").unwrap_or_else(|_| "family-budget-mobile-secret".to_string())
}
fn sign(data: &str) -> String {
let secret = mobile_secret();
let mut hasher = Sha256::new();
hasher.update(format!("{}:{}", secret, data).as_bytes());
hex::encode(hasher.finalize())
}
fn make_mobile_csrf_state(nonce: &str) -> String {
let sig = sign(&format!("csrf.mobile.{}", nonce));
format!("mobile.{}.{}", nonce, sig)
}
fn verify_mobile_csrf_state(state: &str) -> bool {
let mut parts = state.splitn(3, '.');
match (parts.next(), parts.next(), parts.next()) {
(Some("mobile"), Some(nonce), Some(sig)) => {
sign(&format!("csrf.mobile.{}", nonce)) == sig
}
_ => false,
}
}
fn make_auth_token(user_id: i32) -> String {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let payload = format!("{}.{}", user_id, timestamp);
let sig = sign(&format!("auth.{}", payload));
format!("{}.{}", payload, sig)
}
fn verify_auth_token(token: &str) -> Option<i32> {
let mut parts = token.splitn(3, '.');
let user_id_str = parts.next()?;
let timestamp_str = parts.next()?;
let sig = parts.next()?;
let payload = format!("{}.{}", user_id_str, timestamp_str);
if sign(&format!("auth.{}", payload)) != sig {
return None;
}
let timestamp: u64 = timestamp_str.parse().ok()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
if now.saturating_sub(timestamp) > 300 {
return None;
}
user_id_str.parse().ok()
}
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema)]
pub struct GoogleAuthQuery { pub struct GoogleAuthQuery {
pub redirect_url: Option<String>, pub redirect_url: Option<String>,
@@ -41,7 +100,8 @@ pub struct OAuthUrlResponse {
path = "/auth/google", path = "/auth/google",
tag = "auth", tag = "auth",
params( params(
("redirect_url" = Option<String>, Query, description = "Frontend URL to redirect after auth") ("redirect_url" = Option<String>, Query, description = "Frontend URL to redirect after auth"),
("mobile" = Option<bool>, Query, description = "Mobile OAuth flow")
), ),
responses( responses(
(status = 200, description = "Returns Google OAuth URL", body = OAuthUrlResponse) (status = 200, description = "Returns Google OAuth URL", body = OAuthUrlResponse)
@@ -52,6 +112,15 @@ pub async fn google_auth(
Query(query): Query<GoogleAuthQuery>, Query(query): Query<GoogleAuthQuery>,
) -> Result<Json<OAuthUrlResponse>, StatusCode> { ) -> Result<Json<OAuthUrlResponse>, StatusCode> {
let oauth_service = OAuthService::new(); let oauth_service = OAuthService::new();
if query.mobile.unwrap_or(false) {
let nonce = uuid::Uuid::new_v4().to_string();
let mobile_state = make_mobile_csrf_state(&nonce);
let auth_url = oauth_service.get_auth_url_with_state(mobile_state);
info!("mobile google_auth: generated signed state for nonce={}", nonce);
return Ok(Json(OAuthUrlResponse { url: auth_url }));
}
let (auth_url, csrf_token) = oauth_service.get_auth_url(); let (auth_url, csrf_token) = oauth_service.get_auth_url();
session session
@@ -66,13 +135,6 @@ pub async fn google_auth(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
} }
if query.mobile.unwrap_or(false) {
session
.insert("oauth_mobile", true)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
Ok(Json(OAuthUrlResponse { url: auth_url })) Ok(Json(OAuthUrlResponse { url: auth_url }))
} }
@@ -89,32 +151,32 @@ pub async fn google_callback(
mut auth_session: AuthSession<AuthBackend>, mut auth_session: AuthSession<AuthBackend>,
session: Session, session: Session,
State(db): State<DatabaseConnection>, State(db): State<DatabaseConnection>,
Extension(token_store): Extension<MobileTokenStore>,
Query(query): Query<GoogleCallbackQuery>, Query(query): Query<GoogleCallbackQuery>,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let stored_csrf: Option<String> = session let is_mobile = verify_mobile_csrf_state(&query.state);
info!("google_callback: state={} is_mobile={}", &query.state[..query.state.len().min(20)], is_mobile);
if !is_mobile {
let session_csrf: Option<String> = session
.get(CSRF_TOKEN_KEY) .get(CSRF_TOKEN_KEY)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .unwrap_or(None);
session.remove::<String>(CSRF_TOKEN_KEY).await.ok();
match session_csrf {
Some(csrf) if csrf == query.state => {}
_ => {
warn!("google_callback: CSRF mismatch, session_csrf={:?}", session_csrf.as_deref().map(|s| &s[..s.len().min(10)]));
return Err(StatusCode::UNAUTHORIZED);
}
}
}
let frontend_url: Option<String> = session let frontend_url: Option<String> = session
.get(FRONTEND_URL_KEY) .get(FRONTEND_URL_KEY)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .unwrap_or(None);
let is_mobile: bool = session
.get("oauth_mobile")
.await
.unwrap_or(None)
.unwrap_or(false);
session.remove::<String>(CSRF_TOKEN_KEY).await.ok();
session.remove::<String>(FRONTEND_URL_KEY).await.ok(); session.remove::<String>(FRONTEND_URL_KEY).await.ok();
session.remove::<bool>("oauth_mobile").await.ok();
if stored_csrf.as_deref() != Some(&query.state) {
return Err(StatusCode::UNAUTHORIZED);
}
let oauth_service = OAuthService::new(); let oauth_service = OAuthService::new();
@@ -133,6 +195,17 @@ pub async fn google_callback(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if is_mobile {
let token = make_auth_token(user.id);
info!("google_callback: mobile auth for user_id={}, token_prefix={}", user.id, &token[..token.len().min(20)]);
let deep_link = format!("com.arrelin.family-budget-android://auth?token={}", token);
let html = format!(
r#"<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url={0}"></head><body><script>window.location="{0}"</script></body></html>"#,
deep_link
);
return Ok(Html(html).into_response());
}
auth_session auth_session
.login(&user) .login(&user)
.await .await
@@ -146,28 +219,10 @@ pub async fn google_callback(
.unwrap_or_default(); .unwrap_or_default();
if !authorized_families.contains(&family_id) { if !authorized_families.contains(&family_id) {
authorized_families.push(family_id); authorized_families.push(family_id);
session session.insert("authorized_families", authorized_families).await.ok();
.insert("authorized_families", authorized_families)
.await
.ok();
} }
} }
if is_mobile {
let token = uuid::Uuid::new_v4().to_string();
{
let mut store = token_store.lock().unwrap();
store.insert(token.clone(), (user.id, std::time::Instant::now()));
}
let deep_link = format!("com.arrelin.family-budget-android://auth?token={}", token);
let html = format!(
r#"<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url={0}"></head>
<body><script>window.location="{0}"</script></body></html>"#,
deep_link
);
return Ok(Html(html).into_response());
}
let redirect_url = frontend_url.unwrap_or_else(|| "http://localhost:3000".to_string()); let redirect_url = frontend_url.unwrap_or_else(|| "http://localhost:3000".to_string());
Ok(Redirect::temporary(&redirect_url).into_response()) Ok(Redirect::temporary(&redirect_url).into_response())
} }
@@ -179,24 +234,19 @@ pub struct MobileCallbackQuery {
pub async fn mobile_callback( pub async fn mobile_callback(
mut auth_session: AuthSession<AuthBackend>, mut auth_session: AuthSession<AuthBackend>,
session: Session,
State(db): State<DatabaseConnection>, State(db): State<DatabaseConnection>,
Extension(token_store): Extension<MobileTokenStore>,
Query(query): Query<MobileCallbackQuery>, Query(query): Query<MobileCallbackQuery>,
) -> Result<Json<serde_json::Value>, StatusCode> { ) -> Result<Json<serde_json::Value>, StatusCode> {
let user_id = { info!("mobile_callback: received token_prefix={}", &query.token[..query.token.len().min(20)]);
let mut store = token_store.lock().unwrap(); let user_id = match verify_auth_token(&query.token) {
match store.get(&query.token) { Some(id) => id,
Some((uid, created_at)) if created_at.elapsed().as_secs() < 300 => { None => {
let uid = *uid; warn!("mobile_callback: token verification failed for token={}", &query.token[..query.token.len().min(40)]);
store.remove(&query.token);
uid
}
_ => {
store.remove(&query.token);
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
} }
}
}; };
info!("mobile_callback: token valid for user_id={}", user_id);
let user = User::find_by_id(user_id) let user = User::find_by_id(user_id)
.one(&db) .one(&db)
@@ -209,5 +259,17 @@ pub async fn mobile_callback(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if let Some(family_id) = user.family_id {
let mut authorized_families: Vec<i32> = session
.get("authorized_families")
.await
.unwrap_or(None)
.unwrap_or_default();
if !authorized_families.contains(&family_id) {
authorized_families.push(family_id);
session.insert("authorized_families", authorized_families).await.ok();
}
}
Ok(Json(serde_json::json!({"success": true}))) Ok(Json(serde_json::json!({"success": true})))
} }

View File

@@ -44,6 +44,26 @@ impl OAuthService {
(auth_url.to_string(), csrf_token) (auth_url.to_string(), csrf_token)
} }
pub fn get_auth_url_with_state(&self, state: String) -> String {
let client_id = std::env::var("GOOGLE_CLIENT_ID")
.expect("GOOGLE_CLIENT_ID must be set");
let client_secret = std::env::var("GOOGLE_CLIENT_SECRET")
.expect("GOOGLE_CLIENT_SECRET must be set");
let redirect_url = std::env::var("GOOGLE_REDIRECT_URL")
.unwrap_or_else(|_| "http://localhost:8080/api/auth/google/callback".to_string());
let client = Self::get_client(client_id, client_secret, redirect_url);
let (auth_url, _) = client
.authorize_url(move || CsrfToken::new(state))
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("email".to_string()))
.add_scope(Scope::new("profile".to_string()))
.url();
auth_url.to_string()
}
pub async fn exchange_code(&self, code: String) -> Result<String, OAuthError> { pub async fn exchange_code(&self, code: String) -> Result<String, OAuthError> {
let client_id = std::env::var("GOOGLE_CLIENT_ID") let client_id = std::env::var("GOOGLE_CLIENT_ID")
.expect("GOOGLE_CLIENT_ID must be set"); .expect("GOOGLE_CLIENT_ID must be set");

View File

@@ -8,9 +8,13 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/api": "^2.10.1", "@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-deep-link": "^2.4.7", "@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-shell": "^2.3.5",
"axios": "^1.13.2", "axios": "^1.13.2",
"i18next": "^25.8.0", "i18next": "^25.8.0",
@@ -344,6 +348,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -1630,6 +1687,15 @@
"@tauri-apps/api": "^2.10.1" "@tauri-apps/api": "^2.10.1"
} }
}, },
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
"integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-shell": { "node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
@@ -4089,6 +4155,12 @@
"typescript": ">=4.8.4" "typescript": ">=4.8.4"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -10,9 +10,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/api": "^2.10.1", "@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-deep-link": "^2.4.7", "@tauri-apps/plugin-deep-link": "^2.4.7",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-shell": "^2.3.5",
"axios": "^1.13.2", "axios": "^1.13.2",
"i18next": "^25.8.0", "i18next": "^25.8.0",

View File

@@ -63,6 +63,8 @@
"addCategory": "Add category", "addCategory": "Add category",
"deleteConfirm": "Delete category?", "deleteConfirm": "Delete category?",
"resetConfirm": "Delete all expenses for this category?", "resetConfirm": "Delete all expenses for this category?",
"editTitle": "Category settings",
"editError": "Error updating category",
"createError": "Error creating category", "createError": "Error creating category",
"deleteError": "Error deleting category", "deleteError": "Error deleting category",
"resetError": "Error resetting expenses" "resetError": "Error resetting expenses"

View File

@@ -63,6 +63,8 @@
"addCategory": "Добавить категорию", "addCategory": "Добавить категорию",
"deleteConfirm": "Удалить категорию?", "deleteConfirm": "Удалить категорию?",
"resetConfirm": "Удалить все траты по этой категории?", "resetConfirm": "Удалить все траты по этой категории?",
"editTitle": "Настройки категории",
"editError": "Ошибка обновления категории",
"createError": "Ошибка создания категории", "createError": "Ошибка создания категории",
"deleteError": "Ошибка удаления категории", "deleteError": "Ошибка удаления категории",
"resetError": "Ошибка сброса трат" "resetError": "Ошибка сброса трат"

View File

@@ -23,8 +23,64 @@ import {
Copy, Copy,
Check, Check,
User, User,
Settings,
GripVertical,
} from 'lucide-react'; } from 'lucide-react';
import ShoppingListModal from '../components/ShoppingListModal'; import ShoppingListModal from '../components/ShoppingListModal';
import {
DndContext,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
function SortableItem({
id,
children,
}: {
id: number;
children: (props: { listeners: ReturnType<typeof useSortable>['listeners']; attributes: ReturnType<typeof useSortable>['attributes'] }) => React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : 'auto' as any,
position: 'relative',
}}
>
{children({ listeners, attributes })}
</div>
);
}
const getCategoryOrder = (fid: string): number[] => {
try {
const stored = localStorage.getItem(`cat_order_${fid}`);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
};
const saveCategoryOrder = (fid: string, order: number[]) => {
localStorage.setItem(`cat_order_${fid}`, JSON.stringify(order));
};
export default function FamilyView() { export default function FamilyView() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,6 +88,11 @@ export default function FamilyView() {
const navigate = useNavigate(); const navigate = useNavigate();
const { selectedFamily } = useStore(); const { selectedFamily } = useStore();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [remainingLimits, setRemainingLimits] = useState<Map<number, number>>(new Map()); const [remainingLimits, setRemainingLimits] = useState<Map<number, number>>(new Map());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -45,6 +106,10 @@ export default function FamilyView() {
const [expenseAmount, setExpenseAmount] = useState(''); const [expenseAmount, setExpenseAmount] = useState('');
const [expenseDescription, setExpenseDescription] = useState(''); const [expenseDescription, setExpenseDescription] = useState('');
const [showEditCategory, setShowEditCategory] = useState<number | null>(null);
const [editCategoryName, setEditCategoryName] = useState('');
const [editCategoryLimit, setEditCategoryLimit] = useState('');
const [showHistory, setShowHistory] = useState<number | null>(null); const [showHistory, setShowHistory] = useState<number | null>(null);
const [showArchive, setShowArchive] = useState<number | null>(null); const [showArchive, setShowArchive] = useState<number | null>(null);
const [historyData, setHistoryData] = useState<ExpenseHistoryResponse | null>(null); const [historyData, setHistoryData] = useState<ExpenseHistoryResponse | null>(null);
@@ -72,7 +137,19 @@ export default function FamilyView() {
console.log('Loading categories for family:', familyId); console.log('Loading categories for family:', familyId);
const response = await categoryApi.getAllByFamily(parseInt(familyId)); const response = await categoryApi.getAllByFamily(parseInt(familyId));
console.log('Categories loaded:', response.data); console.log('Categories loaded:', response.data);
const savedOrder = getCategoryOrder(familyId);
if (savedOrder.length > 0) {
const sorted = [...response.data].sort((a, b) => {
const ai = savedOrder.indexOf(a.id);
const bi = savedOrder.indexOf(b.id);
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
setCategories(sorted);
} else {
setCategories(response.data); setCategories(response.data);
}
const limits = new Map<number, number>(); const limits = new Map<number, number>();
for (const category of response.data) { for (const category of response.data) {
@@ -181,6 +258,41 @@ export default function FamilyView() {
} }
}; };
const handleOpenEditCategory = (category: Category) => {
setEditCategoryName(category.name);
setEditCategoryLimit(parseFloat(category.limit_amount.toString()).toString());
setShowEditCategory(category.id);
setShowAddExpense(null);
};
const handleUpdateCategory = async (categoryId: number) => {
if (!familyId || !editCategoryName || !editCategoryLimit) return;
try {
await categoryApi.update(parseInt(familyId), categoryId, {
name: editCategoryName,
limit_amount: parseFloat(editCategoryLimit),
});
setShowEditCategory(null);
loadCategories();
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.message || t('category.editError');
alert(`${t('category.editError')}: ${errorMsg}`);
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !familyId) return;
setCategories((prev) => {
const oldIndex = prev.findIndex(c => c.id === active.id);
const newIndex = prev.findIndex(c => c.id === over.id);
const reordered = arrayMove(prev, oldIndex, newIndex);
saveCategoryOrder(familyId, reordered.map(c => c.id));
return reordered;
});
};
const handleShowHistory = async (categoryId: number) => { const handleShowHistory = async (categoryId: number) => {
if (!familyId) return; if (!familyId) return;
@@ -375,6 +487,8 @@ export default function FamilyView() {
</div> </div>
)} )}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={categories.map(c => c.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-5 mb-6 max-w-3xl mx-auto"> <div className="space-y-5 mb-6 max-w-3xl mx-auto">
{categories.map((category) => { {categories.map((category) => {
const remaining = remainingLimits.get(category.id) || 0; const remaining = remainingLimits.get(category.id) || 0;
@@ -382,12 +496,20 @@ export default function FamilyView() {
const percentage = getProgressPercentage(remaining, limit); const percentage = getProgressPercentage(remaining, limit);
return ( return (
<SortableItem key={category.id} id={category.id}>
{({ listeners, attributes }) => (
<div <div
key={category.id}
className="glass-effect rounded-2xl shadow-lg p-4 sm:p-5 card-hover" className="glass-effect rounded-2xl shadow-lg p-4 sm:p-5 card-hover"
> >
<div className="flex items-center justify-between gap-3 mb-4"> <div className="flex items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button
className="cursor-grab active:cursor-grabbing p-1 text-gray-400 hover:text-gray-600 touch-none flex-shrink-0"
{...listeners}
{...attributes}
>
<GripVertical className="w-5 h-5" />
</button>
<div className="p-2 category-icon text-white rounded-xl shadow-lg"> <div className="p-2 category-icon text-white rounded-xl shadow-lg">
<Tag className="w-6 h-6" /> <Tag className="w-6 h-6" />
</div> </div>
@@ -396,7 +518,15 @@ export default function FamilyView() {
</h2> </h2>
</div> </div>
{showAddExpense !== category.id && ( {showAddExpense !== category.id && showEditCategory !== category.id && (
<div className="flex items-center gap-2">
<button
onClick={() => handleOpenEditCategory(category)}
className="p-2 bg-gray-100 hover:bg-gray-200 border border-gray-300 text-gray-500 hover:text-gray-700 rounded-xl transition-all duration-300 shadow-sm"
title={t('category.editTitle')}
>
<Settings className="w-4 h-4" />
</button>
<button <button
onClick={() => setShowAddExpense(category.id)} onClick={() => setShowAddExpense(category.id)}
className="flex items-center gap-2 px-4 py-2 btn-danger text-white rounded-xl hover:shadow-lg transition-all duration-300 font-semibold whitespace-nowrap text-sm" className="flex items-center gap-2 px-4 py-2 btn-danger text-white rounded-xl hover:shadow-lg transition-all duration-300 font-semibold whitespace-nowrap text-sm"
@@ -405,6 +535,7 @@ export default function FamilyView() {
<span className="hidden sm:inline">{t('category.addExpense')}</span> <span className="hidden sm:inline">{t('category.addExpense')}</span>
<span className="sm:hidden">{t('category.expense')}</span> <span className="sm:hidden">{t('category.expense')}</span>
</button> </button>
</div>
)} )}
</div> </div>
@@ -511,6 +642,53 @@ export default function FamilyView() {
</div> </div>
)} )}
{showEditCategory === category.id && (
<div className="glass-effect p-6 rounded-2xl border-2 border-gray-200 mt-4">
<h3 className="font-semibold text-gray-800 mb-4 text-center">
{t('category.editTitle')}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('category.categoryName')}
</label>
<input
type="text"
value={editCategoryName}
onChange={(e) => setEditCategoryName(e.target.value)}
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('category.categoryLimit')}
</label>
<input
type="number"
value={editCategoryLimit}
onChange={(e) => setEditCategoryLimit(e.target.value)}
className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all text-center font-semibold text-lg"
/>
</div>
<div className="flex gap-3">
<button
onClick={() => handleUpdateCategory(category.id)}
className="flex-1 flex items-center justify-center gap-2 px-5 py-3 btn-success text-white rounded-2xl hover:shadow-xl transition-all font-semibold"
>
<Check className="w-5 h-5" />
{t('common.save')}
</button>
<button
onClick={() => setShowEditCategory(null)}
className="px-5 py-3 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-medium"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
)}
{showHistory === category.id && historyData && ( {showHistory === category.id && historyData && (
<div className="mt-4 glass-effect p-4 rounded-2xl border-2 border-blue-200"> <div className="mt-4 glass-effect p-4 rounded-2xl border-2 border-blue-200">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -645,9 +823,13 @@ export default function FamilyView() {
)} )}
</div> </div>
)}
</SortableItem>
); );
})} })}
</div> </div>
</SortableContext>
</DndContext>
<div className="glass-effect rounded-3xl shadow-xl p-6 sm:p-8 max-w-3xl mx-auto"> <div className="glass-effect rounded-3xl shadow-xl p-6 sm:p-8 max-w-3xl mx-auto">
<div className="flex items-center justify-center gap-3 mb-8"> <div className="flex items-center justify-center gap-3 mb-8">

View File

@@ -26,16 +26,24 @@ export default function Login() {
const url = Array.isArray(urls) ? urls[0] : urls; const url = Array.isArray(urls) ? urls[0] : urls;
if (!url.startsWith(DEEP_LINK_SCHEME)) return; if (!url.startsWith(DEEP_LINK_SCHEME)) return;
const token = new URL(url).searchParams.get('token'); let token: string | null;
if (!token) return; try {
token = new URL(url).searchParams.get('token');
} catch {
setError(t('login.authError'));
return;
}
if (!token) { setError(t('login.authError')); return; }
try { try {
setLoading(true); setLoading(true);
await authApi.mobileCallback(token); await authApi.mobileCallback(token);
const me = await authApi.me(); const me = await authApi.me();
setUser(me.data); setUser(me.data);
} catch { } catch (err: any) {
setError(t('login.authError')); const status = err?.response?.status;
const msg = err?.response?.data ? JSON.stringify(err.response.data) : err?.message;
setError(`${status ?? 'network'}: ${msg}`);
setLoading(false); setLoading(false);
} }
}); });
@@ -51,17 +59,17 @@ export default function Login() {
setError(''); setError('');
if (isTauriEnv()) { if (isTauriEnv()) {
const { open } = await import('@tauri-apps/plugin-shell'); const { openUrl } = await import('@tauri-apps/plugin-opener');
const response = await authApi.getGoogleAuthUrl(undefined, true); const response = await authApi.getGoogleAuthUrl(undefined, true);
await open(response.data.url); await openUrl(response.data.url);
return; return;
} }
const currentUrl = window.location.origin; const currentUrl = window.location.origin;
const response = await authApi.getGoogleAuthUrl(currentUrl); const response = await authApi.getGoogleAuthUrl(currentUrl);
window.location.href = response.data.url; window.location.href = response.data.url;
} catch (err) { } catch (err: any) {
setError(t('login.authError')); setError(String(err?.message || err));
console.error(err); console.error(err);
setLoading(false); setLoading(false);
} }

View File

@@ -8,8 +8,24 @@ export default defineConfig({
host: process.env.TAURI_DEV_HOST || 'localhost', host: process.env.TAURI_DEV_HOST || 'localhost',
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8080', target: process.env.TAURI_DEV_HOST
? 'https://family-budget.duckdns.org'
: 'http://localhost:8080',
changeOrigin: true, changeOrigin: true,
configure: (proxy) => {
if (process.env.TAURI_DEV_HOST) {
proxy.on('proxyRes', (proxyRes) => {
const cookies = proxyRes.headers['set-cookie'];
if (cookies) {
proxyRes.headers['set-cookie'] = cookies.map(cookie =>
cookie
.replace(/;\s*Secure/gi, '')
.replace(/;\s*Domain=[^;]*/gi, '')
);
}
});
}
},
} }
} }
} }

View File

@@ -21,7 +21,6 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2" tauri-plugin-deep-link = "2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View File

@@ -6,8 +6,6 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
"shell:default",
"shell:allow-open",
"deep-link:default" "deep-link:default"
] ]
} }

View File

@@ -22,6 +22,19 @@
<!-- AndroidTV support --> <!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
<intent-filter >
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.arrelin.family-budget-android" />
<data android:host="auth" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity> </activity>
<provider <provider

View File

@@ -1,4 +1,4 @@
<resources> <resources>
<string name="app_name">family-budget-android</string> <string name="app_name">Family Budget</string>
<string name="main_activity_title">family-budget-android</string> <string name="main_activity_title">Family Budget</string>
</resources> </resources>

View File

@@ -2,7 +2,6 @@
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -4,9 +4,9 @@
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.arrelin.family-budget-android", "identifier": "com.arrelin.family-budget-android",
"build": { "build": {
"beforeDevCommand": "", "beforeDevCommand": "npm run dev --prefix ../frontend",
"devUrl": "https://family-budget.duckdns.org", "devUrl": "http://localhost:5173",
"beforeBuildCommand": "npm run build --prefix ../frontend", "beforeBuildCommand": "VITE_API_BASE_URL=https://family-budget.duckdns.org/api npm run build --prefix ../frontend",
"frontendDist": "../frontend/dist" "frontendDist": "../frontend/dist"
}, },
"app": { "app": {
@@ -36,7 +36,7 @@
"deep-link": { "deep-link": {
"mobile": [ "mobile": [
{ {
"scheme": "com.arrelin.family-budget-android", "scheme": ["com.arrelin.family-budget-android"],
"host": "auth" "host": "auth"
} }
] ]