feat: add multiple profiles with separate databases and optional PIN (v0.3.0)
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:
Le-King-Fu 2026-02-16 12:54:09 +00:00
parent 0831663bbd
commit 20cae64f60
21 changed files with 1290 additions and 7 deletions

View file

@ -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
View file

@ -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",

View file

@ -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"

View file

@ -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::*;

View 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()
}

View 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');

View file

@ -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");

View file

@ -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");

View file

@ -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",

View file

@ -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 />} />

View file

@ -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];

View 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>
);
}

View 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>
);
}

View 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)} />
)}
</>
);
}

View 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;
}

View file

@ -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",

View file

@ -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",

View file

@ -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>,
); );

View 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>
);
}

View file

@ -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;
}
}

View 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 });
}