From fdbc5dfe9974ab5a199e9fd58fa11d20f81b0564 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:43:27 +0700 Subject: [PATCH] feat: setup auth schema --- .../layouts/components/header.layout.tsx | 69 +++++++++++++++-- apps/web/src/core/constants/api-url.ts | 1 + apps/web/src/core/constants/module-icon.ts | 1 + apps/web/src/core/constants/module-key.ts | 1 + apps/web/src/core/constants/web-url.ts | 1 + apps/web/src/core/lib/api-client.ts | 22 +----- apps/web/src/core/lib/auth.helper.ts | 76 +++++++++++++++++++ apps/web/src/core/storage/local/index.ts | 17 +++-- packages/core-api/src/http-client/index.ts | 4 + .../core-i18n/src/languages/en/common.json | 4 + .../core-i18n/src/languages/id/common.json | 4 + .../constant/default-privilege.ts | 23 ++++++ .../providers/module.provider.tsx | 39 +++++++--- 13 files changed, 217 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/core/constants/api-url.ts create mode 100644 apps/web/src/core/constants/module-icon.ts create mode 100644 apps/web/src/core/constants/module-key.ts create mode 100644 apps/web/src/core/constants/web-url.ts create mode 100644 apps/web/src/core/lib/auth.helper.ts diff --git a/apps/web/src/apps/modules/layouts/components/header.layout.tsx b/apps/web/src/apps/modules/layouts/components/header.layout.tsx index 8dbaf1d..db52533 100644 --- a/apps/web/src/apps/modules/layouts/components/header.layout.tsx +++ b/apps/web/src/apps/modules/layouts/components/header.layout.tsx @@ -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({}); + + 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: ( +
+ {t('common:confirmDialog.logout.description')} + + + + +
+ ), + }); + } + + 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 ( @@ -80,16 +133,16 @@ export default function HeaderLayout() { }} > - - {getInitials(USER.name)} + + {userProfile.name && getInitials(userProfile.name)} - {USER.name} + {userProfile?.name} - {USER.role} + {userProfile?.label} @@ -102,10 +155,10 @@ export default function HeaderLayout() { {/* Mobile User Info */} - {USER.name} + {userProfile.name} - {USER.email} + {userProfile.label} @@ -184,7 +237,7 @@ export default function HeaderLayout() { {/* Sign Out Section */} - }> + } onClick={() => handleLogout()}> {t('common:signOut')} diff --git a/apps/web/src/core/constants/api-url.ts b/apps/web/src/core/constants/api-url.ts new file mode 100644 index 0000000..1a1fbfb --- /dev/null +++ b/apps/web/src/core/constants/api-url.ts @@ -0,0 +1 @@ +export const API_URL = {}; diff --git a/apps/web/src/core/constants/module-icon.ts b/apps/web/src/core/constants/module-icon.ts new file mode 100644 index 0000000..f0b1bea --- /dev/null +++ b/apps/web/src/core/constants/module-icon.ts @@ -0,0 +1 @@ +export const MODULE_ICON = {}; diff --git a/apps/web/src/core/constants/module-key.ts b/apps/web/src/core/constants/module-key.ts new file mode 100644 index 0000000..9edbf5e --- /dev/null +++ b/apps/web/src/core/constants/module-key.ts @@ -0,0 +1 @@ +export const MODULE_KEY = {}; diff --git a/apps/web/src/core/constants/web-url.ts b/apps/web/src/core/constants/web-url.ts new file mode 100644 index 0000000..dbb9428 --- /dev/null +++ b/apps/web/src/core/constants/web-url.ts @@ -0,0 +1 @@ +export const WEB_URL = {}; diff --git a/apps/web/src/core/lib/api-client.ts b/apps/web/src/core/lib/api-client.ts index 6e45437..dcbfb42 100644 --- a/apps/web/src/core/lib/api-client.ts +++ b/apps/web/src/core/lib/api-client.ts @@ -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(AppStorageKey.LANGUAGE); config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE; - const token = await appStorage.getItem(AppStorageKey.ACCESS_TOKEN); + const token = await appDatabase.getItem(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; }, diff --git a/apps/web/src/core/lib/auth.helper.ts b/apps/web/src/core/lib/auth.helper.ts new file mode 100644 index 0000000..ce0fd41 --- /dev/null +++ b/apps/web/src/core/lib/auth.helper.ts @@ -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): + // { + // "TRANSACTION_BOOKING": { + // ALLOW_CREATE: true, + // ALLOW_VIEW: true, + // // ... + // } + // } + const userPrivilege: Record = {}; + + /** + * 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'); +} diff --git a/apps/web/src/core/storage/local/index.ts b/apps/web/src/core/storage/local/index.ts index c998ef5..340554e 100644 --- a/apps/web/src/core/storage/local/index.ts +++ b/apps/web/src/core/storage/local/index.ts @@ -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([ - AppStorageKey.ACCESS_TOKEN, - AppStorageKey.REFRESH_TOKEN, -]); +export const APP_STORAGE_ENCRYPTED_KEYS = new Set([]); export const APP_STORAGE_PLAIN_KEYS = new Set([ AppStorageKey.USER_ID, @@ -58,10 +56,14 @@ export const appStorage = createLocalStorage({ getUserId: () => getUserId(AppStorageKey.USER_ID) || null, }); -export const APP_DATABASE_ENCRYPTED_KEYS = new Set([]); +export const APP_DATABASE_ENCRYPTED_KEYS = new Set([ + AppDatabaseKey.ACCESS_TOKEN, + AppDatabaseKey.REFRESH_TOKEN, +]); export const APP_DATABASE_PLAIN_KEYS = new Set([ 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([ export const APP_DATABASE_PERSONALIZED_KEYS = new Set([ AppDatabaseKey.USER_PROFILE, + AppDatabaseKey.USER_PRIVILEGE, AppDatabaseKey.OFFLINE_DRAFT, AppDatabaseKey.SYSTEM_SETTINGS, AppDatabaseKey.HISTORY_PAGE, diff --git a/packages/core-api/src/http-client/index.ts b/packages/core-api/src/http-client/index.ts index f10da2c..0b7b6c2 100644 --- a/packages/core-api/src/http-client/index.ts +++ b/packages/core-api/src/http-client/index.ts @@ -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; diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json index 79f4bf1..1250e39 100644 --- a/packages/core-i18n/src/languages/en/common.json +++ b/packages/core-i18n/src/languages/en/common.json @@ -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": { diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json index d570b18..493fb98 100644 --- a/packages/core-i18n/src/languages/id/common.json +++ b/packages/core-i18n/src/languages/id/common.json @@ -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": { diff --git a/packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts b/packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts index 1cc649c..2678d0f 100644 --- a/packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts +++ b/packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts @@ -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, +}; diff --git a/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx b/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx index e424703..2b4bdb2 100644 --- a/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx +++ b/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx @@ -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(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)