From b74ea9307ebaceac5587ab017c443615735ee9c2 Mon Sep 17 00:00:00 2001 From: Hafid Nur <73023445+hafidnrzs@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:35:25 +0700 Subject: [PATCH] feat(backoffice): authentication middleware, error pages, and 404 page --- apps/backoffice/index.html | 2 +- .../backoffice/src/app/(protected)/layout.tsx | 2 +- .../(public)/auth/login/_hooks/use-login.ts | 25 +++- .../src/app/(public)/auth/login/page.tsx | 8 +- apps/backoffice/src/app/404.tsx | 23 ++++ apps/backoffice/src/app/error.tsx | 29 +++++ apps/backoffice/src/middleware.ts | 6 +- apps/backoffice/vite.config.ts | 2 +- libs/service/src/api/backoffice.ts | 63 ++++++++++ libs/service/src/hooks/auth/index.ts | 111 ++++++++++++------ 10 files changed, 224 insertions(+), 47 deletions(-) create mode 100644 apps/backoffice/src/app/404.tsx create mode 100644 apps/backoffice/src/app/error.tsx create mode 100644 libs/service/src/api/backoffice.ts diff --git a/apps/backoffice/index.html b/apps/backoffice/index.html index 52a9604..8ba1c9a 100644 --- a/apps/backoffice/index.html +++ b/apps/backoffice/index.html @@ -1,5 +1,5 @@ - + Backoffice diff --git a/apps/backoffice/src/app/(protected)/layout.tsx b/apps/backoffice/src/app/(protected)/layout.tsx index a1fb13e..957a288 100644 --- a/apps/backoffice/src/app/(protected)/layout.tsx +++ b/apps/backoffice/src/app/(protected)/layout.tsx @@ -23,7 +23,7 @@ export const AppLayout: FC = (): ReactElement => { {/* Mobile menu button (shown on small screens) */} diff --git a/apps/backoffice/src/app/404.tsx b/apps/backoffice/src/app/404.tsx new file mode 100644 index 0000000..f1e1736 --- /dev/null +++ b/apps/backoffice/src/app/404.tsx @@ -0,0 +1,23 @@ +import { Link } from 'react-router-dom'; + +export default function NotFoundPage() { + return ( +
+
+

404

+

+ Page Not Found +

+

+ The page you are looking for doesn't exist or has been moved. +

+ + Go Back Home + +
+
+ ); +} diff --git a/apps/backoffice/src/app/error.tsx b/apps/backoffice/src/app/error.tsx new file mode 100644 index 0000000..3859b00 --- /dev/null +++ b/apps/backoffice/src/app/error.tsx @@ -0,0 +1,29 @@ +import { useRouteError, isRouteErrorResponse } from 'react-router-dom'; + +export default function ErrorPage() { + const error = useRouteError(); + let errorMessage: string; + + if (isRouteErrorResponse(error)) { + errorMessage = error.statusText; + } else if (error instanceof Error) { + errorMessage = error.message; + } else if (typeof error === 'string') { + errorMessage = error; + } else { + console.error(error); + errorMessage = 'Unknown error'; + } + + return ( +
+
+

Oops!

+

+ Sorry, an unexpected error has occurred. +

+

{errorMessage}

+
+
+ ); +} diff --git a/apps/backoffice/src/middleware.ts b/apps/backoffice/src/middleware.ts index a1760a2..7a97b12 100644 --- a/apps/backoffice/src/middleware.ts +++ b/apps/backoffice/src/middleware.ts @@ -84,11 +84,11 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => { session?.role?.permissions?.map?.((perm) => perm?.name) ?? []; if (mappingPublicRoutes.includes(pathname)) { - if (token) return redirect('/dashboard'); + if (token) return redirect('/hackathon-dashboard'); return null; } - // if (!session) return redirect('/auth/login'); + if (!session) return redirect('/auth/login'); const matchedRoute = mappingRoutePermissions.find( (route) => route.path === pathname @@ -100,7 +100,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => { matchedRoute.permissions.some((perm) => userPermissions.includes(perm)); if (!hasPermission) { - return '/dashboard'; + return '/hackathon-dashboard'; } } diff --git a/apps/backoffice/vite.config.ts b/apps/backoffice/vite.config.ts index 260280d..f57edc5 100644 --- a/apps/backoffice/vite.config.ts +++ b/apps/backoffice/vite.config.ts @@ -8,7 +8,7 @@ export default defineConfig(() => ({ root: __dirname, cacheDir: '../../node_modules/.vite/apps/backoffice', server: { - port: 3000, + port: 3003, host: 'localhost', }, preview: { diff --git a/libs/service/src/api/backoffice.ts b/libs/service/src/api/backoffice.ts new file mode 100644 index 0000000..2736a93 --- /dev/null +++ b/libs/service/src/api/backoffice.ts @@ -0,0 +1,63 @@ +import axios from 'axios'; +import { useAuthStore } from '../hooks/auth'; + +// Backoffice Backend API Base URL +// In development, use proxy; in production, use full URL +const BACKOFFICE_API_URL = 'https://api.hackathon.imphnen.dev/api/v1'; + +// Create axios instance for backoffice backend +export const backofficeApi = axios.create({ + baseURL: BACKOFFICE_API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Add auth token interceptor +backofficeApi.interceptors.request.use( + (config) => { + const { session } = useAuthStore.getState(); + if (session?.token?.access_token) { + config.headers.Authorization = `Bearer ${session.token.access_token}`; + } + return config; + }, + (error) => { + return Promise.reject(new Error(error.message || 'Request failed')); + } +); + +// Error handling interceptor +backofficeApi.interceptors.response.use( + (response) => response, + (error) => { + // Handle 401 - clear session and redirect to login + if (error.response?.status === 401) { + const isAuthPage = + globalThis.window !== undefined && + globalThis.location.pathname.startsWith('/auth'); + + if (!isAuthPage) { + useAuthStore.getState().clearSession(); + if (globalThis.window !== undefined) { + globalThis.location.href = '/auth/login'; + } + } + } + + // If backend sends a message, use it + const backendMsg = error?.response?.data?.message; + if (backendMsg && typeof backendMsg === 'string') { + return Promise.reject(new Error(backendMsg)); + } + + // Fallback error message + return Promise.reject(new Error(error.message || 'An error occurred')); + } +); + +// Response type +export interface BackofficeApiResponse { + data: T; + message: string; +} diff --git a/libs/service/src/hooks/auth/index.ts b/libs/service/src/hooks/auth/index.ts index 2d41e37..a7fcb5e 100644 --- a/libs/service/src/hooks/auth/index.ts +++ b/libs/service/src/hooks/auth/index.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { hackathonApi, HackathonApiResponse } from '../../api/hackathon'; +import { backofficeApi, BackofficeApiResponse } from '../../api/backoffice'; import { useAuthStore } from './use-auth-store'; export * from './use-auth-store'; @@ -77,10 +78,9 @@ export const useLogin = () => { return useMutation({ mutationFn: async (data: LoginRequest) => { - const response = await hackathonApi.post>( - '/auth/login', - data - ); + const response = await hackathonApi.post< + HackathonApiResponse + >('/auth/login', data); return response.data.data; }, onSuccess: (data) => { @@ -115,10 +115,9 @@ export const useLogin = () => { export const useSignup = () => { return useMutation({ mutationFn: async (data: SignupRequest) => { - const response = await hackathonApi.post>( - '/auth/signup', - data - ); + const response = await hackathonApi.post< + HackathonApiResponse + >('/auth/signup', data); return response.data.data; }, }); @@ -130,10 +129,9 @@ export const useGitHubCallback = () => { return useMutation({ mutationFn: async (data: GitHubAuthRequest) => { - const response = await hackathonApi.post>( - '/auth/github', - data - ); + const response = await hackathonApi.post< + HackathonApiResponse + >('/auth/github', data); return response.data.data; }, onSuccess: (data) => { @@ -171,9 +169,9 @@ export const useSession = () => { return useQuery({ queryKey: ['auth-session'], queryFn: async () => { - const response = await hackathonApi.get>( - '/auth/session' - ); + const response = await hackathonApi.get< + HackathonApiResponse + >('/auth/session'); return response.data.data; }, enabled: !!session?.token, @@ -184,10 +182,9 @@ export const useSession = () => { export const useForgotPassword = () => { return useMutation({ mutationFn: async (data: ForgotPasswordRequest) => { - const response = await hackathonApi.post>( - '/auth/forgot-password', - data - ); + const response = await hackathonApi.post< + HackathonApiResponse + >('/auth/forgot-password', data); return response.data.data; }, }); @@ -197,10 +194,9 @@ export const useForgotPassword = () => { export const useResetPassword = () => { return useMutation({ mutationFn: async (data: ResetPasswordRequest) => { - const response = await hackathonApi.post>( - '/auth/reset-password', - data - ); + const response = await hackathonApi.post< + HackathonApiResponse + >('/auth/reset-password', data); return response.data.data; }, }); @@ -219,6 +215,45 @@ export const useSignOut = () => { }); }; +// Backoffice Login +export const useBackofficeLogin = () => { + const { setSession } = useAuthStore(); + + return useMutation({ + mutationFn: async (data: LoginRequest) => { + const response = await backofficeApi.post< + BackofficeApiResponse + >('/auth/login', data); + return response.data.data; + }, + onSuccess: (data) => { + setSession({ + token: data.token, + user: { + id: data.user.id, + email: data.user.email, + fullname: data.user.fullname, + phone_number: data.user.phone_number || '', + avatar: data.user.avatar || '', + birthdate: data.user.birthdate || '', + gender: data.user.gender || '', + is_active: data.user.is_active, + location: data.user.location, + bio: data.user.bio, + skills: data.user.skills, + role: { + id: data.user.role_id || '', + name: 'admin', + permissions: [], + created_at: '', + updated_at: '', + }, + }, + }); + }, + }); +}; + // GitHub OAuth URL helper // The frontend needs to redirect to GitHub with the client_id // After GitHub redirects back with a code, use useGitHubCallback @@ -239,7 +274,9 @@ export const useGitHubAuth = () => { // Get GitHub client ID from environment const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID || ''; if (!clientId) { - throw new Error('GitHub Client ID not configured. Set VITE_GITHUB_CLIENT_ID environment variable.'); + throw new Error( + 'GitHub Client ID not configured. Set VITE_GITHUB_CLIENT_ID environment variable.' + ); } const redirectUri = `${globalThis.location.origin}/auth/callback`; @@ -270,8 +307,16 @@ export const useEmailAuth = () => { }; }; - const signUpWithEmail = async (email: string, password: string, fullname: string) => { - const result = await signupMutation.mutateAsync({ email, password, fullname }); + const signUpWithEmail = async ( + email: string, + password: string, + fullname: string + ) => { + const result = await signupMutation.mutateAsync({ + email, + password, + fullname, + }); // Signup only returns a message (user needs to verify email first) return { message: result.message, @@ -295,10 +340,9 @@ export const useEmailAuth = () => { export const usePostLogin = () => { return useMutation({ mutationFn: async (data: LoginRequest) => { - const response = await hackathonApi.post>( - '/auth/login', - data - ); + const response = await hackathonApi.post< + HackathonApiResponse + >('/auth/login', data); return { data: response.data.data }; }, }); @@ -308,10 +352,9 @@ export const usePostLogin = () => { export const usePostRegister = () => { return useMutation({ mutationFn: async (data: SignupRequest) => { - const response = await hackathonApi.post>( - '/auth/signup', - data - ); + const response = await hackathonApi.post< + HackathonApiResponse + >('/auth/signup', data); return { data: response.data.data }; }, });