From 94fc29722caeedfdd39403ebe864fe899f2dce50 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:23:46 +0700 Subject: [PATCH 1/3] refactor: replace secure storage with app storage across components and hooks --- .../layouts/components/bookmark/index.tsx | 8 ++-- .../layouts/components/header.layout.tsx | 4 +- .../layouts/components/history/index.tsx | 14 +++---- .../layouts/hooks/useHistoryTracker.ts | 10 ++--- .../setting/components/history-setting.tsx | 10 ++--- apps/web/src/core/lib/api-client.ts | 4 +- apps/web/src/core/storage/local/index.ts | 38 +++++++++++-------- apps/web/src/main.tsx | 8 ++-- 8 files changed, 52 insertions(+), 44 deletions(-) diff --git a/apps/web/src/apps/modules/layouts/components/bookmark/index.tsx b/apps/web/src/apps/modules/layouts/components/bookmark/index.tsx index 182da14..4015ee1 100644 --- a/apps/web/src/apps/modules/layouts/components/bookmark/index.tsx +++ b/apps/web/src/apps/modules/layouts/components/bookmark/index.tsx @@ -4,7 +4,7 @@ import { useAppEvent } from '@repo/core-events'; import { useTranslation } from '@repo/core-i18n'; import { Bookmark, Trash2 } from 'lucide-react'; import { LAYOUT_EVENTS } from '../../../../../core/constants/events'; -import { secureIndexedDB, AppStorageKey } from '../../../../../core/storage/local'; +import { appDatabase, AppDatabaseKey } from '../../../../../core/storage/local'; import { Link } from 'react-router-dom'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -25,7 +25,7 @@ export function BookmarkDrawer() { const loadData = async () => { try { - const data = await secureIndexedDB.getItem(AppStorageKey.BOOKMARK_PAGE); + const data = await appDatabase.getItem(AppDatabaseKey.BOOKMARK_PAGE); setItems(data || []); } catch (e) { console.error('Failed to load bookmark items', e); @@ -44,7 +44,7 @@ export function BookmarkDrawer() { e.stopPropagation(); try { const newItems = items.filter((item) => item.id !== id); - await secureIndexedDB.setItem(AppStorageKey.BOOKMARK_PAGE, newItems); + await appDatabase.setItem(AppDatabaseKey.BOOKMARK_PAGE, newItems); setItems(newItems); } catch (err) { console.error('Failed to remove bookmark item', err); @@ -53,7 +53,7 @@ export function BookmarkDrawer() { const handleClearAll = async () => { try { - await secureIndexedDB.removeItem(AppStorageKey.BOOKMARK_PAGE); + await appDatabase.removeItem(AppDatabaseKey.BOOKMARK_PAGE); setItems([]); } catch (err) { console.error('Failed to clear bookmarks', err); diff --git a/apps/web/src/apps/modules/layouts/components/header.layout.tsx b/apps/web/src/apps/modules/layouts/components/header.layout.tsx index fe0808a..8dbaf1d 100644 --- a/apps/web/src/apps/modules/layouts/components/header.layout.tsx +++ b/apps/web/src/apps/modules/layouts/components/header.layout.tsx @@ -13,7 +13,7 @@ import { Box, } from '@repo/ui/components'; import { Globe, Bell, HelpCircle, Settings, User, Briefcase, LogOut, ChevronDown, Sun, Moon } from 'lucide-react'; -import { AppStorageKey, secureStorage } from '../../../../core/storage/local'; +import { AppStorageKey, appStorage } from '../../../../core/storage/local'; import { useThemeStore } from '../../../../core/stores/theme.store'; import { NotificationDropdown } from './notifications/notification-dropdown'; import { Link } from 'react-router-dom'; @@ -131,7 +131,7 @@ export default function HeaderLayout() { onChange={async (val) => { if (!val) return; await i18n.changeLanguage(val); - await secureStorage.setItem(AppStorageKey.LANGUAGE, val); + await appStorage.setItem(AppStorageKey.LANGUAGE, val); }} styles={{ input: { diff --git a/apps/web/src/apps/modules/layouts/components/history/index.tsx b/apps/web/src/apps/modules/layouts/components/history/index.tsx index 1101b01..88b051e 100644 --- a/apps/web/src/apps/modules/layouts/components/history/index.tsx +++ b/apps/web/src/apps/modules/layouts/components/history/index.tsx @@ -15,7 +15,7 @@ import { useAppEvent } from '@repo/core-events'; import { useTranslation } from '@repo/core-i18n'; import { History, Clock, Trash2 } from 'lucide-react'; import { LAYOUT_EVENTS } from '../../../../../core/constants/events'; -import { secureIndexedDB, AppStorageKey } from '../../../../../core/storage/local'; +import { appDatabase, AppDatabaseKey } from '../../../../../core/storage/local'; import { Link } from 'react-router-dom'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -38,9 +38,9 @@ export function HistoryDrawer() { const loadData = async () => { try { - const data = await secureIndexedDB.getItem(AppStorageKey.HISTORY_PAGE); + const data = await appDatabase.getItem(AppDatabaseKey.HISTORY_PAGE); const settings = - (await secureIndexedDB.getItem(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS; + (await appDatabase.getItem(AppDatabaseKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS; const now = Date.now(); const unitMs = { @@ -55,7 +55,7 @@ export function HistoryDrawer() { // Save back if we purged some items if (data && validItems.length !== data.length) { - await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, validItems); + await appDatabase.setItem(AppDatabaseKey.HISTORY_PAGE, validItems); } setItems(validItems); @@ -74,7 +74,7 @@ export function HistoryDrawer() { const handleClearRange = async (range: 'lastHour' | 'today' | 'allTime') => { try { if (range === 'allTime') { - await secureIndexedDB.removeItem(AppStorageKey.HISTORY_PAGE); + await appDatabase.removeItem(AppDatabaseKey.HISTORY_PAGE); setItems([]); return; } @@ -83,7 +83,7 @@ export function HistoryDrawer() { const cutoff = range === 'lastHour' ? now - 60 * 60 * 1000 : new Date().setHours(0, 0, 0, 0); // start of today const newItems = items.filter((item) => item.timestamp < cutoff); - await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, newItems); + await appDatabase.setItem(AppDatabaseKey.HISTORY_PAGE, newItems); setItems(newItems); } catch (e) { console.error('Failed to clear history items', e); @@ -95,7 +95,7 @@ export function HistoryDrawer() { e.stopPropagation(); try { const newItems = items.filter((item) => item.id !== id); - await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, newItems); + await appDatabase.setItem(AppDatabaseKey.HISTORY_PAGE, newItems); setItems(newItems); } catch (err) { console.error('Failed to remove history item', err); diff --git a/apps/web/src/apps/modules/layouts/hooks/useHistoryTracker.ts b/apps/web/src/apps/modules/layouts/hooks/useHistoryTracker.ts index 042dcc2..7bc6671 100644 --- a/apps/web/src/apps/modules/layouts/hooks/useHistoryTracker.ts +++ b/apps/web/src/apps/modules/layouts/hooks/useHistoryTracker.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; -import { secureIndexedDB, AppStorageKey } from '../../../../core/storage/local'; +import { appDatabase, AppDatabaseKey } from '../../../../core/storage/local'; import type { SavedPageItem } from '../types/saved-page.types'; import type { SystemSettings } from '../../system/setting/types/setting.types'; import { DEFAULT_SYSTEM_SETTINGS } from '../../system/setting/types/setting.types'; @@ -34,7 +34,7 @@ export async function initializeAndPurgeHistoryBackground() { if (isHistoryReady) return; try { - const data = (await secureIndexedDB.getItem(AppStorageKey.HISTORY_PAGE)) || []; + const data = (await appDatabase.getItem(AppDatabaseKey.HISTORY_PAGE)) || []; if (data.length === 0) { isHistoryReady = true; @@ -42,7 +42,7 @@ export async function initializeAndPurgeHistoryBackground() { } const settings = - (await secureIndexedDB.getItem(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS; + (await appDatabase.getItem(AppDatabaseKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS; const now = Date.now(); const unitMs: Record = { seconds: 1000, @@ -58,7 +58,7 @@ export async function initializeAndPurgeHistoryBackground() { isHistoryReady = true; if (validItems.length !== data.length) { - await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, validItems); + await appDatabase.setItem(AppDatabaseKey.HISTORY_PAGE, validItems); } } catch (err) { console.error('[HistoryManager] Failed to init history', err); @@ -100,7 +100,7 @@ export function useHistoryTracker() { historyChannel?.postMessage(newItem); // 3. Save to IndexedDB in the background (Asynchronous) - secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, memoryHistoryCache).catch((err) => { + appDatabase.setItem(AppDatabaseKey.HISTORY_PAGE, memoryHistoryCache).catch((err) => { console.error('[HistoryManager] Failed to track history', err); }); }; diff --git a/apps/web/src/apps/modules/system/setting/components/history-setting.tsx b/apps/web/src/apps/modules/system/setting/components/history-setting.tsx index a9f57e8..a143461 100644 --- a/apps/web/src/apps/modules/system/setting/components/history-setting.tsx +++ b/apps/web/src/apps/modules/system/setting/components/history-setting.tsx @@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { FieldNumberInput, FieldSelect } from '@repo/ui/form'; -import { secureIndexedDB, AppStorageKey } from '../../../../../core/storage/local'; +import { appDatabase, AppDatabaseKey } from '../../../../../core/storage/local'; import type { SystemSettings, RetentionUnit } from '../types/setting.types'; import { DEFAULT_SYSTEM_SETTINGS } from '../types/setting.types'; @@ -40,7 +40,7 @@ export function HistorySetting({ onDirtyChange }: { onDirtyChange: (isDirty: boo useEffect(() => { const loadSettings = async () => { try { - const settings = await secureIndexedDB.getItem(AppStorageKey.SYSTEM_SETTINGS); + const settings = await appDatabase.getItem(AppDatabaseKey.SYSTEM_SETTINGS); if (settings) { reset({ historyRetentionValue: settings.historyRetentionValue, @@ -61,7 +61,7 @@ export function HistorySetting({ onDirtyChange }: { onDirtyChange: (isDirty: boo const handleReset = async () => { try { - const settings = await secureIndexedDB.getItem(AppStorageKey.SYSTEM_SETTINGS); + const settings = await appDatabase.getItem(AppDatabaseKey.SYSTEM_SETTINGS); if (settings) { reset({ historyRetentionValue: settings.historyRetentionValue, @@ -82,13 +82,13 @@ export function HistorySetting({ onDirtyChange }: { onDirtyChange: (isDirty: boo setLoading(true); try { const currentSettings = - (await secureIndexedDB.getItem(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS; + (await appDatabase.getItem(AppDatabaseKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS; const newSettings = { ...currentSettings, historyRetentionValue: data.historyRetentionValue, historyRetentionUnit: data.historyRetentionUnit as RetentionUnit, }; - await secureIndexedDB.setItem(AppStorageKey.SYSTEM_SETTINGS, newSettings); + await appDatabase.setItem(AppDatabaseKey.SYSTEM_SETTINGS, newSettings); reset(data); // Reset form to clear dirty state } catch (err) { diff --git a/apps/web/src/core/lib/api-client.ts b/apps/web/src/core/lib/api-client.ts index 683c005..e161da9 100644 --- a/apps/web/src/core/lib/api-client.ts +++ b/apps/web/src/core/lib/api-client.ts @@ -1,7 +1,7 @@ import { createHttpClient } from '@repo/core-api/http-client'; import { faroAdapter } from '@repo/core-api/observability'; import { ENV } from '../environment'; -import { AppStorageKey, secureStorage } from '../storage/local'; +import { AppStorageKey, appStorage } from '../storage/local'; /** * Enterprise HTTP client for `apps/web`. @@ -29,7 +29,7 @@ export const apiClient = createHttpClient( config.headers['ex-timezone-offset-minutes'] = new Date().getTimezoneOffset(); config.headers['ex-timezone-offset-hours'] = Math.floor(new Date().getTimezoneOffset() / 60); - const language = await secureStorage.getItem(AppStorageKey.LANGUAGE); + const language = await appStorage.getItem(AppStorageKey.LANGUAGE); config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE; const token = localStorage.getItem('access_token'); diff --git a/apps/web/src/core/storage/local/index.ts b/apps/web/src/core/storage/local/index.ts index 6f38f55..72f270f 100644 --- a/apps/web/src/core/storage/local/index.ts +++ b/apps/web/src/core/storage/local/index.ts @@ -6,39 +6,47 @@ export const AppStorageKey = { THEME: 'app_theme', ACCESS_TOKEN: 'access_token', REFRESH_TOKEN: 'refresh_token', - MOCK_DB_COMPANY_A: 'mock_db_company_a', +} as const; + +export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey]; + +export const AppDatabaseKey = { OFFLINE_DRAFT: 'offline_draft', SYSTEM_SETTINGS: 'system_settings', HISTORY_PAGE: 'history_page', BOOKMARK_PAGE: 'bookmark_page', } as const; -export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey]; +export type AppDatabaseKeyValue = (typeof AppDatabaseKey)[keyof typeof AppDatabaseKey]; -export const ENCRYPTED_KEYS = new Set([ +export const APP_STORAGE_ENCRYPTED_KEYS = new Set([ AppStorageKey.USER_PROFILE, AppStorageKey.ACCESS_TOKEN, AppStorageKey.REFRESH_TOKEN, ]); -export const PLAIN_KEYS = new Set([ +export const APP_STORAGE_PLAIN_KEYS = new Set([ AppStorageKey.LANGUAGE, AppStorageKey.THEME, - AppStorageKey.MOCK_DB_COMPANY_A, - AppStorageKey.OFFLINE_DRAFT, - AppStorageKey.SYSTEM_SETTINGS, - AppStorageKey.HISTORY_PAGE, - AppStorageKey.BOOKMARK_PAGE, ]); -export const secureStorage = createLocalStorage({ - encryptedKeys: ENCRYPTED_KEYS, - plainTextKeys: PLAIN_KEYS, +export const APP_DATABASE_ENCRYPTED_KEYS = new Set([]); + +export const APP_DATABASE_PLAIN_KEYS = new Set([ + AppDatabaseKey.OFFLINE_DRAFT, + AppDatabaseKey.SYSTEM_SETTINGS, + AppDatabaseKey.HISTORY_PAGE, + AppDatabaseKey.BOOKMARK_PAGE, +]); + +export const appStorage = createLocalStorage({ + encryptedKeys: APP_STORAGE_ENCRYPTED_KEYS, + plainTextKeys: APP_STORAGE_PLAIN_KEYS, }); -export const secureIndexedDB = createIndexedDB({ +export const appDatabase = createIndexedDB({ dbName: 'e_apps_db', storeName: 'web_store', - encryptedKeys: ENCRYPTED_KEYS, - plainTextKeys: PLAIN_KEYS, + encryptedKeys: APP_DATABASE_ENCRYPTED_KEYS, + plainTextKeys: APP_DATABASE_PLAIN_KEYS, }); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 2c4e1d9..e866187 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -16,21 +16,21 @@ import './main.css'; import { lazy, StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { setupI18n } from '@repo/core-i18n'; -import { secureStorage, AppStorageKey } from './core/storage/local'; +import { appStorage, AppStorageKey } from './core/storage/local'; import { ENV } from './core/environment'; const App = lazy(() => import('./apps')); async function bootstrap() { - // Initialize i18next and load language from secureStorage + // Initialize i18next and load language from appStorage await setupI18n( { storageAdapter: { getLanguage: async () => { - return secureStorage.getItem(AppStorageKey.LANGUAGE); + return appStorage.getItem(AppStorageKey.LANGUAGE); }, setLanguage: async (lng: string) => { - await secureStorage.setItem(AppStorageKey.LANGUAGE, lng); + await appStorage.setItem(AppStorageKey.LANGUAGE, lng); }, }, }, From 0f9b44e70f539a8851ebd7b8f2c98af242445de7 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:29:44 +0700 Subject: [PATCH 2/3] refactor: replace localStorage with appStorage for token management in apiClient --- apps/web/src/core/lib/api-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/core/lib/api-client.ts b/apps/web/src/core/lib/api-client.ts index e161da9..1eb34bb 100644 --- a/apps/web/src/core/lib/api-client.ts +++ b/apps/web/src/core/lib/api-client.ts @@ -32,7 +32,7 @@ export const apiClient = createHttpClient( const language = await appStorage.getItem(AppStorageKey.LANGUAGE); config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE; - const token = localStorage.getItem('access_token'); + const token = await appStorage.getItem(AppStorageKey.ACCESS_TOKEN); if (token) config.headers.Authorization = `Bearer ${token}`; return config; @@ -42,7 +42,7 @@ export const apiClient = createHttpClient( onResponseError: async (error) => { if (error.response?.status === 401) { // Clear stale token and redirect to login - localStorage.removeItem('access_token'); + await appStorage.removeItem(AppStorageKey.ACCESS_TOKEN); window.location.href = '/auth/login'; } throw error; From e2872a3d18720223da7cfa857d0d1495a0276994 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:34:48 +0700 Subject: [PATCH 3/3] fix: enhance 401 error handling by redirecting to login with current path --- apps/web/src/core/lib/api-client.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/web/src/core/lib/api-client.ts b/apps/web/src/core/lib/api-client.ts index 1eb34bb..6e45437 100644 --- a/apps/web/src/core/lib/api-client.ts +++ b/apps/web/src/core/lib/api-client.ts @@ -40,11 +40,26 @@ export const apiClient = createHttpClient( // ── Error Interceptor ───────────────────────────────────────── onResponseError: async (error) => { - if (error.response?.status === 401) { - // Clear stale token and redirect to login + const status = error.response?.status; + + // Catch 401 (Unauthorized) on request + if (status === 401) { + // Delete invalid tokens await appStorage.removeItem(AppStorageKey.ACCESS_TOKEN); - window.location.href = '/auth/login'; + + // Retrieve the current path and query string (example: /dashboard/settings?tab=profile) + const currentPath = window.location.pathname + window.location.search; + + // Retrieve the current path and query string (example: /dashboard/settings?tab=profile) + const redirectParam = encodeURIComponent(currentPath); + + // Use replace() instead of href. + // replace() will not save the history of the page with this error, + // so that if the user presses the 'back' button in the browser from the login page, + // they won't get stuck in an infinite loop back to the login page. + window.location.replace(`/auth/login?redirect=${redirectParam}`); } + throw error; }, },