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:
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Vendored
-1
@@ -25,7 +25,6 @@
|
||||
import type {} from '@repo/core-events';
|
||||
|
||||
declare module '@repo/core-events' {
|
||||
|
||||
// ─── Payload Types ──────────────────────────────────────────
|
||||
|
||||
interface ReceiptItem {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user