refactor: decouple core-storage services from static keys and update i18n setup to use configurable storage adapters

This commit is contained in:
Firman Ramdhani
2026-05-28 13:27:13 +07:00
parent b4af762baa
commit d0ebceb1bb
20 changed files with 377 additions and 375 deletions
+1
View File
@@ -13,6 +13,7 @@
"dependencies": {
"@repo/core-api": "workspace:*",
"@repo/core-i18n": "workspace:*",
"@repo/core-storage": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
+15
View File
@@ -0,0 +1,15 @@
import { createLocalStorage } from '@repo/core-storage';
export const AppStorageKey = {
LOCALE: 'app_locale',
} as const;
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
export const PLAIN_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.LOCALE,
]);
export const secureStorage = createLocalStorage<AppStorageKeyValue>({
plainTextKeys: PLAIN_KEYS
});
+7 -1
View File
@@ -16,10 +16,16 @@ import './main.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n';
import { secureStorage, AppStorageKey } from './core/storage';
import App from './app';
async function bootstrap() {
await setupI18n();
await setupI18n({
storageAdapter: {
getLanguage: async () => await secureStorage.getItem<string>(AppStorageKey.LOCALE),
setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LOCALE, lng),
},
});
createRoot(document.getElementById('app')!).render(
<StrictMode>
@@ -1,6 +1,6 @@
import { useAppEvent } from '@repo/core-events';
import type { ProfileUpdatedPayload } from '@repo/core-events';
import { secureIndexedDB } from '@repo/core-storage';
import { secureIndexedDB, AppStorageKey } from '../../../../core/storage';
// ─── Props ──────────────────────────────────────────────────────
@@ -14,45 +14,16 @@ interface StorageSyncListenerProps {
/**
* Headless component that listens to `AUTH:PROFILE_UPDATED` events
* and persists the profile data to IndexedDB via `@repo/core-storage`.
*
* This component renders nothing — it is purely a side-effect listener.
* Mount it anywhere in the React tree; it will auto-cleanup on unmount.
*
* **Architecture notes for production use:**
*
* 1. The `StorageKey` registry in `@repo/core-storage` should include
* a `USER_PROFILE` key (which it already does — see `storage.key.ts`).
* This means `secureIndexedDB.setItem('user_profile', payload)` will
* automatically encrypt the data at rest because `user_profile` is
* listed in `ENCRYPTED_KEYS`.
*
* 2. If you need to store additional event-driven data, extend `StorageKey`:
* ```ts
* // In packages/core-storage/src/storage.key.ts:
* export const StorageKey = {
* ...existing,
* LAST_PROFILE_SYNC: 'last_profile_sync',
* } as const;
* ```
*
* 3. For bidirectional sync (storage → event), consider adding a
* `STORAGE:PROFILE_LOADED` event to `AppEventRegistry` that fires when
* the app reads the profile from IndexedDB on boot.
*
* 4. Error handling: In production, wrap the `setItem` call in a
* retry mechanism or queue failed writes to a dead-letter store.
*/
export function StorageSyncListener({ onLog }: StorageSyncListenerProps) {
useAppEvent('AUTH:PROFILE_UPDATED', (payload: ProfileUpdatedPayload) => {
onLog(`Received AUTH:PROFILE_UPDATED for "${payload.name}" (${payload.email})`);
// Persist to IndexedDB via @repo/core-storage.
// Uses StorageKey.USER_PROFILE ('user_profile') which is in ENCRYPTED_KEYS,
// so the data will be AES-encrypted at rest automatically.
// Persist to IndexedDB via local AppStorageKey.
secureIndexedDB
.setItem('user_profile', payload)
.setItem(AppStorageKey.USER_PROFILE, payload)
.then(() => {
onLog(`✅ Profile persisted to IndexedDB (key: "user_profile", encrypted: true)`);
onLog(`✅ Profile persisted to IndexedDB (key: "${AppStorageKey.USER_PROFILE}", encrypted: true)`);
})
.catch((err: Error) => {
onLog(`❌ IndexedDB write failed: ${err.message}`);
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useTranslation, changeLanguage, applyTenantOverrides, i18n } from '@repo/core-i18n';
import { secureIndexedDB } from '@repo/core-storage';
import { secureIndexedDB, AppStorageKey } from '../../../../../../core/storage';
// Decentralized locale imports
import bookingId from '../locales/id/booking.json';
@@ -41,7 +41,7 @@ export default function I18nSample() {
const [adminHeaderTitle, setAdminHeaderTitle] = useState('Daftar Pengeluaran');
const [dbPayloadStr, setDbPayloadStr] = useState<string>('No data in DB');
const MOCK_DB_KEY = 'mock_db_company_a';
const MOCK_DB_KEY = AppStorageKey.MOCK_DB_COMPANY_A;
const loadDbPayload = useCallback(async () => {
try {
@@ -1,11 +1,11 @@
import { useState, useCallback } from 'react';
import { secureStorage, secureIndexedDB, StorageKey } from '@repo/core-storage';
import { secureStorage, secureIndexedDB, AppStorageKey } from '../../../../../../core/storage';
// ─── Demo Data ──────────────────────────────────────────────────
interface DemoUser {
id: number;
user: string;
id: string;
name: string;
role: string;
}
@@ -15,11 +15,16 @@ interface DemoDraft {
content: string;
}
const DEMO_USER: DemoUser = { id: 1, user: 'Firman', role: 'admin' };
const DEMO_USER: DemoUser = {
id: 'u-123',
name: 'Firman Ramdhani',
role: 'admin',
};
const DEMO_DRAFT: DemoDraft = { id: 101, type: 'offline_draft', content: 'Draft data saved offline' };
const LS_KEY = StorageKey.USER_PROFILE; // Encrypted at rest (in ENCRYPTED_KEYS)
const IDB_KEY = 'offline_draft'; // Plain key for IndexedDB demo
const LS_KEY = AppStorageKey.USER_PROFILE; // Encrypted at rest (in ENCRYPTED_KEYS)
const IDB_KEY = AppStorageKey.OFFLINE_DRAFT; // Plain key for IndexedDB demo
// ─── Shared Styles ──────────────────────────────────────────────
+35
View File
@@ -0,0 +1,35 @@
import { createLocalStorage, createIndexedDB } from '@repo/core-storage';
export const AppStorageKey = {
USER_PROFILE: 'user_profile',
LOCALE: 'app_locale',
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
MOCK_DB_COMPANY_A: 'mock_db_company_a',
OFFLINE_DRAFT: 'offline_draft',
} as const;
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
export const ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.USER_PROFILE,
AppStorageKey.ACCESS_TOKEN,
AppStorageKey.REFRESH_TOKEN,
]);
export const PLAIN_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.LOCALE,
AppStorageKey.MOCK_DB_COMPANY_A,
AppStorageKey.OFFLINE_DRAFT,
]);
export const secureStorage = createLocalStorage<AppStorageKeyValue>({
encryptedKeys: ENCRYPTED_KEYS,
plainTextKeys: PLAIN_KEYS,
});
export const secureIndexedDB = createIndexedDB<AppStorageKeyValue>({
dbName: 'eigen_erp_db',
storeName: 'web_store',
encryptedKeys: ENCRYPTED_KEYS,
plainTextKeys: PLAIN_KEYS,
});
+7 -1
View File
@@ -16,12 +16,18 @@ 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';
const App = lazy(() => import('./apps'));
async function bootstrap() {
// Initialize i18next and load language from secureStorage
await setupI18n();
await setupI18n({
storageAdapter: {
getLanguage: async () => await secureStorage.getItem<string>(AppStorageKey.LOCALE),
setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LOCALE, lng),
},
});
createRoot(document.getElementById('app')!).render(
<StrictMode>