feat: setup auth schema
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const API_URL = {};
|
||||
@@ -0,0 +1 @@
|
||||
export const MODULE_ICON = {};
|
||||
@@ -0,0 +1 @@
|
||||
export const MODULE_KEY = {};
|
||||
@@ -0,0 +1 @@
|
||||
export const WEB_URL = {};
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user