feat: add multiple profiles with separate databases and optional PIN (v0.3.0)
Some checks failed
Release / build (windows-latest) (push) Has been cancelled
Some checks failed
Release / build (windows-latest) (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
parent
0831663bbd
commit
20cae64f60
21 changed files with 1290 additions and 7 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.12",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
|
||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -3894,7 +3894,7 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.2.8"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.2.12"
|
version = "0.3.0"
|
||||||
description = "Personal finance management app"
|
description = "Personal finance management app"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
pub mod fs_commands;
|
pub mod fs_commands;
|
||||||
pub mod export_import_commands;
|
pub mod export_import_commands;
|
||||||
|
pub mod profile_commands;
|
||||||
|
|
||||||
pub use fs_commands::*;
|
pub use fs_commands::*;
|
||||||
pub use export_import_commands::*;
|
pub use export_import_commands::*;
|
||||||
|
pub use profile_commands::*;
|
||||||
|
|
|
||||||
157
src-tauri/src/commands/profile_commands.rs
Normal file
157
src-tauri/src/commands/profile_commands.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
pub db_filename: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProfilesConfig {
|
||||||
|
pub active_profile_id: String,
|
||||||
|
pub profiles: Vec<Profile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_profiles_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
|
||||||
|
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<ProfilesConfig, String> {
|
||||||
|
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<Vec<String>, String> {
|
||||||
|
Ok(vec![
|
||||||
|
database::CONSOLIDATED_SCHEMA.to_string(),
|
||||||
|
database::SEED_CATEGORIES.to_string(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn hash_pin(pin: String) -> Result<String, String> {
|
||||||
|
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<bool, String> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
184
src-tauri/src/database/consolidated_schema.sql
Normal file
184
src-tauri/src/database/consolidated_schema.sql
Normal file
|
|
@ -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');
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
pub const SCHEMA: &str = include_str!("schema.sql");
|
pub const SCHEMA: &str = include_str!("schema.sql");
|
||||||
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
|
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
|
||||||
|
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,12 @@ pub fn run() {
|
||||||
commands::write_export_file,
|
commands::write_export_file,
|
||||||
commands::read_import_file,
|
commands::read_import_file,
|
||||||
commands::is_file_encrypted,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Simpl Résultat",
|
"productName": "Simpl Résultat",
|
||||||
"version": "0.2.12",
|
"version": "0.3.0",
|
||||||
"identifier": "com.simpl.resultat",
|
"identifier": "com.simpl.resultat",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
35
src/App.tsx
35
src/App.tsx
|
|
@ -1,4 +1,6 @@
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
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 AppShell from "./components/layout/AppShell";
|
||||||
import DashboardPage from "./pages/DashboardPage";
|
import DashboardPage from "./pages/DashboardPage";
|
||||||
import ImportPage from "./pages/ImportPage";
|
import ImportPage from "./pages/ImportPage";
|
||||||
|
|
@ -9,10 +11,41 @@ import BudgetPage from "./pages/BudgetPage";
|
||||||
import ReportsPage from "./pages/ReportsPage";
|
import ReportsPage from "./pages/ReportsPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import DocsPage from "./pages/DocsPage";
|
import DocsPage from "./pages/DocsPage";
|
||||||
|
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
||||||
|
|
||||||
export default function App() {
|
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 (
|
return (
|
||||||
<BrowserRouter>
|
<div className="flex items-center justify-center h-screen bg-[var(--background)]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeProfile) {
|
||||||
|
return <ProfileSelectionPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dbReady) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen bg-[var(--background)]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter key={refreshKey}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { NAV_ITEMS, APP_NAME } from "../../shared/constants";
|
import { NAV_ITEMS, APP_NAME } from "../../shared/constants";
|
||||||
import { useTheme } from "../../hooks/useTheme";
|
import { useTheme } from "../../hooks/useTheme";
|
||||||
|
import ProfileSwitcher from "../profile/ProfileSwitcher";
|
||||||
|
|
||||||
const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
|
const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
|
@ -42,6 +43,8 @@ export default function Sidebar() {
|
||||||
<h1 className="text-lg font-bold tracking-tight">{APP_NAME}</h1>
|
<h1 className="text-lg font-bold tracking-tight">{APP_NAME}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ProfileSwitcher />
|
||||||
|
|
||||||
<nav className="flex-1 py-4 space-y-1 px-3">
|
<nav className="flex-1 py-4 space-y-1 px-3">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const Icon = iconMap[item.icon];
|
const Icon = iconMap[item.icon];
|
||||||
|
|
|
||||||
121
src/components/profile/PinDialog.tsx
Normal file
121
src/components/profile/PinDialog.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { verifyPin } from "../../services/profileService";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profileName: string;
|
||||||
|
storedHash: string;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PinDialog({ profileName, storedHash, onSuccess, onCancel }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [digits, setDigits] = useState<string[]>(["", "", "", "", "", ""]);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInput = async (index: number, value: string) => {
|
||||||
|
if (!/^\d?$/.test(value)) return;
|
||||||
|
|
||||||
|
const newDigits = [...digits];
|
||||||
|
newDigits[index] = value;
|
||||||
|
setDigits(newDigits);
|
||||||
|
setError(false);
|
||||||
|
|
||||||
|
if (value && index < 5) {
|
||||||
|
inputRefs.current[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check PIN when we have at least 4 digits filled
|
||||||
|
const pin = newDigits.join("");
|
||||||
|
if (pin.length >= 4 && !newDigits.slice(0, 4).includes("")) {
|
||||||
|
// If all filled digits are present and we're at the last one typed
|
||||||
|
const filledCount = newDigits.filter((d) => d !== "").length;
|
||||||
|
if (value && filledCount === index + 1) {
|
||||||
|
setChecking(true);
|
||||||
|
try {
|
||||||
|
const valid = await verifyPin(pin.replace(/\s/g, ""), storedHash);
|
||||||
|
if (valid) {
|
||||||
|
onSuccess();
|
||||||
|
} else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) {
|
||||||
|
setError(true);
|
||||||
|
setDigits(["", "", "", "", "", ""]);
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Backspace" && !digits[index] && index > 0) {
|
||||||
|
inputRefs.current[index - 1]?.focus();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const pin = digits.join("");
|
||||||
|
if (pin.length >= 4) {
|
||||||
|
setChecking(true);
|
||||||
|
verifyPin(pin, storedHash).then((valid) => {
|
||||||
|
setChecking(false);
|
||||||
|
if (valid) {
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
setError(true);
|
||||||
|
setDigits(["", "", "", "", "", ""]);
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-xs border border-[var(--border)] p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-[var(--foreground)]">{profileName}</h3>
|
||||||
|
<button onClick={onCancel} className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mb-4">{t("profile.enterPin")}</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-center mb-4">
|
||||||
|
{digits.map((digit, i) => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
ref={(el) => { inputRefs.current[i] = el; }}
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={1}
|
||||||
|
value={digit}
|
||||||
|
onChange={(e) => handleInput(i, e.target.value)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||||
|
disabled={checking}
|
||||||
|
className={`w-10 h-12 text-center text-lg font-bold rounded-lg border-2 bg-[var(--background)] text-[var(--foreground)] ${
|
||||||
|
error ? "border-[var(--negative)]" : "border-[var(--border)] focus:border-[var(--primary)]"
|
||||||
|
} outline-none transition-colors`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-[var(--negative)] text-center">{t("profile.wrongPin")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
src/components/profile/ProfileFormModal.tsx
Normal file
223
src/components/profile/ProfileFormModal.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { X, Trash2, Lock, LockOpen, Plus } from "lucide-react";
|
||||||
|
import { useProfile } from "../../contexts/ProfileContext";
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
"#4A90A4", "#22c55e", "#ef4444", "#f59e0b", "#8b5cf6",
|
||||||
|
"#ec4899", "#06b6d4", "#f97316", "#6366f1", "#14b8a6",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
editProfileId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileFormModal({ onClose, editProfileId }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { profiles, createProfile, updateProfile, deleteProfile, setPin } = useProfile();
|
||||||
|
|
||||||
|
const editProfile = editProfileId
|
||||||
|
? profiles.find((p) => p.id === editProfileId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<"list" | "create" | "edit">(
|
||||||
|
editProfileId ? "edit" : "list"
|
||||||
|
);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(editProfileId ?? null);
|
||||||
|
const [name, setName] = useState(editProfile?.name ?? "");
|
||||||
|
const [color, setColor] = useState(editProfile?.color ?? PRESET_COLORS[0]);
|
||||||
|
const [pin, setPin_] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setMode("create");
|
||||||
|
setName("");
|
||||||
|
setColor(PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]);
|
||||||
|
setPin_("");
|
||||||
|
setSelectedId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (id: string) => {
|
||||||
|
const p = profiles.find((pr) => pr.id === id);
|
||||||
|
if (!p) return;
|
||||||
|
setMode("edit");
|
||||||
|
setSelectedId(id);
|
||||||
|
setName(p.name);
|
||||||
|
setColor(p.color);
|
||||||
|
setPin_("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (mode === "create") {
|
||||||
|
await createProfile(name.trim(), color, pin.length >= 4 ? pin : undefined);
|
||||||
|
} else if (mode === "edit" && selectedId) {
|
||||||
|
await updateProfile(selectedId, { name: name.trim(), color });
|
||||||
|
}
|
||||||
|
setMode("list");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
const profile = profiles.find((p) => p.id === id);
|
||||||
|
if (!profile || profile.db_filename === "simpl_resultat.db") return;
|
||||||
|
if (!confirm(t("profile.deleteConfirm"))) return;
|
||||||
|
await deleteProfile(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePin = async (id: string) => {
|
||||||
|
const profile = profiles.find((p) => p.id === id);
|
||||||
|
if (!profile) return;
|
||||||
|
|
||||||
|
if (profile.pin_hash) {
|
||||||
|
await setPin(id, null);
|
||||||
|
} else {
|
||||||
|
const newPin = prompt(t("profile.setPin"));
|
||||||
|
if (newPin && newPin.length >= 4) {
|
||||||
|
await setPin(id, newPin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]">
|
||||||
|
<h2 className="font-semibold text-[var(--foreground)]">
|
||||||
|
{mode === "create"
|
||||||
|
? t("profile.create")
|
||||||
|
: mode === "edit"
|
||||||
|
? t("profile.edit")
|
||||||
|
: t("profile.manageProfiles")}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
{mode === "list" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<div
|
||||||
|
key={profile.id}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg bg-[var(--muted)]/30 border border-[var(--border)]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0"
|
||||||
|
style={{ backgroundColor: profile.color }}
|
||||||
|
>
|
||||||
|
{profile.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 font-medium text-sm text-[var(--foreground)]">
|
||||||
|
{profile.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTogglePin(profile.id)}
|
||||||
|
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)]"
|
||||||
|
title={profile.pin_hash ? t("profile.removePin") : t("profile.setPin")}
|
||||||
|
>
|
||||||
|
{profile.pin_hash ? <Lock size={14} /> : <LockOpen size={14} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(profile.id)}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-[var(--primary)] text-white hover:opacity-90"
|
||||||
|
>
|
||||||
|
{t("common.edit")}
|
||||||
|
</button>
|
||||||
|
{profile.db_filename !== "simpl_resultat.db" && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(profile.id)}
|
||||||
|
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--negative)]"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="flex items-center gap-2 w-full p-3 rounded-lg border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] text-[var(--muted-foreground)] text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t("profile.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
|
||||||
|
{t("profile.name")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("profile.namePlaceholder")}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
||||||
|
{t("profile.color")}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{PRESET_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`w-8 h-8 rounded-full border-2 transition-all ${
|
||||||
|
color === c ? "border-[var(--foreground)] scale-110" : "border-transparent"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "create" && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
|
||||||
|
{t("profile.pin")} <span className="text-[var(--muted-foreground)] font-normal">({t("common.cancel").toLowerCase()})</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pin}
|
||||||
|
onChange={(e) => setPin_(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
|
placeholder="4-6 digits"
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] text-sm"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode("list")}
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] text-sm text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!name.trim() || saving}
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/profile/ProfileSwitcher.tsx
Normal file
111
src/components/profile/ProfileSwitcher.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ChevronDown, Lock, Settings } from "lucide-react";
|
||||||
|
import { useProfile } from "../../contexts/ProfileContext";
|
||||||
|
import PinDialog from "./PinDialog";
|
||||||
|
import ProfileFormModal from "./ProfileFormModal";
|
||||||
|
import type { Profile } from "../../services/profileService";
|
||||||
|
|
||||||
|
export default function ProfileSwitcher() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { profiles, activeProfile, switchProfile } = useProfile();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pinProfile, setPinProfile] = useState<Profile | null>(null);
|
||||||
|
const [showManage, setShowManage] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (open) document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (profiles.length <= 1) return null;
|
||||||
|
|
||||||
|
const handleSelect = (profile: Profile) => {
|
||||||
|
setOpen(false);
|
||||||
|
if (profile.id === activeProfile?.id) return;
|
||||||
|
|
||||||
|
if (profile.pin_hash) {
|
||||||
|
setPinProfile(profile);
|
||||||
|
} else {
|
||||||
|
switchProfile(profile.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinSuccess = () => {
|
||||||
|
if (pinProfile) {
|
||||||
|
switchProfile(pinProfile.id);
|
||||||
|
setPinProfile(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={ref} className="relative px-3 pb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm hover:bg-[var(--sidebar-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: activeProfile?.color }}
|
||||||
|
/>
|
||||||
|
<span className="truncate flex-1 text-left">{activeProfile?.name}</span>
|
||||||
|
<ChevronDown size={14} className={`transition-transform ${open ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-3 right-3 top-full mt-1 z-50 rounded-lg bg-[var(--sidebar-bg)] border border-white/10 shadow-lg overflow-hidden">
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<button
|
||||||
|
key={profile.id}
|
||||||
|
onClick={() => handleSelect(profile)}
|
||||||
|
className={`flex items-center gap-2 w-full px-3 py-2 text-sm transition-colors ${
|
||||||
|
profile.id === activeProfile?.id
|
||||||
|
? "bg-[var(--sidebar-active)] text-white"
|
||||||
|
: "hover:bg-[var(--sidebar-hover)] text-[var(--sidebar-fg)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: profile.color }}
|
||||||
|
/>
|
||||||
|
<span className="truncate flex-1 text-left">{profile.name}</span>
|
||||||
|
{profile.pin_hash && <Lock size={12} className="opacity-50" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
setShowManage(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm border-t border-white/10 hover:bg-[var(--sidebar-hover)] text-[var(--sidebar-fg)]"
|
||||||
|
>
|
||||||
|
<Settings size={14} />
|
||||||
|
<span>{t("profile.manageProfiles")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pinProfile && (
|
||||||
|
<PinDialog
|
||||||
|
profileName={pinProfile.name}
|
||||||
|
storedHash={pinProfile.pin_hash!}
|
||||||
|
onSuccess={handlePinSuccess}
|
||||||
|
onCancel={() => setPinProfile(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showManage && (
|
||||||
|
<ProfileFormModal onClose={() => setShowManage(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
245
src/contexts/ProfileContext.tsx
Normal file
245
src/contexts/ProfileContext.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { createContext, useContext, useEffect, useReducer, useCallback, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
loadProfiles,
|
||||||
|
saveProfiles,
|
||||||
|
deleteProfileDb,
|
||||||
|
getNewProfileInitSql,
|
||||||
|
hashPin,
|
||||||
|
type Profile,
|
||||||
|
type ProfilesConfig,
|
||||||
|
} from "../services/profileService";
|
||||||
|
import { connectToProfile, initializeNewProfileDb, closeDb } from "../services/db";
|
||||||
|
|
||||||
|
interface ProfileState {
|
||||||
|
config: ProfilesConfig | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
refreshKey: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileAction =
|
||||||
|
| { type: "SET_CONFIG"; config: ProfilesConfig }
|
||||||
|
| { type: "SET_LOADING"; isLoading: boolean }
|
||||||
|
| { type: "SET_ERROR"; error: string | null }
|
||||||
|
| { type: "INCREMENT_REFRESH" };
|
||||||
|
|
||||||
|
function reducer(state: ProfileState, action: ProfileAction): ProfileState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_CONFIG":
|
||||||
|
return { ...state, config: action.config, error: null };
|
||||||
|
case "SET_LOADING":
|
||||||
|
return { ...state, isLoading: action.isLoading };
|
||||||
|
case "SET_ERROR":
|
||||||
|
return { ...state, error: action.error, isLoading: false };
|
||||||
|
case "INCREMENT_REFRESH":
|
||||||
|
return { ...state, refreshKey: state.refreshKey + 1 };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileContextValue {
|
||||||
|
profiles: Profile[];
|
||||||
|
activeProfile: Profile | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
refreshKey: number;
|
||||||
|
error: string | null;
|
||||||
|
switchProfile: (id: string) => Promise<void>;
|
||||||
|
createProfile: (name: string, color: string, pin?: string) => Promise<void>;
|
||||||
|
updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => Promise<void>;
|
||||||
|
deleteProfile: (id: string) => Promise<void>;
|
||||||
|
setPin: (id: string, pin: string | null) => Promise<void>;
|
||||||
|
connectActiveProfile: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileContext = createContext<ProfileContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ProfileProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, dispatch] = useReducer(reducer, {
|
||||||
|
config: null,
|
||||||
|
isLoading: true,
|
||||||
|
refreshKey: 0,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeProfile = state.config?.profiles.find(
|
||||||
|
(p) => p.id === state.config?.active_profile_id
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
|
// Load profiles on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadProfiles()
|
||||||
|
.then((config) => {
|
||||||
|
dispatch({ type: "SET_CONFIG", config });
|
||||||
|
dispatch({ type: "SET_LOADING", isLoading: false });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
dispatch({ type: "SET_ERROR", error: String(err) });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connectActiveProfile = useCallback(async () => {
|
||||||
|
if (!state.config) return;
|
||||||
|
const profile = state.config.profiles.find(
|
||||||
|
(p) => p.id === state.config!.active_profile_id
|
||||||
|
);
|
||||||
|
if (!profile) return;
|
||||||
|
await connectToProfile(profile.db_filename);
|
||||||
|
}, [state.config]);
|
||||||
|
|
||||||
|
const switchProfile = useCallback(async (id: string) => {
|
||||||
|
if (!state.config) return;
|
||||||
|
const profile = state.config.profiles.find((p) => p.id === id);
|
||||||
|
if (!profile) return;
|
||||||
|
|
||||||
|
dispatch({ type: "SET_LOADING", isLoading: true });
|
||||||
|
try {
|
||||||
|
await closeDb();
|
||||||
|
await connectToProfile(profile.db_filename);
|
||||||
|
const newConfig = { ...state.config, active_profile_id: id };
|
||||||
|
await saveProfiles(newConfig);
|
||||||
|
dispatch({ type: "SET_CONFIG", config: newConfig });
|
||||||
|
dispatch({ type: "INCREMENT_REFRESH" });
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({ type: "SET_ERROR", error: String(err) });
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_LOADING", isLoading: false });
|
||||||
|
}
|
||||||
|
}, [state.config]);
|
||||||
|
|
||||||
|
const createProfile = useCallback(async (name: string, color: string, pin?: string) => {
|
||||||
|
if (!state.config) return;
|
||||||
|
|
||||||
|
dispatch({ type: "SET_LOADING", isLoading: true });
|
||||||
|
try {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const dbFilename = `profile_${id.split("-")[0]}.db`;
|
||||||
|
const pinHash = pin ? await hashPin(pin) : null;
|
||||||
|
const now = Date.now().toString();
|
||||||
|
|
||||||
|
const newProfile: Profile = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
pin_hash: pinHash,
|
||||||
|
db_filename: dbFilename,
|
||||||
|
created_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the new database
|
||||||
|
const sqlStatements = await getNewProfileInitSql();
|
||||||
|
await initializeNewProfileDb(dbFilename, sqlStatements);
|
||||||
|
|
||||||
|
// Reconnect to the current active profile's DB
|
||||||
|
const currentProfile = state.config.profiles.find(
|
||||||
|
(p) => p.id === state.config!.active_profile_id
|
||||||
|
);
|
||||||
|
if (currentProfile) {
|
||||||
|
await connectToProfile(currentProfile.db_filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newConfig: ProfilesConfig = {
|
||||||
|
...state.config,
|
||||||
|
profiles: [...state.config.profiles, newProfile],
|
||||||
|
};
|
||||||
|
await saveProfiles(newConfig);
|
||||||
|
dispatch({ type: "SET_CONFIG", config: newConfig });
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({ type: "SET_ERROR", error: String(err) });
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_LOADING", isLoading: false });
|
||||||
|
}
|
||||||
|
}, [state.config]);
|
||||||
|
|
||||||
|
const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => {
|
||||||
|
if (!state.config) return;
|
||||||
|
|
||||||
|
const newProfiles = state.config.profiles.map((p) =>
|
||||||
|
p.id === id ? { ...p, ...updates } : p
|
||||||
|
);
|
||||||
|
const newConfig = { ...state.config, profiles: newProfiles };
|
||||||
|
await saveProfiles(newConfig);
|
||||||
|
dispatch({ type: "SET_CONFIG", config: newConfig });
|
||||||
|
}, [state.config]);
|
||||||
|
|
||||||
|
const deleteProfile = useCallback(async (id: string) => {
|
||||||
|
if (!state.config) return;
|
||||||
|
const profile = state.config.profiles.find((p) => p.id === id);
|
||||||
|
if (!profile) return;
|
||||||
|
if (profile.db_filename === "simpl_resultat.db") return;
|
||||||
|
|
||||||
|
dispatch({ type: "SET_LOADING", isLoading: true });
|
||||||
|
try {
|
||||||
|
// If deleting the active profile, switch to default first
|
||||||
|
if (state.config.active_profile_id === id) {
|
||||||
|
const defaultProfile = state.config.profiles.find(
|
||||||
|
(p) => p.db_filename === "simpl_resultat.db"
|
||||||
|
);
|
||||||
|
if (defaultProfile) {
|
||||||
|
await closeDb();
|
||||||
|
await connectToProfile(defaultProfile.db_filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteProfileDb(profile.db_filename);
|
||||||
|
|
||||||
|
const newProfiles = state.config.profiles.filter((p) => p.id !== id);
|
||||||
|
const newActiveId =
|
||||||
|
state.config.active_profile_id === id
|
||||||
|
? newProfiles[0]?.id ?? "default"
|
||||||
|
: state.config.active_profile_id;
|
||||||
|
|
||||||
|
const newConfig: ProfilesConfig = {
|
||||||
|
active_profile_id: newActiveId,
|
||||||
|
profiles: newProfiles,
|
||||||
|
};
|
||||||
|
await saveProfiles(newConfig);
|
||||||
|
dispatch({ type: "SET_CONFIG", config: newConfig });
|
||||||
|
if (state.config.active_profile_id === id) {
|
||||||
|
dispatch({ type: "INCREMENT_REFRESH" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({ type: "SET_ERROR", error: String(err) });
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_LOADING", isLoading: false });
|
||||||
|
}
|
||||||
|
}, [state.config]);
|
||||||
|
|
||||||
|
const setPin = useCallback(async (id: string, pin: string | null) => {
|
||||||
|
if (!state.config) return;
|
||||||
|
|
||||||
|
const pinHash = pin ? await hashPin(pin) : null;
|
||||||
|
const newProfiles = state.config.profiles.map((p) =>
|
||||||
|
p.id === id ? { ...p, pin_hash: pinHash } : p
|
||||||
|
);
|
||||||
|
const newConfig = { ...state.config, profiles: newProfiles };
|
||||||
|
await saveProfiles(newConfig);
|
||||||
|
dispatch({ type: "SET_CONFIG", config: newConfig });
|
||||||
|
}, [state.config]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileContext.Provider
|
||||||
|
value={{
|
||||||
|
profiles: state.config?.profiles ?? [],
|
||||||
|
activeProfile,
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
refreshKey: state.refreshKey,
|
||||||
|
error: state.error,
|
||||||
|
switchProfile,
|
||||||
|
createProfile,
|
||||||
|
updateProfile,
|
||||||
|
deleteProfile,
|
||||||
|
setPin,
|
||||||
|
connectActiveProfile,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ProfileContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfile() {
|
||||||
|
const ctx = useContext(ProfileContext);
|
||||||
|
if (!ctx) throw new Error("useProfile must be used within ProfileProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
@ -656,6 +656,27 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profiles",
|
||||||
|
"select": "Select a profile",
|
||||||
|
"create": "Create Profile",
|
||||||
|
"edit": "Edit Profile",
|
||||||
|
"delete": "Delete Profile",
|
||||||
|
"deleteConfirm": "Delete this profile and all its data? This cannot be undone.",
|
||||||
|
"name": "Profile Name",
|
||||||
|
"namePlaceholder": "Enter a name...",
|
||||||
|
"color": "Color",
|
||||||
|
"pin": "PIN",
|
||||||
|
"pinSet": "PIN set",
|
||||||
|
"pinNotSet": "No PIN",
|
||||||
|
"setPin": "Set PIN",
|
||||||
|
"removePin": "Remove PIN",
|
||||||
|
"enterPin": "Enter your PIN",
|
||||||
|
"wrongPin": "Wrong PIN. Try again.",
|
||||||
|
"switchProfile": "Switch Profile",
|
||||||
|
"manageProfiles": "Manage Profiles",
|
||||||
|
"default": "Default"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|
|
||||||
|
|
@ -656,6 +656,27 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profils",
|
||||||
|
"select": "Sélectionnez un profil",
|
||||||
|
"create": "Créer un profil",
|
||||||
|
"edit": "Modifier le profil",
|
||||||
|
"delete": "Supprimer le profil",
|
||||||
|
"deleteConfirm": "Supprimer ce profil et toutes ses données ? Cette action est irréversible.",
|
||||||
|
"name": "Nom du profil",
|
||||||
|
"namePlaceholder": "Entrez un nom...",
|
||||||
|
"color": "Couleur",
|
||||||
|
"pin": "NIP",
|
||||||
|
"pinSet": "NIP défini",
|
||||||
|
"pinNotSet": "Aucun NIP",
|
||||||
|
"setPin": "Définir un NIP",
|
||||||
|
"removePin": "Supprimer le NIP",
|
||||||
|
"enterPin": "Entrez votre NIP",
|
||||||
|
"wrongPin": "NIP incorrect. Réessayez.",
|
||||||
|
"switchProfile": "Changer de profil",
|
||||||
|
"manageProfiles": "Gérer les profils",
|
||||||
|
"default": "Par défaut"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { ProfileProvider } from "./contexts/ProfileContext";
|
||||||
import "./i18n/config";
|
import "./i18n/config";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<ProfileProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ProfileProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
87
src/pages/ProfileSelectionPage.tsx
Normal file
87
src/pages/ProfileSelectionPage.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Lock, Plus } from "lucide-react";
|
||||||
|
import { useProfile } from "../contexts/ProfileContext";
|
||||||
|
import { APP_NAME } from "../shared/constants";
|
||||||
|
import PinDialog from "../components/profile/PinDialog";
|
||||||
|
import ProfileFormModal from "../components/profile/ProfileFormModal";
|
||||||
|
|
||||||
|
export default function ProfileSelectionPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { profiles, switchProfile } = useProfile();
|
||||||
|
const [pinProfileId, setPinProfileId] = useState<string | null>(null);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
const handleSelect = (profileId: string) => {
|
||||||
|
const profile = profiles.find((p) => p.id === profileId);
|
||||||
|
if (!profile) return;
|
||||||
|
|
||||||
|
if (profile.pin_hash) {
|
||||||
|
setPinProfileId(profileId);
|
||||||
|
} else {
|
||||||
|
switchProfile(profileId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinSuccess = () => {
|
||||||
|
if (pinProfileId) {
|
||||||
|
switchProfile(pinProfileId);
|
||||||
|
setPinProfileId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pinProfile = profiles.find((p) => p.id === pinProfileId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-[var(--background)] p-8">
|
||||||
|
<h1 className="text-3xl font-bold text-[var(--foreground)] mb-2">{APP_NAME}</h1>
|
||||||
|
<p className="text-[var(--muted-foreground)] mb-10">{t("profile.select")}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 max-w-lg w-full">
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<button
|
||||||
|
key={profile.id}
|
||||||
|
onClick={() => handleSelect(profile.id)}
|
||||||
|
className="flex flex-col items-center gap-3 p-6 rounded-xl bg-[var(--card)] border border-[var(--border)] hover:border-[var(--primary)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-full flex items-center justify-center text-white text-xl font-bold"
|
||||||
|
style={{ backgroundColor: profile.color }}
|
||||||
|
>
|
||||||
|
{profile.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-[var(--foreground)]">{profile.name}</span>
|
||||||
|
{profile.pin_hash && (
|
||||||
|
<Lock size={14} className="text-[var(--muted-foreground)]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="flex flex-col items-center justify-center gap-3 p-6 rounded-xl border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-full flex items-center justify-center bg-[var(--muted)]">
|
||||||
|
<Plus size={24} className="text-[var(--muted-foreground)]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("profile.create")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pinProfileId && pinProfile && (
|
||||||
|
<PinDialog
|
||||||
|
profileName={pinProfile.name}
|
||||||
|
storedHash={pinProfile.pin_hash!}
|
||||||
|
onSuccess={handlePinSuccess}
|
||||||
|
onCancel={() => setPinProfileId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<ProfileFormModal onClose={() => setShowCreate(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,33 @@ let dbInstance: Database | null = null;
|
||||||
|
|
||||||
export async function getDb(): Promise<Database> {
|
export async function getDb(): Promise<Database> {
|
||||||
if (!dbInstance) {
|
if (!dbInstance) {
|
||||||
dbInstance = await Database.load("sqlite:simpl_resultat.db");
|
throw new Error("No database connection. Call connectToProfile() first.");
|
||||||
}
|
}
|
||||||
return dbInstance;
|
return dbInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function connectToProfile(dbFilename: string): Promise<void> {
|
||||||
|
if (dbInstance) {
|
||||||
|
await dbInstance.close();
|
||||||
|
dbInstance = null;
|
||||||
|
}
|
||||||
|
dbInstance = await Database.load(`sqlite:${dbFilename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeNewProfileDb(dbFilename: string, sqlStatements: string[]): Promise<void> {
|
||||||
|
if (dbInstance) {
|
||||||
|
await dbInstance.close();
|
||||||
|
dbInstance = null;
|
||||||
|
}
|
||||||
|
dbInstance = await Database.load(`sqlite:${dbFilename}`);
|
||||||
|
for (const sql of sqlStatements) {
|
||||||
|
await dbInstance.execute(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDb(): Promise<void> {
|
||||||
|
if (dbInstance) {
|
||||||
|
await dbInstance.close();
|
||||||
|
dbInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
39
src/services/profileService.ts
Normal file
39
src/services/profileService.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
pin_hash: string | null;
|
||||||
|
db_filename: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfilesConfig {
|
||||||
|
active_profile_id: string;
|
||||||
|
profiles: Profile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadProfiles(): Promise<ProfilesConfig> {
|
||||||
|
return invoke<ProfilesConfig>("load_profiles");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveProfiles(config: ProfilesConfig): Promise<void> {
|
||||||
|
return invoke("save_profiles", { config });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProfileDb(dbFilename: string): Promise<void> {
|
||||||
|
return invoke("delete_profile_db", { dbFilename });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNewProfileInitSql(): Promise<string[]> {
|
||||||
|
return invoke<string[]>("get_new_profile_init_sql");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPin(pin: string): Promise<string> {
|
||||||
|
return invoke<string>("hash_pin", { pin });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPin(pin: string, storedHash: string): Promise<boolean> {
|
||||||
|
return invoke<boolean>("verify_pin", { pin, storedHash });
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue