Remove SyncService and introduce context-based offline mode and state management

- Delete `SyncService` and its associated bidirectional synchronization logic.
- Add multiple context providers (`OfflineProvider`, `AlertProvider`, `LangContext`, `UserContext`, `SessionContext`, `WorldContext`, `SettingBookContext`) for contextual state management.
- Implement `SecureStorage` for OS-level secure data encryption and replace dependency on `SyncService` synchronization.
- Update localization files (`en.json`, `fr.json`) with offline mode and error-related strings.
This commit is contained in:
natreex
2025-11-19 22:01:24 -05:00
parent f85c2d2269
commit 9e51cc93a8
20 changed files with 961 additions and 484 deletions

View File

@@ -34,6 +34,7 @@ import {AIUsageContext} from "@/context/AIUsageContext";
import OfflineProvider from "@/context/OfflineProvider"; import OfflineProvider from "@/context/OfflineProvider";
import OfflineContext from "@/context/OfflineContext"; import OfflineContext from "@/context/OfflineContext";
import OfflinePinSetup from "@/components/offline/OfflinePinSetup"; import OfflinePinSetup from "@/components/offline/OfflinePinSetup";
import OfflinePinVerify from "@/components/offline/OfflinePinVerify";
const messagesMap = { const messagesMap = {
fr: frMessages, fr: frMessages,
@@ -72,6 +73,7 @@ function ScribeContent() {
const [isTermsAccepted, setIsTermsAccepted] = useState<boolean>(false); const [isTermsAccepted, setIsTermsAccepted] = useState<boolean>(false);
const [homeStepsGuide, setHomeStepsGuide] = useState<boolean>(false); const [homeStepsGuide, setHomeStepsGuide] = useState<boolean>(false);
const [showPinSetup, setShowPinSetup] = useState<boolean>(false); const [showPinSetup, setShowPinSetup] = useState<boolean>(false);
const [showPinVerify, setShowPinVerify] = useState<boolean>(false);
const homeSteps: GuideStep[] = [ const homeSteps: GuideStep[] = [
{ {
@@ -188,6 +190,52 @@ function ScribeContent() {
checkPinSetup(); checkPinSetup();
}, [session.isConnected]); // Run when session connection status changes }, [session.isConnected]); // Run when session connection status changes
async function handlePinVerifySuccess(userId: string): Promise<void> {
console.log('[OfflinePin] PIN verified successfully for user:', userId);
try {
// Initialize database with user's encryption key
if (window.electron) {
const encryptionKey:string|null = await window.electron.getUserEncryptionKey(userId);
if (encryptionKey) {
await window.electron.dbInitialize(userId, encryptionKey);
// Load user from local DB
const localUser = await window.electron.invoke('db:user:info');
if (localUser && localUser.success) {
// Use local data and continue in offline mode
setSession({
isConnected: true,
user: localUser.data,
accessToken: 'offline', // Special offline token
});
setShowPinVerify(false);
setCurrentCredits(localUser.data.creditsBalance || 0);
setAmountSpent(localUser.data.aiUsage || 0);
console.log('[OfflinePin] Running in offline mode');
} else {
errorMessage(t("homePage.errors.localDataError"));
if (window.electron) {
//window.electron.logout();
}
}
} else {
errorMessage(t("homePage.errors.encryptionKeyError"));
if (window.electron) {
//window.electron.logout();
}
}
}
} catch (error) {
console.error('[OfflinePin] Error initializing offline mode:', error);
errorMessage(t("homePage.errors.offlineModeError"));
if (window.electron) {
//window.electron.logout();
}
}
}
async function handleHomeTour(): Promise<void> { async function handleHomeTour(): Promise<void> {
try { try {
const response: boolean = await System.authPostToServer<boolean>('logs/tour', { const response: boolean = await System.authPostToServer<boolean>('logs/tour', {
@@ -211,7 +259,6 @@ function ScribeContent() {
} }
async function checkAuthentification(): Promise<void> { async function checkAuthentification(): Promise<void> {
// Essayer de récupérer le token depuis electron-store en priorité
let token: string | null = null; let token: string | null = null;
if (typeof window !== 'undefined' && window.electron) { if (typeof window !== 'undefined' && window.electron) {
@@ -222,11 +269,6 @@ function ScribeContent() {
} }
} }
// Fallback sur les cookies si pas d'Electron
if (!token) {
token = System.getCookie('token');
}
if (token) { if (token) {
try { try {
const user: UserProps = await System.authGetQueryToServer<UserProps>('user/infos', token, locale); const user: UserProps = await System.authGetQueryToServer<UserProps>('user/infos', token, locale);
@@ -240,6 +282,8 @@ function ScribeContent() {
return; return;
} }
console.log('user: ' , user);
// Initialize user in Electron (sets userId and creates/gets encryption key) // Initialize user in Electron (sets userId and creates/gets encryption key)
if (window.electron && user.id) { if (window.electron && user.id) {
try { try {
@@ -306,35 +350,19 @@ function ScribeContent() {
} }
} catch (e: unknown) { } catch (e: unknown) {
console.log('[Auth] Server error, checking offline mode...'); console.log('[Auth] Server error, checking offline mode...');
// Check if we can use offline mode
if (window.electron) { if (window.electron) {
try { try {
// Check offline mode status const offlineStatus = await window.electron.offlineModeGet();
const offlineStatus = await window.electron.invoke('offline:mode:get');
// If offline mode is enabled and we have local data if (offlineStatus.hasPin && offlineStatus.lastUserId) {
if (offlineStatus.enabled && offlineStatus.hasPin) { console.log('[Auth] Server unreachable but PIN configured, showing PIN verification');
console.log('[Auth] Offline mode enabled, loading local user data'); setShowPinVerify(true);
setIsLoading(false);
// Try to load user from local DB return;
try { } else {
const localUser = await window.electron.invoke('db:user:info'); if (window.electron) {
if (localUser && localUser.success) { await window.electron.removeToken();
// Use local data window.electron.logout();
setSession({
isConnected: true,
user: localUser.data,
accessToken: 'offline', // Special offline token
});
setIsLoading(false);
// Show offline mode notification
console.log('[Auth] Running in offline mode');
return;
}
} catch (dbError) {
console.error('[Auth] Failed to load local user:', dbError);
} }
} }
} catch (offlineError) { } catch (offlineError) {
@@ -342,23 +370,28 @@ function ScribeContent() {
} }
} }
// If not in offline mode or failed to load local data, show error and logout // If not in offline mode or no PIN configured, show error and logout
if (e instanceof Error) { if (e instanceof Error) {
errorMessage(e.message); errorMessage(e.message);
} else { } else {
errorMessage(t("homePage.errors.authenticationError")); errorMessage(t("homePage.errors.authenticationError"));
} }
// Token invalide/erreur auth, supprimer et logout
if (window.electron) {
await window.electron.removeToken();
window.electron.logout();
}
} }
} else { } else {
// Pas de token - en Electron cela ne devrait jamais arriver
// car main.ts vérifie le token avant d'ouvrir mainWindow
// Si on arrive ici, c'est une erreur - fermer et ouvrir login
if (window.electron) { if (window.electron) {
try {
const offlineStatus = await window.electron.offlineModeGet();
if (offlineStatus.hasPin && offlineStatus.lastUserId) {
console.log('[Auth] No token but PIN configured, showing PIN verification for offline mode');
setShowPinVerify(true);
setIsLoading(false);
return;
}
} catch (error) {
console.error('[Auth] Error checking offline mode:', error);
}
window.electron.logout(); window.electron.logout();
} }
} }
@@ -475,6 +508,16 @@ function ScribeContent() {
/> />
) )
} }
{
showPinVerify && window.electron && (
<OfflinePinVerify
onSuccess={handlePinVerifySuccess}
onCancel={():void => {
//window.electron.logout();
}}
/>
)
}
</AIUsageContext.Provider> </AIUsageContext.Provider>
</ChapterContext.Provider> </ChapterContext.Provider>
</BookContext.Provider> </BookContext.Provider>

80
context/AlertProvider.tsx Normal file
View File

@@ -0,0 +1,80 @@
'use client';
import type {Context, Dispatch, JSX, ReactNode, SetStateAction} from 'react';
import {createContext, useCallback, useState} from 'react';
import AlertStack from '@/components/AlertStack';
export type AlertType = 'success' | 'error' | 'info' | 'warning';
export interface Alert {
id: string;
type: AlertType;
message: string;
}
export interface AlertContextProps {
successMessage: (message: string) => void;
errorMessage: (message: string) => void;
infoMessage: (message: string) => void;
warningMessage: (message: string) => void;
}
interface AlertProviderProps {
children: ReactNode;
}
export const AlertContext: Context<AlertContextProps> = createContext<AlertContextProps>({
successMessage: (_message: string): void => {
},
errorMessage: (_message: string): void => {
},
infoMessage: (_message: string): void => {
},
warningMessage: (_message: string): void => {
},
});
export function AlertProvider({children}: AlertProviderProps): JSX.Element {
const [alerts, setAlerts]: [Alert[], Dispatch<SetStateAction<Alert[]>>] = useState<Alert[]>([]);
const addAlert: (type: AlertType, message: string) => void = useCallback((type: AlertType, message: string): void => {
const id: string = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const newAlert: Alert = {id, type, message};
setAlerts((prev: Alert[]): Alert[] => [...prev, newAlert]);
}, []);
const removeAlert: (id: string) => void = useCallback((id: string): void => {
setAlerts((prev: Alert[]): Alert[] => prev.filter((alert: Alert): boolean => alert.id !== id));
}, []);
const successMessage: (message: string) => void = useCallback((message: string): void => {
addAlert('success', message);
}, [addAlert]);
const errorMessage: (message: string) => void = useCallback((message: string): void => {
addAlert('error', message);
}, [addAlert]);
const infoMessage: (message: string) => void = useCallback((message: string): void => {
addAlert('info', message);
}, [addAlert]);
const warningMessage: (message: string) => void = useCallback((message: string): void => {
addAlert('warning', message);
}, [addAlert]);
return (
<AlertContext.Provider
value={{
successMessage,
errorMessage,
infoMessage,
warningMessage,
}}
>
{children}
<AlertStack alerts={alerts} onClose={removeAlert}/>
</AlertContext.Provider>
);
}

13
context/BookContext.ts Executable file
View File

@@ -0,0 +1,13 @@
import {Context, createContext, Dispatch, SetStateAction} from "react";
import {BookProps} from "@/lib/models/Book";
export interface BookContextProps {
book: BookProps | null,
setBook?: Dispatch<SetStateAction<BookProps | null>>
}
export const BookContext: Context<BookContextProps> = createContext<BookContextProps>({
book: null,
setBook: () => {
}
})

14
context/ChapterContext.ts Executable file
View File

@@ -0,0 +1,14 @@
import {ChapterProps} from "@/lib/models/Chapter";
import {createContext, Dispatch, SetStateAction} from "react";
export interface ChapterContextProps {
chapter: ChapterProps | undefined,
setChapter: Dispatch<SetStateAction<ChapterProps | undefined>>
}
export const ChapterContext = createContext<ChapterContextProps>({
chapter: undefined,
setChapter: () => {
}
})

10
context/EditorContext.ts Executable file
View File

@@ -0,0 +1,10 @@
import {createContext} from "react";
import {Editor} from "@tiptap/core";
export interface EditorContextProps {
editor: Editor | null
}
export const EditorContext = createContext<EditorContextProps>({
editor: null
});

14
context/LangContext.ts Normal file
View File

@@ -0,0 +1,14 @@
import {Context, createContext, Dispatch, SetStateAction} from "react";
export type SupportedLocale = 'fr' | 'en';
export interface LangContextProps {
lang: SupportedLocale;
setLang: Dispatch<SetStateAction<SupportedLocale>>;
}
export const LangContext: Context<LangContextProps> = createContext<LangContextProps>({
lang: 'fr',
setLang: (): void => {
}
});

35
context/OfflineContext.ts Normal file
View File

@@ -0,0 +1,35 @@
import { createContext, Dispatch, SetStateAction } from 'react';
export interface OfflineMode {
isManuallyOffline: boolean;
isNetworkOnline: boolean;
isOffline: boolean;
isDatabaseInitialized: boolean;
error: string | null;
}
export interface OfflineContextType {
offlineMode: OfflineMode;
setOfflineMode: Dispatch<SetStateAction<OfflineMode>>;
toggleOfflineMode: () => void;
initializeDatabase: (userId: string, encryptionKey?: string) => Promise<boolean>;
isCurrentlyOffline: () => boolean;
}
export const defaultOfflineMode: OfflineMode = {
isManuallyOffline: false,
isNetworkOnline: typeof navigator !== 'undefined' ? navigator.onLine : true,
isOffline: false,
isDatabaseInitialized: false,
error: null
};
const OfflineContext = createContext<OfflineContextType>({
offlineMode: defaultOfflineMode,
setOfflineMode: () => {},
toggleOfflineMode: () => {},
initializeDatabase: async () => false,
isCurrentlyOffline: () => false
});
export default OfflineContext;

129
context/OfflineProvider.tsx Normal file
View File

@@ -0,0 +1,129 @@
'use client';
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
import OfflineContext, { OfflineMode, defaultOfflineMode } from './OfflineContext';
interface OfflineProviderProps {
children: ReactNode;
}
export default function OfflineProvider({ children }: OfflineProviderProps) {
const [offlineMode, setOfflineMode] = useState<OfflineMode>(defaultOfflineMode);
const initializeDatabase = useCallback(async (userId: string, encryptionKey?: string): Promise<boolean> => {
try {
if (typeof window === 'undefined' || !(window as any).electron) {
console.warn('Not running in Electron, offline mode not available');
return false;
}
let userKey = encryptionKey;
if (!userKey) {
const storedKey = await (window as any).electron.getUserEncryptionKey(userId);
if (storedKey) {
userKey = storedKey;
} else {
const keyResult = await (window as any).electron.generateEncryptionKey(userId);
if (!keyResult.success) {
throw new Error(keyResult.error || 'Failed to generate encryption key');
}
userKey = keyResult.key;
await (window as any).electron.setUserEncryptionKey(userId, userKey);
}
}
const result = await (window as any).electron.dbInitialize(userId, userKey);
if (!result.success) {
throw new Error(result.error || 'Failed to initialize database');
}
setOfflineMode(prev => ({
...prev,
isDatabaseInitialized: true,
error: null
}));
console.log('Database initialized successfully for user:', userId);
return true;
} catch (error) {
console.error('Failed to initialize database:', error);
setOfflineMode(prev => ({
...prev,
isDatabaseInitialized: false,
error: error instanceof Error ? error.message : 'Failed to initialize database'
}));
return false;
}
}, []);
const toggleOfflineMode = useCallback(() => {
setOfflineMode(prev => {
const newManuallyOffline = !prev.isManuallyOffline;
const newIsOffline = newManuallyOffline || !prev.isNetworkOnline;
console.log('Toggle offline mode:', {
wasManuallyOffline: prev.isManuallyOffline,
nowManuallyOffline: newManuallyOffline,
wasOffline: prev.isOffline,
nowOffline: newIsOffline,
networkOnline: prev.isNetworkOnline
});
return {
...prev,
isManuallyOffline: newManuallyOffline,
isOffline: newIsOffline
};
});
}, []);
const isCurrentlyOffline = useCallback((): boolean => {
return offlineMode.isOffline;
}, [offlineMode.isOffline]);
useEffect(() => {
const handleOnline = () => {
setOfflineMode(prev => {
const newIsOffline = prev.isManuallyOffline;
return {
...prev,
isNetworkOnline: true,
isOffline: newIsOffline
};
});
};
const handleOffline = () => {
setOfflineMode(prev => {
return {
...prev,
isNetworkOnline: false,
isOffline: true
};
});
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const value = {
offlineMode,
setOfflineMode,
toggleOfflineMode,
initializeDatabase,
isCurrentlyOffline
};
return (
<OfflineContext.Provider value={value}>
{children}
</OfflineContext.Provider>
);
}

17
context/SessionContext.ts Executable file
View File

@@ -0,0 +1,17 @@
import {SessionProps} from "@/lib/models/Session";
import {Context, createContext, Dispatch, SetStateAction} from "react";
export interface SessionContextProps {
session: SessionProps;
setSession: Dispatch<SetStateAction<SessionProps>>;
}
export const SessionContext: Context<SessionContextProps> = createContext<SessionContextProps>({
session: {
isConnected: false,
accessToken: "",
user: null,
},
setSession: () => {
},
})

12
context/SettingBookContext.ts Executable file
View File

@@ -0,0 +1,12 @@
import {Context, createContext, Dispatch, SetStateAction} from "react";
export interface SettingBookContextProps {
bookSettingId: string;
setBookSettingId: Dispatch<SetStateAction<string>>
}
export const SettingBookContext: Context<SettingBookContextProps> = createContext<SettingBookContextProps>({
bookSettingId: '',
setBookSettingId: (): void => {
}
})

25
context/UserContext.ts Executable file
View File

@@ -0,0 +1,25 @@
import {UserProps} from "@/lib/models/User";
import {Context, createContext} from "react";
export interface UserContextProps {
user: UserProps
}
export const UserContext: Context<UserContextProps> = createContext<UserContextProps>({
user: {
id: '',
name: '',
lastName: '',
username: '',
writingLang: 0,
writingLevel: 0,
ritePoints: 0,
groupId: 0,
aiUsage: 0,
apiKeys: {
openai: false,
anthropic: false,
gemini: false,
}
}
})

10
context/WorldContext.ts Executable file
View File

@@ -0,0 +1,10 @@
import {WorldProps} from "@/lib/models/World";
import {createContext, Dispatch, SetStateAction} from "react";
export interface WorldContextProps {
worlds: WorldProps[];
setWorlds: Dispatch<SetStateAction<WorldProps[]>>;
selectedWorldIndex: number;
}
export const WorldContext = createContext<WorldContextProps>({} as WorldContextProps);

2
electron.d.ts vendored
View File

@@ -41,7 +41,7 @@ export interface IElectronAPI {
offlinePinSet: (pin: string) => Promise<{ success: boolean; error?: string }>; offlinePinSet: (pin: string) => Promise<{ success: boolean; error?: string }>;
offlinePinVerify: (pin: string) => Promise<{ success: boolean; userId?: string; error?: string }>; offlinePinVerify: (pin: string) => Promise<{ success: boolean; userId?: string; error?: string }>;
offlineModeSet: (enabled: boolean, syncInterval?: number) => Promise<{ success: boolean }>; offlineModeSet: (enabled: boolean, syncInterval?: number) => Promise<{ success: boolean }>;
offlineModeGet: () => Promise<{ enabled: boolean; syncInterval: number; hasPin: boolean }>; offlineModeGet: () => Promise<{ enabled: boolean; syncInterval: number; hasPin: boolean; lastUserId?: string }>;
offlineSyncCheck: () => Promise<{ shouldSync: boolean; daysSinceSync?: number; syncInterval?: number }>; offlineSyncCheck: () => Promise<{ shouldSync: boolean; daysSinceSync?: number; syncInterval?: number }>;
} }

150
electron/ipc/offline.ipc.ts Normal file
View File

@@ -0,0 +1,150 @@
import { ipcMain } from 'electron';
import { createHandler } from '../database/LocalSystem.js';
import * as bcrypt from 'bcrypt';
import { getSecureStorage } from '../storage/SecureStorage.js';
interface SetPinData {
pin: string;
}
interface VerifyPinData {
pin: string;
}
interface OfflineModeData {
enabled: boolean;
syncInterval?: number; // days
}
ipcMain.handle('offline:pin:set', async (_event, data: SetPinData) => {
try {
const storage = getSecureStorage();
const userId = storage.get<string>('userId');
if (!userId) {
return { success: false, error: 'No user logged in' };
}
// Hash the PIN
const hashedPin = await bcrypt.hash(data.pin, 10);
// Store hashed PIN
storage.set(`pin-${userId}`, hashedPin);
storage.save();
console.log('[Offline] PIN set for user');
return { success: true };
} catch (error) {
console.error('[Offline] Error setting PIN:', error);
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
});
// Verify PIN for offline access
ipcMain.handle('offline:pin:verify', async (_event, data: VerifyPinData) => {
try {
const storage = getSecureStorage();
// Try to get last known userId
const lastUserId = storage.get<string>('lastUserId');
if (!lastUserId) {
return { success: false, error: 'No offline account found' };
}
const hashedPin = storage.get<string>(`pin-${lastUserId}`);
if (!hashedPin) {
return { success: false, error: 'No PIN configured' };
}
// Verify PIN
const isValid = await bcrypt.compare(data.pin, hashedPin);
if (isValid) {
// Set userId for session
storage.set('userId', lastUserId);
console.log('[Offline] PIN verified, user authenticated locally');
return {
success: true,
userId: lastUserId
};
}
return { success: false, error: 'Invalid PIN' };
} catch (error) {
console.error('[Offline] Error verifying PIN:', error);
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
});
// Set offline mode preference
ipcMain.handle('offline:mode:set', (_event, data: OfflineModeData) => {
try {
const storage = getSecureStorage();
storage.set('offlineMode', data.enabled);
if (data.syncInterval) {
storage.set('syncInterval', data.syncInterval);
}
storage.save();
console.log('[Offline] Mode set to:', data.enabled);
return { success: true };
} catch (error) {
console.error('[Offline] Error setting mode:', error);
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
});
// Get offline mode status
ipcMain.handle('offline:mode:get', () => {
try {
const storage = getSecureStorage();
const offlineMode = storage.get<boolean>('offlineMode', false);
const syncInterval = storage.get<number>('syncInterval', 30);
const lastUserId = storage.get<string>('lastUserId');
const hasPin = lastUserId ? !!storage.get<string>(`pin-${lastUserId}`) : false;
return {
enabled: offlineMode,
syncInterval,
hasPin,
lastUserId
};
} catch (error) {
console.error('[Offline] Error getting mode:', error);
return {
enabled: false,
syncInterval: 30,
hasPin: false
};
}
});
// Check if should sync
ipcMain.handle('offline:sync:check', () => {
try {
const storage = getSecureStorage();
const lastSync = storage.get<string>('lastSync');
const syncInterval = storage.get<number>('syncInterval', 30) || 30;
if (!lastSync) {
return { shouldSync: true };
}
const daysSinceSync = Math.floor(
(Date.now() - new Date(lastSync).getTime()) / (1000 * 60 * 60 * 24)
);
return {
shouldSync: daysSinceSync >= syncInterval,
daysSinceSync,
syncInterval
};
} catch (error) {
console.error('[Offline] Error checking sync:', error);
return { shouldSync: false };
}
});
console.log('[IPC] Offline handlers registered');

View File

@@ -5,6 +5,8 @@ import { fileURLToPath } from 'url';
import * as fs from 'fs'; import * as fs from 'fs';
import { getDatabaseService } from './database/database.service.js'; import { getDatabaseService } from './database/database.service.js';
import { getSecureStorage } from './storage/SecureStorage.js'; import { getSecureStorage } from './storage/SecureStorage.js';
import { getUserEncryptionKey, setUserEncryptionKey, hasUserEncryptionKey } from './database/keyManager.js';
import { generateUserEncryptionKey } from './database/encryption.js';
// Import IPC handlers // Import IPC handlers
import './ipc/book.ipc.js'; import './ipc/book.ipc.js';
@@ -120,7 +122,10 @@ function createMainWindow(): void {
// IPC Handlers pour la gestion du token (OS-encrypted storage) // IPC Handlers pour la gestion du token (OS-encrypted storage)
ipcMain.handle('get-token', () => { ipcMain.handle('get-token', () => {
const storage = getSecureStorage(); const storage = getSecureStorage();
return storage.get('authToken', null); const token = storage.get('authToken', null);
console.log('[GetToken] Token requested, exists:', !!token);
console.log('[GetToken] Storage has authToken:', storage.has('authToken'));
return token;
}); });
ipcMain.handle('set-token', (_event, token: string) => { ipcMain.handle('set-token', (_event, token: string) => {
@@ -156,12 +161,9 @@ ipcMain.handle('init-user', async (_event, userId: string) => {
storage.set('lastUserId', userId); // Save for offline mode storage.set('lastUserId', userId); // Save for offline mode
try { try {
const { getUserEncryptionKey, setUserEncryptionKey, hasUserEncryptionKey } = await import('./database/keyManager.js');
let encryptionKey: string | null = null; let encryptionKey: string | null = null;
if (!hasUserEncryptionKey(userId)) { if (!hasUserEncryptionKey(userId)) {
const { generateUserEncryptionKey } = await import('./database/encryption.js');
encryptionKey = generateUserEncryptionKey(userId); encryptionKey = generateUserEncryptionKey(userId);
console.log('[InitUser] Generated new encryption key for user'); console.log('[InitUser] Generated new encryption key for user');
@@ -214,8 +216,10 @@ ipcMain.handle('init-user', async (_event, userId: string) => {
}); });
ipcMain.on('login-success', async (_event, token: string) => { ipcMain.on('login-success', async (_event, token: string) => {
console.log('[Login] Received token, setting in storage');
const storage = getSecureStorage(); const storage = getSecureStorage();
storage.set('authToken', token); storage.set('authToken', token);
console.log('[Login] Token set in cache, has authToken:', storage.has('authToken'));
// Note: userId will be set later when we get user info from server // Note: userId will be set later when we get user info from server
if (loginWindow) { if (loginWindow) {
@@ -298,7 +302,6 @@ interface SyncUserData {
ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise<boolean> => { ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise<boolean> => {
try { try {
// Import User models dynamically to avoid circular dependencies
const { default: User } = await import('./database/models/User.js'); const { default: User } = await import('./database/models/User.js');
const { default: UserRepo } = await import('./database/repositories/user.repository.js'); const { default: UserRepo } = await import('./database/repositories/user.repository.js');
@@ -339,8 +342,6 @@ ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise<boole
*/ */
ipcMain.handle('generate-encryption-key', async (_event, userId: string) => { ipcMain.handle('generate-encryption-key', async (_event, userId: string) => {
try { try {
// Import encryption module dynamically
const { generateUserEncryptionKey } = await import('./database/encryption.js');
const key = generateUserEncryptionKey(userId); const key = generateUserEncryptionKey(userId);
return { success: true, key }; return { success: true, key };
} catch (error) { } catch (error) {
@@ -453,43 +454,8 @@ app.whenReady().then(() => {
console.log('[Startup] Has PIN:', hasPin); console.log('[Startup] Has PIN:', hasPin);
if (token) { if (token) {
// Token existe, ouvrir la fenêtre principale
createMainWindow(); createMainWindow();
} else if (offlineMode && hasPin && lastUserId) {
// Mode offline activé avec PIN, ouvrir login offline
console.log('[Startup] Opening offline login page');
loginWindow = new BrowserWindow({
width: 500,
height: 900,
resizable: false,
...(process.platform !== 'darwin' && { icon: iconPath }),
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
frame: true,
show: false,
});
if (isDev) {
const devPort = process.env.PORT || '4000';
loginWindow.loadURL(`http://localhost:${devPort}/login/offline`);
loginWindow.webContents.openDevTools();
} else {
loginWindow.loadURL('app://./login/offline/index.html');
}
loginWindow.once('ready-to-show', () => {
loginWindow?.show();
});
loginWindow.on('closed', () => {
loginWindow = null;
});
} else { } else {
// Pas de token ou pas de mode offline, ouvrir la fenêtre de login normale
createLoginWindow(); createLoginWindow();
} }

View File

@@ -0,0 +1,244 @@
import { safeStorage, app } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
/**
* SecureStorage - Replacement for electron-store using Electron's safeStorage API
*
* Uses OS-level encryption:
* - macOS: Keychain
* - Windows: DPAPI (Data Protection API)
* - Linux: gnome-libsecret/kwallet
*
* Security notes:
* - Protects against physical theft (when PC is off)
* - Protects against other users on same machine
* - Does NOT protect against malware running under same user
* - On Linux, check getStorageBackend() - if 'basic_text', encryption is weak
*/
class SecureStorage {
private storePath: string;
private cache: Map<string, string> = new Map();
private isLoaded: boolean = false;
private appReady: boolean = false;
constructor() {
const userDataPath = app.getPath('userData');
this.storePath = path.join(userDataPath, 'secure-config.json');
// Wait for app to be ready before using safeStorage
if (app.isReady()) {
this.appReady = true;
} else {
app.whenReady().then(() => {
this.appReady = true;
});
}
}
/**
* Ensure data is loaded from disk (lazy loading)
*/
private ensureLoaded(): void {
if (!this.isLoaded) {
this.loadFromDisk();
this.isLoaded = true;
}
}
/**
* Load encrypted data from disk into memory cache
*/
private loadFromDisk(): void {
try {
if (!fs.existsSync(this.storePath)) {
return;
}
const fileData = fs.readFileSync(this.storePath, 'utf-8');
const parsed = JSON.parse(fileData);
// Load all values and store in cache
for (const [key, storedValue] of Object.entries(parsed)) {
if (typeof storedValue !== 'string' || storedValue.length === 0) {
continue;
}
try {
if (storedValue.startsWith('encrypted:')) {
// Decrypt encrypted value
const encryptedBase64 = storedValue.substring('encrypted:'.length);
const buffer = Buffer.from(encryptedBase64, 'base64');
const decrypted = safeStorage.decryptString(buffer);
this.cache.set(key, decrypted);
} else if (storedValue.startsWith('plain:')) {
// Load plain value
const plainValue = storedValue.substring('plain:'.length);
this.cache.set(key, plainValue);
} else {
// Legacy format (try to decrypt)
try {
const buffer = Buffer.from(storedValue, 'base64');
const decrypted = safeStorage.decryptString(buffer);
this.cache.set(key, decrypted);
} catch {
// If decrypt fails, assume it's plain text
this.cache.set(key, storedValue);
}
}
} catch (error) {
console.error(`[SecureStorage] Failed to load key '${key}':`, error);
}
}
} catch (error) {
console.error('[SecureStorage] Failed to load from disk:', error);
}
}
/**
* Save encrypted data from memory cache to disk
*/
private saveToDisk(): void {
try {
const data: Record<string, string> = {};
// Check if encryption is available
const canEncrypt = safeStorage.isEncryptionAvailable();
for (const [key, value] of this.cache.entries()) {
if (canEncrypt && safeStorage.isEncryptionAvailable()) {
try {
if (value && typeof value === 'string') {
const buffer = safeStorage.encryptString(value);
if (buffer && buffer.length > 0) {
data[key] = `encrypted:${buffer.toString('base64')}`;
} else {
throw new Error(`Failed to encrypt key '${key}'`);
}
} else {
throw new Error(`Invalid value for key '${key}'`);
}
} catch (encryptError) {
console.error(`[SecureStorage] CRITICAL: Cannot encrypt key '${key}':`, encryptError);
throw encryptError;
}
} else {
throw new Error('Encryption not available - cannot save securely');
}
}
// Ensure directory exists
const dir = path.dirname(this.storePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.storePath, JSON.stringify(data, null, 2), 'utf-8');
} catch (error) {
console.error('[SecureStorage] Failed to save to disk:', error);
}
}
/**
* Get a value from secure storage
* @param key - Storage key
* @param defaultValue - Default value if key doesn't exist
* @returns Stored value or default
*/
get<T = string>(key: string, defaultValue: T | null = null): T | null {
this.ensureLoaded();
const value = this.cache.get(key);
if (value === undefined) {
return defaultValue;
}
// Try to parse as JSON for objects/arrays
try {
return JSON.parse(value) as T;
} catch {
// Return as-is if not JSON
return value as unknown as T;
}
}
/**
* Set a value in secure storage (kept in memory only)
* @param key - Storage key
* @param value - Value to store
*/
set(key: string, value: unknown): void {
this.ensureLoaded();
// Convert to string (JSON if object/array)
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
this.cache.set(key, stringValue);
}
/**
* Delete a value from secure storage (memory only)
* @param key - Storage key
*/
delete(key: string): void {
this.ensureLoaded();
this.cache.delete(key);
}
/**
* Check if a key exists in secure storage
* @param key - Storage key
* @returns True if key exists
*/
has(key: string): boolean {
this.ensureLoaded();
return this.cache.has(key);
}
/**
* Clear all data from secure storage (memory only)
*/
clear(): void {
this.cache.clear();
}
/**
* Manually save to disk (encrypted with safeStorage)
* Call this when you want to persist data
*/
save(): void {
this.saveToDisk();
}
/**
* Check if encryption is available
* @returns True if OS-level encryption is available
*/
isEncryptionAvailable(): boolean {
return safeStorage.isEncryptionAvailable();
}
}
// Store singleton in global scope to avoid multiple instances with dynamic imports
declare global {
var __secureStorageInstance: SecureStorage | undefined;
}
/**
* Get the SecureStorage singleton instance
* @returns SecureStorage instance
*/
export function getSecureStorage(): SecureStorage {
if (!global.__secureStorageInstance) {
global.__secureStorageInstance = new SecureStorage();
// Log encryption availability
if (!global.__secureStorageInstance.isEncryptionAvailable()) {
console.warn(
'[SecureStorage] WARNING: OS-level encryption is not available. ' +
'Data will still be stored but with reduced security.'
);
}
}
return global.__secureStorageInstance;
}
export default SecureStorage;

View File

@@ -833,7 +833,10 @@
"userNotFound": "User not found", "userNotFound": "User not found",
"authenticationError": "Error during authentication", "authenticationError": "Error during authentication",
"termsAcceptError": "Error accepting terms of service", "termsAcceptError": "Error accepting terms of service",
"lastChapterError": "Error retrieving last chapter" "lastChapterError": "Error retrieving last chapter",
"localDataError": "Unable to load local data",
"encryptionKeyError": "Encryption key not found",
"offlineModeError": "Error initializing offline mode"
} }
}, },
"shortStoryGenerator": { "shortStoryGenerator": {

View File

@@ -834,7 +834,10 @@
"userNotFound": "Utilisateur non trouvé", "userNotFound": "Utilisateur non trouvé",
"authenticationError": "Erreur pendant l'authentification", "authenticationError": "Erreur pendant l'authentification",
"termsAcceptError": "Erreur lors de l'acceptation des conditions d'utilisation", "termsAcceptError": "Erreur lors de l'acceptation des conditions d'utilisation",
"lastChapterError": "Erreur lors de la récupération du dernier chapitre" "lastChapterError": "Erreur lors de la récupération du dernier chapitre",
"localDataError": "Impossible de charger les données locales",
"encryptionKeyError": "Clé de chiffrement non trouvée",
"offlineModeError": "Erreur lors de l'initialisation du mode hors ligne"
} }
}, },
"shortStoryGenerator": { "shortStoryGenerator": {

View File

@@ -1,397 +0,0 @@
import System from '@/lib/models/System';
/**
* SyncService - Handles bidirectional synchronization between local DB and server
* Implements conflict resolution and retry logic
*/
export class SyncService {
private syncInterval: NodeJS.Timeout | null = null;
private isSyncing: boolean = false;
private isOnline: boolean = navigator.onLine;
private accessToken: string | null = null;
constructor() {
// Listen to online/offline events
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
this.isOnline = true;
this.onlineStatusChanged(true);
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.onlineStatusChanged(false);
});
}
}
/**
* Start automatic sync every interval
* @param intervalMs - Sync interval in milliseconds (default 30 seconds)
*/
startAutoSync(intervalMs: number = 30000): void {
if (this.syncInterval) {
clearInterval(this.syncInterval);
}
this.syncInterval = setInterval(() => {
if (this.isOnline && !this.isSyncing) {
this.sync();
}
}, intervalMs);
console.log(`Auto-sync started with interval: ${intervalMs}ms`);
}
/**
* Stop automatic sync
*/
stopAutoSync(): void {
if (this.syncInterval) {
clearInterval(this.syncInterval);
this.syncInterval = null;
}
console.log('Auto-sync stopped');
}
/**
* Set access token for API requests
*/
setAccessToken(token: string): void {
this.accessToken = token;
}
/**
* Check if currently online
*/
getOnlineStatus(): boolean {
return this.isOnline;
}
/**
* Force set online/offline status (for manual toggle)
*/
setOnlineStatus(online: boolean): void {
this.isOnline = online;
this.onlineStatusChanged(online);
}
/**
* Handle online/offline status change
*/
private onlineStatusChanged(online: boolean): void {
console.log(`Network status changed: ${online ? 'ONLINE' : 'OFFLINE'}`);
if (online && !this.isSyncing) {
// When going online, trigger immediate sync
setTimeout(() => this.sync(), 1000);
}
// Notify listeners (will be implemented in OfflineContext)
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('offline-status-changed', { detail: { online } }));
}
}
/**
* Perform full bidirectional sync
*/
async sync(): Promise<SyncResult> {
if (!this.isOnline) {
return {
success: false,
error: 'Cannot sync while offline',
pushedChanges: 0,
pulledChanges: 0
};
}
if (this.isSyncing) {
return {
success: false,
error: 'Sync already in progress',
pushedChanges: 0,
pulledChanges: 0
};
}
this.isSyncing = true;
console.log('Starting sync...');
try {
// Check Electron API availability
if (typeof window === 'undefined' || !(window as any).electron) {
throw new Error('Electron API not available');
}
// Step 1: Push local changes to server
const pushedChanges = await this.pushChanges();
// Step 2: Pull server changes to local
const pulledChanges = await this.pullChanges();
console.log(`Sync completed: pushed ${pushedChanges}, pulled ${pulledChanges} changes`);
// Dispatch sync completion event
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('sync-completed', {
detail: { pushedChanges, pulledChanges }
}));
}
return {
success: true,
pushedChanges,
pulledChanges
};
} catch (error) {
console.error('Sync failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
pushedChanges: 0,
pulledChanges: 0
};
} finally {
this.isSyncing = false;
}
}
/**
* Push local changes to server
*/
private async pushChanges(): Promise<number> {
if (!this.accessToken) {
console.warn('No access token available for sync');
return 0;
}
// Get pending changes via Electron IPC
const result = await (window as any).electron.dbGetPendingChanges(50);
if (!result.success) {
console.error('Failed to get pending changes:', result.error);
return 0;
}
const pendingChanges = result.data || [];
if (pendingChanges.length === 0) {
return 0;
}
console.log(`Pushing ${pendingChanges.length} pending changes...`);
let successCount = 0;
const syncedIds: number[] = [];
for (const change of pendingChanges) {
try {
const success = await this.pushSingleChange(change);
if (success) {
successCount++;
syncedIds.push(change.id);
}
} catch (error) {
console.error(`Failed to push change ${change.id}:`, error);
// Continue with next change
}
}
// Mark successfully synced changes via IPC
if (syncedIds.length > 0) {
// TODO: Add IPC handler for marking synced
console.log('Synced changes:', syncedIds);
}
return successCount;
}
/**
* Push a single change to server
*/
private async pushSingleChange(change: any): Promise<boolean> {
if (!this.accessToken) return false;
const { table_name, operation, record_id, data } = change;
let url = '';
let method: 'POST' | 'PUT' | 'DELETE' = 'POST';
// Map table names to API endpoints
switch (table_name) {
case 'erit_books':
url = operation === 'DELETE' ? `books/${record_id}` : 'books';
method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT';
break;
case 'book_chapters':
url = operation === 'DELETE' ? `chapters/${record_id}` : 'chapters';
method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT';
break;
case 'book_characters':
url = operation === 'DELETE' ? `characters/${record_id}` : 'characters';
method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT';
break;
case 'ai_conversations':
url = operation === 'DELETE' ? `ai/conversations/${record_id}` : 'ai/conversations';
method = operation === 'DELETE' ? 'DELETE' : operation === 'INSERT' ? 'POST' : 'PUT';
break;
default:
console.warn(`Unknown table for sync: ${table_name}`);
return false;
}
try {
if (method === 'DELETE') {
await System.authDeleteToServer(url, {}, this.accessToken);
} else if (method === 'PUT') {
await System.authPutToServer(url, JSON.parse(data), this.accessToken);
} else {
await System.authPostToServer(url, JSON.parse(data), this.accessToken);
}
return true;
} catch (error) {
console.error(`Failed to sync ${table_name} ${operation}:`, error);
return false;
}
}
/**
* Pull changes from server
*/
private async pullChanges(): Promise<number> {
if (!this.accessToken) {
console.warn('No access token available for sync');
return 0;
}
// Get sync status via Electron IPC
const statusResult = await (window as any).electron.dbGetSyncStatus();
if (!statusResult.success) {
console.error('Failed to get sync status:', statusResult.error);
return 0;
}
const syncStatus = statusResult.data || [];
let totalPulled = 0;
// Pull updates for each table
for (const status of syncStatus) {
try {
const count = await this.pullTableChanges(status.table, status.lastSync);
totalPulled += count;
} catch (error) {
console.error(`Failed to pull changes for ${status.table}:`, error);
}
}
return totalPulled;
}
/**
* Pull changes for a specific table
*/
private async pullTableChanges(tableName: string, lastSync: number): Promise<number> {
if (!this.accessToken) return 0;
// Map table names to API endpoints
let endpoint = '';
switch (tableName) {
case 'erit_books':
endpoint = 'books';
break;
case 'book_chapters':
endpoint = 'chapters';
break;
case 'book_characters':
endpoint = 'characters';
break;
case 'ai_conversations':
endpoint = 'ai/conversations';
break;
default:
return 0;
}
try {
// Request changes since last sync
const response = await System.authGetQueryToServer<any>(
`${endpoint}/sync?since=${lastSync}`,
this.accessToken
);
if (!response || !response.data) {
return 0;
}
const changes = Array.isArray(response.data) ? response.data : [response.data];
// Apply changes to local database
// This would require implementing merge logic for each table
// For now, we'll just log the changes
console.log(`Pulled ${changes.length} changes for ${tableName}`);
// Update last sync time via IPC
// TODO: Add IPC handler for updating last sync
return changes.length;
} catch (error) {
console.error(`Failed to pull changes for ${tableName}:`, error);
return 0;
}
}
/**
* Resolve conflicts between local and server data
* Strategy: Server wins (can be customized)
*/
private resolveConflict(localData: any, serverData: any): any {
// Simple strategy: server wins
// TODO: Implement more sophisticated conflict resolution
console.warn('Conflict detected, using server data');
return serverData;
}
/**
* Get sync progress
*/
getSyncProgress(): SyncProgress {
// This will be called synchronously, so we return cached state
// The actual sync status is updated via events
return {
isSyncing: this.isSyncing,
pendingChanges: 0, // Will be updated via IPC
isOnline: this.isOnline
};
}
}
export interface SyncResult {
success: boolean;
error?: string;
pushedChanges: number;
pulledChanges: number;
}
export interface SyncProgress {
isSyncing: boolean;
pendingChanges: number;
isOnline: boolean;
tables?: { table: string; lastSync: number; pending: number }[];
}
// Singleton instance
let syncServiceInstance: SyncService | null = null;
export function getSyncService(): SyncService {
if (!syncServiceInstance) {
syncServiceInstance = new SyncService();
}
return syncServiceInstance;
}

View File

@@ -0,0 +1,106 @@
/**
* Database Error Handler for Frontend
* Handles errors from Electron IPC calls
*/
export interface SerializedError {
name: string;
message: string;
messageFr: string;
messageEn: string;
statusCode: number;
stack?: string;
}
/**
* Check if error is a serialized database error
*/
export function isDbError(error: unknown): error is SerializedError {
return (
typeof error === 'object' &&
error !== null &&
'name' in error &&
'messageFr' in error &&
'messageEn' in error &&
'statusCode' in error
);
}
/**
* Get error message based on current language
*/
export function getErrorMessage(error: SerializedError, lang: 'fr' | 'en' = 'fr'): string {
return lang === 'fr' ? error.messageFr : error.messageEn;
}
/**
* Handle database operation with error catching
* Use this to wrap all IPC calls
*/
export async function handleDbOperation<T>(
operation: () => Promise<T>,
onError?: (error: SerializedError) => void,
lang: 'fr' | 'en' = 'fr'
): Promise<T> {
try {
return await operation();
} catch (error: unknown) {
if (isDbError(error)) {
const errorMessage = getErrorMessage(error, lang);
console.error(`[DB Error ${error.statusCode}]: ${errorMessage}`);
if (onError) {
onError(error);
} else {
// Default: throw with localized message
throw new Error(errorMessage);
}
}
// Not a database error, rethrow as-is
throw error;
}
}
/**
* React Hook for database operations
* Example usage in a React component:
*
* const { data, error, loading, execute } = useDbOperation();
*
* const loadBooks = async () => {
* await execute(() => window.electron.invoke('db:book:getAll'));
* };
*/
export function useDbOperation<T>() {
const [data, setData] = React.useState<T | null>(null);
const [error, setError] = React.useState<SerializedError | null>(null);
const [loading, setLoading] = React.useState<boolean>(false);
const execute = async (
operation: () => Promise<T>,
lang: 'fr' | 'en' = 'fr'
): Promise<T | null> => {
setLoading(true);
setError(null);
try {
const result = await handleDbOperation(
operation,
(err) => setError(err),
lang
);
setData(result);
setLoading(false);
return result;
} catch (err) {
setLoading(false);
return null;
}
};
return { data, error, loading, execute };
}
// For non-React usage
import React from 'react';