diff --git a/apps/web/src/core/storage/local/index.ts b/apps/web/src/core/storage/local/index.ts index 8e9b3ed..c998ef5 100644 --- a/apps/web/src/core/storage/local/index.ts +++ b/apps/web/src/core/storage/local/index.ts @@ -5,7 +5,7 @@ export const AppStorageKey = { THEME: 'app_theme', ACCESS_TOKEN: 'access_token', REFRESH_TOKEN: 'refresh_token', - USER_ID: 'u_id', + USER_ID: 'uid', SIDEBAR_OPEN_MENUS: 'sidebar_open_menus', } as const; @@ -21,18 +21,43 @@ export const AppDatabaseKey = { export type AppDatabaseKeyValue = (typeof AppDatabaseKey)[keyof typeof AppDatabaseKey]; +function getUserId(userIdKey: string) { + const rawId = localStorage.getItem(userIdKey); + if (!rawId) return null; + + try { + return JSON.parse(rawId); + } catch (error) { + console.error('Failed to parse User ID:', error); + return null; + } +} + export const APP_STORAGE_ENCRYPTED_KEYS = new Set([ - AppStorageKey.USER_ID, AppStorageKey.ACCESS_TOKEN, AppStorageKey.REFRESH_TOKEN, ]); export const APP_STORAGE_PLAIN_KEYS = new Set([ + AppStorageKey.USER_ID, AppStorageKey.LANGUAGE, AppStorageKey.THEME, AppStorageKey.SIDEBAR_OPEN_MENUS, ]); +export const APP_STORAGE_PERSONALIZED_KEYS = new Set([ + AppStorageKey.LANGUAGE, + AppStorageKey.THEME, + AppStorageKey.SIDEBAR_OPEN_MENUS, +]); + +export const appStorage = createLocalStorage({ + encryptedKeys: APP_STORAGE_ENCRYPTED_KEYS, + plainTextKeys: APP_STORAGE_PLAIN_KEYS, + personalizedKeys: APP_STORAGE_PERSONALIZED_KEYS, + getUserId: () => getUserId(AppStorageKey.USER_ID) || null, +}); + export const APP_DATABASE_ENCRYPTED_KEYS = new Set([]); export const APP_DATABASE_PLAIN_KEYS = new Set([ @@ -43,14 +68,19 @@ export const APP_DATABASE_PLAIN_KEYS = new Set([ AppDatabaseKey.BOOKMARK_PAGE, ]); -export const appStorage = createLocalStorage({ - encryptedKeys: APP_STORAGE_ENCRYPTED_KEYS, - plainTextKeys: APP_STORAGE_PLAIN_KEYS, -}); +export const APP_DATABASE_PERSONALIZED_KEYS = new Set([ + AppDatabaseKey.USER_PROFILE, + AppDatabaseKey.OFFLINE_DRAFT, + AppDatabaseKey.SYSTEM_SETTINGS, + AppDatabaseKey.HISTORY_PAGE, + AppDatabaseKey.BOOKMARK_PAGE, +]); export const appDatabase = createIndexedDB({ dbName: 'e_apps_db', storeName: 'web_store', encryptedKeys: APP_DATABASE_ENCRYPTED_KEYS, plainTextKeys: APP_DATABASE_PLAIN_KEYS, + personalizedKeys: APP_DATABASE_PERSONALIZED_KEYS, + getUserId: () => getUserId(AppStorageKey.USER_ID) || null, }); diff --git a/packages/core-storage/src/indexed-db/indexed-db.service.ts b/packages/core-storage/src/indexed-db/indexed-db.service.ts index c04cd85..dd36dca 100644 --- a/packages/core-storage/src/indexed-db/indexed-db.service.ts +++ b/packages/core-storage/src/indexed-db/indexed-db.service.ts @@ -37,7 +37,7 @@ function openDatabase(dbName: string, storeName: string, version: number): Promi /** * Execute a single IndexedDB transaction and return the result. - * Handles open → transaction → request → close lifecycle cleanly. + * Resolves only when the entire transaction is completed to prevent silent failures. */ function withTransaction( db: IDBDatabase, @@ -48,10 +48,20 @@ function withTransaction( return new Promise((resolve, reject) => { const tx = db.transaction(storeName, mode); const store = tx.objectStore(storeName); + let requestResult: R; + const request = operation(store); - request.onsuccess = () => resolve(request.result); + request.onsuccess = () => { + requestResult = request.result; + }; + request.onerror = () => reject(request.error); + + // Resolve promise ONLY after transaction is successfully committed + tx.oncomplete = () => resolve(requestResult); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); }); } @@ -67,6 +77,8 @@ export class IndexedDBService implements IStorageService; private readonly plainTextKeys: Set; + private readonly personalizedKeys?: Set; + private readonly getUserId?: () => string | null | undefined; private dbPromise: Promise | null = null; constructor(config?: IndexedDBConfig, encryptionUtils?: EncryptionUtils) { @@ -76,6 +88,8 @@ export class IndexedDBService implements IStorageService implements IStorageService(key: TKey, value: T): Promise { this.validateKey(key); const db = await this.getDB(); + const resolvedKey = this.resolveKey(key); + const serialized = JSON.stringify(value); const payload = this.shouldEncrypt(key) ? this.encryption.encrypt(serialized) : serialized; - await withTransaction(db, this.storeName, 'readwrite', (store) => store.put(payload, key as string)); + await withTransaction(db, this.storeName, 'readwrite', (store) => store.put(payload, resolvedKey)); } async getItem(key: TKey): Promise { this.validateKey(key); const db = await this.getDB(); + const resolvedKey = this.resolveKey(key); const raw = await withTransaction( db, this.storeName, 'readonly', - (store) => store.get(key as string) as IDBRequest, + (store) => store.get(resolvedKey) as IDBRequest, ); if (raw === undefined || raw === null) return null; try { - if (this.shouldEncrypt(key)) { - const decrypted = this.encryption.decrypt(raw); - if (!decrypted) return null; - return JSON.parse(decrypted) as T; - } - return JSON.parse(raw) as T; + const decrypted = this.shouldEncrypt(key) ? this.encryption.decrypt(raw) : raw; + if (!decrypted) return null; + + return JSON.parse(decrypted) as T; } catch { - console.warn(`[core-storage] Failed to parse IndexedDB key "${key}". Removing corrupt entry.`); + console.warn(`[core-storage] Failed to parse IndexedDB key "${String(key)}". Removing corrupt entry.`); await this.removeItem(key); return null; } @@ -135,12 +158,16 @@ export class IndexedDBService implements IStorageService { this.validateKey(key); const db = await this.getDB(); - await withTransaction(db, this.storeName, 'readwrite', (store) => store.delete(key as string)); + const resolvedKey = this.resolveKey(key); + + await withTransaction(db, this.storeName, 'readwrite', (store) => store.delete(resolvedKey)); } async clear(): Promise { - const db = await this.getDB(); - await withTransaction(db, this.storeName, 'readwrite', (store) => store.clear()); + const allValidKeys = await this.keys(); + for (const key of allValidKeys) { + await this.removeItem(key); + } } async hasItem(key: TKey): Promise { @@ -150,13 +177,34 @@ export class IndexedDBService implements IStorageService { const db = await this.getDB(); - const allKeys = await withTransaction( + const rawKeys = await withTransaction( db, this.storeName, 'readonly', (store) => store.getAllKeys() as IDBRequest, ); - return allKeys as TKey[]; + + const resultSet = new Set(); + const currentUserId = this.getUserId?.() || 'guest'; + + for (const rawKey of rawKeys) { + const isGlobalKey = this.encryptedKeys.has(rawKey as TKey) || this.plainTextKeys.has(rawKey as TKey); + + if (isGlobalKey && !this.personalizedKeys?.has(rawKey as TKey)) { + resultSet.add(rawKey as TKey); + } + + if (this.personalizedKeys) { + for (const pKey of this.personalizedKeys) { + if (rawKey === `${String(pKey)}_${currentUserId}`) { + resultSet.add(pKey); + break; + } + } + } + } + + return Array.from(resultSet); } } diff --git a/packages/core-storage/src/local-storage/local-storage.service.ts b/packages/core-storage/src/local-storage/local-storage.service.ts index 7675a98..b36086d 100644 --- a/packages/core-storage/src/local-storage/local-storage.service.ts +++ b/packages/core-storage/src/local-storage/local-storage.service.ts @@ -4,6 +4,8 @@ import type { IStorageService } from '../types/storage.interface'; export interface StorageOptions { encryptedKeys?: Set; plainTextKeys?: Set; + personalizedKeys?: Set; + getUserId?: () => string | null | undefined; } /** @@ -13,11 +15,15 @@ export class LocalStorageService implements IStorageService private readonly encryption: EncryptionUtils; private readonly encryptedKeys: Set; private readonly plainTextKeys: Set; + private readonly personalizedKeys?: Set; + private readonly getUserId?: () => string | null | undefined; constructor(options?: StorageOptions, encryptionUtils?: EncryptionUtils) { this.encryption = encryptionUtils ?? EncryptionUtils.getInstance(); this.encryptedKeys = options?.encryptedKeys ?? new Set(); this.plainTextKeys = options?.plainTextKeys ?? new Set(); + this.personalizedKeys = options?.personalizedKeys; + this.getUserId = options?.getUserId; } private validateKey(key: TKey): void { @@ -26,25 +32,35 @@ export class LocalStorageService implements IStorageService } } + private resolveKey(key: TKey): string { + if (this.personalizedKeys?.has(key)) { + const userId = this.getUserId?.() || 'guest'; + return `${String(key)}_${userId}`; + } + return String(key); + } + private shouldEncrypt(key: TKey): boolean { return this.encryptedKeys.has(key); } async setItem(key: TKey, value: T): Promise { this.validateKey(key); + const resolvedKey = this.resolveKey(key); const serialized = JSON.stringify(value); if (this.shouldEncrypt(key)) { const encrypted = this.encryption.encrypt(serialized); - localStorage.setItem(key as string, encrypted); + localStorage.setItem(resolvedKey, encrypted); } else { - localStorage.setItem(key as string, serialized); + localStorage.setItem(resolvedKey, serialized); } } async getItem(key: TKey): Promise { this.validateKey(key); - const raw = localStorage.getItem(key as string); + const resolvedKey = this.resolveKey(key); + const raw = localStorage.getItem(resolvedKey); if (raw === null) return null; try { @@ -55,15 +71,16 @@ export class LocalStorageService implements IStorageService } return JSON.parse(raw) as T; } catch { - console.warn(`[core-storage] Failed to parse key "${key}". Removing corrupt entry.`); - localStorage.removeItem(key as string); + console.warn(`[core-storage] Failed to parse key "${String(key)}". Removing corrupt entry.`); + localStorage.removeItem(resolvedKey); return null; } } async removeItem(key: TKey): Promise { this.validateKey(key); - localStorage.removeItem(key as string); + const resolvedKey = this.resolveKey(key); + localStorage.removeItem(resolvedKey); } async clear(): Promise { @@ -71,7 +88,8 @@ export class LocalStorageService implements IStorageService } async hasItem(key: TKey): Promise { - return localStorage.getItem(key as string) !== null; + const resolvedKey = this.resolveKey(key); + return localStorage.getItem(resolvedKey) !== null; } async keys(): Promise { diff --git a/packages/ui/src/foundations/enterprise-module/hooks/use-form-draft.context.ts b/packages/ui/src/foundations/enterprise-module/hooks/use-form-draft.context.ts index 1dfc3a9..217a573 100644 --- a/packages/ui/src/foundations/enterprise-module/hooks/use-form-draft.context.ts +++ b/packages/ui/src/foundations/enterprise-module/hooks/use-form-draft.context.ts @@ -3,8 +3,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useEnterpriseModuleConfigContext } from './use-module.context'; import { DraftConfig, FormPageType } from '../entities/entity'; import { - appStorage, - AppStorageKey, appDatabase, AppDatabaseKey, } from '../../../../../../apps/web/src/core/storage/local'; @@ -19,36 +17,28 @@ export function useFormDraftContext({ config }: FormDraftContextProps) { const { config: moduleConfig } = useEnterpriseModuleConfigContext(); const [hasDraft, setHasDraft] = useState(false); const [draftData, setDraftData] = useState(null); - const [userID, setUserID] = useState('UNRESOLVED_PRINCIPAL'); const isEnabled = config?.enableDraft === true; // Use a generic 'CREATE' key for both CREATE and DUPLICATE so that drafts saved // during DUPLICATE can be restored when entering a new CREATE form. - const draftKey = `${userID}:draft:${moduleConfig.moduleKey}:CREATE`; + const draftKey = `draft:${moduleConfig.moduleKey}:CREATE`; // Check for existing draft on mount useEffect(() => { if (!isEnabled) return; - // Fetch user ID asynchronously using appStorage - appStorage.getItem(AppStorageKey.USER_ID).then((id: any) => { - const resolvedUserId = id || 'UNRESOLVED_PRINCIPAL'; - setUserID(resolvedUserId); - - const resolvedDraftKey = `${resolvedUserId}:draft:${moduleConfig.moduleKey}:CREATE`; - appDatabase - .getItem(AppDatabaseKey.OFFLINE_DRAFT) - .then((allDrafts: any) => { - const drafts = allDrafts || {}; - if (drafts[resolvedDraftKey]) { - setDraftData(drafts[resolvedDraftKey]); - setHasDraft(true); - } - }) - .catch((err) => { - console.error('[Draft Recovery] Failed to read from appDatabase:', err); - }); - }); - }, [isEnabled, moduleConfig.moduleKey]); + appDatabase + .getItem(AppDatabaseKey.OFFLINE_DRAFT) + .then((allDrafts: any) => { + const drafts = allDrafts || {}; + if (drafts[draftKey]) { + setDraftData(drafts[draftKey]); + setHasDraft(true); + } + }) + .catch((err) => { + console.error('[Draft Recovery] Failed to read from appDatabase:', err); + }); + }, [isEnabled, draftKey]); const saveDraft = useCallback( (data: any) => {