refactor: improve code formatting and consistency across multiple files

- Standardized import statements and removed unnecessary line breaks for better readability in various components.
- Enhanced error handling and logging in the useElectronPrinter hook.
- Updated sample data formatting in AgGridShowcase for improved clarity.
- Refactored JSX elements for consistent indentation and structure in LandingSample, AuthPage, and EventsPage components.
- Consolidated and simplified conditional rendering logic in several components.

These changes aim to enhance code maintainability and readability throughout the project.
This commit is contained in:
shancheas
2026-08-25 17:50:48 +07:00
parent f2f0be111a
commit 67ae5b6c11
94 changed files with 1742 additions and 849 deletions
+1 -2
View File
@@ -52,8 +52,7 @@ console.log(` Destination : ${DEST_DIR}`);
// ── Validate ───────────────────────────────────────────────────
if (!existsSync(SOURCE_DIR)) {
console.error(
`\n❌ Build output not found at: ${SOURCE_DIR}\n` +
` Run "pnpm build --filter=${targetApp}" first.\n`,
`\n❌ Build output not found at: ${SOURCE_DIR}\n` + ` Run "pnpm build --filter=${targetApp}" first.\n`,
);
process.exit(1);
}
+3 -14
View File
@@ -1,11 +1,4 @@
import {
app,
shell,
BrowserWindow,
ipcMain,
protocol,
session,
} from 'electron';
import { app, shell, BrowserWindow, ipcMain, protocol, session } from 'electron';
import { autoUpdater } from 'electron-updater';
import { join, extname, normalize } from 'path';
import { readFileSync, existsSync, statSync } from 'fs';
@@ -227,8 +220,7 @@ function setupCorsBypass(): void {
// a "normal" request or a null origin
if (
requestHeaders['Origin'] &&
(requestHeaders['Origin'].startsWith('app://') ||
requestHeaders['Origin'].startsWith('file://'))
(requestHeaders['Origin'].startsWith('app://') || requestHeaders['Origin'].startsWith('file://'))
) {
delete requestHeaders['Origin'];
}
@@ -427,10 +419,7 @@ if (!gotTheLock) {
app.on('web-contents-created', (_event, contents) => {
contents.on('will-navigate', (event, url) => {
// Allow navigation within the app protocol and dev server
if (
url.startsWith('app://') ||
(IS_DEV && url.startsWith(DEV_SERVER_URL))
) {
if (url.startsWith('app://') || (IS_DEV && url.startsWith(DEV_SERVER_URL))) {
return;
}
event.preventDefault();
+3 -3
View File
@@ -1,6 +1,6 @@
import DefaultTheme from 'vitepress/theme'
import './custom.css'
import DefaultTheme from 'vitepress/theme';
import './custom.css';
export default {
extends: DefaultTheme,
}
};
@@ -29,7 +29,7 @@ export default function LandingSample() {
telemetryContext: {
customSpanName: 'public.content.fetch',
tags: {
'feature': 'landing',
feature: 'landing',
'ui.component': 'LandingSample',
'ui.action': 'content_fetch',
},
@@ -74,14 +74,19 @@ export default function LandingSample() {
{loading ? 'Fetching…' : 'Test Public Fetch'}
</button>
{error && (
<pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}>
{error}
</pre>
)}
{error && <pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}> {error}</pre>}
{result && (
<pre style={{ marginTop: 16, background: '#1e1e2e', color: '#a6e3a1', padding: 16, borderRadius: 8, overflow: 'auto' }}>
<pre
style={{
marginTop: 16,
background: '#1e1e2e',
color: '#a6e3a1',
padding: 16,
borderRadius: 8,
overflow: 'auto',
}}
>
{JSON.stringify(result, null, 2)}
</pre>
)}
+23 -19
View File
@@ -21,25 +21,29 @@ export default function App() {
const colorScheme = useThemeStore((s) => s.colorScheme);
const [density, setDensity] = useState<DensityType>('compact');
const router = useMemo(() => createBrowserRouter([
{
path: "/",
element: <ShowcaseLayout density={density} setDensity={setDensity} />,
children: [
{ index: true, element: <Navigate to="/ui-components" replace /> },
{ path: "ui-components", element: <UiComponentsPage /> },
{ path: "forms", element: <FormsPage /> },
{ path: "storage", element: <StoragePage /> },
{ path: "events", element: <EventsPage /> },
{ path: "hardware", element: <HardwarePage /> },
{ path: "rbac", element: <RbacPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "action-tools", element: <ActionToolsPage /> },
{ path: "ag-grid", element: <AgGridPage /> },
]
},
{ path: "/shell-demo", element: <ShellDemoPage /> }
]), [density]);
const router = useMemo(
() =>
createBrowserRouter([
{
path: '/',
element: <ShowcaseLayout density={density} setDensity={setDensity} />,
children: [
{ index: true, element: <Navigate to="/ui-components" replace /> },
{ path: 'ui-components', element: <UiComponentsPage /> },
{ path: 'forms', element: <FormsPage /> },
{ path: 'storage', element: <StoragePage /> },
{ path: 'events', element: <EventsPage /> },
{ path: 'hardware', element: <HardwarePage /> },
{ path: 'rbac', element: <RbacPage /> },
{ path: 'auth', element: <AuthPage /> },
{ path: 'action-tools', element: <ActionToolsPage /> },
{ path: 'ag-grid', element: <AgGridPage /> },
],
},
{ path: '/shell-demo', element: <ShellDemoPage /> },
]),
[density],
);
return (
<ThemeProvider colorScheme={colorScheme} density={density}>
@@ -74,30 +74,27 @@ export function useElectronPrinter(): UseElectronPrinterReturn {
}
}, []);
const print = useCallback(
async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
if (!window.electronAPI) {
return { success: false, failureReason: 'Not running in Electron' };
}
const print = useCallback(async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
if (!window.electronAPI) {
return { success: false, failureReason: 'Not running in Electron' };
}
setLoading(true);
setError(null);
try {
const result = await window.electronAPI.print(options);
if (!result.success && result.failureReason) {
setError(result.failureReason);
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Print failed';
setError(message);
return { success: false, failureReason: message };
} finally {
setLoading(false);
setLoading(true);
setError(null);
try {
const result = await window.electronAPI.print(options);
if (!result.success && result.failureReason) {
setError(result.failureReason);
}
},
[],
);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Print failed';
setError(message);
return { success: false, failureReason: message };
} finally {
setLoading(false);
}
}, []);
return {
printers,
@@ -3,14 +3,7 @@ import { useIsElectron } from './use-is-electron';
// ─── Types ──────────────────────────────────────────────────────
export type UpdateStatus =
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'ready'
| 'error';
export type UpdateStatus = 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'ready' | 'error';
export interface UseElectronUpdaterReturn {
/** Current status of the auto-updater lifecycle */
+38 -16
View File
@@ -34,17 +34,28 @@ export default function ShowcaseLayout({ density, setDensity }: ShowcaseLayoutPr
const currentPath = location.pathname.replace('/', '');
switch (currentPath) {
case 'rbac': return 'Role-Based Access Control and Permissions';
case 'storage': return 'Offline-First PouchDB Synchronization';
case 'auth': return 'Authentication & Security Layers';
case 'ui-components': return 'Theme, Typography, Forms & Data Grids';
case 'forms': return 'Enterprise Form Engine & Zod Validation';
case 'events': return 'Global Event Bus Synchronization';
case 'hardware': return 'Hardware Integration & Printers';
case 'layout-engine': return 'Core Layout Engine & Variants';
case 'action-tools': return 'Showcase for PageActions and RowActions components';
case 'ag-grid': return 'Enterprise Data Grid with Mantine Theme Integration';
default: return 'Architecture Showcase';
case 'rbac':
return 'Role-Based Access Control and Permissions';
case 'storage':
return 'Offline-First PouchDB Synchronization';
case 'auth':
return 'Authentication & Security Layers';
case 'ui-components':
return 'Theme, Typography, Forms & Data Grids';
case 'forms':
return 'Enterprise Form Engine & Zod Validation';
case 'events':
return 'Global Event Bus Synchronization';
case 'hardware':
return 'Hardware Integration & Printers';
case 'layout-engine':
return 'Core Layout Engine & Variants';
case 'action-tools':
return 'Showcase for PageActions and RowActions components';
case 'ag-grid':
return 'Enterprise Data Grid with Mantine Theme Integration';
default:
return 'Architecture Showcase';
}
};
@@ -58,8 +69,12 @@ export default function ShowcaseLayout({ density, setDensity }: ShowcaseLayoutPr
>
<AppShell.Navbar p="md">
<Box mb="xl">
<Title order={3} c="brand.7">Eigen ERP</Title>
<Text size="xs" c="dimmed">Architecture Showcase</Text>
<Title order={3} c="brand.7">
Eigen ERP
</Title>
<Text size="xs" c="dimmed">
Architecture Showcase
</Text>
</Box>
<Box style={{ flex: 1 }}>
{navItems.map((item) => (
@@ -74,7 +89,7 @@ export default function ShowcaseLayout({ density, setDensity }: ShowcaseLayoutPr
/>
))}
</Box>
<Box mt="auto" pt="md" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
<Select
label="Color Scheme"
@@ -101,11 +116,18 @@ export default function ShowcaseLayout({ density, setDensity }: ShowcaseLayoutPr
</AppShell.Navbar>
<AppShell.Main style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
<Paper p="md" radius={0} withBorder style={{ borderTop: 0, borderLeft: 0, borderRight: 0, zIndex: 10, flexShrink: 0 }}>
<Paper
p="md"
radius={0}
withBorder
style={{ borderTop: 0, borderLeft: 0, borderRight: 0, zIndex: 10, flexShrink: 0 }}
>
<Group justify="space-between">
<Box>
<Title order={3}>Architecture Showcase</Title>
<Text size="sm" c="dimmed">{getSubtitle()}</Text>
<Text size="sm" c="dimmed">
{getSubtitle()}
</Text>
</Box>
<Select
w={180}
+160 -17
View File
@@ -20,18 +20,162 @@ interface OrderRow {
}
const SAMPLE_DATA: OrderRow[] = [
{ id: '1', orderCode: 'ORD-2026-001', customer: 'Acme Corp', email: 'acme@example.com', product: 'Widget A', quantity: 120, unitPrice: 24.5, total: 2940, status: 'Delivered', createdAt: '2026-01-15', region: 'North America' },
{ id: '2', orderCode: 'ORD-2026-002', customer: 'Globex Inc', email: 'info@globex.com', product: 'Widget B', quantity: 50, unitPrice: 89.99, total: 4499.5, status: 'Shipped', createdAt: '2026-02-03', region: 'Europe' },
{ id: '3', orderCode: 'ORD-2026-003', customer: 'Initech', email: 'orders@initech.com', product: 'Gadget Pro', quantity: 200, unitPrice: 15.0, total: 3000, status: 'Processing', createdAt: '2026-03-21', region: 'Asia Pacific' },
{ id: '4', orderCode: 'ORD-2026-004', customer: 'Umbrella Ltd', email: 'sales@umbrella.co', product: 'Widget A', quantity: 75, unitPrice: 24.5, total: 1837.5, status: 'Pending', createdAt: '2026-04-10', region: 'Europe' },
{ id: '5', orderCode: 'ORD-2026-005', customer: 'Stark Industries', email: 'tony@stark.io', product: 'Gadget Elite', quantity: 10, unitPrice: 499.0, total: 4990, status: 'Delivered', createdAt: '2026-04-18', region: 'North America' },
{ id: '6', orderCode: 'ORD-2026-006', customer: 'Wayne Enterprises', email: 'bruce@wayne.com', product: 'Widget C', quantity: 300, unitPrice: 12.75, total: 3825, status: 'Draft', createdAt: '2026-05-02', region: 'North America' },
{ id: '7', orderCode: 'ORD-2026-007', customer: 'Cyberdyne', email: 'info@cyberdyne.jp', product: 'Gadget Pro', quantity: 150, unitPrice: 15.0, total: 2250, status: 'Cancelled', createdAt: '2026-05-15', region: 'Asia Pacific' },
{ id: '8', orderCode: 'ORD-2026-008', customer: 'Oscorp', email: 'orders@oscorp.com', product: 'Widget B', quantity: 90, unitPrice: 89.99, total: 8099.1, status: 'Shipped', createdAt: '2026-06-01', region: 'North America' },
{ id: '9', orderCode: 'ORD-2026-009', customer: 'LexCorp', email: 'lex@lexcorp.com', product: 'Gadget Elite', quantity: 5, unitPrice: 499.0, total: 2495, status: 'Processing', createdAt: '2026-06-12', region: 'Europe' },
{ id: '10', orderCode: 'ORD-2026-010', customer: 'Pied Piper', email: 'richard@piedpiper.io', product: 'Widget A', quantity: 500, unitPrice: 24.5, total: 12250, status: 'Pending', createdAt: '2026-06-20', region: 'North America' },
{ id: '11', orderCode: 'ORD-2026-011', customer: 'Hooli', email: 'gavin@hooli.com', product: 'Gadget Pro', quantity: 1000, unitPrice: 15.0, total: 15000, status: 'Delivered', createdAt: '2026-07-01', region: 'North America' },
{ id: '12', orderCode: 'ORD-2026-012', customer: 'Massive Dynamic', email: 'nina@massive.com', product: 'Widget C', quantity: 60, unitPrice: 12.75, total: 765, status: 'Shipped', createdAt: '2026-07-08', region: 'Europe' },
{
id: '1',
orderCode: 'ORD-2026-001',
customer: 'Acme Corp',
email: 'acme@example.com',
product: 'Widget A',
quantity: 120,
unitPrice: 24.5,
total: 2940,
status: 'Delivered',
createdAt: '2026-01-15',
region: 'North America',
},
{
id: '2',
orderCode: 'ORD-2026-002',
customer: 'Globex Inc',
email: 'info@globex.com',
product: 'Widget B',
quantity: 50,
unitPrice: 89.99,
total: 4499.5,
status: 'Shipped',
createdAt: '2026-02-03',
region: 'Europe',
},
{
id: '3',
orderCode: 'ORD-2026-003',
customer: 'Initech',
email: 'orders@initech.com',
product: 'Gadget Pro',
quantity: 200,
unitPrice: 15.0,
total: 3000,
status: 'Processing',
createdAt: '2026-03-21',
region: 'Asia Pacific',
},
{
id: '4',
orderCode: 'ORD-2026-004',
customer: 'Umbrella Ltd',
email: 'sales@umbrella.co',
product: 'Widget A',
quantity: 75,
unitPrice: 24.5,
total: 1837.5,
status: 'Pending',
createdAt: '2026-04-10',
region: 'Europe',
},
{
id: '5',
orderCode: 'ORD-2026-005',
customer: 'Stark Industries',
email: 'tony@stark.io',
product: 'Gadget Elite',
quantity: 10,
unitPrice: 499.0,
total: 4990,
status: 'Delivered',
createdAt: '2026-04-18',
region: 'North America',
},
{
id: '6',
orderCode: 'ORD-2026-006',
customer: 'Wayne Enterprises',
email: 'bruce@wayne.com',
product: 'Widget C',
quantity: 300,
unitPrice: 12.75,
total: 3825,
status: 'Draft',
createdAt: '2026-05-02',
region: 'North America',
},
{
id: '7',
orderCode: 'ORD-2026-007',
customer: 'Cyberdyne',
email: 'info@cyberdyne.jp',
product: 'Gadget Pro',
quantity: 150,
unitPrice: 15.0,
total: 2250,
status: 'Cancelled',
createdAt: '2026-05-15',
region: 'Asia Pacific',
},
{
id: '8',
orderCode: 'ORD-2026-008',
customer: 'Oscorp',
email: 'orders@oscorp.com',
product: 'Widget B',
quantity: 90,
unitPrice: 89.99,
total: 8099.1,
status: 'Shipped',
createdAt: '2026-06-01',
region: 'North America',
},
{
id: '9',
orderCode: 'ORD-2026-009',
customer: 'LexCorp',
email: 'lex@lexcorp.com',
product: 'Gadget Elite',
quantity: 5,
unitPrice: 499.0,
total: 2495,
status: 'Processing',
createdAt: '2026-06-12',
region: 'Europe',
},
{
id: '10',
orderCode: 'ORD-2026-010',
customer: 'Pied Piper',
email: 'richard@piedpiper.io',
product: 'Widget A',
quantity: 500,
unitPrice: 24.5,
total: 12250,
status: 'Pending',
createdAt: '2026-06-20',
region: 'North America',
},
{
id: '11',
orderCode: 'ORD-2026-011',
customer: 'Hooli',
email: 'gavin@hooli.com',
product: 'Gadget Pro',
quantity: 1000,
unitPrice: 15.0,
total: 15000,
status: 'Delivered',
createdAt: '2026-07-01',
region: 'North America',
},
{
id: '12',
orderCode: 'ORD-2026-012',
customer: 'Massive Dynamic',
email: 'nina@massive.com',
product: 'Widget C',
quantity: 60,
unitPrice: 12.75,
total: 765,
status: 'Shipped',
createdAt: '2026-07-08',
region: 'Europe',
},
];
/* ─── Status Badge Renderer ─────────────────────────────────────── */
@@ -164,11 +308,10 @@ export default function AgGridShowcase() {
AG Grid Enterprise Mantine Integration
</Title>
<Text size="sm" c="dimmed" mb="md">
This showcase demonstrates AG Grid integrated with Mantine's theme system. The grid
automatically adapts to dark/light mode changes, inherits brand colors, typography, and
border radius from the Mantine theme — all via AG Grid's built-in CSS variable system with
zero custom stylesheets. Toggle the color scheme in the Theme Controls above to see it in
action.
This showcase demonstrates AG Grid integrated with Mantine's theme system. The grid automatically adapts to
dark/light mode changes, inherits brand colors, typography, and border radius from the Mantine theme — all
via AG Grid's built-in CSS variable system with zero custom stylesheets. Toggle the color scheme in the
Theme Controls above to see it in action.
</Text>
<Group>
<NumberInput
+3 -1
View File
@@ -5,7 +5,9 @@ export default function AuthPage() {
<Container size="xl" m={0} p={0}>
<Stack gap="xl">
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">Auth & Security</Title>
<Title order={4} mb="md">
Auth & Security
</Title>
<Text c="dimmed">Auth Demo Component Coming Soon...</Text>
</Card>
</Stack>
@@ -1,12 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import {
Card,
Title,
Text,
Stack,
Badge,
Divider,
} from '@repo/ui/components';
import { Card, Title, Text, Stack, Badge, Divider } from '@repo/ui/components';
// ── Showcase Components ──────────────────────────────────────────
import { CashierUI } from './printer/cashier.ui';
@@ -65,8 +58,8 @@ export default function EventsDemoPage() {
🖨 Showcase 1: Cross-Platform Printer Abstraction
</Title>
<Text size="sm" c="dimmed" mb="md">
The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event.
The PrinterListener listens for it and simulates interacting with a physical printer.
The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event. The PrinterListener listens for it
and simulates interacting with a physical printer.
</Text>
{/* Headless listener — renders nothing visible */}
@@ -110,9 +103,8 @@ export default function EventsDemoPage() {
📈 Showcase 2: High-Frequency Real-Time Data (50 updates/sec)
</Title>
<Text size="sm" c="dimmed" mb="md">
A mock WebSocket fires <code>WS:STOCK_UPDATE</code> every 20ms.
Each StockRow subscribes to the global event but only updates when{' '}
<code>payload.id === row.id</code>. The parent grid never re-renders.
A mock WebSocket fires <code>WS:STOCK_UPDATE</code> every 20ms. Each StockRow subscribes to the global event
but only updates when <code>payload.id === row.id</code>. The parent grid never re-renders.
</Text>
<LiveStockGrid />
@@ -126,8 +118,8 @@ export default function EventsDemoPage() {
💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code>
</Title>
<Text size="sm" c="dimmed" mb="md">
ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>.
StorageSyncListener silently catches it in the background and saves to IndexedDB via <code>secureIndexedDB</code>.
ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>. StorageSyncListener silently catches
it in the background and saves to IndexedDB via <code>secureIndexedDB</code>.
</Text>
{/* Headless listener — renders nothing visible */}
@@ -1,15 +1,7 @@
import { useState } from 'react';
import { usePublishEvent } from '@repo/core-events';
import type { ReceiptItem } from '@repo/core-events';
import {
Button,
Group,
Stack,
Text,
TextInput,
Table,
Badge,
} from '@repo/ui/components';
import { Button, Group, Stack, Text, TextInput, Table, Badge } from '@repo/ui/components';
import { DEVICE_EVENTS } from '../../../../../core/constants/events';
// ─── Mock Receipt Data ──────────────────────────────────────────
@@ -38,8 +38,8 @@ export function PrinterListener({ onLog }: PrinterListenerProps) {
// Uses the preload-exposed API. The Electron main process
// handles the actual OS-level print job via `webContents.print()`.
onLog(`[Electron] Sending receipt ${payload.receiptId} to OS printer via IPC bridge...`);
window.electronAPI!
.print({ silent: true, printBackground: true })
window
.electronAPI!.print({ silent: true, printBackground: true })
.then((result) => {
if (result.success) {
onLog(`[Electron] ✅ Receipt ${payload.receiptId} printed successfully.`);
@@ -55,7 +55,9 @@ export function PrinterListener({ onLog }: PrinterListenerProps) {
// Opens the native browser print dialog. In production, you'd
// likely render a hidden print-optimized iframe first.
onLog(`[Browser] 🖨️ Receipt ${payload.receiptId} — opening browser print dialog...`);
onLog(` → Cashier: ${payload.cashierName} | Items: ${payload.items.length} | Total: $${payload.total.toFixed(2)}`);
onLog(
` → Cashier: ${payload.cashierName} | Items: ${payload.items.length} | Total: $${payload.total.toFixed(2)}`,
);
window.print();
}
});
@@ -8,9 +8,26 @@ import { WS_EVENTS } from '../../../../../core/constants/events';
* We generate 1000 unique IDs from these base tickers + numeric suffix.
*/
const BASE_TICKERS = [
'AAPL', 'GOOG', 'MSFT', 'AMZN', 'META', 'NVDA', 'TSLA', 'AMD',
'NFLX', 'ORCL', 'CRM', 'INTC', 'PYPL', 'ADBE', 'CSCO', 'QCOM',
'AVGO', 'TXN', 'MU', 'SHOP',
'AAPL',
'GOOG',
'MSFT',
'AMZN',
'META',
'NVDA',
'TSLA',
'AMD',
'NFLX',
'ORCL',
'CRM',
'INTC',
'PYPL',
'ADBE',
'CSCO',
'QCOM',
'AVGO',
'TXN',
'MU',
'SHOP',
];
/**
@@ -58,18 +58,12 @@ export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
: '#fa5252' // red
: undefined;
const changeArrow = data
? data.change >= 0
? '▲'
: '▼'
: '';
const changeArrow = data ? (data.change >= 0 ? '▲' : '▼') : '';
return (
<tr style={{ fontSize: 12, fontFamily: 'monospace' }}>
<td style={{ padding: '2px 8px', fontWeight: 600 }}>{stockId}</td>
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
{data ? `$${data.price.toFixed(2)}` : '—'}
</td>
<td style={{ padding: '2px 8px', textAlign: 'right' }}>{data ? `$${data.price.toFixed(2)}` : '—'}</td>
<td
style={{
padding: '2px 8px',
@@ -80,12 +74,8 @@ export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
>
{data ? `${changeArrow} ${data.change >= 0 ? '+' : ''}${data.change.toFixed(2)}` : '—'}
</td>
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
{data ? data.volume.toLocaleString() : ''}
</td>
<td style={{ padding: '2px 8px', textAlign: 'right', color: '#868e96' }}>
{renderCountRef.current}
</td>
<td style={{ padding: '2px 8px', textAlign: 'right' }}>{data ? data.volume.toLocaleString() : '—'}</td>
<td style={{ padding: '2px 8px', textAlign: 'right', color: '#868e96' }}>{renderCountRef.current}</td>
</tr>
);
});
@@ -151,9 +151,7 @@ export class AdvancedBookingTransformer extends BookingTransformer {
return super.transformGetManyResponse(dtos).map((entity) => ({
...entity,
// Normalize 'cancelled' vs 'canceled' from different API versions
status: entity.status === ('canceled' as BookingEntity['status'])
? 'cancelled'
: entity.status,
status: entity.status === ('canceled' as BookingEntity['status']) ? 'cancelled' : entity.status,
}));
}
}
@@ -55,4 +55,3 @@ export const bookingServices = new CommonRemoteDataServices<BookingEntity, Booki
moduleKey: 'BOOKING',
transformer: new BookingTransformer(),
});
@@ -30,10 +30,10 @@ export default function BookingSample() {
telemetryContext: {
customSpanName: 'booking.list.fetch',
tags: {
'feature': 'booking',
feature: 'booking',
'ui.component': 'BookingSample',
'ui.action': 'list_fetch',
'page': 1,
page: 1,
},
pushEventOnSuccess: 'booking_list_loaded',
},
@@ -76,14 +76,19 @@ export default function BookingSample() {
{loading ? 'Fetching…' : 'Test Fetch Bookings'}
</button>
{error && (
<pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}>
{error}
</pre>
)}
{error && <pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}> {error}</pre>}
{result && (
<pre style={{ marginTop: 16, background: '#1e1e2e', color: '#a6e3a1', padding: 16, borderRadius: 8, overflow: 'auto' }}>
<pre
style={{
marginTop: 16,
background: '#1e1e2e',
color: '#a6e3a1',
padding: 16,
borderRadius: 8,
overflow: 'auto',
}}
>
{JSON.stringify(result, null, 2)}
</pre>
)}
+3 -1
View File
@@ -7,7 +7,9 @@ export default function EventsPage() {
<Container size="xl" m={0} p={0}>
<Stack gap="xl">
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">Nested Showcase Example</Title>
<Title order={4} mb="md">
Nested Showcase Example
</Title>
<Text mb="md">This is an example of a nested showcase component.</Text>
<ExamplePage />
</Card>
@@ -1,13 +1,31 @@
import { useForm } from 'react-hook-form';
import { Button, Paper, Title, Group, Stack, Code, Divider, Text, Chip, Radio } from '@repo/ui/components';
import {
FieldTextInput, FieldPasswordInput, FieldTextarea, FieldNumberInput,
FieldJsonInput, FieldPinInput, FieldAutocomplete, FieldSelect,
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
FieldColorPicker, FieldFileInput, FieldLocalSelect, FieldAsyncSelect,
FieldRichTextEditor
import {
FieldTextInput,
FieldPasswordInput,
FieldTextarea,
FieldNumberInput,
FieldJsonInput,
FieldPinInput,
FieldAutocomplete,
FieldSelect,
FieldMultiSelect,
FieldNativeSelect,
FieldTagsInput,
FieldCheckbox,
FieldRadioGroup,
FieldSwitch,
FieldChipGroup,
FieldSegmentedControl,
FieldSlider,
FieldRangeSlider,
FieldRating,
FieldColorInput,
FieldColorPicker,
FieldFileInput,
FieldLocalSelect,
FieldAsyncSelect,
FieldRichTextEditor,
} from '@repo/ui/form';
import type { LoadOptionsFn } from '@repo/ui/form';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
@@ -48,13 +66,15 @@ const MOCK_VENDORS = [
const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
const filtered = MOCK_VENDORS.filter(
(v) => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()),
);
return { options: filtered, hasMore: false };
};
export default function AllFieldsDemo() {
const t = useFormDemoTranslation();
const { control, handleSubmit, watch } = useForm<any>({
defaultValues: {
customerName: '',
@@ -86,17 +106,18 @@ export default function AllFieldsDemo() {
asyncSelectPrefilled: { id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' },
localMultiPrefilled: [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
],
asyncMultiPrefilled: [
{ id: 888, code: 'ASYNC-88', name: 'Ghost Async Vendor 1' },
{ id: 999, code: 'ASYNC-99', name: 'Ghost Async Vendor 2' }
{ id: 999, code: 'ASYNC-99', name: 'Ghost Async Vendor 2' },
],
richTextEmpty: "",
richTextPrefilled: "<h2 style=\"text-align: center\">ERP Release Notes</h2><p>This is a <b>highly important</b> update. Please observe the following:</p><ul><li>System maintenance at <i>midnight</i>.</li><li><u style=\"text-align: justify\">All users must log out.</u></li></ul><p style=\"text-align: justify\">Thank you for your cooperation.</p>",
richTextEmpty: '',
richTextPrefilled:
'<h2 style="text-align: center">ERP Release Notes</h2><p>This is a <b>highly important</b> update. Please observe the following:</p><ul><li>System maintenance at <i>midnight</i>.</li><li><u style="text-align: justify">All users must log out.</u></li></ul><p style="text-align: justify">Thank you for your cooperation.</p>',
realPokeSelect: null,
multiRealPokeSelect: []
}
multiRealPokeSelect: [],
},
});
const onSubmit = (data: any) => console.log('All Fields Submitted:', data);
@@ -109,7 +130,9 @@ export default function AllFieldsDemo() {
<Stack gap="xl">
{/* --- Text & Numbers --- */}
<div>
<Title order={5} mb="sm" c="brand">{t.sections.textAndNumbers}</Title>
<Title order={5} mb="sm" c="brand">
{t.sections.textAndNumbers}
</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldTextInput name="customerName" control={control} label={t.fields.customerName} />
@@ -124,41 +147,40 @@ export default function AllFieldsDemo() {
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
</Group>
<div>
<Text size="sm" fw={500} mb={3}>{t.fields.pin}</Text>
<Text size="sm" fw={500} mb={3}>
{t.fields.pin}
</Text>
<FieldPinInput name="pin" control={control} length={6} />
</div>
</div>
{/* --- Selections --- */}
<div>
<Title order={5} mb="sm" c="brand">{t.sections.selections}</Title>
<Title order={5} mb="sm" c="brand">
{t.sections.selections}
</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldSelect
name="orderType"
control={control}
label={t.fields.orderType}
data={['BULK', 'RETAIL']}
/>
<FieldNativeSelect
name="nativeOrderType"
control={control}
label={`Native ${t.fields.orderType}`}
data={['BULK', 'RETAIL']}
<FieldSelect name="orderType" control={control} label={t.fields.orderType} data={['BULK', 'RETAIL']} />
<FieldNativeSelect
name="nativeOrderType"
control={control}
label={`Native ${t.fields.orderType}`}
data={['BULK', 'RETAIL']}
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldAutocomplete
name="country"
control={control}
label={t.fields.country}
data={['Indonesia', 'Singapore', 'Malaysia']}
<FieldAutocomplete
name="country"
control={control}
label={t.fields.country}
data={['Indonesia', 'Singapore', 'Malaysia']}
/>
<FieldMultiSelect
name="categories"
control={control}
label={t.fields.categories}
data={['Electronics', 'Fashion', 'Food']}
<FieldMultiSelect
name="categories"
control={control}
label={t.fields.categories}
data={['Electronics', 'Fashion', 'Food']}
/>
</Group>
<Group grow align="flex-start" mb="md">
@@ -170,7 +192,7 @@ export default function AllFieldsDemo() {
options={[
{ id: 1, name: 'Apple', type: 'Fruit' },
{ id: 2, name: 'Carrot', type: 'Vegetable' },
{ id: 3, name: 'Banana', type: 'Fruit' }
{ id: 3, name: 'Banana', type: 'Fruit' },
]}
valueKey="id"
labelKey="name"
@@ -185,7 +207,7 @@ export default function AllFieldsDemo() {
options={[
{ id: 1, name: 'Red', hex: '#f00' },
{ id: 2, name: 'Green', hex: '#0f0' },
{ id: 3, name: 'Blue', hex: '#00f' }
{ id: 3, name: 'Blue', hex: '#00f' },
]}
valueKey="id"
renderLabel={(item) => `${item.name} (${item.hex})`}
@@ -240,7 +262,9 @@ export default function AllFieldsDemo() {
</Group>
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.advancedObjectSelects}</Title>
<Title order={5} mb="sm" mt="lg" c="brand">
{t.sections.advancedObjectSelects}
</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldLocalSelect
@@ -284,7 +308,9 @@ export default function AllFieldsDemo() {
/>
</Group>
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.multiSelectEditMode}</Title>
<Title order={5} mb="sm" mt="lg" c="brand">
{t.sections.multiSelectEditMode}
</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldLocalSelect
@@ -309,7 +335,9 @@ export default function AllFieldsDemo() {
/>
</Group>
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.richTextEditor}</Title>
<Title order={5} mb="sm" mt="lg" c="brand">
{t.sections.richTextEditor}
</Title>
<Divider mb="md" />
<FieldRichTextEditor
name="richTextEmpty"
@@ -329,39 +357,35 @@ export default function AllFieldsDemo() {
{/* --- Toggles & Choices --- */}
<div>
<Title order={5} mb="sm" c="brand">{t.sections.togglesAndChoices}</Title>
<Title order={5} mb="sm" c="brand">
{t.sections.togglesAndChoices}
</Title>
<Divider mb="md" />
<Group mb="md">
<FieldCheckbox name="terms" control={control} label={t.fields.terms} />
<FieldSwitch name="receiveEmails" control={control} label={t.fields.receiveEmails} />
</Group>
<FieldRadioGroup
name="priority"
control={control}
label={t.fields.priority}
mb="md"
>
<FieldRadioGroup name="priority" control={control} label={t.fields.priority} mb="md">
<Group mt="xs">
<Radio value="low" label="Low" />
<Radio value="high" label="High" />
</Group>
</FieldRadioGroup>
<FieldSegmentedControl
name="segmentedPriority"
control={control}
<FieldSegmentedControl
name="segmentedPriority"
control={control}
label={t.fields.priority}
data={[
{ label: 'Normal', value: 'normal' },
{ label: 'Urgent', value: 'urgent' }
{ label: 'Urgent', value: 'urgent' },
]}
mb="md"
/>
<div>
<Text size="sm" fw={500} mb={3}>Chip Selection</Text>
<FieldChipGroup
name="chipSelection"
control={control}
>
<Text size="sm" fw={500} mb={3}>
Chip Selection
</Text>
<FieldChipGroup name="chipSelection" control={control}>
<Group>
<Chip value="1">Option 1</Chip>
<Chip value="2">Option 2</Chip>
@@ -372,7 +396,9 @@ export default function AllFieldsDemo() {
{/* --- Ranges & Specialized --- */}
<div>
<Title order={5} mb="sm" c="brand">{t.sections.rangesAndSpecialized}</Title>
<Title order={5} mb="sm" c="brand">
{t.sections.rangesAndSpecialized}
</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldSlider name="satisfaction" control={control} label={t.fields.rating} />
@@ -384,23 +410,31 @@ export default function AllFieldsDemo() {
</Group>
<Group grow align="flex-start" mb="md">
<div>
<Text size="sm" fw={500} mb={3}>{t.fields.themeColor} Picker</Text>
<Text size="sm" fw={500} mb={3}>
{t.fields.themeColor} Picker
</Text>
<FieldColorPicker name="colorPicker" control={control} />
</div>
<div>
<Text size="sm" fw={500} mb={3}>{t.fields.rating}</Text>
<Text size="sm" fw={500} mb={3}>
{t.fields.rating}
</Text>
<FieldRating name="rating" control={control} />
</div>
</Group>
</div>
<Button type="submit" mt="md">{t.common.submit}</Button>
<Button type="submit" mt="md">
{t.common.submit}
</Button>
</Stack>
</form>
</Paper>
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
<Title order={6} mb="xs">{t.common.submittedData}</Title>
<Title order={6} mb="xs">
{t.common.submittedData}
</Title>
<Code block>{JSON.stringify(data, null, 2)}</Code>
</Paper>
</Stack>
@@ -2,7 +2,14 @@ import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button, Paper, Title, Divider, Stack, Code, Alert, TypographyStylesProvider } from '@repo/ui/components';
import { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor } from '@repo/ui/form';
import {
FieldTextInput,
FieldSelect,
FieldSwitch,
FieldLocalSelect,
FieldAsyncSelect,
FieldRichTextEditor,
} from '@repo/ui/form';
import { useConditionalField } from '@repo/ui/hooks';
import { compose, required, emailValidator } from '@repo/ui/validators';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
@@ -23,7 +30,7 @@ interface Warehouse {
const REGIONS: Region[] = [
{ id: 'R1', code: 'APAC', taxRate: 0.1 },
{ id: 'R2', code: 'EMEA', taxRate: 0.2 }
{ id: 'R2', code: 'EMEA', taxRate: 0.2 },
];
const mockFetchWarehouses = async (regionIds: string[], search: string, page: number) => {
@@ -34,15 +41,17 @@ const mockFetchWarehouses = async (regionIds: string[], search: string, page: nu
{ id: 'W3', regionId: 'R2', name: 'London Central' },
{ id: 'W4', regionId: 'R2', name: 'Berlin Storage' },
];
const filtered = allWarehouses.filter(w => regionIds.includes(w.regionId) && w.name.toLowerCase().includes(search.toLowerCase()));
const filtered = allWarehouses.filter(
(w) => regionIds.includes(w.regionId) && w.name.toLowerCase().includes(search.toLowerCase()),
);
const pageSize = 10;
const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize);
return {
options: paginated,
hasMore: start + pageSize < filtered.length
hasMore: start + pageSize < filtered.length,
};
};
@@ -55,44 +64,48 @@ export default function ReactiveWatchDemo() {
const newsletterEmailValidator = compose(z.string(), required(t.fields.email), emailValidator());
const roleValidator = compose(z.string(), required(t.fields.role));
const reactiveSchema = useMemo(() => z
.object({
userType: z.enum(['PERSONAL', 'CORPORATE']),
corporateTaxId: z.string().optional(),
hasSpouse: z.boolean(),
spouseName: z.string().optional(),
newsletter: z.boolean(),
newsletterEmail: z.string().optional(),
department: z.string().optional(),
role: z.string().optional(),
regions: z.array(z.object({ id: z.string(), code: z.string(), taxRate: z.number() })).optional(),
warehouses: z.array(z.object({ id: z.string(), regionId: z.string(), name: z.string() })).optional(),
richTextLive: z.string().optional(),
})
.and(
z.discriminatedUnion('userType', [
z.object({ userType: z.literal('PERSONAL') }),
z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }),
]),
)
.and(
z.union([
z.object({ hasSpouse: z.literal(false) }),
z.object({ hasSpouse: z.literal(true), spouseName: spouseNameValidator }),
]),
)
.and(
z.union([
z.object({ newsletter: z.literal(false) }),
z.object({ newsletter: z.literal(true), newsletterEmail: newsletterEmailValidator }),
]),
)
.and(
z.union([
z.object({ department: z.string().min(1), role: roleValidator }),
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
]),
), [t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator]);
const reactiveSchema = useMemo(
() =>
z
.object({
userType: z.enum(['PERSONAL', 'CORPORATE']),
corporateTaxId: z.string().optional(),
hasSpouse: z.boolean(),
spouseName: z.string().optional(),
newsletter: z.boolean(),
newsletterEmail: z.string().optional(),
department: z.string().optional(),
role: z.string().optional(),
regions: z.array(z.object({ id: z.string(), code: z.string(), taxRate: z.number() })).optional(),
warehouses: z.array(z.object({ id: z.string(), regionId: z.string(), name: z.string() })).optional(),
richTextLive: z.string().optional(),
})
.and(
z.discriminatedUnion('userType', [
z.object({ userType: z.literal('PERSONAL') }),
z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }),
]),
)
.and(
z.union([
z.object({ hasSpouse: z.literal(false) }),
z.object({ hasSpouse: z.literal(true), spouseName: spouseNameValidator }),
]),
)
.and(
z.union([
z.object({ newsletter: z.literal(false) }),
z.object({ newsletter: z.literal(true), newsletterEmail: newsletterEmailValidator }),
]),
)
.and(
z.union([
z.object({ department: z.string().min(1), role: roleValidator }),
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
]),
),
[t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator],
);
const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({
resolver: zodResolver(reactiveSchema as any),
@@ -108,9 +121,9 @@ export default function ReactiveWatchDemo() {
regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }],
warehouses: [
{ id: 'W-99', regionId: 'R1', name: 'APAC Central Hub' },
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' }
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' },
],
richTextLive: "<p>Start typing to see the live preview...</p>",
richTextLive: '<p>Start typing to see the live preview...</p>',
},
});
@@ -184,18 +197,18 @@ export default function ReactiveWatchDemo() {
const isMounted = useRef(false);
const prevRegionIds = useRef<string[]>(regions?.map((r: Region) => r.id) || []);
useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
return;
}
const currentIds = regions?.map((r: Region) => r.id) || [];
const prevIds = prevRegionIds.current;
const hasChanged = currentIds.length !== prevIds.length || currentIds.some((id: string) => !prevIds.includes(id));
if (hasChanged) {
setValue('warehouses', []);
clearErrors('warehouses');
@@ -297,7 +310,7 @@ export default function ReactiveWatchDemo() {
{t.sections.reactiveWatchCascading}
</Title>
<Divider mb="sm" />
<FieldLocalSelect<Region>
multiple
name="regions"
@@ -308,7 +321,7 @@ export default function ReactiveWatchDemo() {
labelKey="code"
clearable
/>
<FieldAsyncSelect<Warehouse>
multiple
key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`}
@@ -316,18 +329,26 @@ export default function ReactiveWatchDemo() {
control={control as any}
label={t.fields.warehouses}
disabled={!regions || regions.length === 0}
loadOptions={useCallback(async (search, page) => {
if (!regions || regions.length === 0) return { options: [], hasMore: false };
return mockFetchWarehouses(regions.map((r: any) => r.id), search, page);
}, [regions])}
loadOptions={useCallback(
async (search, page) => {
if (!regions || regions.length === 0) return { options: [], hasMore: false };
return mockFetchWarehouses(
regions.map((r: any) => r.id),
search,
page,
);
},
[regions],
)}
valueKey="id"
renderLabel={(item) => `[${item.id}] ${item.name}`}
clearable
/>
{regions && regions.length > 0 && (
<Alert mt="sm" color="teal">
{t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
{t.descriptions.selectedRegionsTax}{' '}
{regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
</Alert>
)}
@@ -335,16 +356,18 @@ export default function ReactiveWatchDemo() {
{t.sections.reactiveRichTextPreview}
</Title>
<Divider mb="sm" />
<FieldRichTextEditor
name="richTextLive"
control={control as any}
label={t.fields.liveEditor}
description={t.descriptions.typeToSeePreview}
/>
<Paper p="md" withBorder radius="md" mt="sm">
<Title order={6} mb="xs">{t.sections.liveHtmlPreview}</Title>
<Title order={6} mb="xs">
{t.sections.liveHtmlPreview}
</Title>
<TypographyStylesProvider>
<div dangerouslySetInnerHTML={{ __html: watchedRichTextLive }} />
</TypographyStylesProvider>
+3 -1
View File
@@ -5,7 +5,9 @@ export default function RbacPage() {
<Container size="xl" m={0} p={0}>
<Stack gap="xl">
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">RBAC Engine</Title>
<Title order={4} mb="md">
RBAC Engine
</Title>
<Text c="dimmed">RBAC Demo Component Coming Soon...</Text>
</Card>
</Stack>
+67 -16
View File
@@ -1,4 +1,24 @@
import { Stack, Card, Title, Group, Select, Text, Badge, Divider, Button, TextInput, NumberInput, PasswordInput, Textarea, Checkbox, Switch, Table, StatusBadge, STATUS_DATA, Container } from '@repo/ui/components';
import {
Stack,
Card,
Title,
Group,
Select,
Text,
Badge,
Divider,
Button,
TextInput,
NumberInput,
PasswordInput,
Textarea,
Checkbox,
Switch,
Table,
StatusBadge,
STATUS_DATA,
Container,
} from '@repo/ui/components';
import { useThemeStore } from '../../core/stores/theme.store';
import { ColorSchemeType } from '@repo/ui/provider';
@@ -16,7 +36,9 @@ export default function UiComponentsPage() {
<Stack gap="xl">
{/* Control Panel */}
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">Theme Controls</Title>
<Title order={4} mb="md">
Theme Controls
</Title>
<Group grow align="flex-end">
<Select
label="Color Scheme"
@@ -34,18 +56,30 @@ export default function UiComponentsPage() {
<Card withBorder shadow="sm" radius="md" p="md">
<Stack gap="lg">
<div>
<Title order={4} mb="xs">Typography & Badges</Title>
<Text size="sm" c="dimmed">This is dimmed small text indicating a subtitle.</Text>
<Title order={4} mb="xs">
Typography & Badges
</Title>
<Text size="sm" c="dimmed">
This is dimmed small text indicating a subtitle.
</Text>
<Group mt="md">
<Badge color="brand">Brand Badge</Badge>
<Badge color="success" variant="light">Success Status</Badge>
<Badge color="error" variant="outline">Error State</Badge>
<Badge color="success" variant="light">
Success Status
</Badge>
<Badge color="error" variant="outline">
Error State
</Badge>
</Group>
</div>
<Divider />
<div>
<Title order={4} mb="xs">Enterprise Status Badges</Title>
<Text size="sm" c="dimmed" mb="md">Pre-configured status badges for transaction and master data.</Text>
<Title order={4} mb="xs">
Enterprise Status Badges
</Title>
<Text size="sm" c="dimmed" mb="md">
Pre-configured status badges for transaction and master data.
</Text>
<Group>
<StatusBadge status={STATUS_DATA.DRAFT} />
<StatusBadge status={STATUS_DATA.PENDING} />
@@ -59,12 +93,22 @@ export default function UiComponentsPage() {
</div>
<Divider />
<div>
<Title order={4} mb="md">Buttons</Title>
<Title order={4} mb="md">
Buttons
</Title>
<Group>
<Button variant="filled" color="brand">Filled Button</Button>
<Button variant="outline" color="brand">Outline Button</Button>
<Button variant="light" color="info">Light Info</Button>
<Button variant="subtle" color="error">Cancel</Button>
<Button variant="filled" color="brand">
Filled Button
</Button>
<Button variant="outline" color="brand">
Outline Button
</Button>
<Button variant="light" color="info">
Light Info
</Button>
<Button variant="subtle" color="error">
Cancel
</Button>
</Group>
</div>
</Stack>
@@ -72,7 +116,9 @@ export default function UiComponentsPage() {
{/* Forms */}
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">Form Elements</Title>
<Title order={4} mb="md">
Form Elements
</Title>
<Stack gap="md">
<Group grow align="flex-start">
<TextInput label="First Name" placeholder="Enter your first name" withAsterisk />
@@ -92,7 +138,9 @@ export default function UiComponentsPage() {
{/* Data Grid */}
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">Data Grid</Title>
<Title order={4} mb="md">
Data Grid
</Title>
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
@@ -108,7 +156,10 @@ export default function UiComponentsPage() {
<Table.Td>{row.id}</Table.Td>
<Table.Td>{row.customer}</Table.Td>
<Table.Td>
<Badge size="sm" color={row.status === 'Delivered' ? 'success' : row.status === 'Shipped' ? 'info' : 'warning'}>
<Badge
size="sm"
color={row.status === 'Delivered' ? 'success' : row.status === 'Shipped' ? 'info' : 'warning'}
>
{row.status}
</Badge>
</Table.Td>
-1
View File
@@ -25,7 +25,6 @@
import type {} from '@repo/core-events';
declare module '@repo/core-events' {
// ─── Payload Types ──────────────────────────────────────────
interface ReceiptItem {
+1 -1
View File
@@ -2,7 +2,7 @@ VITE_APP_ENV=development
VITE_APP_NAME=development_fe-monorepo-web
VITE_APP_VERSION=0.0.1
VITE_API_BASE_URL=http://localhost:8000/api
VITE_API_BASE_URL=http://localhost:3346
VITE_COUCHDB_BASE_URL=http://202.146.229.134:7700
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosInstance } from '@repo/core-api/http-client';
import { AuthRemoteDataServices } from './auth.remote.service';
function createMockClient() {
return {
request: vi.fn(),
} as unknown as AxiosInstance & { request: ReturnType<typeof vi.fn> };
}
describe('AuthRemoteDataServices', () => {
let httpClient: ReturnType<typeof createMockClient>;
let service: AuthRemoteDataServices;
beforeEach(() => {
httpClient = createMockClient();
service = new AuthRemoteDataServices(httpClient);
});
it('posts credentials to /auth/login', async () => {
const tokens = { accessToken: 'a', refreshToken: 'r' };
httpClient.request.mockResolvedValue({ data: tokens, status: 200 });
const result = await service.login({ username: 'alice', password: 'password123' });
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/auth/login',
method: 'POST',
data: { username: 'alice', password: 'password123' },
}),
);
expect(result).toEqual(tokens);
});
it('gets the current user from /auth/me', async () => {
const me = {
id: 'user-1',
username: 'alice',
isSuperadmin: false,
privilege: null,
permissions: {},
};
httpClient.request.mockResolvedValue({ data: me, status: 200 });
const result = await service.me();
expect(httpClient.request).toHaveBeenCalledWith(expect.objectContaining({ url: '/auth/me', method: 'GET' }));
expect(result).toEqual(me);
});
it('posts the refresh token to /auth/refresh', async () => {
const tokens = { accessToken: 'a2', refreshToken: 'r2' };
httpClient.request.mockResolvedValue({ data: tokens, status: 200 });
const result = await service.refresh('refresh-1');
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/auth/refresh',
method: 'POST',
data: { refreshToken: 'refresh-1' },
}),
);
expect(result).toEqual(tokens);
});
it('posts the refresh token to /auth/revoke', async () => {
httpClient.request.mockResolvedValue({ data: undefined, status: 204 });
await service.revoke('refresh-1');
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/auth/revoke',
method: 'POST',
data: { refreshToken: 'refresh-1' },
}),
);
});
});
@@ -0,0 +1,44 @@
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
import type { AxiosInstance } from '@repo/core-api/http-client';
import { API_URL } from '../../../core/constants/api-url';
import type { AuthUser, LoginPayload, TokenPair } from '../domain/entities/auth.entity';
export class AuthRemoteDataServices extends BaseRemoteDataServices {
constructor(httpClient: AxiosInstance) {
super(httpClient, { apiUrl: '/auth', moduleKey: 'AUTH' });
}
async login(payload: LoginPayload): Promise<TokenPair> {
const { data } = await this.customRequest<TokenPair>({
url: API_URL.AUTH_LOGIN,
method: 'POST',
data: payload,
});
return data;
}
async refresh(refreshToken: string): Promise<TokenPair> {
const { data } = await this.customRequest<TokenPair>({
url: API_URL.AUTH_REFRESH,
method: 'POST',
data: { refreshToken },
});
return data;
}
async revoke(refreshToken: string): Promise<void> {
await this.customRequest({
url: API_URL.AUTH_REVOKE,
method: 'POST',
data: { refreshToken },
});
}
async me(): Promise<AuthUser> {
const { data } = await this.customRequest<AuthUser>({
url: API_URL.AUTH_ME,
method: 'GET',
});
return data;
}
}
@@ -0,0 +1,7 @@
export type {
AuthPermissionFlags,
AuthPrivilege,
AuthUser,
LoginPayload,
TokenPair,
} from '../../../../core/lib/auth.types';
@@ -0,0 +1,4 @@
import { apiClient } from '../../../../core/lib/api-client';
import { AuthRemoteDataServices } from '../../data/auth.remote.service';
export const authDataService = new AuthRemoteDataServices(apiClient);
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { createLoginSchema } from './login.validator';
const t = (key: string) => key;
describe('createLoginSchema', () => {
const schema = createLoginSchema(t);
it('rejects empty username and password', () => {
const result = schema.safeParse({ username: '', password: '' });
expect(result.success).toBe(false);
});
it('rejects a username shorter than 3 characters', () => {
const result = schema.safeParse({ username: 'ab', password: 'password123' });
expect(result.success).toBe(false);
});
it('rejects a username longer than 32 characters', () => {
const result = schema.safeParse({
username: 'a'.repeat(33),
password: 'password123',
});
expect(result.success).toBe(false);
});
it('rejects a password shorter than 8 characters', () => {
const result = schema.safeParse({ username: 'alice', password: 'short' });
expect(result.success).toBe(false);
});
it('rejects a password longer than 72 characters', () => {
const result = schema.safeParse({ username: 'alice', password: 'p'.repeat(73) });
expect(result.success).toBe(false);
});
it('accepts credentials within the API length limits', () => {
const result = schema.safeParse({ username: 'alice', password: 'password123' });
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,14 @@
import { z } from 'zod';
import { compose, required, rangeLength } from '@repo/ui/validators';
export const createLoginSchema = (t: (key: string) => string) => {
return z.object({
username: compose(z.string(), required(t('username_label')), rangeLength(3, 32, t('username_label'))),
password: compose(z.string(), required(t('password_label')), rangeLength(8, 72, t('password_label'))),
});
};
export type LoginFormValues = {
username: string;
password: string;
};
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { getLoginErrorKey } from './get-login-error-key';
describe('getLoginErrorKey', () => {
it('maps 401 to invalidCredentials', () => {
expect(getLoginErrorKey({ response: { status: 401 } })).toBe('invalidCredentials');
});
it('maps 429 to rateLimited', () => {
expect(getLoginErrorKey({ response: { status: 429 } })).toBe('rateLimited');
});
it('maps unknown errors to generic', () => {
expect(getLoginErrorKey(new Error('network'))).toBe('generic');
expect(getLoginErrorKey({ status: 500 })).toBe('generic');
});
});
@@ -0,0 +1,10 @@
import { getHttpErrorStatus } from '../../../core/lib/get-http-error-status';
export type LoginErrorKey = 'invalidCredentials' | 'rateLimited' | 'generic';
export function getLoginErrorKey(error: unknown): LoginErrorKey {
const status = getHttpErrorStatus(error);
if (status === 401) return 'invalidCredentials';
if (status === 429) return 'rateLimited';
return 'generic';
}
+15 -12
View File
@@ -16,22 +16,20 @@ import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
import { AppStorageKey, appStorage } from '../../../core/storage/local';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createLoginSchema } from './validators/login.validator';
import { createLoginSchema } from '../domain/validators/login.validator';
import { useMemo, useState } from 'react';
import loginEn from './languages/en/login.json';
import loginId from './languages/id/login.json';
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
import { apiClient } from '../../../core/lib/api-client';
import { initiateAuthSession } from '../../../core/lib/auth.helper';
import { authDataService } from '../domain/factories';
import { initiateAuthSession, persistTokenPair, terminateAuthSession } from '../../../core/lib/auth.helper';
import { getLoginErrorKey } from './get-login-error-key';
registerModuleNamespace('auth-login', {
en: loginEn,
id: loginId,
});
export const authDataService = new CommonRemoteDataServices(apiClient, {});
export default function LoginPage() {
const { t, i18n } = useTranslation('auth-login');
const [loadingLogin, setLoadingLogin] = useState<boolean>(false);
@@ -46,16 +44,21 @@ export default function LoginPage() {
});
const { control } = formControl;
const onSubmit = async (data: any) => {
const onSubmit = async (data: { username?: string; password?: string }) => {
setLoadingLogin(true);
try {
const response = await authDataService.customRequest({ url: '/api/v1/auth', method: 'POST', data });
await initiateAuthSession(response.data);
} catch (error: any) {
const message = error?.response?.data?.message;
const tokens = await authDataService.login({
username: data.username ?? '',
password: data.password ?? '',
});
await persistTokenPair(tokens);
const me = await authDataService.me();
await initiateAuthSession(tokens, me);
} catch (error) {
await terminateAuthSession({ preserveRedirect: false });
notifications.show({
title: t('common:notifications.errorTitle'),
message: message ?? error?.message ?? 'Login failed',
message: t(getLoginErrorKey(error)),
color: 'red',
});
} finally {
@@ -8,5 +8,8 @@
"remember_me": "Remember me",
"forgot_password": "Forgot Password ?",
"login_button": "Login",
"or_login_with": "Or login with"
"or_login_with": "Or login with",
"invalidCredentials": "Invalid username or password",
"rateLimited": "Too many attempts. Please try again later.",
"generic": "Unable to sign in. Please try again."
}
@@ -8,5 +8,8 @@
"remember_me": "Ingat saya",
"forgot_password": "Lupa Kata Sandi ?",
"login_button": "Masuk",
"or_login_with": "Atau login dengan"
"or_login_with": "Atau login dengan",
"invalidCredentials": "Username atau password tidak valid",
"rateLimited": "Terlalu banyak percobaan. Silakan coba lagi nanti.",
"generic": "Tidak dapat masuk. Silakan coba lagi."
}
@@ -1,9 +0,0 @@
import { z } from 'zod';
import { compose, required } from '@repo/ui/validators';
export const createLoginSchema = (t: any) => {
return z.object({
username: compose(z.string(), required(t('username_label'))),
password: compose(z.string(), required(t('password_label'))),
});
};
@@ -15,12 +15,12 @@ import {
Button,
} from '@repo/ui/components';
import { Globe, Bell, HelpCircle, Settings, User, Briefcase, LogOut, ChevronDown, Sun, Moon } from 'lucide-react';
import { AppStorageKey, appStorage } from '../../../../core/storage/local';
import { AppDatabaseKey, AppStorageKey, appDatabase, appStorage } from '../../../../core/storage/local';
import { useThemeStore } from '../../../../core/stores/theme.store';
import { NotificationDropdown } from './notifications/notification-dropdown';
import { Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { terminateAuthSession } from '../../../../core/lib/auth.helper';
import { logoutAuthSession } from '../../../../core/lib/auth.helper';
// Dummy user data for preview
const USER = {
@@ -67,7 +67,7 @@ export default function HeaderLayout() {
size="xs"
onClick={() => {
modals.close('logout-confirmation');
terminateAuthSession();
logoutAuthSession();
}}
>
{t('common:signOut')}
@@ -79,12 +79,22 @@ export default function HeaderLayout() {
}
async function initProfile() {
// FIXME: Replace the hardcoded `USER` mock data with the actual profile data.
// Uncomment the database fetch below and update the state using the retrieved profile.
const profile = await appDatabase.getItem<{
name?: string;
username?: string;
label?: string;
avatar?: string | null;
}>(AppDatabaseKey.USER_PROFILE);
// const profile = await appDatabase.getItem(AppDatabaseKey.USER_PROFILE);
if (profile) {
setUserProfile({
name: profile.name ?? profile.username ?? '',
label: profile.label ?? '',
avatar: profile.avatar ?? null,
});
return;
}
// TODO: Change this to setUserProfile(profile);
setUserProfile(USER);
}
@@ -17,8 +17,7 @@ export default function FullPagePageDetail() {
],
}}
>
<DetailGeneral/>
<DetailGeneral />
</EnterpriseDetailPageProvider>
);
}
@@ -17,7 +17,12 @@ export function NotificationSetting({ onDirtyChange }: { onDirtyChange: (isDirty
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const { control, handleSubmit, reset, formState: { isDirty } } = useForm<NotificationSettings>({
const {
control,
handleSubmit,
reset,
formState: { isDirty },
} = useForm<NotificationSettings>({
resolver: zodResolver(notificationSchema),
defaultValues: {
email: false,
@@ -40,7 +45,7 @@ export function NotificationSetting({ onDirtyChange }: { onDirtyChange: (isDirty
setLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
// Reset form to clear dirty state
reset(data);
} catch (err) {
@@ -54,9 +59,13 @@ export function NotificationSetting({ onDirtyChange }: { onDirtyChange: (isDirty
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Box>
<Text fw={500} size="md" mb="xs">{t('setting:notification.title')}</Text>
<Text c="dimmed" size="sm" mb="md">{t('setting:notification.desc')}</Text>
<Text fw={500} size="md" mb="xs">
{t('setting:notification.title')}
</Text>
<Text c="dimmed" size="sm" mb="md">
{t('setting:notification.desc')}
</Text>
<Stack gap="sm">
<FieldSwitch
control={control}
+7 -1
View File
@@ -1 +1,7 @@
export const API_URL = {};
export const API_URL = {
AUTH_LOGIN: '/auth/login',
AUTH_REGISTER: '/auth/register',
AUTH_REFRESH: '/auth/refresh',
AUTH_REVOKE: '/auth/revoke',
AUTH_ME: '/auth/me',
} as const;
+19 -22
View File
@@ -74,30 +74,27 @@ export function useElectronPrinter(): UseElectronPrinterReturn {
}
}, []);
const print = useCallback(
async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
if (!window.electronAPI) {
return { success: false, failureReason: 'Not running in Electron' };
}
const print = useCallback(async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
if (!window.electronAPI) {
return { success: false, failureReason: 'Not running in Electron' };
}
setLoading(true);
setError(null);
try {
const result = await window.electronAPI.print(options);
if (!result.success && result.failureReason) {
setError(result.failureReason);
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Print failed';
setError(message);
return { success: false, failureReason: message };
} finally {
setLoading(false);
setLoading(true);
setError(null);
try {
const result = await window.electronAPI.print(options);
if (!result.success && result.failureReason) {
setError(result.failureReason);
}
},
[],
);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Print failed';
setError(message);
return { success: false, failureReason: message };
} finally {
setLoading(false);
}
}, []);
return {
printers,
@@ -3,14 +3,7 @@ import { useIsElectron } from './use-is-electron';
// ─── Types ──────────────────────────────────────────────────────
export type UpdateStatus =
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'ready'
| 'error';
export type UpdateStatus = 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'ready' | 'error';
export interface UseElectronUpdaterReturn {
/** Current status of the auto-updater lifecycle */
+14 -8
View File
@@ -2,14 +2,15 @@ import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability';
import { ENV } from '../environment';
import { AppDatabaseKey, AppStorageKey, appDatabase, appStorage } from '../storage/local';
import { terminateAuthSession } from './auth.helper';
import { refreshAuthSession, terminateAuthSession } from './auth.helper';
import { handleUnauthorized, isPublicAuthRequest } from './handle-unauthorized';
/**
* Enterprise HTTP client for `apps/web`.
*
* - Full Faro observability via the shared `faroAdapter`
* - Automatic Bearer token injection from localStorage
* - 401 redirect to `/auth/login`
* - 401 refresh-once + retry, then redirect to `/auth/login`
* - Supports per-request `telemetryContext` for custom spans/tags
*
* All interceptors (auth, observability, error normalization)
@@ -23,7 +24,6 @@ export const apiClient = createHttpClient(
observability: faroAdapter,
},
{
// ── Auth Interceptor ──────────────────────────────────────────
onRequest: async (config) => {
config.headers['ex-app-name'] = ENV.APP_NAME;
config.headers['ex-app-version'] = ENV.APP_VERSION;
@@ -33,18 +33,24 @@ export const apiClient = createHttpClient(
const language = await appStorage.getItem<string>(AppStorageKey.LANGUAGE);
config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE;
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
if (token) config.headers.Authorization = `Bearer ${token}`;
if (!isPublicAuthRequest(config.url)) {
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
if (token) config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
// ── Error Interceptor ─────────────────────────────────────────
onResponseError: async (error) => {
const status = error.response?.status;
// Catch 401 (Unauthorized) on request
if (status === 401) if (status === 401) await terminateAuthSession({ preserveRedirect: true });
if (status === 401) {
return handleUnauthorized(error, {
refresh: refreshAuthSession,
terminate: () => terminateAuthSession({ preserveRedirect: true }),
retry: (config) => apiClient.request(config),
}) as Promise<never>;
}
throw error;
},
+40 -21
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { appDatabase, AppDatabaseKey } from '../storage/local';
import { terminateAuthSession } from './auth.helper';
import { refreshAuthSession, terminateAuthSession } from './auth.helper';
/**
* Basic JWT decoder to check expiration.
@@ -12,16 +12,30 @@ function isTokenExpired(token: string): boolean {
if (payload.exp) {
return payload.exp * 1000 < Date.now();
}
return false; // If no exp, assume valid
} catch (e) {
return true; // Invalid token format -> treat as expired
return false;
} catch {
return true;
}
}
async function restoreSessionWithRefresh(): Promise<boolean> {
const refreshToken = await appDatabase.getItem<string>(AppDatabaseKey.REFRESH_TOKEN);
if (!refreshToken) {
return false;
}
try {
await refreshAuthSession();
return true;
} catch {
return false;
}
}
/**
* For Auth Page (Login/Register).
* - If valid token exists -> Redirect to main application.
* - If expired token exists -> Clear session, stay on auth page.
* - If expired token exists -> Try refresh, otherwise clear session and stay on auth page.
*/
export function AuthPageGuard({ children }: { children: ReactNode }) {
const navigate = useNavigate();
@@ -31,15 +45,18 @@ export function AuthPageGuard({ children }: { children: ReactNode }) {
async function checkCredential() {
try {
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
if (token && !isTokenExpired(token)) {
navigate('/app', { replace: true });
return;
}
if (await restoreSessionWithRefresh()) {
navigate('/app', { replace: true });
return;
}
if (token) {
if (!isTokenExpired(token)) {
// Valid token exists, redirect to dashboard/main app
navigate('/app', { replace: true });
return;
} else {
// Token exists but is expired. Terminate session without redirecting (we are already in Auth).
await terminateAuthSession({ preserveRedirect: false });
}
await terminateAuthSession({ preserveRedirect: false });
}
setIsChecking(false);
} catch (error) {
@@ -59,30 +76,32 @@ export function AuthPageGuard({ children }: { children: ReactNode }) {
/**
* For Root/Main App.
* - Monitors token status.
* - If expired -> Immediately terminate session and redirect to login.
* - If expired -> refresh first; terminate only when refresh is missing or fails.
*/
export function GlobalCredentialChecker({ children }: { children: ReactNode }) {
useEffect(() => {
let intervalId: any;
let intervalId: ReturnType<typeof setInterval>;
async function checkCredential() {
try {
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
if (!token || isTokenExpired(token)) {
// Token is missing or expired -> clear session and redirect to login
await terminateAuthSession({ preserveRedirect: true });
if (token && !isTokenExpired(token)) {
return;
}
if (await restoreSessionWithRefresh()) {
return;
}
await terminateAuthSession({ preserveRedirect: true });
} catch (error) {
console.error('Failed to check credentials in GlobalCredentialChecker:', error);
await terminateAuthSession({ preserveRedirect: true });
}
}
// Immediate check on mount
checkCredential();
// Periodic check every 1 minute
// eslint-disable-next-line prefer-const
intervalId = setInterval(checkCredential, 60000);
return () => clearInterval(intervalId);
+147
View File
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../storage/local', () => ({
appDatabase: {
setItem: vi.fn().mockResolvedValue(undefined),
getItem: vi.fn(),
removeItem: vi.fn().mockResolvedValue(undefined),
},
appStorage: {
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
},
AppDatabaseKey: {
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
USER_PROFILE: 'user_profile',
USER_PRIVILEGE: 'user_privilege',
},
AppStorageKey: {
USER_ID: 'uid',
},
}));
vi.mock('./api-client', () => ({
apiClient: {
request: vi.fn().mockResolvedValue({ status: 204, data: undefined }),
},
}));
import { appDatabase, appStorage } from '../storage/local';
import { apiClient } from './api-client';
import { initiateAuthSession, logoutAuthSession, persistTokenPair, terminateAuthSession } from './auth.helper';
const tokens = { accessToken: 'access-1', refreshToken: 'refresh-1' };
const me = {
id: 'user-1',
username: 'alice',
isSuperadmin: false,
privilege: { id: 'priv-1', name: 'Admin', code: 'ADMIN' },
permissions: {
PRIVILEGES: { view: true, create: false, update: false, delete: false },
},
};
function stubLocation(pathname: string, search = '') {
const replace = vi.fn();
vi.stubGlobal('window', {
location: { pathname, search, replace },
});
return replace;
}
describe('auth.helper', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('persists the token pair', async () => {
await persistTokenPair(tokens);
expect(appDatabase.setItem).toHaveBeenCalledWith('access_token', 'access-1');
expect(appDatabase.setItem).toHaveBeenCalledWith('refresh_token', 'refresh-1');
});
it('persists tokens, profile, privileges and redirects after login', async () => {
const replace = stubLocation('/auth/login', '');
await initiateAuthSession(tokens, me);
expect(appStorage.setItem).toHaveBeenCalledWith('uid', 'user-1');
expect(appDatabase.setItem).toHaveBeenCalledWith('access_token', 'access-1');
expect(appDatabase.setItem).toHaveBeenCalledWith('refresh_token', 'refresh-1');
expect(appDatabase.setItem).toHaveBeenCalledWith(
'user_profile',
expect.objectContaining({
id: 'user-1',
username: 'alice',
name: 'alice',
label: 'Admin',
isSuperadmin: false,
}),
);
expect(appDatabase.setItem).toHaveBeenCalledWith(
'user_privilege',
expect.objectContaining({
PRIVILEGES: expect.objectContaining({ ALLOW_VIEW: true, ALLOW_CREATE: false }),
}),
);
expect(replace).toHaveBeenCalledWith('/app');
});
it('honours the redirect query param after login', async () => {
const replace = stubLocation('/auth/login', '?redirect=%2Fapp%2Fcustomers');
await initiateAuthSession(tokens, me);
expect(replace).toHaveBeenCalledWith('/app/customers');
});
it('clears the refresh token when terminating a session', async () => {
const replace = stubLocation('/app/customers');
await terminateAuthSession({ preserveRedirect: true });
expect(appDatabase.removeItem).toHaveBeenCalledWith('user_privilege');
expect(appDatabase.removeItem).toHaveBeenCalledWith('user_profile');
expect(appDatabase.removeItem).toHaveBeenCalledWith('access_token');
expect(appDatabase.removeItem).toHaveBeenCalledWith('refresh_token');
expect(appStorage.removeItem).toHaveBeenCalledWith('uid');
expect(replace).toHaveBeenCalledWith('/auth/login?redirect=%2Fapp%2Fcustomers');
});
it('does not redirect when already on the login page', async () => {
const replace = stubLocation('/auth/login');
await terminateAuthSession({ preserveRedirect: true });
expect(replace).not.toHaveBeenCalled();
});
it('revokes the refresh token then terminates on logout', async () => {
stubLocation('/app');
vi.mocked(appDatabase.getItem).mockResolvedValue('refresh-1');
await logoutAuthSession();
expect(apiClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/auth/revoke',
method: 'POST',
data: { refreshToken: 'refresh-1' },
}),
);
expect(appDatabase.removeItem).toHaveBeenCalledWith('refresh_token');
});
it('still terminates when revoke fails', async () => {
stubLocation('/app');
vi.mocked(appDatabase.getItem).mockResolvedValue('refresh-1');
vi.mocked(apiClient.request).mockRejectedValueOnce(new Error('network'));
await logoutAuthSession();
expect(appDatabase.removeItem).toHaveBeenCalledWith('access_token');
expect(appDatabase.removeItem).toHaveBeenCalledWith('refresh_token');
});
});
+89 -55
View File
@@ -1,6 +1,7 @@
import { lodash } from '@repo/utils';
import { appDatabase, AppDatabaseKey, appStorage, AppStorageKey } from '../storage/local';
import { PrivilegeEntity } from '@repo/ui/foundations';
import { API_URL } from '../constants/api-url';
import { mapUserPrivileges } from './map-user-privileges';
import type { AuthUser, TokenPair } from './auth.types';
interface TerminateOptions {
/** When true, appends ?redirect= so the user returns to their page after re-login.
@@ -9,78 +10,111 @@ interface TerminateOptions {
preserveRedirect?: boolean;
}
export async function terminateAuthSession(options: TerminateOptions = {}) {
export async function persistTokenPair(tokens: TokenPair): Promise<void> {
await appDatabase.setItem(AppDatabaseKey.ACCESS_TOKEN, tokens.accessToken);
await appDatabase.setItem(AppDatabaseKey.REFRESH_TOKEN, tokens.refreshToken);
}
export async function persistUserSession(me: AuthUser): Promise<void> {
const label = me.isSuperadmin ? 'Superadmin' : (me.privilege?.name ?? me.username);
await appStorage.setItem(AppStorageKey.USER_ID, me.id);
await appDatabase.setItem(AppDatabaseKey.USER_PROFILE, {
id: me.id,
username: me.username,
isSuperadmin: me.isSuperadmin,
privilege: me.privilege,
name: me.username,
label,
});
await appDatabase.setItem(AppDatabaseKey.USER_PRIVILEGE, mapUserPrivileges(me.permissions, me.isSuperadmin));
}
export async function terminateAuthSession(options: TerminateOptions = {}): Promise<void> {
const { preserveRedirect = false } = options;
/**
* Delete invalid tokens (Clear local state)
*/
await appDatabase.removeItem(AppDatabaseKey.USER_PRIVILEGE);
await appDatabase.removeItem(AppDatabaseKey.USER_PROFILE);
await appDatabase.removeItem(AppDatabaseKey.ACCESS_TOKEN);
await appDatabase.removeItem(AppDatabaseKey.REFRESH_TOKEN);
await appStorage.removeItem(AppStorageKey.USER_ID);
/**
* Build the login URL
*/
let loginUrl = '/auth/login';
// Check if the current page is NOT the login page
const isNotLoginPage = !window.location.pathname.includes('/auth/login');
if (isNotLoginPage) {
// Set redirect parameters only if requested AND the user is not currently on the login page
if (preserveRedirect) {
const currentPath = window.location.pathname + window.location.search;
loginUrl += `?redirect=${encodeURIComponent(currentPath)}`;
}
/**
* Redirect without saving history to prevent infinite back-loops
*/
window.location.replace(loginUrl);
}
}
export async function initiateAuthSession(respLogin: any) {
try {
const data = respLogin?.data ?? {};
const { token, id } = data;
const userProfile = lodash.omit(data, ['token']);
if (!token) throw new Error('Login response missing token');
// FIXME: Populate this with the mapped privilege data.
// NOTE: The assignment below needs to be updated. Replace the empty object `{}`
// with the actual mapped data (e.g., from mappingUserPrivilege(data)).
// Expected structure (Record<string, PrivilegeEntity>):
// {
// "TRANSACTION_BOOKING": {
// ALLOW_CREATE: true,
// ALLOW_VIEW: true,
// // ...
// }
// }
const userPrivilege: Record<string, PrivilegeEntity> = {};
/**
* Store credentials in local storage
*/
await appStorage.setItem(AppStorageKey.USER_ID, id);
await appDatabase.setItem(AppDatabaseKey.ACCESS_TOKEN, token);
await appDatabase.setItem(AppDatabaseKey.USER_PROFILE, { ...userProfile, label: userProfile.role });
await appDatabase.setItem(AppDatabaseKey.USER_PRIVILEGE, userPrivilege);
/**
* Determine redirect destination
* If the user was redirected here from a protected page, honour that URL.
* Otherwise fall back to the default app entry point.
*/
const params = new URLSearchParams(window.location.search);
const redirectTo = params.get('redirect');
window.location.replace(redirectTo ? decodeURIComponent(redirectTo) : '/app');
} catch (error) {
throw error as any;
export async function initiateAuthSession(tokens: TokenPair, me: AuthUser): Promise<void> {
if (!tokens.accessToken) {
throw new Error('Login response missing access token');
}
await persistTokenPair(tokens);
await persistUserSession(me);
const params = new URLSearchParams(window.location.search);
const redirectTo = params.get('redirect');
window.location.replace(redirectTo ? decodeURIComponent(redirectTo) : '/app');
}
let refreshInFlight: Promise<TokenPair> | null = null;
export async function refreshAuthSession(): Promise<TokenPair> {
if (!refreshInFlight) {
refreshInFlight = rotateRefreshToken().finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
async function rotateRefreshToken(): Promise<TokenPair> {
const refreshToken = await appDatabase.getItem<string>(AppDatabaseKey.REFRESH_TOKEN);
if (!refreshToken) {
throw new Error('Missing refresh token');
}
const { apiClient } = await import('./api-client');
const response = await apiClient.request<TokenPair>({
url: API_URL.AUTH_REFRESH,
method: 'POST',
data: { refreshToken },
skipAuthRefresh: true,
});
const tokens: TokenPair = {
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
};
await persistTokenPair(tokens);
return tokens;
}
export async function logoutAuthSession(): Promise<void> {
const refreshToken = await appDatabase.getItem<string>(AppDatabaseKey.REFRESH_TOKEN);
try {
if (refreshToken) {
const { apiClient } = await import('./api-client');
await apiClient.request({
url: API_URL.AUTH_REVOKE,
method: 'POST',
data: { refreshToken },
skipAuthRefresh: true,
});
}
} catch {
// Always clear the local session, even if the server revoke call fails.
}
await terminateAuthSession({ preserveRedirect: false });
}
+31
View File
@@ -0,0 +1,31 @@
export interface LoginPayload {
username: string;
password: string;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
export interface AuthPrivilege {
id: string;
name: string;
code: string;
}
export interface AuthPermissionFlags {
view?: boolean;
create?: boolean;
update?: boolean;
delete?: boolean;
import?: boolean;
}
export interface AuthUser {
id: string;
username: string;
isSuperadmin: boolean;
privilege: AuthPrivilege | null;
permissions: Record<string, AuthPermissionFlags>;
}
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { defaultPrivileges, noPrivileges } from '@repo/ui/foundations';
vi.mock('../storage/local', () => ({
appDatabase: {
getItem: vi.fn(),
setItem: vi.fn(),
},
AppDatabaseKey: {
USER_PRIVILEGE: 'user_privilege',
USER_PROFILE: 'user_profile',
OFFLINE_DRAFT: 'offline_draft',
},
}));
import { appDatabase } from '../storage/local';
import { enterpriseStorageAdapter } from './enterprise-storage-adapter';
describe('enterpriseStorageAdapter', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns defaultPrivileges for any module when the user is superadmin', async () => {
vi.mocked(appDatabase.getItem).mockImplementation(async (key) => {
if (key === 'user_profile') return { isSuperadmin: true };
return null;
});
await expect(enterpriseStorageAdapter.getPrivileges('PRIVILEGES')).resolves.toEqual(defaultPrivileges);
await expect(enterpriseStorageAdapter.getPrivileges('UNKNOWN')).resolves.toEqual(defaultPrivileges);
});
it('returns stored module privileges for a regular user', async () => {
vi.mocked(appDatabase.getItem).mockImplementation(async (key) => {
if (key === 'user_profile') return { isSuperadmin: false };
if (key === 'user_privilege') {
return { PRIVILEGES: { ...noPrivileges, ALLOW_VIEW: true } };
}
return null;
});
await expect(enterpriseStorageAdapter.getPrivileges('PRIVILEGES')).resolves.toEqual({
...noPrivileges,
ALLOW_VIEW: true,
});
});
it('returns null when the module key is missing', async () => {
vi.mocked(appDatabase.getItem).mockImplementation(async (key) => {
if (key === 'user_profile') return { isSuperadmin: false };
if (key === 'user_privilege') return {};
return null;
});
await expect(enterpriseStorageAdapter.getPrivileges('MISSING')).resolves.toBeNull();
});
});
@@ -1,7 +1,11 @@
import type { EnterpriseStorageAdapter } from '@repo/ui/foundations';
import type { PrivilegeEntity } from '@repo/ui/foundations';
import { defaultPrivileges, type PrivilegeEntity } from '@repo/ui/foundations';
import { appDatabase, AppDatabaseKey } from '../storage/local';
interface StoredUserProfile {
isSuperadmin?: boolean;
}
/**
* Concrete storage adapter that wires the Enterprise Module framework
* to this app's IndexedDB instance.
@@ -10,7 +14,12 @@ import { appDatabase, AppDatabaseKey } from '../storage/local';
*/
export const enterpriseStorageAdapter: EnterpriseStorageAdapter = {
async getPrivileges(moduleKey: string): Promise<PrivilegeEntity | null> {
const allPrivileges: any = await appDatabase.getItem(AppDatabaseKey.USER_PRIVILEGE);
const profile = await appDatabase.getItem<StoredUserProfile>(AppDatabaseKey.USER_PROFILE);
if (profile?.isSuperadmin) {
return defaultPrivileges;
}
const allPrivileges = await appDatabase.getItem<Record<string, PrivilegeEntity>>(AppDatabaseKey.USER_PRIVILEGE);
if (!allPrivileges) return null;
return allPrivileges[moduleKey] ?? null;
},
@@ -0,0 +1,8 @@
export function getHttpErrorStatus(error: unknown): number | undefined {
if (!error || typeof error !== 'object') {
return undefined;
}
const candidate = error as { response?: { status?: number }; status?: number };
return candidate.response?.status ?? candidate.status;
}
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from '@repo/core-api/http-client';
import { handleUnauthorized, isPublicAuthRequest } from './handle-unauthorized';
function createAxiosError(status: number, url: string, skipAuthRefresh?: boolean): AxiosError {
return {
response: { status } as AxiosError['response'],
config: { url, skipAuthRefresh } as InternalAxiosRequestConfig,
isAxiosError: true,
name: 'AxiosError',
message: 'Request failed',
toJSON: () => ({}),
} as AxiosError;
}
describe('isPublicAuthRequest', () => {
it('treats login, register, refresh, and revoke as public', () => {
expect(isPublicAuthRequest('/auth/login')).toBe(true);
expect(isPublicAuthRequest('http://localhost:3346/auth/refresh')).toBe(true);
expect(isPublicAuthRequest('/customers')).toBe(false);
});
});
describe('handleUnauthorized', () => {
it('does not refresh or terminate for public auth routes', async () => {
const refresh = vi.fn();
const terminate = vi.fn();
const retry = vi.fn();
const error = createAxiosError(401, '/auth/login');
await expect(handleUnauthorized(error, { refresh, terminate, retry })).rejects.toBe(error);
expect(refresh).not.toHaveBeenCalled();
expect(terminate).not.toHaveBeenCalled();
expect(retry).not.toHaveBeenCalled();
});
it('refreshes once and retries the original request', async () => {
const refresh = vi.fn().mockResolvedValue(undefined);
const terminate = vi.fn();
const retried = { status: 200, data: { ok: true } } as AxiosResponse;
const retry = vi.fn().mockResolvedValue(retried);
const error = createAxiosError(401, '/customers');
const result = await handleUnauthorized(error, { refresh, terminate, retry });
expect(refresh).toHaveBeenCalledTimes(1);
expect(retry).toHaveBeenCalledWith(expect.objectContaining({ url: '/customers', skipAuthRefresh: true }));
expect(terminate).not.toHaveBeenCalled();
expect(result).toBe(retried);
});
it('terminates when skipAuthRefresh is set', async () => {
const refresh = vi.fn();
const terminate = vi.fn().mockResolvedValue(undefined);
const retry = vi.fn();
const error = createAxiosError(401, '/customers', true);
await expect(handleUnauthorized(error, { refresh, terminate, retry })).rejects.toBe(error);
expect(refresh).not.toHaveBeenCalled();
expect(terminate).toHaveBeenCalledTimes(1);
});
it('terminates when refresh fails', async () => {
const refresh = vi.fn().mockRejectedValue(new Error('invalid refresh'));
const terminate = vi.fn().mockResolvedValue(undefined);
const retry = vi.fn();
const error = createAxiosError(401, '/customers');
await expect(handleUnauthorized(error, { refresh, terminate, retry })).rejects.toBe(error);
expect(terminate).toHaveBeenCalledTimes(1);
expect(retry).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,36 @@
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from '@repo/core-api/http-client';
const PUBLIC_AUTH_PATHS = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/revoke'];
export function isPublicAuthRequest(url?: string): boolean {
if (!url) return false;
return PUBLIC_AUTH_PATHS.some((path) => url.includes(path));
}
export interface UnauthorizedHandlerDeps {
refresh: () => Promise<unknown>;
terminate: () => Promise<unknown>;
retry: (config: InternalAxiosRequestConfig) => Promise<AxiosResponse>;
}
export async function handleUnauthorized(error: AxiosError, deps: UnauthorizedHandlerDeps): Promise<AxiosResponse> {
const config = error.config;
const url = config?.url;
if (isPublicAuthRequest(url)) {
throw error;
}
if (!config || config.skipAuthRefresh) {
await deps.terminate();
throw error;
}
try {
await deps.refresh();
return await deps.retry({ ...config, skipAuthRefresh: true });
} catch {
await deps.terminate();
throw error;
}
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { noPrivileges } from '@repo/ui/foundations';
import { mapUserPrivileges } from './map-user-privileges';
describe('mapUserPrivileges', () => {
it('maps view/create/update/delete and ignores extra API flags', () => {
const result = mapUserPrivileges(
{
PRIVILEGES: {
view: true,
create: true,
update: false,
delete: false,
import: true,
},
},
false,
);
expect(result.PRIVILEGES).toEqual({
...noPrivileges,
ALLOW_VIEW: true,
ALLOW_CREATE: true,
ALLOW_EDIT: false,
ALLOW_DELETE: false,
});
});
it('defaults missing ALLOW flags to false', () => {
const result = mapUserPrivileges({ CUSTOMERS: { view: true } }, false);
expect(result.CUSTOMERS).toEqual({
...noPrivileges,
ALLOW_VIEW: true,
ALLOW_CREATE: false,
ALLOW_EDIT: false,
ALLOW_DELETE: false,
});
});
it('returns an empty map for superadmin (adapter grants full privileges)', () => {
const result = mapUserPrivileges(
{
PRIVILEGES: { view: true, create: true, update: true, delete: true },
},
true,
);
expect(result).toEqual({});
});
it('returns an empty map when permissions are missing', () => {
expect(mapUserPrivileges(undefined, false)).toEqual({});
});
});
@@ -0,0 +1,25 @@
import { noPrivileges, type PrivilegeEntity } from '@repo/ui/foundations';
import type { AuthPermissionFlags, AuthUser } from './auth.types';
export function mapUserPrivileges(
permissions: AuthUser['permissions'] | undefined,
isSuperadmin: boolean,
): Record<string, PrivilegeEntity> {
if (isSuperadmin || !permissions) {
return {};
}
return Object.fromEntries(
Object.entries(permissions).map(([moduleKey, flags]) => [moduleKey, mapPermissionFlags(flags)]),
);
}
function mapPermissionFlags(flags: AuthPermissionFlags = {}): PrivilegeEntity {
return {
...noPrivileges,
ALLOW_VIEW: flags.view ?? false,
ALLOW_CREATE: flags.create ?? false,
ALLOW_EDIT: flags.update ?? false,
ALLOW_DELETE: flags.delete ?? false,
};
}
-1
View File
@@ -25,7 +25,6 @@
import type {} from '@repo/core-events';
declare module '@repo/core-events' {
// ─── Payload Types ──────────────────────────────────────────
interface ReceiptItem {
+5 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'vite';
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
@@ -18,4 +18,8 @@ export default defineConfig({
events: 'events',
},
},
test: {
environment: 'node',
globals: false,
},
});