diff --git a/package.json b/package.json index dcc43ba..eb39000 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "simpl_result_scaffold", "private": true, - "version": "0.2.12", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 48113b8..b800b60 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3894,7 +3894,7 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simpl-result" -version = "0.2.8" +version = "0.3.0" dependencies = [ "aes-gcm", "argon2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4b6759c..19a2e5e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "simpl-result" -version = "0.2.12" +version = "0.3.0" description = "Personal finance management app" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d2fee51..e4f4300 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,7 @@ pub mod fs_commands; pub mod export_import_commands; +pub mod profile_commands; pub use fs_commands::*; pub use export_import_commands::*; +pub use profile_commands::*; diff --git a/src-tauri/src/commands/profile_commands.rs b/src-tauri/src/commands/profile_commands.rs new file mode 100644 index 0000000..b4c2bce --- /dev/null +++ b/src-tauri/src/commands/profile_commands.rs @@ -0,0 +1,157 @@ +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use tauri::Manager; + +use crate::database; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Profile { + pub id: String, + pub name: String, + pub color: String, + pub pin_hash: Option, + pub db_filename: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfilesConfig { + pub active_profile_id: String, + pub profiles: Vec, +} + +fn get_profiles_path(app: &tauri::AppHandle) -> Result { + let app_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Cannot get app data dir: {}", e))?; + Ok(app_dir.join("profiles.json")) +} + +fn make_default_config() -> ProfilesConfig { + let now = chrono_now(); + let default_id = "default".to_string(); + ProfilesConfig { + active_profile_id: default_id.clone(), + profiles: vec![Profile { + id: default_id, + name: "Default".to_string(), + color: "#4A90A4".to_string(), + pin_hash: None, + db_filename: "simpl_resultat.db".to_string(), + created_at: now, + }], + } +} + +fn chrono_now() -> String { + // Simple ISO-ish timestamp without pulling in chrono crate + let dur = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = dur.as_secs(); + // Return as unix timestamp string — frontend can format it + secs.to_string() +} + +#[tauri::command] +pub fn load_profiles(app: tauri::AppHandle) -> Result { + let path = get_profiles_path(&app)?; + + if !path.exists() { + let config = make_default_config(); + let json = + serde_json::to_string_pretty(&config).map_err(|e| format!("JSON error: {}", e))?; + + // Ensure parent dir exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Cannot create app data dir: {}", e))?; + } + + fs::write(&path, json).map_err(|e| format!("Cannot write profiles.json: {}", e))?; + return Ok(config); + } + + let content = + fs::read_to_string(&path).map_err(|e| format!("Cannot read profiles.json: {}", e))?; + let config: ProfilesConfig = + serde_json::from_str(&content).map_err(|e| format!("Invalid profiles.json: {}", e))?; + Ok(config) +} + +#[tauri::command] +pub fn save_profiles(app: tauri::AppHandle, config: ProfilesConfig) -> Result<(), String> { + let path = get_profiles_path(&app)?; + let json = + serde_json::to_string_pretty(&config).map_err(|e| format!("JSON error: {}", e))?; + fs::write(&path, json).map_err(|e| format!("Cannot write profiles.json: {}", e)) +} + +#[tauri::command] +pub fn delete_profile_db(app: tauri::AppHandle, db_filename: String) -> Result<(), String> { + if db_filename == "simpl_resultat.db" { + return Err("Cannot delete the default profile database".to_string()); + } + + let app_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Cannot get app data dir: {}", e))?; + let db_path = app_dir.join(&db_filename); + + if db_path.exists() { + fs::remove_file(&db_path) + .map_err(|e| format!("Cannot delete database file: {}", e))?; + } + + Ok(()) +} + +#[tauri::command] +pub fn get_new_profile_init_sql() -> Result, String> { + Ok(vec![ + database::CONSOLIDATED_SCHEMA.to_string(), + database::SEED_CATEGORIES.to_string(), + ]) +} + +#[tauri::command] +pub fn hash_pin(pin: String) -> Result { + let mut salt = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut salt); + let salt_hex = hex_encode(&salt); + + let mut hasher = Sha256::new(); + hasher.update(salt_hex.as_bytes()); + hasher.update(pin.as_bytes()); + let result = hasher.finalize(); + let hash_hex = hex_encode(&result); + + // Store as "salt:hash" + Ok(format!("{}:{}", salt_hex, hash_hex)) +} + +#[tauri::command] +pub fn verify_pin(pin: String, stored_hash: String) -> Result { + let parts: Vec<&str> = stored_hash.split(':').collect(); + if parts.len() != 2 { + return Err("Invalid stored hash format".to_string()); + } + let salt_hex = parts[0]; + let expected_hash = parts[1]; + + let mut hasher = Sha256::new(); + hasher.update(salt_hex.as_bytes()); + hasher.update(pin.as_bytes()); + let result = hasher.finalize(); + let computed_hash = hex_encode(&result); + + Ok(computed_hash == expected_hash) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} diff --git a/src-tauri/src/database/consolidated_schema.sql b/src-tauri/src/database/consolidated_schema.sql new file mode 100644 index 0000000..227d3b7 --- /dev/null +++ b/src-tauri/src/database/consolidated_schema.sql @@ -0,0 +1,184 @@ +-- Consolidated schema for new profile databases +-- This file bakes in the base schema + all migrations (v3-v6) +-- Used ONLY for initializing new profile databases (not for the default profile) + +CREATE TABLE IF NOT EXISTS import_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + date_format TEXT NOT NULL DEFAULT '%d/%m/%Y', + delimiter TEXT NOT NULL DEFAULT ';', + encoding TEXT NOT NULL DEFAULT 'utf-8', + column_mapping TEXT NOT NULL, + skip_lines INTEGER NOT NULL DEFAULT 0, + has_header INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS imported_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + filename TEXT NOT NULL, + file_hash TEXT NOT NULL, + import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + row_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'completed', + notes TEXT, + FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE, + UNIQUE(source_id, filename) +); + +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER, + color TEXT, + icon TEXT, + type TEXT NOT NULL DEFAULT 'expense', + is_active INTEGER NOT NULL DEFAULT 1, + is_inputable INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS suppliers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + normalized_name TEXT NOT NULL, + category_id INTEGER, + is_active INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS keywords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT NOT NULL, + category_id INTEGER NOT NULL, + supplier_id INTEGER, + priority INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL, + UNIQUE(keyword, category_id) +); + +CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + description TEXT NOT NULL, + amount REAL NOT NULL, + category_id INTEGER, + supplier_id INTEGER, + source_id INTEGER, + file_id INTEGER, + original_description TEXT, + notes TEXT, + is_manually_categorized INTEGER NOT NULL DEFAULT 0, + is_split INTEGER NOT NULL DEFAULT 0, + parent_transaction_id INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL, + FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL, + FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL, + FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL, + FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS adjustments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + date DATE NOT NULL, + is_recurring INTEGER NOT NULL DEFAULT 0, + recurrence_rule TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS adjustment_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + adjustment_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + description TEXT, + FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS budget_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + amount REAL NOT NULL, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(category_id, year, month) +); + +CREATE TABLE IF NOT EXISTS budget_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS budget_template_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(template_id, category_id) +); + +CREATE TABLE IF NOT EXISTS import_config_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + delimiter TEXT NOT NULL DEFAULT ';', + encoding TEXT NOT NULL DEFAULT 'utf-8', + date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY', + skip_lines INTEGER NOT NULL DEFAULT 0, + has_header INTEGER NOT NULL DEFAULT 1, + column_mapping TEXT NOT NULL, + amount_mode TEXT NOT NULL DEFAULT 'single', + sign_convention TEXT NOT NULL DEFAULT 'negative_expense', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS user_preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date); +CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id); +CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id); +CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id); +CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id); +CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id); +CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id); +CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type); +CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id); +CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name); +CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id); +CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword); +CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month); +CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id); +CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id); + +-- Default preferences +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY'); diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 22c48e5..2560b01 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -1,2 +1,3 @@ pub const SCHEMA: &str = include_str!("schema.sql"); pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql"); +pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 38b158f..51449af 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -95,6 +95,12 @@ pub fn run() { commands::write_export_file, commands::read_import_file, commands::is_file_encrypted, + commands::load_profiles, + commands::save_profiles, + commands::delete_profile_db, + commands::get_new_profile_init_sql, + commands::hash_pin, + commands::verify_pin, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 528beb1..be40e92 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Simpl Résultat", - "version": "0.2.12", + "version": "0.3.0", "identifier": "com.simpl.resultat", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 03ec323..ed9a1c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,6 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { useProfile } from "./contexts/ProfileContext"; import AppShell from "./components/layout/AppShell"; import DashboardPage from "./pages/DashboardPage"; import ImportPage from "./pages/ImportPage"; @@ -9,10 +11,41 @@ import BudgetPage from "./pages/BudgetPage"; import ReportsPage from "./pages/ReportsPage"; import SettingsPage from "./pages/SettingsPage"; import DocsPage from "./pages/DocsPage"; +import ProfileSelectionPage from "./pages/ProfileSelectionPage"; export default function App() { + const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile(); + const [dbReady, setDbReady] = useState(false); + + useEffect(() => { + if (activeProfile && !isLoading) { + setDbReady(false); + connectActiveProfile().then(() => setDbReady(true)); + } + }, [activeProfile, isLoading, connectActiveProfile]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!activeProfile) { + return ; + } + + if (!dbReady) { + return ( +
+
+
+ ); + } + return ( - + }> } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index dc99a36..a402de0 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { NAV_ITEMS, APP_NAME } from "../../shared/constants"; import { useTheme } from "../../hooks/useTheme"; +import ProfileSwitcher from "../profile/ProfileSwitcher"; const iconMap: Record> = { LayoutDashboard, @@ -42,6 +43,8 @@ export default function Sidebar() {

{APP_NAME}

+ +