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:
94
app/page.tsx
94
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<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
17
context/AIUsageContext.ts
Normal 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
4
context/AlertContext.ts
Normal 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
7
electron.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
// Pas de token, ouvrir la fenêtre de login
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
package-lock.json
generated
37
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user