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 -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,
},
});