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",
|
||||
"private": true,
|
||||
"version": "0.2.12",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -3894,7 +3894,7 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
|||
|
||||
[[package]]
|
||||
name = "simpl-result"
|
||||
version = "0.2.8"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
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 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::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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
35
src/App.tsx
35
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 (
|
||||
<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>
|
||||
<BrowserRouter key={refreshKey}>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
|
|
|
|||
|
|
@ -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<string, React.ComponentType<{ size?: number }>> = {
|
||||
LayoutDashboard,
|
||||
|
|
@ -42,6 +43,8 @@ export default function Sidebar() {
|
|||
<h1 className="text-lg font-bold tracking-tight">{APP_NAME}</h1>
|
||||
</div>
|
||||
|
||||
<ProfileSwitcher />
|
||||
|
||||
<nav className="flex-1 py-4 space-y-1 px-3">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
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": {
|
||||
"save": "Save",
|
||||
"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": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { ProfileProvider } from "./contexts/ProfileContext";
|
||||
import "./i18n/config";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ProfileProvider>
|
||||
<App />
|
||||
</ProfileProvider>
|
||||
</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> {
|
||||
if (!dbInstance) {
|
||||
dbInstance = await Database.load("sqlite:simpl_resultat.db");
|
||||
throw new Error("No database connection. Call connectToProfile() first.");
|
||||
}
|
||||
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