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,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import AxiosInstance from 'axios';
|
||||
export { createHttpClient } from './create-http-client';
|
||||
|
||||
export type {
|
||||
HttpClientConfig,
|
||||
InterceptorHooks,
|
||||
@@ -10,3 +12,5 @@ export type {
|
||||
AxiosRequestConfig,
|
||||
InternalAxiosRequestConfig,
|
||||
} from './types';
|
||||
|
||||
export const axios = AxiosInstance;
|
||||
|
||||
@@ -111,6 +111,10 @@
|
||||
"discard": "Don't Save",
|
||||
"discardEdit": "Discard Changes",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"logout": {
|
||||
"title": "Sign Out",
|
||||
"description": "Are you sure you want to sign out? You will need to log in again to access your account."
|
||||
}
|
||||
},
|
||||
"draft": {
|
||||
|
||||
@@ -111,6 +111,10 @@
|
||||
"discard": "Jangan Simpan",
|
||||
"discardEdit": "Abaikan Perubahan",
|
||||
"cancel": "Batal"
|
||||
},
|
||||
"logout": {
|
||||
"title": "Keluar",
|
||||
"description": "Apakah Anda yakin ingin keluar? Anda perlu masuk kembali untuk mengakses akun Anda."
|
||||
}
|
||||
},
|
||||
"draft": {
|
||||
|
||||
@@ -22,3 +22,26 @@ export const defaultPrivileges: PrivilegeEntity = {
|
||||
ALLOW_LOGS: true,
|
||||
ALLOW_NOTES: true,
|
||||
};
|
||||
|
||||
export const noPrivileges: PrivilegeEntity = {
|
||||
ALLOW_VIEW: false,
|
||||
|
||||
ALLOW_CREATE: false,
|
||||
ALLOW_EDIT: false,
|
||||
ALLOW_DELETE: false,
|
||||
|
||||
ALLOW_PRINT: false,
|
||||
ALLOW_PRINT_COPY: false,
|
||||
|
||||
ALLOW_APPROVAL: false,
|
||||
ALLOW_ACTIVATE: false,
|
||||
ALLOW_DEACTIVATE: false,
|
||||
|
||||
ALLOW_CONFIRM: false,
|
||||
ALLOW_CANCEL: false,
|
||||
ALLOW_ROLLBACK: false,
|
||||
ALLOW_HOLD: false,
|
||||
|
||||
ALLOW_LOGS: false,
|
||||
ALLOW_NOTES: false,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, ReactNode } from 'react';
|
||||
import { useMemo, useState, ReactNode, useEffect } from 'react';
|
||||
import type { UseBoundStore, StoreApi } from 'zustand';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
EnterpriseModalContext,
|
||||
EnterpriseTranslationContext,
|
||||
} from '../hooks/use-module.context';
|
||||
import { defaultPrivileges } from '../constant/default-privilege';
|
||||
import { defaultPrivileges, noPrivileges } from '../constant/default-privilege';
|
||||
import { Forbidden } from '../../../components';
|
||||
|
||||
export interface EnterpriseModuleProviderProps<
|
||||
@@ -45,19 +45,34 @@ export function EnterpriseModuleProvider<
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config Slice (Static)
|
||||
// ---------------------------------------------------------------------------
|
||||
const [parsedPrivileges, setParsedPrivileges] = useState<PrivilegeEntity>(defaultPrivileges);
|
||||
|
||||
const storePrivileges = store((state: S) => state.privileges);
|
||||
useEffect(() => {
|
||||
const fetchPrivilegeData = async () => {
|
||||
try {
|
||||
// FIXME: Uncomment and complete the logic below to fetch real data.
|
||||
// Currently, this is hardcoded to use `defaultPrivileges`.
|
||||
// You need to retrieve the user privileges from the local database,
|
||||
// extract the specific config using `moduleKey`, and apply the mapping function.
|
||||
|
||||
// const privilege: any = await appDatabase.getItem(AppDatabaseKey.USER_PRIVILEGE);
|
||||
// const moduleKey: string | undefined = config?.moduleKey;
|
||||
// const privilegeConfig = privilege[moduleKey];
|
||||
|
||||
// TODO: Replace this with the mapped result, e.g., setParsedPrivileges(mappingUserPrivilege(privilegeConfig));
|
||||
setParsedPrivileges(defaultPrivileges);
|
||||
} catch (error) {
|
||||
setParsedPrivileges(noPrivileges);
|
||||
console.error('Failed to retrieve privilege data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPrivilegeData();
|
||||
}, [config?.moduleKey]);
|
||||
|
||||
const configSlice: ConfigSlice = useMemo(() => {
|
||||
// Parsing privilege: mapping string array from store to PrivilegeEntity format (boolean)
|
||||
const parsedPrivileges: PrivilegeEntity = defaultPrivileges;
|
||||
|
||||
return {
|
||||
config,
|
||||
privileges: parsedPrivileges,
|
||||
IS_MACOS: IS_MACOS,
|
||||
};
|
||||
}, [config, storePrivileges]);
|
||||
return { config, privileges: parsedPrivileges, IS_MACOS: IS_MACOS };
|
||||
}, [config, parsedPrivileges]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Translation Slice (Dedicated context — decoupled from config)
|
||||
|
||||
Reference in New Issue
Block a user