diff --git a/app/page.tsx b/app/page.tsx index 6bcd4db..9e9e04c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -34,6 +34,7 @@ import {AIUsageContext} from "@/context/AIUsageContext"; import OfflineProvider from "@/context/OfflineProvider"; import OfflineContext from "@/context/OfflineContext"; import OfflinePinSetup from "@/components/offline/OfflinePinSetup"; +import OfflinePinVerify from "@/components/offline/OfflinePinVerify"; const messagesMap = { fr: frMessages, @@ -72,6 +73,7 @@ function ScribeContent() { const [isTermsAccepted, setIsTermsAccepted] = useState(false); const [homeStepsGuide, setHomeStepsGuide] = useState(false); const [showPinSetup, setShowPinSetup] = useState(false); + const [showPinVerify, setShowPinVerify] = useState(false); const homeSteps: GuideStep[] = [ { @@ -188,6 +190,52 @@ function ScribeContent() { checkPinSetup(); }, [session.isConnected]); // Run when session connection status changes + async function handlePinVerifySuccess(userId: string): Promise { + 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 { try { const response: boolean = await System.authPostToServer('logs/tour', { @@ -211,7 +259,6 @@ function ScribeContent() { } async function checkAuthentification(): Promise { - // Essayer de récupérer le token depuis electron-store en priorité let token: string | null = null; 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) { try { const user: UserProps = await System.authGetQueryToServer('user/infos', token, locale); @@ -239,6 +281,8 @@ function ScribeContent() { } return; } + + console.log('user: ' , user); // Initialize user in Electron (sets userId and creates/gets encryption key) if (window.electron && user.id) { @@ -306,35 +350,19 @@ function ScribeContent() { } } catch (e: unknown) { console.log('[Auth] Server error, checking offline mode...'); - - // Check if we can use offline mode if (window.electron) { try { - // Check offline mode status - const offlineStatus = await window.electron.invoke('offline:mode:get'); - - // If offline mode is enabled and we have local data - if (offlineStatus.enabled && offlineStatus.hasPin) { - console.log('[Auth] Offline mode enabled, loading local user data'); - - // Try to load user from local DB - try { - const localUser = await window.electron.invoke('db:user:info'); - if (localUser && localUser.success) { - // Use local data - 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); + const offlineStatus = await window.electron.offlineModeGet(); + + if (offlineStatus.hasPin && offlineStatus.lastUserId) { + console.log('[Auth] Server unreachable but PIN configured, showing PIN verification'); + setShowPinVerify(true); + setIsLoading(false); + return; + } else { + if (window.electron) { + await window.electron.removeToken(); + window.electron.logout(); } } } 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) { errorMessage(e.message); } else { errorMessage(t("homePage.errors.authenticationError")); } - // Token invalide/erreur auth, supprimer et logout - if (window.electron) { - await window.electron.removeToken(); - window.electron.logout(); - } } } 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) { + 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(); } } @@ -475,6 +508,16 @@ function ScribeContent() { /> ) } + { + showPinVerify && window.electron && ( + { + //window.electron.logout(); + }} + /> + ) + } diff --git a/context/AlertProvider.tsx b/context/AlertProvider.tsx new file mode 100644 index 0000000..2abc819 --- /dev/null +++ b/context/AlertProvider.tsx @@ -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 = createContext({ + 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>] = useState([]); + + 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 ( + + {children} + + + ); +} diff --git a/context/BookContext.ts b/context/BookContext.ts new file mode 100755 index 0000000..980090e --- /dev/null +++ b/context/BookContext.ts @@ -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> +} + +export const BookContext: Context = createContext({ + book: null, + setBook: () => { + } +}) diff --git a/context/ChapterContext.ts b/context/ChapterContext.ts new file mode 100755 index 0000000..2827bf4 --- /dev/null +++ b/context/ChapterContext.ts @@ -0,0 +1,14 @@ +import {ChapterProps} from "@/lib/models/Chapter"; +import {createContext, Dispatch, SetStateAction} from "react"; + + +export interface ChapterContextProps { + chapter: ChapterProps | undefined, + setChapter: Dispatch> +} + +export const ChapterContext = createContext({ + chapter: undefined, + setChapter: () => { + } +}) diff --git a/context/EditorContext.ts b/context/EditorContext.ts new file mode 100755 index 0000000..677c358 --- /dev/null +++ b/context/EditorContext.ts @@ -0,0 +1,10 @@ +import {createContext} from "react"; +import {Editor} from "@tiptap/core"; + +export interface EditorContextProps { + editor: Editor | null +} + +export const EditorContext = createContext({ + editor: null +}); diff --git a/context/LangContext.ts b/context/LangContext.ts new file mode 100644 index 0000000..b04bca3 --- /dev/null +++ b/context/LangContext.ts @@ -0,0 +1,14 @@ +import {Context, createContext, Dispatch, SetStateAction} from "react"; + +export type SupportedLocale = 'fr' | 'en'; + +export interface LangContextProps { + lang: SupportedLocale; + setLang: Dispatch>; +} + +export const LangContext: Context = createContext({ + lang: 'fr', + setLang: (): void => { + } +}); \ No newline at end of file diff --git a/context/OfflineContext.ts b/context/OfflineContext.ts new file mode 100644 index 0000000..77832f5 --- /dev/null +++ b/context/OfflineContext.ts @@ -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>; + toggleOfflineMode: () => void; + initializeDatabase: (userId: string, encryptionKey?: string) => Promise; + isCurrentlyOffline: () => boolean; +} + +export const defaultOfflineMode: OfflineMode = { + isManuallyOffline: false, + isNetworkOnline: typeof navigator !== 'undefined' ? navigator.onLine : true, + isOffline: false, + isDatabaseInitialized: false, + error: null +}; + +const OfflineContext = createContext({ + offlineMode: defaultOfflineMode, + setOfflineMode: () => {}, + toggleOfflineMode: () => {}, + initializeDatabase: async () => false, + isCurrentlyOffline: () => false +}); + +export default OfflineContext; diff --git a/context/OfflineProvider.tsx b/context/OfflineProvider.tsx new file mode 100644 index 0000000..6367385 --- /dev/null +++ b/context/OfflineProvider.tsx @@ -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(defaultOfflineMode); + + const initializeDatabase = useCallback(async (userId: string, encryptionKey?: string): Promise => { + 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 ( + + {children} + + ); +} diff --git a/context/SessionContext.ts b/context/SessionContext.ts new file mode 100755 index 0000000..66d2a1b --- /dev/null +++ b/context/SessionContext.ts @@ -0,0 +1,17 @@ +import {SessionProps} from "@/lib/models/Session"; +import {Context, createContext, Dispatch, SetStateAction} from "react"; + +export interface SessionContextProps { + session: SessionProps; + setSession: Dispatch>; +} + +export const SessionContext: Context = createContext({ + session: { + isConnected: false, + accessToken: "", + user: null, + }, + setSession: () => { + }, +}) diff --git a/context/SettingBookContext.ts b/context/SettingBookContext.ts new file mode 100755 index 0000000..cebf408 --- /dev/null +++ b/context/SettingBookContext.ts @@ -0,0 +1,12 @@ +import {Context, createContext, Dispatch, SetStateAction} from "react"; + +export interface SettingBookContextProps { + bookSettingId: string; + setBookSettingId: Dispatch> +} + +export const SettingBookContext: Context = createContext({ + bookSettingId: '', + setBookSettingId: (): void => { + } +}) diff --git a/context/UserContext.ts b/context/UserContext.ts new file mode 100755 index 0000000..3b8284b --- /dev/null +++ b/context/UserContext.ts @@ -0,0 +1,25 @@ +import {UserProps} from "@/lib/models/User"; +import {Context, createContext} from "react"; + +export interface UserContextProps { + user: UserProps +} + +export const UserContext: Context = createContext({ + user: { + id: '', + name: '', + lastName: '', + username: '', + writingLang: 0, + writingLevel: 0, + ritePoints: 0, + groupId: 0, + aiUsage: 0, + apiKeys: { + openai: false, + anthropic: false, + gemini: false, + } + } +}) diff --git a/context/WorldContext.ts b/context/WorldContext.ts new file mode 100755 index 0000000..7e91021 --- /dev/null +++ b/context/WorldContext.ts @@ -0,0 +1,10 @@ +import {WorldProps} from "@/lib/models/World"; +import {createContext, Dispatch, SetStateAction} from "react"; + +export interface WorldContextProps { + worlds: WorldProps[]; + setWorlds: Dispatch>; + selectedWorldIndex: number; +} + +export const WorldContext = createContext({} as WorldContextProps); diff --git a/electron.d.ts b/electron.d.ts index 4dc040a..19c2b4a 100644 --- a/electron.d.ts +++ b/electron.d.ts @@ -41,7 +41,7 @@ export interface IElectronAPI { offlinePinSet: (pin: string) => Promise<{ success: boolean; error?: string }>; offlinePinVerify: (pin: string) => Promise<{ success: boolean; userId?: string; error?: string }>; 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 }>; } diff --git a/electron/ipc/offline.ipc.ts b/electron/ipc/offline.ipc.ts new file mode 100644 index 0000000..e30c9c4 --- /dev/null +++ b/electron/ipc/offline.ipc.ts @@ -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('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('lastUserId'); + if (!lastUserId) { + return { success: false, error: 'No offline account found' }; + } + + const hashedPin = storage.get(`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('offlineMode', false); + const syncInterval = storage.get('syncInterval', 30); + const lastUserId = storage.get('lastUserId'); + const hasPin = lastUserId ? !!storage.get(`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('lastSync'); + const syncInterval = storage.get('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'); \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index b02efb5..f5c71a0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -5,6 +5,8 @@ import { fileURLToPath } from 'url'; import * as fs from 'fs'; import { getDatabaseService } from './database/database.service.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/book.ipc.js'; @@ -120,7 +122,10 @@ function createMainWindow(): void { // IPC Handlers pour la gestion du token (OS-encrypted storage) ipcMain.handle('get-token', () => { 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) => { @@ -156,12 +161,9 @@ ipcMain.handle('init-user', async (_event, userId: string) => { storage.set('lastUserId', userId); // Save for offline mode try { - const { getUserEncryptionKey, setUserEncryptionKey, hasUserEncryptionKey } = await import('./database/keyManager.js'); - let encryptionKey: string | null = null; if (!hasUserEncryptionKey(userId)) { - const { generateUserEncryptionKey } = await import('./database/encryption.js'); encryptionKey = generateUserEncryptionKey(userId); 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) => { + console.log('[Login] Received token, setting in storage'); const storage = getSecureStorage(); 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 if (loginWindow) { @@ -298,7 +302,6 @@ interface SyncUserData { ipcMain.handle('db:user:sync', async (_event, data: SyncUserData): Promise => { try { - // Import User models dynamically to avoid circular dependencies const { default: User } = await import('./database/models/User.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 { try { - // Import encryption module dynamically - const { generateUserEncryptionKey } = await import('./database/encryption.js'); const key = generateUserEncryptionKey(userId); return { success: true, key }; } catch (error) { @@ -453,43 +454,8 @@ app.whenReady().then(() => { console.log('[Startup] Has PIN:', hasPin); if (token) { - // Token existe, ouvrir la fenêtre principale 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 { - // Pas de token ou pas de mode offline, ouvrir la fenêtre de login normale createLoginWindow(); } diff --git a/electron/storage/SecureStorage.ts b/electron/storage/SecureStorage.ts new file mode 100644 index 0000000..a262765 --- /dev/null +++ b/electron/storage/SecureStorage.ts @@ -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 = 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 = {}; + + // 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(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; diff --git a/lib/locales/en.json b/lib/locales/en.json index b908594..2ffb4b6 100644 --- a/lib/locales/en.json +++ b/lib/locales/en.json @@ -833,7 +833,10 @@ "userNotFound": "User not found", "authenticationError": "Error during authentication", "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": { diff --git a/lib/locales/fr.json b/lib/locales/fr.json index 457f77c..fe33797 100644 --- a/lib/locales/fr.json +++ b/lib/locales/fr.json @@ -834,7 +834,10 @@ "userNotFound": "Utilisateur non trouvé", "authenticationError": "Erreur pendant l'authentification", "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": { diff --git a/lib/services/sync.service.ts b/lib/services/sync.service.ts deleted file mode 100644 index 230d7d2..0000000 --- a/lib/services/sync.service.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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( - `${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; -} diff --git a/lib/utils/db-error-handler.ts b/lib/utils/db-error-handler.ts new file mode 100644 index 0000000..06ac00a --- /dev/null +++ b/lib/utils/db-error-handler.ts @@ -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( + operation: () => Promise, + onError?: (error: SerializedError) => void, + lang: 'fr' | 'en' = 'fr' +): Promise { + 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() { + const [data, setData] = React.useState(null); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(false); + + const execute = async ( + operation: () => Promise, + lang: 'fr' | 'en' = 'fr' + ): Promise => { + 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';