6 Commits

Author SHA1 Message Date
arrelin
ecf0240ba9 ci/cd fix 2026-05-16 16:33:51 +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
11 changed files with 154 additions and 26 deletions

View File

@@ -13,22 +13,22 @@ jobs:
uses: actions/checkout@v4
- 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
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 push 192.168.31.100:3847/arrelin/family_budget-backend:latest
docker push 192.168.31.100:3847/arrelin/family_budget-backend:${{ gitea.sha }}
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 gitea.arreliny.dedyn.io/arrelin/family_budget-backend:latest
docker push gitea.arreliny.dedyn.io/arrelin/family_budget-backend:${{ gitea.sha }}
- name: Build and push frontend image
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 push 192.168.31.100:3847/arrelin/family_budget-frontend:latest
docker push 192.168.31.100:3847/arrelin/family_budget-frontend:${{ gitea.sha }}
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 gitea.arreliny.dedyn.io/arrelin/family_budget-frontend:latest
docker push gitea.arreliny.dedyn.io/arrelin/family_budget-frontend:${{ gitea.sha }}
- name: Logout
run: docker logout 192.168.31.100:3847
run: docker logout gitea.arreliny.dedyn.io
- name: Trigger Coolify redeploy
run: |

View File

@@ -27,4 +27,6 @@ reqwest = { version = "0.13.1", features = ["json"] }
rand = "0.9.2"
uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"
hex = "0.4"
hex = "0.4"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -231,7 +231,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
.url("/api-docs/openapi.json", ApiDoc::openapi());
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
.split(',')

View File

@@ -3,6 +3,13 @@ use sea_orm::DbErr;
use sea_orm_migration::prelude::*;
#[tokio::main]
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?;
println!("Successfully connected to database!");

View File

@@ -9,6 +9,7 @@ use sea_orm::{DatabaseConnection, EntityTrait};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tower_sessions::Session;
use tracing::{info, warn};
use utoipa::ToSchema;
use crate::auth::AuthBackend;
@@ -116,6 +117,7 @@ pub async fn google_auth(
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 }));
}
@@ -152,6 +154,7 @@ pub async fn google_callback(
Query(query): Query<GoogleCallbackQuery>,
) -> Result<Response, StatusCode> {
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
@@ -162,7 +165,10 @@ pub async fn google_callback(
match session_csrf {
Some(csrf) if csrf == query.state => {}
_ => return Err(StatusCode::UNAUTHORIZED),
_ => {
warn!("google_callback: CSRF mismatch, session_csrf={:?}", session_csrf.as_deref().map(|s| &s[..s.len().min(10)]));
return Err(StatusCode::UNAUTHORIZED);
}
}
}
@@ -191,6 +197,7 @@ pub async fn google_callback(
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>"#,
@@ -231,7 +238,15 @@ pub async fn mobile_callback(
State(db): State<DatabaseConnection>,
Query(query): Query<MobileCallbackQuery>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let user_id = verify_auth_token(&query.token).ok_or(StatusCode::UNAUTHORIZED)?;
info!("mobile_callback: received token_prefix={}", &query.token[..query.token.len().min(20)]);
let user_id = match verify_auth_token(&query.token) {
Some(id) => id,
None => {
warn!("mobile_callback: token verification failed for token={}", &query.token[..query.token.len().min(40)]);
return Err(StatusCode::UNAUTHORIZED);
}
};
info!("mobile_callback: token valid for user_id={}", user_id);
let user = User::find_by_id(user_id)
.one(&db)

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import {
Copy,
Check,
User,
Settings,
} from 'lucide-react';
import ShoppingListModal from '../components/ShoppingListModal';
@@ -45,6 +46,10 @@ export default function FamilyView() {
const [expenseAmount, setExpenseAmount] = 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 [showArchive, setShowArchive] = useState<number | null>(null);
const [historyData, setHistoryData] = useState<ExpenseHistoryResponse | null>(null);
@@ -181,6 +186,29 @@ 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 handleShowHistory = async (categoryId: number) => {
if (!familyId) return;
@@ -396,15 +424,24 @@ export default function FamilyView() {
</h2>
</div>
{showAddExpense !== category.id && (
<button
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"
>
<TrendingDown className="w-4 h-4" />
<span className="hidden sm:inline">{t('category.addExpense')}</span>
<span className="sm:hidden">{t('category.expense')}</span>
</button>
{showAddExpense !== category.id && showEditCategory !== category.id && (
<div className="flex items-center gap-2">
<button
onClick={() => handleOpenEditCategory(category)}
className="p-2 bg-gray-200 hover:bg-gray-300 text-gray-600 rounded-xl transition-all duration-300"
title={t('category.editTitle')}
>
<Settings className="w-4 h-4" />
</button>
<button
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"
>
<TrendingDown className="w-4 h-4" />
<span className="hidden sm:inline">{t('category.addExpense')}</span>
<span className="sm:hidden">{t('category.expense')}</span>
</button>
</div>
)}
</div>
@@ -511,6 +548,53 @@ export default function FamilyView() {
</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 && (
<div className="mt-4 glass-effect p-4 rounded-2xl border-2 border-blue-200">
<div className="flex items-center justify-between mb-4">

View File

@@ -30,18 +30,20 @@ export default function Login() {
try {
token = new URL(url).searchParams.get('token');
} catch {
setError(t('login.error'));
setError(t('login.authError'));
return;
}
if (!token) { setError(t('login.error')); return; }
if (!token) { setError(t('login.authError')); return; }
try {
setLoading(true);
await authApi.mobileCallback(token);
const me = await authApi.me();
setUser(me.data);
} catch {
setError(t('login.error'));
} catch (err: any) {
const status = err?.response?.status;
const msg = err?.response?.data ? JSON.stringify(err.response.data) : err?.message;
setError(`${status ?? 'network'}: ${msg}`);
setLoading(false);
}
});

View File

@@ -12,6 +12,20 @@ export default defineConfig({
? 'https://family-budget.duckdns.org'
: 'http://localhost:8080',
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

@@ -6,7 +6,7 @@
"build": {
"beforeDevCommand": "npm run dev --prefix ../frontend",
"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"
},
"app": {