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.
This commit is contained in:
natreex
2025-11-19 19:58:55 -05:00
parent dde4683c38
commit f85c2d2269
10 changed files with 290 additions and 9 deletions

View File

@@ -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<boolean>(false);
const [homeStepsGuide, setHomeStepsGuide] = useState<boolean>(false);
const [showPinSetup, setShowPinSetup] = useState<boolean>(false);
const homeSteps: GuideStep[] = [
{
@@ -161,6 +163,31 @@ function ScribeContent() {
}
}, [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<void> {
try {
const response: boolean = await System.authPostToServer<boolean>('logs/tour', {
@@ -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 && <TermsOfUse onAccept={handleTermsAcceptance}/>
}
{
showPinSetup && window.electron && (
<OfflinePinSetup
showOnFirstLogin={true}
onClose={() => setShowPinSetup(false)}
onSuccess={() => {
setShowPinSetup(false);
console.log('[Page] PIN configured successfully');
}}
/>
)
}
</AIUsageContext.Provider>
</ChapterContext.Provider>
</BookContext.Provider>

17
context/AIUsageContext.ts Normal file
View File

@@ -0,0 +1,17 @@
import {Context, createContext, Dispatch, SetStateAction} from "react";
export interface AIUsageContextProps {
totalCredits: number;
totalPrice: number;
setTotalCredits: Dispatch<SetStateAction<number>>;
setTotalPrice: Dispatch<SetStateAction<number>>;
}
export const AIUsageContext: Context<AIUsageContextProps> = createContext<AIUsageContextProps>({
totalCredits: 0,
totalPrice: 0,
setTotalCredits: (): void => {
},
setTotalPrice: (): void => {
}
})

4
context/AlertContext.ts Normal file
View File

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

7
electron.d.ts vendored
View File

@@ -36,6 +36,13 @@ export interface IElectronAPI {
// Database initialization (shortcut for convenience)
dbInitialize: (userId: string, encryptionKey: string) => Promise<boolean>;
// 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 {

View File

@@ -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<boolean>('offlineMode', false);
const lastUserId = storage.get<string>('lastUserId');
const hasPin = !!storage.get<string>(`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();
}

View File

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

View File

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

View File

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

37
package-lock.json generated
View File

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

View File

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