Release 1.2.0: Remove backup encryption and switch to JSON-only backups with history support

This commit is contained in:
michael.borak
2026-01-24 13:10:18 +01:00
parent 897f2ec0c2
commit a3e4fa4ec7
2 changed files with 229 additions and 121 deletions

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Settings as SettingsIcon } from "lucide-react"; import { Settings as SettingsIcon } from "lucide-react";
import Settings, { SmtpConfig, AzureConfig } from "./components/Settings"; import Settings, { SmtpConfig, AzureConfig } from "./components/Settings";
import Recorder from "./components/Recorder"; import Recorder from "./components/Recorder";
@@ -60,6 +61,11 @@ function App() {
return localStorage.getItem('hearbit_selected_model') || 'mixtral'; return localStorage.getItem('hearbit_selected_model') || 'mixtral';
}); });
// Daily Backup State
const [dailyBackupEnabled, setDailyBackupEnabled] = useState(() => localStorage.getItem('hearbit_daily_backup_enabled') === 'true');
const [dailyBackupPath, setDailyBackupPath] = useState(() => localStorage.getItem('hearbit_daily_backup_path') || '');
const [lastBackupDate, setLastBackupDate] = useState(() => localStorage.getItem('hearbit_last_backup_date') || '');
const handleModelChange = (model: string) => { const handleModelChange = (model: string) => {
setSelectedModel(model); setSelectedModel(model);
localStorage.setItem('hearbit_selected_model', model); localStorage.setItem('hearbit_selected_model', model);
@@ -227,6 +233,7 @@ Thanks!`
return saved ? JSON.parse(saved) : defaultEmailTemplates; return saved ? JSON.parse(saved) : defaultEmailTemplates;
}); });
const handleSaveSettings = ( const handleSaveSettings = (
newApiKey: string, newApiKey: string,
newProductId: string, newProductId: string,
@@ -234,7 +241,9 @@ Thanks!`
newSavePath: string, newSavePath: string,
newSmtp: SmtpConfig, newSmtp: SmtpConfig,
newAzure: AzureConfig, newAzure: AzureConfig,
newEmailTemplates: EmailTemplate[] newEmailTemplates: EmailTemplate[],
newDailyBackupEnabled: boolean,
newDailyBackupPath: string
) => { ) => {
setApiKey(newApiKey); setApiKey(newApiKey);
setProductId(newProductId); setProductId(newProductId);
@@ -244,14 +253,20 @@ Thanks!`
setAzureConfig(newAzure); setAzureConfig(newAzure);
setEmailTemplates(newEmailTemplates); setEmailTemplates(newEmailTemplates);
localStorage.setItem('infomaniak_api_key', newApiKey); setDailyBackupEnabled(newDailyBackupEnabled);
localStorage.setItem('infomaniak_product_id', newProductId); setDailyBackupPath(newDailyBackupPath);
localStorage.setItem('infomaniak_prompts', JSON.stringify(newPrompts));
localStorage.setItem('infomaniak_save_path', newSavePath); localStorage.setItem('hearbit_api_key', newApiKey);
localStorage.setItem('hearbit_product_id', newProductId);
localStorage.setItem('hearbit_prompts', JSON.stringify(newPrompts));
localStorage.setItem('hearbit_save_path', newSavePath);
localStorage.setItem('hearbit_smtp_config', JSON.stringify(newSmtp)); localStorage.setItem('hearbit_smtp_config', JSON.stringify(newSmtp));
localStorage.setItem('hearbit_azure_config', JSON.stringify(newAzure)); localStorage.setItem('hearbit_azure_config', JSON.stringify(newAzure));
localStorage.setItem('hearbit_email_templates', JSON.stringify(newEmailTemplates)); localStorage.setItem('hearbit_email_templates', JSON.stringify(newEmailTemplates));
localStorage.setItem('hearbit_daily_backup_enabled', String(newDailyBackupEnabled));
localStorage.setItem('hearbit_daily_backup_path', newDailyBackupPath);
setView(lastTab); setView(lastTab);
}; };
@@ -332,6 +347,80 @@ Thanks!`
setView('transcription'); // Switch to Transcription view to see content setView('transcription'); // Switch to Transcription view to see content
}; };
const performBackup = useCallback(async (isAuto = false) => {
try {
if (isAuto && !dailyBackupEnabled) return;
const dataToBackup = {
apiKey,
productId,
prompts,
savePath,
smtp: smtpConfig,
azure: azureConfig,
emailTemplates,
history, // Including history!
// Also include daily backup settings so they persist on restore
dailyBackup: {
enabled: dailyBackupEnabled,
path: dailyBackupPath,
}
};
// Always save as JSON (no encryption)
const content = JSON.stringify(dataToBackup, null, 2);
const dateStr = new Date().toISOString().slice(0, 10);
const fileName = `hearbit_backup_${isAuto ? 'auto_' : ''}${dateStr}.json`;
// Determine path: use specific daily backup path, or general savePath
const targetDir = (isAuto ? dailyBackupPath : savePath) || savePath;
if (!targetDir) {
if (!isAuto) addToast('No backup path configured.', 'error');
return;
}
const fullPath = `${targetDir}/${fileName}`;
await invoke('save_text_file', { path: fullPath, content });
if (isAuto) {
const now = new Date().toISOString();
setLastBackupDate(now);
localStorage.setItem('hearbit_last_backup_date', now);
console.log("Auto-backup completed:", fullPath);
} else {
addToast(`Backup saved to ${fullPath}`, 'success');
}
} catch (e) {
console.error("Backup failed:", e);
if (!isAuto) addToast(`Backup failed: ${e}`, 'error');
}
}, [apiKey, productId, prompts, savePath, smtpConfig, azureConfig, emailTemplates, history, dailyBackupEnabled, dailyBackupPath]);
// Check for Daily Backup on Mount / State Change
useEffect(() => {
if (!dailyBackupEnabled) return;
const check = async () => {
const today = new Date().toISOString().slice(0, 10);
const last = lastBackupDate ? lastBackupDate.slice(0, 10) : '';
if (last !== today) {
// Perform backup
await performBackup(true);
}
};
const timer = setTimeout(() => {
check();
}, 5000); // Check 5s after load to allow state to settle
return () => clearTimeout(timer);
}, [dailyBackupEnabled, lastBackupDate, performBackup]);
return ( return (
@@ -474,6 +563,18 @@ Thanks!`
smtpConfig={smtpConfig} smtpConfig={smtpConfig}
azureConfig={azureConfig} azureConfig={azureConfig}
emailTemplates={emailTemplates} emailTemplates={emailTemplates}
// Pass new backup props
dailyBackupEnabled={dailyBackupEnabled}
dailyBackupPath={dailyBackupPath}
lastBackupDate={lastBackupDate}
// Pass history and update callback
history={history}
onHistoryUpdate={(newHistory) => {
setHistory(newHistory);
localStorage.setItem('infomaniak_history', JSON.stringify(newHistory));
}}
/> />
)} )}
</div> </div>

View File

@@ -1,9 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Save, FolderOpen, Lock, Upload, Download, Eye, EyeOff, Mail, FileText, ScrollText } from 'lucide-react'; import { Save, FolderOpen, Lock, Upload, Download, Mail, FileText, ScrollText } from 'lucide-react';
import { save, open } from '@tauri-apps/plugin-dialog'; import { save, open } from '@tauri-apps/plugin-dialog';
// Removed writeTextFile as we use invoke 'save_text_file'
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { encryptData, decryptData } from '../utils/backup';
import EmailTemplateEditor from './EmailTemplateEditor'; import EmailTemplateEditor from './EmailTemplateEditor';
import logo from '../assets/logo.png'; import logo from '../assets/logo.png';
@@ -17,6 +15,10 @@ interface SettingsProps {
emailTemplates: EmailTemplate[]; emailTemplates: EmailTemplate[];
smtpConfig: SmtpConfig; smtpConfig: SmtpConfig;
azureConfig: AzureConfig; azureConfig: AzureConfig;
dailyBackupEnabled: boolean;
dailyBackupPath: string;
lastBackupDate: string;
history: any[];
onSave: ( onSave: (
apiKey: string, apiKey: string,
productId: string, productId: string,
@@ -24,8 +26,11 @@ interface SettingsProps {
savePath: string, savePath: string,
smtp: SmtpConfig, smtp: SmtpConfig,
azure: AzureConfig, azure: AzureConfig,
emailTemplates: EmailTemplate[] emailTemplates: EmailTemplate[],
dailyBackupEnabled: boolean,
dailyBackupPath: string
) => void; ) => void;
onHistoryUpdate: (history: any[]) => void;
onClose: () => void; onClose: () => void;
} }
@@ -52,14 +57,10 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
const [localEmailTemplates, setLocalEmailTemplates] = useState<EmailTemplate[]>(props.emailTemplates); // New state const [localEmailTemplates, setLocalEmailTemplates] = useState<EmailTemplate[]>(props.emailTemplates); // New state
const [localSmtp, setLocalSmtp] = useState<SmtpConfig>(props.smtpConfig); const [localSmtp, setLocalSmtp] = useState<SmtpConfig>(props.smtpConfig);
const [localAzure, setLocalAzure] = useState<AzureConfig>(props.azureConfig); const [localAzure, setLocalAzure] = useState<AzureConfig>(props.azureConfig);
const [localDailyBackupEnabled, setLocalDailyBackupEnabled] = useState(props.dailyBackupEnabled);
const [localDailyBackupPath, setLocalDailyBackupPath] = useState(props.dailyBackupPath);
const [statusIdx, setStatusIdx] = useState<string | null>(null); const [statusIdx, setStatusIdx] = useState<string | null>(null);
// Backup & Restore State
const [backupPassword, setBackupPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [importFileContent, setImportFileContent] = useState<string | null>(null);
// Email Template Editor State // Email Template Editor State
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null); const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
const [isEmailEditorOpen, setIsEmailEditorOpen] = useState(false); const [isEmailEditorOpen, setIsEmailEditorOpen] = useState(false);
@@ -133,7 +134,17 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
}; };
const handleSave = () => { const handleSave = () => {
onSave(localApiKey, localProductId, localPrompts, localSavePath, localSmtp, localAzure, localEmailTemplates); onSave(
localApiKey,
localProductId,
localPrompts,
localSavePath,
localSmtp,
localAzure,
localEmailTemplates,
localDailyBackupEnabled,
localDailyBackupPath
);
onClose(); onClose();
}; };
@@ -154,10 +165,6 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
}; };
const handleExport = async () => { const handleExport = async () => {
if (!backupPassword) {
setStatusIdx('Error: Password required to encrypt backup.');
return;
}
try { try {
const data = { const data = {
apiKey: localApiKey, apiKey: localApiKey,
@@ -165,21 +172,28 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
prompts: localPrompts, prompts: localPrompts,
savePath: localSavePath, savePath: localSavePath,
smtp: localSmtp, smtp: localSmtp,
azure: localAzure azure: localAzure,
emailTemplates: localEmailTemplates,
history: props.history,
dailyBackup: {
enabled: localDailyBackupEnabled,
path: localDailyBackupPath,
}
}; };
const encrypted = await encryptData(data, backupPassword);
// Always save as JSON (no encryption)
const content = JSON.stringify(data, null, 2);
const filePath = await save({ const filePath = await save({
defaultPath: `hearbit_backup_${new Date().toISOString().slice(0, 10)}.conf`, defaultPath: `hearbit_backup_${new Date().toISOString().slice(0, 10)}.json`,
filters: [{ filters: [{
name: 'Hearbit Config', name: 'Hearbit Config',
extensions: ['conf'] extensions: ['json']
}] }]
}); });
if (filePath) { if (filePath) {
// Use backend invoke to write file (bypasses fs scope issues) await invoke('save_text_file', { path: filePath, content });
await invoke('save_text_file', { path: filePath, content: encrypted });
setStatusIdx(`Configuration exported to: ${filePath}`); setStatusIdx(`Configuration exported to: ${filePath}`);
} }
} catch (e) { } catch (e) {
@@ -199,41 +213,39 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
if (event.target?.result) { if (event.target?.result) {
setImportFileContent(event.target.result as string); const content = event.target.result as string;
setIsImportModalOpen(true); // Directly import without password modal since we don't use encryption
setBackupPassword(''); try {
const data = JSON.parse(content);
if (data.apiKey) setLocalApiKey(data.apiKey);
if (data.productId) setLocalProductId(data.productId);
if (data.prompts) setLocalPrompts(data.prompts);
if (data.emailTemplates) setLocalEmailTemplates(data.emailTemplates);
if (data.savePath) setLocalSavePath(data.savePath);
if (data.smtp) setLocalSmtp(data.smtp);
if (data.azure) setLocalAzure(data.azure);
if (data.dailyBackup) {
if (data.dailyBackup.enabled !== undefined) setLocalDailyBackupEnabled(data.dailyBackup.enabled);
if (data.dailyBackup.path) setLocalDailyBackupPath(data.dailyBackup.path);
}
// Import history!
if (data.history && Array.isArray(data.history)) {
props.onHistoryUpdate(data.history);
}
setStatusIdx('Configuration imported! Click Save to apply.');
} catch (error) {
console.error(error);
setStatusIdx(`Import failed: ${error}`);
}
} }
}; };
reader.readAsText(file); reader.readAsText(file);
e.target.value = ''; e.target.value = '';
}; };
const confirmImport = async () => {
if (!backupPassword) {
setStatusIdx('Error: Password required to decrypt.');
return;
}
if (!importFileContent) return;
try {
const data = await decryptData(importFileContent, backupPassword);
if (data.apiKey) setLocalApiKey(data.apiKey);
if (data.productId) setLocalProductId(data.productId);
if (data.prompts) setLocalPrompts(data.prompts);
if (data.emailTemplates) setLocalEmailTemplates(data.emailTemplates);
if (data.savePath) setLocalSavePath(data.savePath);
if (data.smtp) setLocalSmtp(data.smtp);
if (data.azure) setLocalAzure(data.azure);
setStatusIdx('Configuration imported! Click Save to apply.');
setIsImportModalOpen(false);
setImportFileContent(null);
} catch (e) {
console.error(e);
setStatusIdx('Import failed: Wrong password or corrupted file.');
}
};
const handleCreateDevice = async () => { const handleCreateDevice = async () => {
try { try {
@@ -257,49 +269,6 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
return ( return (
<div className="flex flex-col h-full bg-background font-mono text-sm relative"> <div className="flex flex-col h-full bg-background font-mono text-sm relative">
{/* Import Password Modal */} {/* Import Password Modal */}
{isImportModalOpen && (
<div className="absolute inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<div className="bg-background border border-border rounded-lg shadow-xl p-6 w-full max-w-sm space-y-4">
<div className="flex items-center gap-2 text-foreground font-semibold">
<Lock size={16} /> Import Configuration
</div>
<p className="text-muted-foreground text-xs">
Enter the password used to encrypt this backup file.
</p>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={backupPassword}
onChange={(e) => setBackupPassword(e.target.value)}
placeholder="Backup Password"
className="w-full p-2 pr-8 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none"
/>
<button
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => setIsImportModalOpen(false)}
className="px-3 py-1.5 text-xs font-medium rounded border border-border hover:bg-secondary text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={confirmImport}
disabled={!backupPassword}
className="px-3 py-1.5 text-xs font-medium rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
Decrypt & Import
</button>
</div>
</div>
</div>
)}
{/* Email Template Editor Modal */} {/* Email Template Editor Modal */}
<EmailTemplateEditor <EmailTemplateEditor
isOpen={isEmailEditorOpen} isOpen={isEmailEditorOpen}
@@ -471,7 +440,7 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
onClick={() => removePrompt(prompt.id)} onClick={() => removePrompt(prompt.id)}
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity text-xs flex items-center gap-1" className="absolute top-2 right-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity text-xs flex items-center gap-1"
> >
<EyeOff size={14} /> Remove Remove
</button> </button>
<input <input
type="text" type="text"
@@ -619,31 +588,13 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
{activeTab === 'backup' && ( {activeTab === 'backup' && (
<div className="space-y-6 max-w-xl"> <div className="space-y-6 max-w-xl">
{/* Manual Configuration Backup */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">Configuration Backup</h3> <h3 className="text-foreground font-semibold border-b border-border pb-2">Manual Configuration Backup</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Securely export your settings, including API keys and prompts. You must set a password to encrypt the backup file. Export all your settings, including API keys, prompts, email templates, and history as JSON files.
</p> </p>
<div className="relative">
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Encryption Password
</label>
<input
type={showPassword ? "text" : "password"}
value={backupPassword}
onChange={(e) => setBackupPassword(e.target.value)}
placeholder="Enter a strong password"
className="w-full p-2 pr-8 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none text-sm"
/>
<button
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-8 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<div className="flex gap-4 pt-2"> <div className="flex gap-4 pt-2">
<button <button
onClick={handleExport} onClick={handleExport}
@@ -660,12 +611,68 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
<input <input
type="file" type="file"
id="import-file-input" id="import-file-input"
accept=".conf" accept=".json"
className="hidden" className="hidden"
onChange={handleFileSelect} onChange={handleFileSelect}
/> />
</div> </div>
</div> </div>
{/* Daily Automated Backup */}
<div className="space-y-4 border-t border-border pt-6">
<h3 className="text-foreground font-semibold border-b border-border pb-2">Daily Automated Backup</h3>
<p className="text-xs text-muted-foreground">
Automatically backup your configuration once per day to prevent data loss. Backups are saved as JSON files.
</p>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enable-daily-backup"
checked={localDailyBackupEnabled}
onChange={(e) => setLocalDailyBackupEnabled(e.target.checked)}
className="w-4 h-4"
/>
<label htmlFor="enable-daily-backup" className="text-sm text-foreground cursor-pointer">
Enable daily automated backup
</label>
</div>
{localDailyBackupEnabled && (
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Backup Location
</label>
<div className="flex gap-2">
<input
type="text"
value={localDailyBackupPath}
onChange={(e) => setLocalDailyBackupPath(e.target.value)}
placeholder="Leave empty to use recordings folder"
className="flex-1 p-2 rounded border border-border bg-secondary text-foreground focus:ring-2 focus:ring-primary outline-none text-sm"
/>
<button
onClick={async () => {
try {
const selected = await open({ directory: true, multiple: false });
if (selected && typeof selected === 'string') {
setLocalDailyBackupPath(selected);
}
} catch (e) {
console.error(e);
}
}}
className="p-2 aspect-square flex items-center justify-center bg-secondary hover:bg-secondary/80 border border-border rounded text-foreground transition-all active:scale-95"
>
<FolderOpen size={16} />
</button>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
Last backup: {props.lastBackupDate || 'Never'}
</p>
</div>
)}
</div>
</div> </div>
)} )}