feat: migrate all 5 Vite apps from react-router to TanStack Router

Migrated backoffice, hackathon, dimentorin, gacha, qrcampaign, and
infra from custom react-router file-based routing to TanStack Router
file-based routing following the tanstack-frontend-best-practice
convention.

Key changes per app:
- New routes/ directory with __root.tsx, _public.tsx, _authenticated.tsx
- Auth guards via beforeLoad (replaces old middleware.ts)
- createFileRoute pattern for all page components
- TanStackRouterVite plugin in vite.config for auto route generation
- _components/_hooks folders colocated with routes (ignored by router)
- routeTree.gen.ts auto-generated on dev/build

Convention:
- _public/* routes redirect to dashboard if authenticated
- _authenticated/* routes redirect to /auth/login if not authenticated
- $param for dynamic segments (was [param] in old convention)
- _layout suffix for pathless layout routes

Removed:
- Old src/app/ directories from all apps
- Old src/middleware.ts files
- Custom convertPagesToRoute utility (no longer needed)
- react-router dependency usage (kept in package.json for shared libs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-11 17:04:14 +07:00
co-authored by Claude Opus 4.6
parent 86d9e759a6
commit 4240e8eb51
291 changed files with 3889 additions and 4585 deletions
-23
View File
@@ -1,23 +0,0 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
<h2 className="text-3xl font-semibold text-gray-900 mb-4">
Page Not Found
</h2>
<p className="text-gray-600 mb-8">
The page you are looking for doesn't exist or has been moved.
</p>
<Link
to="/"
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Go Back Home
</Link>
</div>
</div>
);
}
-10
View File
@@ -1,10 +0,0 @@
import { Outlet } from 'react-router-dom';
import { RequireAdmin } from '../features/admin/components/RequireAdmin';
export default function AdminLayout() {
return (
<RequireAdmin>
<Outlet />
</RequireAdmin>
);
}
-3
View File
@@ -1,3 +0,0 @@
export default function AdminPage() {
return <div className="p-4">Select a menu item from the sidebar.</div>;
}
-29
View File
@@ -1,29 +0,0 @@
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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
<p className="text-xl text-gray-700 mb-2">
Sorry, an unexpected error has occurred.
</p>
<p className="text-gray-500 italic">{errorMessage}</p>
</div>
</div>
);
}
@@ -1,5 +1,3 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../auth/store/auth.store';
interface RequireAdminProps {
@@ -9,15 +7,14 @@ interface RequireAdminProps {
export const RequireAdmin = ({ children }: RequireAdminProps) => {
const user = useAuthStore((state) => state.user);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
return null;
}
const userRole = user?.role?.name;
if (userRole !== 'Admin' && userRole !== 'Super Admin') {
return <Navigate to="/" replace />;
return null;
}
return children;
@@ -1,4 +1,4 @@
import { Outlet, Link, useLocation } from 'react-router-dom';
import { Outlet, Link, useLocation } from '@tanstack/react-router';
import { useAuthStore } from '../../auth/store/auth.store';
export const AdminDashboard = () => {
@@ -1,6 +1,4 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../../features/auth/store/auth.store';
import { useAuthStore } from '../store/auth.store';
interface RequireAuthProps {
children: JSX.Element;
@@ -8,10 +6,9 @@ interface RequireAuthProps {
export const RequireAuth = ({ children }: RequireAuthProps) => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
return null;
}
return children;
@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useAuthStore } from '../store/auth.store';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation } from '@tanstack/react-router';
interface LoginFormInputs {
email: string;
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { FC, ReactElement, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation } from '@tanstack/react-router';
import { cn, For } from '@imphnen-frontend-service/utils';
import { useAuthStore } from '../app/features/auth/store/auth.store';
+18 -32
View File
@@ -1,42 +1,28 @@
import { createRoot } from 'react-dom/client';
import { StrictMode } from 'react';
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router';
import {
add404PageToRoutesChildren,
addErrorElementToRoutes,
convertPagesToRoute,
ModalLoginProvider,
QueryProvider,
} from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner';
import './index.css';
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { QueryProvider } from '@imphnen-frontend-service/utils'
import { Toaster } from 'sonner'
import { routeTree } from './routeTree.gen'
import './index.css'
const files = import.meta.glob('./app/**/*(page|layout).tsx');
const errorFiles = import.meta.glob('./app/**/*error.tsx');
const notFoundFiles = import.meta.glob('./app/**/*404.tsx');
const loadingFiles = import.meta.glob('./app/**/*loading.tsx');
const router = createRouter({ routeTree })
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject;
addErrorElementToRoutes(errorFiles, routes);
add404PageToRoutesChildren(notFoundFiles, routes);
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
const router = createBrowserRouter([
{
...routes,
},
]);
const rootElement = document.getElementById('root')
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
if (!rootElement) throw new Error('Failed to find the root element')
createRoot(rootElement).render(
<StrictMode>
<QueryProvider>
<ModalLoginProvider>
<Toaster position="top-right" richColors />
<RouterProvider router={router} />
</ModalLoginProvider>
<RouterProvider router={router} />
<Toaster position="top-right" richColors />
</QueryProvider>
</StrictMode>
);
)
+2
View File
@@ -0,0 +1,2 @@
// This file is auto-generated by TanStack Router
export const routeTree = {} as any
+5
View File
@@ -0,0 +1,5 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => <Outlet />,
})
@@ -1,47 +1,21 @@
import {
Outlet,
ScrollRestoration,
useLocation,
useNavigate,
} from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useAuthStore } from './features/auth/store/auth.store';
import { Sidebar } from '../components/Sidebar';
import { MenuOutlined } from '@ant-design/icons';
const isPublicRoute = (pathname: string) => {
return pathname.startsWith('/auth') || pathname === '/auth/callback';
};
export default function RootLayout() {
const location = useLocation();
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
useEffect(() => {
if (isPublicRoute(location.pathname)) {
return;
}
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { useAuthStore } from '../app/features/auth/store/auth.store'
import { Sidebar } from '../components/Sidebar'
import { MenuOutlined } from '@ant-design/icons'
import { useState } from 'react'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
const { isAuthenticated } = useAuthStore.getState()
if (!isAuthenticated) {
navigate('/auth/login', { replace: true });
return;
throw redirect({ to: '/auth/login' })
}
}, [location.pathname, navigate, isAuthenticated]);
},
component: AuthenticatedLayout,
})
if (isPublicRoute(location.pathname)) {
return (
<>
<Outlet />
<ScrollRestoration />
</>
);
}
if (!isAuthenticated) {
return null;
}
function AuthenticatedLayout() {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
return (
<div className="flex min-h-screen bg-gray-50">
@@ -67,8 +41,6 @@ export default function RootLayout() {
<Outlet />
</main>
</div>
<ScrollRestoration />
</div>
);
)
}
@@ -0,0 +1,16 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { useAuthStore } from '../../app/features/auth/store/auth.store'
export const Route = createFileRoute('/_authenticated/admin')({
beforeLoad: () => {
const { user, isAuthenticated } = useAuthStore.getState()
if (!isAuthenticated) {
throw redirect({ to: '/auth/login' })
}
const userRole = user?.role?.name
if (userRole !== 'Admin' && userRole !== 'Super Admin') {
throw redirect({ to: '/' })
}
},
component: () => <Outlet />,
})
@@ -1,88 +1,93 @@
import { useState, useEffect } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
import { createFileRoute } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import { ColumnDef } from '@tanstack/react-table'
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
import {
campaignService,
Campaign,
CreateCampaignRequest,
} from '../../features/admin/api/campaign.service';
} from '../../../app/features/admin/api/campaign.service'
import {
DeleteOutlined,
CheckCircleOutlined,
PlusOutlined,
FileOutlined,
} from '@ant-design/icons';
} from '@ant-design/icons'
export default function CampaignsPage() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [createLoading, setCreateLoading] = useState(false);
export const Route = createFileRoute('/_authenticated/admin/campaigns')({
component: CampaignsPage,
})
function CampaignsPage() {
const [campaigns, setCampaigns] = useState<Campaign[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const [formData, setFormData] = useState<CreateCampaignRequest>({
name: '',
url: '',
});
})
const fetchCampaigns = async () => {
try {
setLoading(true);
setError(null);
const data = await campaignService.getCampaigns();
setCampaigns(data);
setLoading(true)
setError(null)
const data = await campaignService.getCampaigns()
setCampaigns(data)
} catch (err) {
setError('Failed to load campaigns');
console.error('Error fetching campaigns:', err);
setError('Failed to load campaigns')
console.error('Error fetching campaigns:', err)
} finally {
setLoading(false);
setLoading(false)
}
};
}
useEffect(() => {
fetchCampaigns();
}, []);
fetchCampaigns()
}, [])
const handleCreateCampaign = async (e: React.FormEvent) => {
e.preventDefault();
e.preventDefault()
try {
setCreateLoading(true);
await campaignService.createCampaign(formData);
setShowCreateModal(false);
setFormData({ name: '', url: '' });
await fetchCampaigns();
setCreateLoading(true)
await campaignService.createCampaign(formData)
setShowCreateModal(false)
setFormData({ name: '', url: '' })
await fetchCampaigns()
} catch (err) {
console.error('Error creating campaign:', err);
alert('Failed to create campaign');
console.error('Error creating campaign:', err)
alert('Failed to create campaign')
} finally {
setCreateLoading(false);
setCreateLoading(false)
}
};
}
const handleActivateCampaign = async (campaignId: string) => {
try {
await campaignService.activateCampaign(campaignId);
await fetchCampaigns();
await campaignService.activateCampaign(campaignId)
await fetchCampaigns()
} catch (err) {
console.error('Error activating campaign:', err);
alert('Failed to activate campaign');
console.error('Error activating campaign:', err)
alert('Failed to activate campaign')
}
};
}
const handleDeleteCampaign = async (
campaignId: string,
campaignName: string
) => {
if (!confirm(`Are you sure you want to delete "${campaignName}"?`)) {
return;
return
}
try {
await campaignService.deleteCampaign(campaignId);
await fetchCampaigns();
await campaignService.deleteCampaign(campaignId)
await fetchCampaigns()
} catch (err) {
console.error('Error deleting campaign:', err);
alert('Failed to delete campaign');
console.error('Error deleting campaign:', err)
alert('Failed to delete campaign')
}
};
}
const columns: ColumnDef<Campaign>[] = [
{
@@ -156,7 +161,7 @@ export default function CampaignsPage() {
</div>
),
},
];
]
if (loading) {
return (
@@ -165,7 +170,7 @@ export default function CampaignsPage() {
<div className="text-gray-600">Loading campaigns...</div>
</div>
</div>
);
)
}
if (error) {
@@ -175,7 +180,7 @@ export default function CampaignsPage() {
{error}
</div>
</div>
);
)
}
return (
@@ -260,8 +265,8 @@ export default function CampaignsPage() {
<button
type="button"
onClick={() => {
setShowCreateModal(false);
setFormData({ name: '', url: '' });
setShowCreateModal(false)
setFormData({ name: '', url: '' })
}}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors cursor-pointer"
disabled={createLoading}
@@ -281,5 +286,5 @@ export default function CampaignsPage() {
</div>
)}
</div>
);
)
}
@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/admin/')({
component: AdminPage,
})
function AdminPage() {
return <div className="p-4">Select a menu item from the sidebar.</div>
}
@@ -1,63 +1,68 @@
import { useState, useEffect } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
import { userService, User } from '../../features/admin/api/user.service';
import { DeleteOutlined, EditOutlined, UserOutlined } from '@ant-design/icons';
import { createFileRoute } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import { ColumnDef } from '@tanstack/react-table'
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
import { userService, User } from '../../../app/features/admin/api/user.service'
import { DeleteOutlined, EditOutlined, UserOutlined } from '@ant-design/icons'
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingUserId, setEditingUserId] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<string>('');
export const Route = createFileRoute('/_authenticated/admin/users')({
component: UsersPage,
})
function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editingUserId, setEditingUserId] = useState<string | null>(null)
const [selectedRole, setSelectedRole] = useState<string>('')
const fetchUsers = async () => {
try {
setLoading(true);
setError(null);
const data = await userService.getUsers();
setUsers(data);
setLoading(true)
setError(null)
const data = await userService.getUsers()
setUsers(data)
} catch (err) {
setError('Failed to load users');
console.error('Error fetching users:', err);
setError('Failed to load users')
console.error('Error fetching users:', err)
} finally {
setLoading(false);
setLoading(false)
}
};
}
useEffect(() => {
fetchUsers();
}, []);
fetchUsers()
}, [])
const handleUpdateRole = async (userId: string, currentRole: string) => {
if (editingUserId === userId) {
try {
await userService.updateUserRole(userId, selectedRole);
setEditingUserId(null);
setSelectedRole('');
await fetchUsers();
await userService.updateUserRole(userId, selectedRole)
setEditingUserId(null)
setSelectedRole('')
await fetchUsers()
} catch (err) {
console.error('Error updating user role:', err);
alert('Failed to update user role');
console.error('Error updating user role:', err)
alert('Failed to update user role')
}
} else {
setEditingUserId(userId);
setSelectedRole(currentRole);
setEditingUserId(userId)
setSelectedRole(currentRole)
}
};
}
const handleDeleteUser = async (userId: string, userName: string) => {
if (!confirm(`Are you sure you want to delete user "${userName}"?`)) {
return;
return
}
try {
await userService.deleteUser(userId);
await fetchUsers();
await userService.deleteUser(userId)
await fetchUsers()
} catch (err) {
console.error('Error deleting user:', err);
alert('Failed to delete user');
console.error('Error deleting user:', err)
alert('Failed to delete user')
}
};
}
const columns: ColumnDef<User>[] = [
{
@@ -78,7 +83,7 @@ export default function UsersPage() {
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => {
const isEditing = editingUserId === row.original.id;
const isEditing = editingUserId === row.original.id
return (
<div className="flex items-center gap-2">
{isEditing ? (
@@ -103,7 +108,7 @@ export default function UsersPage() {
</span>
)}
</div>
);
)
},
},
{
@@ -119,7 +124,7 @@ export default function UsersPage() {
id: 'actions',
header: 'Actions',
cell: ({ row }) => {
const isEditing = editingUserId === row.original.id;
const isEditing = editingUserId === row.original.id
return (
<div className="flex gap-2">
<button
@@ -138,8 +143,8 @@ export default function UsersPage() {
{isEditing && (
<button
onClick={() => {
setEditingUserId(null);
setSelectedRole('');
setEditingUserId(null)
setSelectedRole('')
}}
className="p-2 text-gray-600 hover:bg-gray-50 rounded-md transition-colors cursor-pointer"
title="Cancel"
@@ -159,10 +164,10 @@ export default function UsersPage() {
</button>
)}
</div>
);
)
},
},
];
]
if (loading) {
return (
@@ -171,7 +176,7 @@ export default function UsersPage() {
<div className="text-gray-600">Loading users...</div>
</div>
</div>
);
)
}
if (error) {
@@ -181,7 +186,7 @@ export default function UsersPage() {
{error}
</div>
</div>
);
)
}
return (
@@ -209,5 +214,5 @@ export default function UsersPage() {
<DataTable data={users} columns={columns} pageSize={10} />
)}
</div>
);
)
}
@@ -1,31 +1,36 @@
import { useState } from 'react';
import { Dropzone } from './features/watermark/components/Dropzone';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { toast } from 'sonner';
import { api } from './features/auth/api/auth.service';
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { Dropzone } from '../../app/features/watermark/components/Dropzone'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { toast } from 'sonner'
import { api } from '../../app/features/auth/api/auth.service'
export default function HomePage() {
const [imageFile, setImageFile] = useState<File | null>(null);
const [generatedImage, setGeneratedImage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
export const Route = createFileRoute('/_authenticated/')({
component: HomePage,
})
function HomePage() {
const [imageFile, setImageFile] = useState<File | null>(null)
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const handleImageDropped = (file: File) => {
setImageFile(file);
setGeneratedImage(null);
toast.success('Image selected ready for generation!');
};
setImageFile(file)
setGeneratedImage(null)
toast.success('Image selected ready for generation!')
}
const handleReset = () => {
setImageFile(null);
setGeneratedImage(null);
};
setImageFile(null)
setGeneratedImage(null)
}
const handleGenerate = async () => {
if (!imageFile) return;
if (!imageFile) return
setIsLoading(true);
const formData = new FormData();
formData.append('image', imageFile);
setIsLoading(true)
const formData = new FormData()
formData.append('image', imageFile)
try {
const response = await api.post('/campaigns/process-image', formData, {
@@ -33,30 +38,30 @@ export default function HomePage() {
'Content-Type': 'multipart/form-data',
},
responseType: 'blob',
});
})
const imageUrl = URL.createObjectURL(response.data);
setGeneratedImage(imageUrl);
toast.success('QR Code generated successfully!');
const imageUrl = URL.createObjectURL(response.data)
setGeneratedImage(imageUrl)
toast.success('QR Code generated successfully!')
} catch (error: any) {
console.error(error);
console.error(error)
const message =
error.response?.data?.message || 'Failed to generate QR code.';
toast.error(message);
error.response?.data?.message || 'Failed to generate QR code.'
toast.error(message)
} finally {
setIsLoading(false);
setIsLoading(false)
}
};
}
const handleDownload = () => {
if (!generatedImage) return;
const link = document.createElement('a');
link.href = generatedImage;
link.download = `qr-campaign-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
if (!generatedImage) return
const link = document.createElement('a')
link.href = generatedImage
link.download = `qr-campaign-${Date.now()}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
return (
<div className="max-w-4xl mx-auto py-8 px-4 flex flex-col gap-8">
@@ -140,5 +145,5 @@ export default function HomePage() {
)}
</div>
</div>
);
)
}
+16
View File
@@ -0,0 +1,16 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { useAuthStore } from '../app/features/auth/store/auth.store'
export const Route = createFileRoute('/_public')({
beforeLoad: () => {
const { isAuthenticated } = useAuthStore.getState()
if (isAuthenticated) {
throw redirect({ to: '/' })
}
},
component: PublicLayout,
})
function PublicLayout() {
return <Outlet />
}
@@ -1,32 +1,36 @@
import { useState } from 'react';
import { useForgotPassword } from '@imphnen-frontend-service/service';
import { Link, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { useForgotPassword } from '@imphnen-frontend-service/service'
import { toast } from 'sonner'
import { Icon } from '@iconify/react'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [emailSent, setEmailSent] = useState(false);
const navigate = useNavigate();
const forgotPasswordMutation = useForgotPassword();
export const Route = createFileRoute('/_public/auth/forgot-password')({
component: ForgotPasswordPage,
})
function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [emailSent, setEmailSent] = useState(false)
const navigate = useNavigate()
const forgotPasswordMutation = useForgotPassword()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
e.preventDefault()
if (!email) {
toast.error('Please enter your email');
return;
toast.error('Please enter your email')
return
}
try {
await forgotPasswordMutation.mutateAsync({ email });
await forgotPasswordMutation.mutateAsync({ email })
setEmailSent(true);
toast.success('Password reset email sent! Check your inbox.');
setEmailSent(true)
toast.success('Password reset email sent! Check your inbox.')
} catch (err) {
toast.error((err as Error).message || 'Failed to send reset email');
toast.error((err as Error).message || 'Failed to send reset email')
}
};
}
if (emailSent) {
return (
@@ -65,7 +69,7 @@ export default function ForgotPasswordPage() {
</div>
</div>
</div>
);
)
}
return (
@@ -73,7 +77,7 @@ export default function ForgotPasswordPage() {
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/auth/login')}
onClick={() => navigate({ to: '/auth/login' })}
className="cursor-pointer text-primary-500 hover:text-primary-600 text-base font-sans flex items-center"
>
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
@@ -121,5 +125,5 @@ export default function ForgotPasswordPage() {
</form>
</div>
</div>
);
)
}
@@ -1,46 +1,50 @@
import { useState, useEffect } from 'react';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { useAuthStore } from '../../features/auth/store/auth.store';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import { GithubOutlined } from '@ant-design/icons'
import { toast } from 'sonner'
import { Icon } from '@iconify/react'
import { useAuthStore } from '../../../app/features/auth/store/auth.store'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().min(1, 'Email is required').email('Please enter a valid email'),
password: z.string().min(1, 'Password is required'),
});
type LoginForm = z.infer<typeof loginSchema>;
})
type LoginForm = z.infer<typeof loginSchema>
export default function LoginPage() {
const navigate = useNavigate();
const login = useAuthStore((state) => state.login);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
export const Route = createFileRoute('/_public/auth/login')({
component: LoginPage,
})
function LoginPage() {
const navigate = useNavigate()
const login = useAuthStore((state) => state.login)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const [isGithubLoading, setIsGithubLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const { register, handleSubmit, formState: { errors, isValid } } = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
mode: 'onChange',
defaultValues: { email: '', password: '' },
});
})
useEffect(() => { if (isAuthenticated) navigate('/'); }, [isAuthenticated, navigate]);
useEffect(() => { if (isAuthenticated) navigate({ to: '/' }) }, [isAuthenticated, navigate])
const onSubmit = handleSubmit(async (data) => {
setError(null);
setIsSubmitting(true);
setError(null)
setIsSubmitting(true)
try {
const success = await login(data.email, data.password);
if (success) { toast.success('Login successful!'); navigate('/'); }
else setError('Login failed. Please check your credentials.');
} catch { setError('Login failed. Please try again.'); }
finally { setIsSubmitting(false); }
});
const success = await login(data.email, data.password)
if (success) { toast.success('Login successful!'); navigate({ to: '/' }) }
else setError('Login failed. Please check your credentials.')
} catch { setError('Login failed. Please try again.') }
finally { setIsSubmitting(false) }
})
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
@@ -105,5 +109,5 @@ export default function LoginPage() {
</div>
</div>
</div>
);
)
}
@@ -1,70 +1,74 @@
import { useState, useEffect } from 'react';
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import {
useResetPassword,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
} from '@imphnen-frontend-service/service'
import { toast } from 'sonner'
import { Icon } from '@iconify/react'
export default function ResetPasswordPage() {
const navigate = useNavigate();
const { clearSession } = useAuthStore();
const resetPasswordMutation = useResetPassword();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [accessToken, setAccessToken] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
export const Route = createFileRoute('/_public/auth/reset-password')({
component: ResetPasswordPage,
})
function ResetPasswordPage() {
const navigate = useNavigate()
const { clearSession } = useAuthStore()
const resetPasswordMutation = useResetPassword()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [accessToken, setAccessToken] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
useEffect(() => {
const hashParams = new URLSearchParams(
globalThis.location.hash.substring(1)
);
const queryParams = new URLSearchParams(globalThis.location.search);
)
const queryParams = new URLSearchParams(globalThis.location.search)
const token =
hashParams.get('access_token') || queryParams.get('access_token');
hashParams.get('access_token') || queryParams.get('access_token')
if (token) {
setAccessToken(token);
setAccessToken(token)
} else {
toast.error('Invalid or expired reset link');
setTimeout(() => navigate('/auth/forgot-password'), 2000);
toast.error('Invalid or expired reset link')
setTimeout(() => navigate({ to: '/auth/forgot-password' }), 2000)
}
}, [navigate]);
}, [navigate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
e.preventDefault()
if (password !== confirmPassword) {
toast.error('Passwords do not match');
return;
toast.error('Passwords do not match')
return
}
if (password.length < 6) {
toast.error('Password must be at least 6 characters');
return;
toast.error('Password must be at least 6 characters')
return
}
if (!accessToken) {
toast.error('Invalid reset token');
return;
toast.error('Invalid reset token')
return
}
try {
await resetPasswordMutation.mutateAsync({
access_token: accessToken,
new_password: password,
});
})
toast.success('Password updated successfully!');
toast.success('Password updated successfully!')
clearSession();
navigate('/auth/login');
clearSession()
navigate({ to: '/auth/login' })
} catch (err) {
toast.error((err as Error).message || 'Failed to reset password');
toast.error((err as Error).message || 'Failed to reset password')
}
};
}
if (!accessToken) {
return (
@@ -74,7 +78,7 @@ export default function ResetPasswordPage() {
<p className="text-gray-600">Verifying reset link...</p>
</div>
</div>
);
)
}
return (
@@ -164,5 +168,5 @@ export default function ResetPasswordPage() {
</form>
</div>
</div>
);
)
}
@@ -1,13 +1,13 @@
import { useState } from 'react';
import { useGitHubAuth } from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { useAuthStore } from '../../features/auth/store/auth.store';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { useGitHubAuth } from '@imphnen-frontend-service/service'
import { GithubOutlined } from '@ant-design/icons'
import { toast } from 'sonner'
import { Icon } from '@iconify/react'
import { useAuthStore } from '../../../app/features/auth/store/auth.store'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const signupSchema = z
.object({
@@ -28,20 +28,24 @@ const signupSchema = z
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
})
type SignupFormData = z.infer<typeof signupSchema>;
type SignupFormData = z.infer<typeof signupSchema>
export default function SignupPage() {
const navigate = useNavigate();
const registerUser = useAuthStore((state) => state.register);
export const Route = createFileRoute('/_public/auth/signup')({
component: SignupPage,
})
const { signInWithGitHub } = useGitHubAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
function SignupPage() {
const navigate = useNavigate()
const registerUser = useAuthStore((state) => state.register)
const { signInWithGitHub } = useGitHubAuth()
const [isGithubLoading, setIsGithubLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const {
register,
@@ -50,56 +54,56 @@ export default function SignupPage() {
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
mode: 'onChange',
});
})
const onSubmit = async (data: SignupFormData) => {
setError(null);
setIsSubmitting(true);
setError(null)
setIsSubmitting(true)
try {
await registerUser(data.fullname, data.email, data.password);
toast.success('Registration successful! Redirecting...');
navigate('/');
await registerUser(data.fullname, data.email, data.password)
toast.success('Registration successful! Redirecting...')
navigate({ to: '/' })
} catch (err: any) {
console.error('[Signup] Email signup failed:', err);
console.error('[Signup] Email signup failed:', err)
const errorMessage =
err.response?.data?.message || err.message || 'Signup failed';
setError(errorMessage);
err.response?.data?.message || err.message || 'Signup failed'
setError(errorMessage)
} finally {
setIsSubmitting(false);
setIsSubmitting(false)
}
};
}
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
setIsGithubLoading(true)
const result = await signInWithGitHub();
const result = await signInWithGitHub()
if (result?.url) {
globalThis.location.href = result.url;
globalThis.location.href = result.url
} else {
setIsGithubLoading(false);
setError('Failed to get GitHub OAuth URL');
setIsGithubLoading(false)
setError('Failed to get GitHub OAuth URL')
}
} catch (err) {
console.error('[Signup] GitHub login failed:', err);
setError((err as Error).message || 'GitHub login failed');
setIsGithubLoading(false);
console.error('[Signup] GitHub login failed:', err)
setError((err as Error).message || 'GitHub login failed')
setIsGithubLoading(false)
}
};
}
const inputBaseClass =
'w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed';
const inputErrorClass = 'border-red-500';
const inputNormalClass = 'border-gray-300';
'w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed'
const inputErrorClass = 'border-red-500'
const inputNormalClass = 'border-gray-300'
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/')}
onClick={() => navigate({ to: '/' })}
className="cursor-pointer text-primary-500 hover:text-primary-600 text-base font-sans flex items-center"
>
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
@@ -300,5 +304,5 @@ export default function SignupPage() {
</div>
</div>
</div>
);
)
}
@@ -1,98 +1,102 @@
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useGitHubCallback } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { FC, ReactElement, useEffect, useState, useRef } from 'react'
import { useGitHubCallback } from '@imphnen-frontend-service/service'
import { toast } from 'sonner'
const CallbackPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback();
const [isProcessing, setIsProcessing] = useState(true);
const [error, setError] = useState<string | null>(null);
const hasRunRef = useRef(false);
export const Route = createFileRoute('/auth/callback')({
component: CallbackPage,
})
function CallbackPage(): ReactElement {
const navigate = useNavigate()
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback()
const [isProcessing, setIsProcessing] = useState(true)
const [error, setError] = useState<string | null>(null)
const hasRunRef = useRef(false)
useEffect(() => {
const handleCallback = async () => {
if (hasRunRef.current) {
return;
return
}
hasRunRef.current = true;
hasRunRef.current = true
try {
const hashParams = new URLSearchParams(
globalThis.location.hash.substring(1)
);
const urlParams = new URLSearchParams(globalThis.location.search);
)
const urlParams = new URLSearchParams(globalThis.location.search)
const type = hashParams.get('type') || urlParams.get('type');
const type = hashParams.get('type') || urlParams.get('type')
const accessToken =
hashParams.get('access_token') || urlParams.get('access_token');
hashParams.get('access_token') || urlParams.get('access_token')
console.log('[Callback] Params:', {
type,
accessToken: !!accessToken,
hash: globalThis.location.hash,
search: globalThis.location.search,
});
})
if (accessToken) {
setIsProcessing(false);
setIsProcessing(false)
if (type === 'recovery' || type === 'magiclink') {
toast.success('Email verified! Please set your new password.');
navigate('/auth/reset-password?access_token=' + accessToken);
return;
toast.success('Email verified! Please set your new password.')
navigate({ to: '/auth/reset-password', search: { access_token: accessToken } })
return
}
if (type === 'signup' || type === 'email_confirmation') {
toast.success(
'Email verified successfully! Please log in to continue.'
);
navigate('/auth/login');
return;
)
navigate({ to: '/auth/login' })
return
}
toast.success('Email verified! Please set your new password.');
navigate('/auth/reset-password?access_token=' + accessToken);
return;
toast.success('Email verified! Please set your new password.')
navigate({ to: '/auth/reset-password', search: { access_token: accessToken } })
return
}
const code = urlParams.get('code');
const code = urlParams.get('code')
if (!code) {
throw new Error('No authorization code received');
throw new Error('No authorization code received')
}
const result = await exchangeGitHubCode({ code });
const result = await exchangeGitHubCode({ code })
toast.success('Login successful!');
setIsProcessing(false);
toast.success('Login successful!')
setIsProcessing(false)
if (result.user.location) {
globalThis.location.replace('/dashboard');
globalThis.location.replace('/dashboard')
} else {
globalThis.location.replace('/onboarding/user');
globalThis.location.replace('/onboarding/user')
}
} catch (err) {
console.error('[Callback] Error:', err);
setError((err as Error).message);
setIsProcessing(false);
toast.error('An error occurred during login');
console.error('[Callback] Error:', err)
setError((err as Error).message)
setIsProcessing(false)
toast.error('An error occurred during login')
setTimeout(() => {
navigate('/auth/login');
}, 3000);
navigate({ to: '/auth/login' })
}, 3000)
}
};
}
handleCallback();
handleCallback()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [])
if (error) {
const isPrivateEmailError =
error.toLowerCase().includes('failed to create user') ||
error.toLowerCase().includes('email') ||
error.toLowerCase().includes('user record');
error.toLowerCase().includes('user record')
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
@@ -152,7 +156,7 @@ const CallbackPage: FC = (): ReactElement => {
</p>
</div>
</div>
);
)
}
return (
@@ -165,7 +169,5 @@ const CallbackPage: FC = (): ReactElement => {
<p className="text-gray-600">Please wait</p>
</div>
</div>
);
};
export default CallbackPage;
)
}