feat: setup auth schema

This commit is contained in:
Firman Ramdhani
2026-07-30 16:43:27 +07:00
parent 8a6a16ce9a
commit fdbc5dfe99
13 changed files with 217 additions and 45 deletions
@@ -11,18 +11,22 @@ import {
Avatar,
UnstyledButton,
Box,
modals,
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 { 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';
// Dummy user data for preview
const USER = {
name: 'Jane Doe',
email: 'jane.doe@enterprise.com',
role: 'System Administrator',
label: 'System Administrator',
// avatar: null as string | null, // Simulated null for testing fallback
avatar: 'https://i.pravatar.cc/150?u=jane.doe',
};
@@ -38,6 +42,55 @@ export default function HeaderLayout() {
const { i18n, t } = useTranslation();
const { mobileOpened, toggleMobile } = useCoreAppShell();
const { colorScheme, setColorScheme } = useThemeStore();
const [userProfile, setUserProfile] = useState<any>({});
function handleLogout() {
modals.open({
modalId: 'logout-confirmation',
title: t('common:confirmDialog.logout.title'),
centered: true,
size: 'sm',
padding: 'lg',
styles: {
title: { fontWeight: 600, fontSize: 'var(--mantine-font-size-xl)' },
header: { paddingBottom: 'var(--mantine-spacing-md)' },
},
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<Text size="sm">{t('common:confirmDialog.logout.description')}</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" size="xs" onClick={() => modals.close('logout-confirmation')}>
{t('common:cancel')}
</Button>
<Button
color="red"
size="xs"
onClick={() => {
modals.close('logout-confirmation');
terminateAuthSession();
}}
>
{t('common:signOut')}
</Button>
</Group>
</div>
),
});
}
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(AppDatabaseKey.USER_PROFILE);
// TODO: Change this to setUserProfile(profile);
setUserProfile(USER);
}
useEffect(() => {
initProfile();
}, []);
return (
<Group h="100%" px="md" justify="space-between" bg="var(--mantine-color-body)" wrap="nowrap">
@@ -80,16 +133,16 @@ export default function HeaderLayout() {
}}
>
<Group gap="sm" wrap="nowrap" pl="sm">
<Avatar src={USER.avatar} size={28} radius="xl" color="blue">
{getInitials(USER.name)}
<Avatar src={userProfile?.avatar} size={28} radius="xl" color="blue">
{userProfile.name && getInitials(userProfile.name)}
</Avatar>
<Box visibleFrom="sm" style={{ textAlign: 'left' }}>
<Text size="sm" fw={600} lh={1.1} maw={140} truncate="end">
{USER.name}
{userProfile?.name}
</Text>
<Text size="xs" c="dimmed" maw={140} truncate="end">
{USER.role}
{userProfile?.label}
</Text>
</Box>
@@ -102,10 +155,10 @@ export default function HeaderLayout() {
{/* Mobile User Info */}
<Box px="sm" py="xs" hiddenFrom="sm">
<Text size="sm" fw={600} truncate="end">
{USER.name}
{userProfile.name}
</Text>
<Text size="xs" c="dimmed" truncate="end">
{USER.email}
{userProfile.label}
</Text>
</Box>
<Menu.Divider hiddenFrom="sm" />
@@ -184,7 +237,7 @@ export default function HeaderLayout() {
<Menu.Divider />
{/* Sign Out Section */}
<Menu.Item color="red" leftSection={<LogOut size={14} />}>
<Menu.Item color="red" leftSection={<LogOut size={14} />} onClick={() => handleLogout()}>
{t('common:signOut')}
</Menu.Item>
</Menu.Dropdown>
+1
View File
@@ -0,0 +1 @@
export const API_URL = {};
@@ -0,0 +1 @@
export const MODULE_ICON = {};
@@ -0,0 +1 @@
export const MODULE_KEY = {};
+1
View File
@@ -0,0 +1 @@
export const WEB_URL = {};
+4 -18
View File
@@ -1,7 +1,8 @@
import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability';
import { ENV } from '../environment';
import { AppStorageKey, appStorage } from '../storage/local';
import { AppDatabaseKey, AppStorageKey, appDatabase, appStorage } from '../storage/local';
import { terminateAuthSession } from './auth.helper';
/**
* Enterprise HTTP client for `apps/web`.
@@ -32,7 +33,7 @@ export const apiClient = createHttpClient(
const language = await appStorage.getItem<string>(AppStorageKey.LANGUAGE);
config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE;
const token = await appStorage.getItem<string>(AppStorageKey.ACCESS_TOKEN);
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
@@ -43,22 +44,7 @@ export const apiClient = createHttpClient(
const status = error.response?.status;
// Catch 401 (Unauthorized) on request
if (status === 401) {
// Delete invalid tokens
await appStorage.removeItem(AppStorageKey.ACCESS_TOKEN);
// Retrieve the current path and query string (example: /dashboard/settings?tab=profile)
const currentPath = window.location.pathname + window.location.search;
// Retrieve the current path and query string (example: /dashboard/settings?tab=profile)
const redirectParam = encodeURIComponent(currentPath);
// Use replace() instead of href.
// replace() will not save the history of the page with this error,
// so that if the user presses the 'back' button in the browser from the login page,
// they won't get stuck in an infinite loop back to the login page.
window.location.replace(`/auth/login?redirect=${redirectParam}`);
}
if (status === 401) if (status === 401) await terminateAuthSession({ preserveRedirect: true });
throw error;
},
+76
View File
@@ -0,0 +1,76 @@
import { lodash } from '@repo/utils';
import { appDatabase, AppDatabaseKey, appStorage, AppStorageKey } from '../storage/local';
import { PrivilegeEntity } from '@repo/ui/foundations';
interface TerminateOptions {
/** When true, appends ?redirect= so the user returns to their page after re-login.
* Defaults to false (intended for 401 / expired token).
* Set to false for manual logout so the user lands on a clean login page. */
preserveRedirect?: boolean;
}
export async function terminateAuthSession(options: TerminateOptions = {}) {
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 appStorage.removeItem(AppStorageKey.USER_ID);
/**
* Build the login URL
*/
let loginUrl = '/auth/login';
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) {
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');
}
+10 -7
View File
@@ -3,8 +3,6 @@ import { createLocalStorage, createIndexedDB } from '@repo/core-storage';
export const AppStorageKey = {
LANGUAGE: 'app_language',
THEME: 'app_theme',
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
USER_ID: 'uid',
SIDEBAR_OPEN_MENUS: 'sidebar_open_menus',
} as const;
@@ -12,7 +10,10 @@ export const AppStorageKey = {
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
export const AppDatabaseKey = {
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
USER_PROFILE: 'user_profile',
USER_PRIVILEGE: 'user_privilege',
OFFLINE_DRAFT: 'offline_draft',
SYSTEM_SETTINGS: 'system_settings',
HISTORY_PAGE: 'history_page',
@@ -33,10 +34,7 @@ function getUserId(userIdKey: string) {
}
}
export const APP_STORAGE_ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.ACCESS_TOKEN,
AppStorageKey.REFRESH_TOKEN,
]);
export const APP_STORAGE_ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([]);
export const APP_STORAGE_PLAIN_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.USER_ID,
@@ -58,10 +56,14 @@ export const appStorage = createLocalStorage<AppStorageKeyValue>({
getUserId: () => getUserId(AppStorageKey.USER_ID) || null,
});
export const APP_DATABASE_ENCRYPTED_KEYS = new Set<AppDatabaseKeyValue>([]);
export const APP_DATABASE_ENCRYPTED_KEYS = new Set<AppDatabaseKeyValue>([
AppDatabaseKey.ACCESS_TOKEN,
AppDatabaseKey.REFRESH_TOKEN,
]);
export const APP_DATABASE_PLAIN_KEYS = new Set<AppDatabaseKeyValue>([
AppDatabaseKey.USER_PROFILE,
AppDatabaseKey.USER_PRIVILEGE,
AppDatabaseKey.OFFLINE_DRAFT,
AppDatabaseKey.SYSTEM_SETTINGS,
AppDatabaseKey.HISTORY_PAGE,
@@ -70,6 +72,7 @@ export const APP_DATABASE_PLAIN_KEYS = new Set<AppDatabaseKeyValue>([
export const APP_DATABASE_PERSONALIZED_KEYS = new Set<AppDatabaseKeyValue>([
AppDatabaseKey.USER_PROFILE,
AppDatabaseKey.USER_PRIVILEGE,
AppDatabaseKey.OFFLINE_DRAFT,
AppDatabaseKey.SYSTEM_SETTINGS,
AppDatabaseKey.HISTORY_PAGE,