From f85c2d22698424c52ac22ac1f5f0601eda076984 Mon Sep 17 00:00:00 2001 From: natreex Date: Wed, 19 Nov 2025 19:58:55 -0500 Subject: [PATCH] Add offline mode support with PIN configuration and management - Introduce `OfflinePinSetup` component for users to configure secure offline access. - Add new `AIUsageContext` and extend `OfflineProvider` for offline-related state management. - Implement offline login functionality in `electron/main.ts` with PIN verification and fallback support. - Enhance IPC handlers to manage offline mode data, PIN setup, and synchronization. - Update localization files (`en.json`, `fr.json`) with offline mode and PIN-related strings. - Add `bcrypt` and `@types/bcrypt` dependencies for secure PIN hashing and validation. - Refactor login and session management to handle offline mode scenarios with improved error handling and flow. --- app/page.tsx | 94 +++++++++++++++++++++++++++++++++++++++ context/AIUsageContext.ts | 17 +++++++ context/AlertContext.ts | 4 ++ electron.d.ts | 7 +++ electron/main.ts | 50 ++++++++++++++++++--- electron/preload.ts | 8 ++++ lib/locales/en.json | 40 +++++++++++++++++ lib/locales/fr.json | 40 +++++++++++++++++ package-lock.json | 37 +++++++++++++-- package.json | 2 + 10 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 context/AIUsageContext.ts create mode 100644 context/AlertContext.ts diff --git a/app/page.tsx b/app/page.tsx index 1ffc796..6bcd4db 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -33,6 +33,7 @@ import {LangContext} from "@/context/LangContext"; import {AIUsageContext} from "@/context/AIUsageContext"; import OfflineProvider from "@/context/OfflineProvider"; import OfflineContext from "@/context/OfflineContext"; +import OfflinePinSetup from "@/components/offline/OfflinePinSetup"; const messagesMap = { fr: frMessages, @@ -70,6 +71,7 @@ function ScribeContent() { const [isTermsAccepted, setIsTermsAccepted] = useState(false); const [homeStepsGuide, setHomeStepsGuide] = useState(false); + const [showPinSetup, setShowPinSetup] = useState(false); const homeSteps: GuideStep[] = [ { @@ -160,6 +162,31 @@ function ScribeContent() { getLastChapter().then(); } }, [currentBook]); + + // Check for PIN setup after successful connection + useEffect(() => { + async function checkPinSetup() { + if (session.isConnected && window.electron) { + try { + const offlineStatus = await window.electron.offlineModeGet(); + console.log('[Page] Session connected, offline status:', offlineStatus); + + if (!offlineStatus.hasPin) { + console.log('[Page] No PIN configured, will show setup dialog'); + // Show PIN setup dialog after a short delay + setTimeout(() => { + console.log('[Page] Showing PIN setup dialog'); + setShowPinSetup(true); + }, 2000); // 2 seconds delay after page load + } + } catch (error) { + console.error('[Page] Error checking offline mode:', error); + } + } + } + + checkPinSetup(); + }, [session.isConnected]); // Run when session connection status changes async function handleHomeTour(): Promise { try { @@ -221,6 +248,23 @@ function ScribeContent() { console.error('[Page] Failed to initialize user:', initResult.error); } else { console.log('[Page] User initialized successfully, key created:', initResult.keyCreated); + + // Check if PIN is configured for offline mode + try { + const offlineStatus = await window.electron.offlineModeGet(); + console.log('[Page] Offline status:', offlineStatus); + if (!offlineStatus.hasPin) { + // First login or no PIN configured yet + // Show PIN setup dialog after a short delay + console.log('[Page] No PIN configured, will show setup dialog'); + setTimeout(() => { + console.log('[Page] Showing PIN setup dialog'); + setShowPinSetup(true); + }, 2000); // 2 seconds delay after successful login + } + } catch (error) { + console.error('[Page] Error checking offline mode:', error); + } } } catch (error) { console.error('[Page] Error initializing user:', error); @@ -261,6 +305,44 @@ 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); + } + } + } catch (offlineError) { + console.error('[Auth] Error checking offline mode:', offlineError); + } + } + + // If not in offline mode or failed to load local data, show error and logout if (e instanceof Error) { errorMessage(e.message); } else { @@ -381,6 +463,18 @@ function ScribeContent() { { !isTermsAccepted && } + { + showPinSetup && window.electron && ( + setShowPinSetup(false)} + onSuccess={() => { + setShowPinSetup(false); + console.log('[Page] PIN configured successfully'); + }} + /> + ) + } diff --git a/context/AIUsageContext.ts b/context/AIUsageContext.ts new file mode 100644 index 0000000..5c09f65 --- /dev/null +++ b/context/AIUsageContext.ts @@ -0,0 +1,17 @@ +import {Context, createContext, Dispatch, SetStateAction} from "react"; + +export interface AIUsageContextProps { + totalCredits: number; + totalPrice: number; + setTotalCredits: Dispatch>; + setTotalPrice: Dispatch>; +} + +export const AIUsageContext: Context = createContext({ + totalCredits: 0, + totalPrice: 0, + setTotalCredits: (): void => { + }, + setTotalPrice: (): void => { + } +}) \ No newline at end of file diff --git a/context/AlertContext.ts b/context/AlertContext.ts new file mode 100644 index 0000000..abf1d78 --- /dev/null +++ b/context/AlertContext.ts @@ -0,0 +1,4 @@ +// This file is kept for backwards compatibility +// It now re-exports from the new AlertProvider system +export {AlertContext, AlertProvider} from './AlertProvider'; +export type {AlertContextProps} from './AlertProvider'; diff --git a/electron.d.ts b/electron.d.ts index ffeb6cb..4dc040a 100644 --- a/electron.d.ts +++ b/electron.d.ts @@ -36,6 +36,13 @@ export interface IElectronAPI { // Database initialization (shortcut for convenience) dbInitialize: (userId: string, encryptionKey: string) => Promise; + + // Offline mode management + 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 }>; + offlineSyncCheck: () => Promise<{ shouldSync: boolean; daysSinceSync?: number; syncInterval?: number }>; } declare global { diff --git a/electron/main.ts b/electron/main.ts index ce91b94..b02efb5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -12,6 +12,7 @@ import './ipc/user.ipc.js'; import './ipc/chapter.ipc.js'; import './ipc/character.ipc.js'; import './ipc/location.ipc.js'; +import './ipc/offline.ipc.js'; // Fix pour __dirname en ES modules const __filename = fileURLToPath(import.meta.url); @@ -152,6 +153,7 @@ ipcMain.handle('init-user', async (_event, userId: string) => { const storage = getSecureStorage(); storage.set('userId', userId); + storage.set('lastUserId', userId); // Save for offline mode try { const { getUserEncryptionKey, setUserEncryptionKey, hasUserEncryptionKey } = await import('./database/keyManager.js'); @@ -192,11 +194,11 @@ ipcMain.handle('init-user', async (_event, userId: string) => { } } - // Save userId to disk now that we have everything + // Save userId and lastUserId to disk now that we have everything // This is the ONLY additional save after login if (safeStorage.isEncryptionAvailable()) { storage.save(); - console.log('[InitUser] User ID saved to disk (encrypted)'); + console.log('[InitUser] User ID and lastUserId saved to disk (encrypted)'); } else { console.error('[InitUser] WARNING: Cannot save user ID - encryption not available'); } @@ -441,17 +443,53 @@ app.whenReady().then(() => { const storage = getSecureStorage(); const token = storage.get('authToken'); const userId = storage.get('userId'); + const offlineMode = storage.get('offlineMode', false); + const lastUserId = storage.get('lastUserId'); + const hasPin = !!storage.get(`pin-${lastUserId}`); + console.log('[Startup] Token exists:', !!token); console.log('[Startup] UserId exists:', !!userId); - if (token) { - console.log('[Startup] Token value:', token.substring(0, 20) + '...'); - } + console.log('[Startup] Offline mode:', offlineMode); + 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, ouvrir la fenêtre de login + // Pas de token ou pas de mode offline, ouvrir la fenêtre de login normale createLoginWindow(); } diff --git a/electron/preload.ts b/electron/preload.ts index 383067c..c1469c3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -34,4 +34,12 @@ contextBridge.exposeInMainWorld('electron', { // Database initialization (shortcut for convenience) dbInitialize: (userId: string, encryptionKey: string) => ipcRenderer.invoke('db-initialize', userId, encryptionKey), + + // Offline mode management + offlinePinSet: (pin: string) => ipcRenderer.invoke('offline:pin:set', { pin }), + offlinePinVerify: (pin: string) => ipcRenderer.invoke('offline:pin:verify', { pin }), + offlineModeSet: (enabled: boolean, syncInterval?: number) => + ipcRenderer.invoke('offline:mode:set', { enabled, syncInterval }), + offlineModeGet: () => ipcRenderer.invoke('offline:mode:get'), + offlineSyncCheck: () => ipcRenderer.invoke('offline:sync:check'), }); diff --git a/lib/locales/en.json b/lib/locales/en.json index 1565235..b908594 100644 --- a/lib/locales/en.json +++ b/lib/locales/en.json @@ -891,5 +891,45 @@ "message": "Sorry! This feature is reserved for advanced members. You must have a higher subscription or the advanced AI activation option.", "close": "Close" } + }, + "offline": { + "mode": { + "title": "Offline Mode", + "backToOnline": "Back to online login" + }, + "pin": { + "setup": { + "title": "Configure PIN", + "titleFirstLogin": "Secure your offline access", + "subtitle": "Protect your local data", + "description": "This PIN will allow you to access your works even without an internet connection", + "pinLabel": "PIN Code (4-8 digits)", + "confirmPinLabel": "Confirm PIN", + "laterButton": "Later", + "configureButton": "Configure PIN", + "configuringButton": "Configuring...", + "footer": "Your PIN is stored securely on your device" + }, + "verify": { + "title": "Offline Mode", + "subtitle": "Enter your PIN to access your local works", + "placeholder": "Enter your PIN", + "enterPin": "Please enter your PIN", + "incorrect": "Incorrect PIN", + "tooManyAttempts": "Too many failed attempts. Please reconnect online.", + "error": "Error verifying PIN", + "cancelButton": "Cancel", + "unlockButton": "Unlock", + "verifyingButton": "Verifying...", + "attemptsRemaining": "{{count}} attempt(s) remaining" + }, + "errors": { + "tooShort": "PIN must be at least 4 digits", + "tooLong": "PIN cannot exceed 8 digits", + "digitsOnly": "PIN must contain only digits", + "mismatch": "PINs do not match", + "setupFailed": "Error configuring PIN" + } + } } } \ No newline at end of file diff --git a/lib/locales/fr.json b/lib/locales/fr.json index 3ddb77f..457f77c 100644 --- a/lib/locales/fr.json +++ b/lib/locales/fr.json @@ -892,5 +892,45 @@ "message": "Désolé! Cette fonctionnalité est réservée aux membres avancés. Tu dois avoir un abonnement supérieur ou l’option activation IA avancée.", "close": "Fermer" } + }, + "offline": { + "mode": { + "title": "Mode Hors Ligne", + "backToOnline": "Retour à la connexion en ligne" + }, + "pin": { + "setup": { + "title": "Configurer le PIN", + "titleFirstLogin": "Sécurisez votre accès hors ligne", + "subtitle": "Protégez vos données locales", + "description": "Ce PIN vous permettra d'accéder à vos œuvres même sans connexion internet", + "pinLabel": "Code PIN (4-8 chiffres)", + "confirmPinLabel": "Confirmer le PIN", + "laterButton": "Plus tard", + "configureButton": "Configurer le PIN", + "configuringButton": "Configuration...", + "footer": "Votre PIN est stocké de manière sécurisée sur votre appareil" + }, + "verify": { + "title": "Mode Hors Ligne", + "subtitle": "Entrez votre PIN pour accéder à vos œuvres locales", + "placeholder": "Entrez votre PIN", + "enterPin": "Veuillez entrer votre PIN", + "incorrect": "PIN incorrect", + "tooManyAttempts": "Trop de tentatives échouées. Veuillez vous reconnecter en ligne.", + "error": "Erreur lors de la vérification du PIN", + "cancelButton": "Annuler", + "unlockButton": "Déverrouiller", + "verifyingButton": "Vérification...", + "attemptsRemaining": "{{count}} tentative(s) restante(s)" + }, + "errors": { + "tooShort": "Le PIN doit contenir au moins 4 chiffres", + "tooLong": "Le PIN ne peut pas dépasser 8 chiffres", + "digitsOnly": "Le PIN doit contenir uniquement des chiffres", + "mismatch": "Les codes PIN ne correspondent pas", + "setupFailed": "Erreur lors de la configuration du PIN" + } + } } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3711436..ca451c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,9 +23,11 @@ "@tiptap/extension-underline": "^3.10.7", "@tiptap/react": "^3.10.7", "@tiptap/starter-kit": "^3.10.7", + "@types/bcrypt": "^6.0.0", "antd": "^5.28.1", "autoprefixer": "^10.4.22", "axios": "^1.13.2", + "bcrypt": "^6.0.0", "i18next": "^25.6.2", "js-cookie": "^3.0.5", "next": "^16.0.3", @@ -4220,6 +4222,15 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -4328,7 +4339,6 @@ "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -4895,6 +4905,29 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bcrypt/node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -8702,7 +8735,6 @@ "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "dev": true, "license": "MIT", "bin": { "node-gyp-build": "bin.js", @@ -11229,7 +11261,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unique-filename": { diff --git a/package.json b/package.json index 974649f..799a355 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,11 @@ "@tiptap/extension-underline": "^3.10.7", "@tiptap/react": "^3.10.7", "@tiptap/starter-kit": "^3.10.7", + "@types/bcrypt": "^6.0.0", "antd": "^5.28.1", "autoprefixer": "^10.4.22", "axios": "^1.13.2", + "bcrypt": "^6.0.0", "i18next": "^25.6.2", "js-cookie": "^3.0.5", "next": "^16.0.3",