Merge pull request 'core/page-provider' (#35) from core/page-provider into main

Reviewed-on: eigen/fe-monorepo-template#35
This commit is contained in:
2026-07-27 05:42:08 +00:00
8 changed files with 72 additions and 49 deletions
@@ -4,7 +4,7 @@ import { useAppEvent } from '@repo/core-events';
import { useTranslation } from '@repo/core-i18n'; import { useTranslation } from '@repo/core-i18n';
import { Bookmark, Trash2 } from 'lucide-react'; import { Bookmark, Trash2 } from 'lucide-react';
import { LAYOUT_EVENTS } from '../../../../../core/constants/events'; 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 { Link } from 'react-router-dom';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
@@ -25,7 +25,7 @@ export function BookmarkDrawer() {
const loadData = async () => { const loadData = async () => {
try { try {
const data = await secureIndexedDB.getItem<SavedPageItem[]>(AppStorageKey.BOOKMARK_PAGE); const data = await appDatabase.getItem<SavedPageItem[]>(AppDatabaseKey.BOOKMARK_PAGE);
setItems(data || []); setItems(data || []);
} catch (e) { } catch (e) {
console.error('Failed to load bookmark items', e); console.error('Failed to load bookmark items', e);
@@ -44,7 +44,7 @@ export function BookmarkDrawer() {
e.stopPropagation(); e.stopPropagation();
try { try {
const newItems = items.filter((item) => item.id !== id); const newItems = items.filter((item) => item.id !== id);
await secureIndexedDB.setItem(AppStorageKey.BOOKMARK_PAGE, newItems); await appDatabase.setItem(AppDatabaseKey.BOOKMARK_PAGE, newItems);
setItems(newItems); setItems(newItems);
} catch (err) { } catch (err) {
console.error('Failed to remove bookmark item', err); console.error('Failed to remove bookmark item', err);
@@ -53,7 +53,7 @@ export function BookmarkDrawer() {
const handleClearAll = async () => { const handleClearAll = async () => {
try { try {
await secureIndexedDB.removeItem(AppStorageKey.BOOKMARK_PAGE); await appDatabase.removeItem(AppDatabaseKey.BOOKMARK_PAGE);
setItems([]); setItems([]);
} catch (err) { } catch (err) {
console.error('Failed to clear bookmarks', err); console.error('Failed to clear bookmarks', err);
@@ -13,7 +13,7 @@ import {
Box, Box,
} from '@repo/ui/components'; } from '@repo/ui/components';
import { Globe, Bell, HelpCircle, Settings, User, Briefcase, LogOut, ChevronDown, Sun, Moon } from 'lucide-react'; 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 { useThemeStore } from '../../../../core/stores/theme.store';
import { NotificationDropdown } from './notifications/notification-dropdown'; import { NotificationDropdown } from './notifications/notification-dropdown';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@@ -131,7 +131,7 @@ export default function HeaderLayout() {
onChange={async (val) => { onChange={async (val) => {
if (!val) return; if (!val) return;
await i18n.changeLanguage(val); await i18n.changeLanguage(val);
await secureStorage.setItem(AppStorageKey.LANGUAGE, val); await appStorage.setItem(AppStorageKey.LANGUAGE, val);
}} }}
styles={{ styles={{
input: { input: {
@@ -15,7 +15,7 @@ import { useAppEvent } from '@repo/core-events';
import { useTranslation } from '@repo/core-i18n'; import { useTranslation } from '@repo/core-i18n';
import { History, Clock, Trash2 } from 'lucide-react'; import { History, Clock, Trash2 } from 'lucide-react';
import { LAYOUT_EVENTS } from '../../../../../core/constants/events'; 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 { Link } from 'react-router-dom';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
@@ -38,9 +38,9 @@ export function HistoryDrawer() {
const loadData = async () => { const loadData = async () => {
try { try {
const data = await secureIndexedDB.getItem<SavedPageItem[]>(AppStorageKey.HISTORY_PAGE); const data = await appDatabase.getItem<SavedPageItem[]>(AppDatabaseKey.HISTORY_PAGE);
const settings = const settings =
(await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS; (await appDatabase.getItem<SystemSettings>(AppDatabaseKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS;
const now = Date.now(); const now = Date.now();
const unitMs = { const unitMs = {
@@ -55,7 +55,7 @@ export function HistoryDrawer() {
// Save back if we purged some items // Save back if we purged some items
if (data && validItems.length !== data.length) { if (data && validItems.length !== data.length) {
await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, validItems); await appDatabase.setItem(AppDatabaseKey.HISTORY_PAGE, validItems);
} }
setItems(validItems); setItems(validItems);
@@ -74,7 +74,7 @@ export function HistoryDrawer() {
const handleClearRange = async (range: 'lastHour' | 'today' | 'allTime') => { const handleClearRange = async (range: 'lastHour' | 'today' | 'allTime') => {
try { try {
if (range === 'allTime') { if (range === 'allTime') {
await secureIndexedDB.removeItem(AppStorageKey.HISTORY_PAGE); await appDatabase.removeItem(AppDatabaseKey.HISTORY_PAGE);
setItems([]); setItems([]);
return; 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 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); const newItems = items.filter((item) => item.timestamp < cutoff);
await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, newItems); await appDatabase.setItem(AppDatabaseKey.HISTORY_PAGE, newItems);
setItems(newItems); setItems(newItems);
} catch (e) { } catch (e) {
console.error('Failed to clear history items', e); console.error('Failed to clear history items', e);
@@ -95,7 +95,7 @@ export function HistoryDrawer() {
e.stopPropagation(); e.stopPropagation();
try { try {
const newItems = items.filter((item) => item.id !== id); const newItems = items.filter((item) => item.id !== id);
await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, newItems); await appDatabase.setItem(AppDatabaseKey.HISTORY_PAGE, newItems);
setItems(newItems); setItems(newItems);
} catch (err) { } catch (err) {
console.error('Failed to remove history item', err); console.error('Failed to remove history item', err);
@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLocation } from 'react-router-dom'; 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 { SavedPageItem } from '../types/saved-page.types';
import type { SystemSettings } from '../../system/setting/types/setting.types'; import type { SystemSettings } from '../../system/setting/types/setting.types';
import { DEFAULT_SYSTEM_SETTINGS } 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; if (isHistoryReady) return;
try { try {
const data = (await secureIndexedDB.getItem<SavedPageItem[]>(AppStorageKey.HISTORY_PAGE)) || []; const data = (await appDatabase.getItem<SavedPageItem[]>(AppDatabaseKey.HISTORY_PAGE)) || [];
if (data.length === 0) { if (data.length === 0) {
isHistoryReady = true; isHistoryReady = true;
@@ -42,7 +42,7 @@ export async function initializeAndPurgeHistoryBackground() {
} }
const settings = const settings =
(await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS; (await appDatabase.getItem<SystemSettings>(AppDatabaseKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS;
const now = Date.now(); const now = Date.now();
const unitMs: Record<string, number> = { const unitMs: Record<string, number> = {
seconds: 1000, seconds: 1000,
@@ -58,7 +58,7 @@ export async function initializeAndPurgeHistoryBackground() {
isHistoryReady = true; isHistoryReady = true;
if (validItems.length !== data.length) { if (validItems.length !== data.length) {
await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, validItems); await appDatabase.setItem(AppDatabaseKey.HISTORY_PAGE, validItems);
} }
} catch (err) { } catch (err) {
console.error('[HistoryManager] Failed to init history', err); console.error('[HistoryManager] Failed to init history', err);
@@ -100,7 +100,7 @@ export function useHistoryTracker() {
historyChannel?.postMessage(newItem); historyChannel?.postMessage(newItem);
// 3. Save to IndexedDB in the background (Asynchronous) // 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); console.error('[HistoryManager] Failed to track history', err);
}); });
}; };
@@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { FieldNumberInput, FieldSelect } from '@repo/ui/form'; 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 type { SystemSettings, RetentionUnit } from '../types/setting.types';
import { DEFAULT_SYSTEM_SETTINGS } from '../types/setting.types'; import { DEFAULT_SYSTEM_SETTINGS } from '../types/setting.types';
@@ -40,7 +40,7 @@ export function HistorySetting({ onDirtyChange }: { onDirtyChange: (isDirty: boo
useEffect(() => { useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
try { try {
const settings = await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS); const settings = await appDatabase.getItem<SystemSettings>(AppDatabaseKey.SYSTEM_SETTINGS);
if (settings) { if (settings) {
reset({ reset({
historyRetentionValue: settings.historyRetentionValue, historyRetentionValue: settings.historyRetentionValue,
@@ -61,7 +61,7 @@ export function HistorySetting({ onDirtyChange }: { onDirtyChange: (isDirty: boo
const handleReset = async () => { const handleReset = async () => {
try { try {
const settings = await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS); const settings = await appDatabase.getItem<SystemSettings>(AppDatabaseKey.SYSTEM_SETTINGS);
if (settings) { if (settings) {
reset({ reset({
historyRetentionValue: settings.historyRetentionValue, historyRetentionValue: settings.historyRetentionValue,
@@ -82,13 +82,13 @@ export function HistorySetting({ onDirtyChange }: { onDirtyChange: (isDirty: boo
setLoading(true); setLoading(true);
try { try {
const currentSettings = const currentSettings =
(await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS; (await appDatabase.getItem<SystemSettings>(AppDatabaseKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS;
const newSettings = { const newSettings = {
...currentSettings, ...currentSettings,
historyRetentionValue: data.historyRetentionValue, historyRetentionValue: data.historyRetentionValue,
historyRetentionUnit: data.historyRetentionUnit as RetentionUnit, 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 reset(data); // Reset form to clear dirty state
} catch (err) { } catch (err) {
+22 -7
View File
@@ -1,7 +1,7 @@
import { createHttpClient } from '@repo/core-api/http-client'; import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability'; import { faroAdapter } from '@repo/core-api/observability';
import { ENV } from '../environment'; import { ENV } from '../environment';
import { AppStorageKey, secureStorage } from '../storage/local'; import { AppStorageKey, appStorage } from '../storage/local';
/** /**
* Enterprise HTTP client for `apps/web`. * Enterprise HTTP client for `apps/web`.
@@ -29,10 +29,10 @@ export const apiClient = createHttpClient(
config.headers['ex-timezone-offset-minutes'] = new Date().getTimezoneOffset(); config.headers['ex-timezone-offset-minutes'] = new Date().getTimezoneOffset();
config.headers['ex-timezone-offset-hours'] = Math.floor(new Date().getTimezoneOffset() / 60); config.headers['ex-timezone-offset-hours'] = Math.floor(new Date().getTimezoneOffset() / 60);
const language = await secureStorage.getItem<string>(AppStorageKey.LANGUAGE); const language = await appStorage.getItem<string>(AppStorageKey.LANGUAGE);
config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE; config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE;
const token = localStorage.getItem('access_token'); const token = await appStorage.getItem<string>(AppStorageKey.ACCESS_TOKEN);
if (token) config.headers.Authorization = `Bearer ${token}`; if (token) config.headers.Authorization = `Bearer ${token}`;
return config; return config;
@@ -40,11 +40,26 @@ export const apiClient = createHttpClient(
// ── Error Interceptor ───────────────────────────────────────── // ── Error Interceptor ─────────────────────────────────────────
onResponseError: async (error) => { onResponseError: async (error) => {
if (error.response?.status === 401) { const status = error.response?.status;
// Clear stale token and redirect to login
localStorage.removeItem('access_token'); // Catch 401 (Unauthorized) on request
window.location.href = '/auth/login'; if (status === 401) {
// Delete invalid tokens
await appStorage.removeItem(AppStorageKey.ACCESS_TOKEN);
// 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; throw error;
}, },
}, },
+23 -15
View File
@@ -6,39 +6,47 @@ export const AppStorageKey = {
THEME: 'app_theme', THEME: 'app_theme',
ACCESS_TOKEN: 'access_token', ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_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', OFFLINE_DRAFT: 'offline_draft',
SYSTEM_SETTINGS: 'system_settings', SYSTEM_SETTINGS: 'system_settings',
HISTORY_PAGE: 'history_page', HISTORY_PAGE: 'history_page',
BOOKMARK_PAGE: 'bookmark_page', BOOKMARK_PAGE: 'bookmark_page',
} as const; } as const;
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey]; export type AppDatabaseKeyValue = (typeof AppDatabaseKey)[keyof typeof AppDatabaseKey];
export const ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([ export const APP_STORAGE_ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.USER_PROFILE, AppStorageKey.USER_PROFILE,
AppStorageKey.ACCESS_TOKEN, AppStorageKey.ACCESS_TOKEN,
AppStorageKey.REFRESH_TOKEN, AppStorageKey.REFRESH_TOKEN,
]); ]);
export const PLAIN_KEYS = new Set<AppStorageKeyValue>([ export const APP_STORAGE_PLAIN_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.LANGUAGE, AppStorageKey.LANGUAGE,
AppStorageKey.THEME, AppStorageKey.THEME,
AppStorageKey.MOCK_DB_COMPANY_A,
AppStorageKey.OFFLINE_DRAFT,
AppStorageKey.SYSTEM_SETTINGS,
AppStorageKey.HISTORY_PAGE,
AppStorageKey.BOOKMARK_PAGE,
]); ]);
export const secureStorage = createLocalStorage<AppStorageKeyValue>({ export const APP_DATABASE_ENCRYPTED_KEYS = new Set<AppDatabaseKeyValue>([]);
encryptedKeys: ENCRYPTED_KEYS,
plainTextKeys: PLAIN_KEYS, export const APP_DATABASE_PLAIN_KEYS = new Set<AppDatabaseKeyValue>([
AppDatabaseKey.OFFLINE_DRAFT,
AppDatabaseKey.SYSTEM_SETTINGS,
AppDatabaseKey.HISTORY_PAGE,
AppDatabaseKey.BOOKMARK_PAGE,
]);
export const appStorage = createLocalStorage<AppStorageKeyValue>({
encryptedKeys: APP_STORAGE_ENCRYPTED_KEYS,
plainTextKeys: APP_STORAGE_PLAIN_KEYS,
}); });
export const secureIndexedDB = createIndexedDB<AppStorageKeyValue>({ export const appDatabase = createIndexedDB<AppDatabaseKeyValue>({
dbName: 'e_apps_db', dbName: 'e_apps_db',
storeName: 'web_store', storeName: 'web_store',
encryptedKeys: ENCRYPTED_KEYS, encryptedKeys: APP_DATABASE_ENCRYPTED_KEYS,
plainTextKeys: PLAIN_KEYS, plainTextKeys: APP_DATABASE_PLAIN_KEYS,
}); });
+4 -4
View File
@@ -16,21 +16,21 @@ import './main.css';
import { lazy, StrictMode } from 'react'; import { lazy, StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n'; 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'; import { ENV } from './core/environment';
const App = lazy(() => import('./apps')); const App = lazy(() => import('./apps'));
async function bootstrap() { async function bootstrap() {
// Initialize i18next and load language from secureStorage // Initialize i18next and load language from appStorage
await setupI18n( await setupI18n(
{ {
storageAdapter: { storageAdapter: {
getLanguage: async () => { getLanguage: async () => {
return secureStorage.getItem<string>(AppStorageKey.LANGUAGE); return appStorage.getItem<string>(AppStorageKey.LANGUAGE);
}, },
setLanguage: async (lng: string) => { setLanguage: async (lng: string) => {
await secureStorage.setItem(AppStorageKey.LANGUAGE, lng); await appStorage.setItem(AppStorageKey.LANGUAGE, lng);
}, },
}, },
}, },