From 20cae64f60ddac02e132ea4bece84506c1d7b477 Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Mon, 16 Feb 2026 12:54:09 +0000 Subject: [PATCH] feat: add multiple profiles with separate databases and optional PIN (v0.3.0) Each profile gets its own SQLite database file for complete data isolation. Profile selection screen at launch, sidebar switcher for quick switching, and optional 4-6 digit PIN for privacy. Existing database becomes the default profile with seamless upgrade. Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/profile_commands.rs | 157 +++++++++++ .../src/database/consolidated_schema.sql | 184 +++++++++++++ src-tauri/src/database/mod.rs | 1 + src-tauri/src/lib.rs | 6 + src-tauri/tauri.conf.json | 2 +- src/App.tsx | 35 ++- src/components/layout/Sidebar.tsx | 3 + src/components/profile/PinDialog.tsx | 121 +++++++++ src/components/profile/ProfileFormModal.tsx | 223 ++++++++++++++++ src/components/profile/ProfileSwitcher.tsx | 111 ++++++++ src/contexts/ProfileContext.tsx | 245 ++++++++++++++++++ src/i18n/locales/en.json | 21 ++ src/i18n/locales/fr.json | 21 ++ src/main.tsx | 5 +- src/pages/ProfileSelectionPage.tsx | 87 +++++++ src/services/db.ts | 28 +- src/services/profileService.ts | 39 +++ 21 files changed, 1290 insertions(+), 7 deletions(-) create mode 100644 src-tauri/src/commands/profile_commands.rs create mode 100644 src-tauri/src/database/consolidated_schema.sql create mode 100644 src/components/profile/PinDialog.tsx create mode 100644 src/components/profile/ProfileFormModal.tsx create mode 100644 src/components/profile/ProfileSwitcher.tsx create mode 100644 src/contexts/ProfileContext.tsx create mode 100644 src/pages/ProfileSelectionPage.tsx create mode 100644 src/services/profileService.ts 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}

+ +