feat: add auth setup and login flow

This commit is contained in:
2026-06-15 15:56:35 +05:30
parent 134e2d1bb8
commit 9de65d0ce6
18 changed files with 664 additions and 19 deletions

View File

@@ -0,0 +1,103 @@
'use client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLoginForm } from '@/hooks/useLoginForm';
import { Loader2 } from 'lucide-react';
import Image from 'next/image';
export default function LoginPage() {
const { register, handleSubmit, errors, isLoading } = useLoginForm();
return (
<main className="grid min-h-screen bg-background text-foreground lg:grid-cols-[1.05fr_0.95fr]">
<section className="relative hidden overflow-hidden border-r border-border/60 lg:block">
<Image
src="/background.png"
alt=""
fill
priority
className="object-cover"
sizes="55vw"
/>
<div className="absolute inset-0 bg-background/35" />
<div className="absolute inset-x-0 bottom-0 p-10">
<div className="max-w-lg space-y-3">
<p className="text-sm font-medium uppercase tracking-[0.18em] text-primary">
VisionRoad
</p>
<h1 className="text-4xl font-semibold tracking-normal text-foreground">
Road intelligence for faster inspection decisions
</h1>
<p className="text-sm leading-6 text-muted-foreground">
Review projects, uploads, detections, and chainage insights from one secure
workspace.
</p>
</div>
</div>
</section>
<section className="flex min-h-screen items-center justify-center px-6 py-10">
<div className="w-full max-w-sm space-y-7">
<header className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">VisionRoad</p>
<h2 className="text-2xl font-semibold tracking-normal">Sign in</h2>
<p className="text-sm text-muted-foreground">
Use your email and password to continue.
</p>
</header>
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@visionroad.ai"
autoComplete="email"
disabled={isLoading}
aria-invalid={!!errors.email}
{...register('email', {
required: 'Email is required',
})}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter password"
autoComplete="current-password"
disabled={isLoading}
aria-invalid={!!errors.password}
{...register('password', {
required: 'Password is required',
})}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin" />
Signing in
</>
) : (
'Sign in'
)}
</Button>
</form>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,6 @@
import { authOptions } from '@/lib/auth/authOptions';
import NextAuth from 'next-auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -2,6 +2,8 @@ import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css';
import { ThemeProvider } from '@/providers/ThemeProvider';
import AuthProvider from '@/providers/AuthProvider';
import QueryProvider from '@/providers/QueryProvider';
import { Toaster } from '@/components/ui/sonner';
import { BackgroundGradient } from '@/components/background-gradient';
@@ -34,11 +36,15 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<div className="relative min-h-screen isolate">
<BackgroundGradient />
{children}
</div>
<Toaster position="top-center" />
<QueryProvider>
<AuthProvider>
<div className="relative min-h-screen isolate">
<BackgroundGradient />
{children}
</div>
<Toaster position="top-center" />
</AuthProvider>
</QueryProvider>
</ThemeProvider>
</body>
</html>

63
src/hooks/useLoginForm.ts Normal file
View File

@@ -0,0 +1,63 @@
import { useAuthStore } from '@/store/auth.store';
import type { LoginPayload, UserProfile } from '@/types';
import { ROUTES } from '@/utils/routes';
import { getSession, signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
export const useLoginForm = () => {
const router = useRouter();
const setAuth = useAuthStore((state) => state.setAuth);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginPayload>({
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
});
const onSubmit = async (data: LoginPayload) => {
try {
const callbackUrl =
new URLSearchParams(window.location.search).get('callbackUrl') ?? ROUTES.DASHBOARD;
const result = await signIn('credentials', {
email: data.email,
password: data.password,
redirect: false,
callbackUrl,
});
if (result?.error) {
toast.error(result.error);
return;
}
const session = await getSession();
if (session?.user) {
setAuth(
session.user as UserProfile,
session.accessToken ?? null,
session.refreshToken ?? null,
);
}
toast.success('Login successful');
router.push(result?.url ?? callbackUrl);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to sign in');
}
};
return {
register,
handleSubmit: handleSubmit(onSubmit),
errors,
isLoading: isSubmitting,
};
};

View File

@@ -0,0 +1,83 @@
import { authService } from '@/services/api/auth.service';
import { ROUTES } from '@/utils/routes';
import type { AuthOptions, User } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
export const authOptions: AuthOptions = {
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials.password) {
throw new Error('Missing email or password');
}
try {
const data = await authService.login({
email: credentials.email,
password: credentials.password,
});
const accessToken = data.accessToken ?? data.token;
const refreshToken = data.refreshToken;
const user = data.user;
const id = data.userId ?? data.id ?? user?.id ?? user?.email ?? credentials.email;
const email = data.email ?? user?.email ?? credentials.email;
const name = data.name ?? user?.name ?? null;
if (!accessToken) {
throw new Error('Invalid login response');
}
return {
id: String(id),
name,
email,
accessToken,
refreshToken,
} as User;
} catch (error) {
const message =
error instanceof Error ? error.message : 'Authentication failed';
throw new Error(message);
}
},
}),
],
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60,
},
pages: {
signIn: ROUTES.LOGIN,
error: ROUTES.LOGIN,
},
callbacks: {
async jwt({ token, user, trigger, session }) {
if (user) {
token.id = user.id;
token.accessToken = user.accessToken;
token.refreshToken = user.refreshToken;
}
if (trigger === 'update' && session?.user) {
return { ...token, ...session.user };
}
return token;
},
async session({ session, token }) {
session.user.id = token.id;
session.accessToken = token.accessToken;
session.refreshToken = token.refreshToken;
return session;
},
},
secret: process.env.NEXTAUTH_SECRET,
};

View File

@@ -0,0 +1,7 @@
'use client';
import { SessionProvider } from 'next-auth/react';
export default function AuthProvider({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

38
src/proxy.ts Normal file
View File

@@ -0,0 +1,38 @@
import { ROUTES } from '@/utils/routes';
import { getToken } from 'next-auth/jwt';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function proxy(request: NextRequest) {
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
const isLoginPage = request.nextUrl.pathname === ROUTES.LOGIN;
if (!token && !isLoginPage) {
const url = new URL(ROUTES.LOGIN, request.url);
url.searchParams.set('callbackUrl', request.url);
return NextResponse.redirect(url);
}
if (token && isLoginPage) {
return NextResponse.redirect(new URL(ROUTES.DASHBOARD, request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
'/',
'/login',
// '/dashboard/:path*',
// '/project/:path*',
// '/package/:path*',
// '/chainage/:path*',
// '/segment/:path*',
// '/upload/:path*',
// '/results/:path*',
],
};

View File

@@ -0,0 +1,9 @@
import type { AuthResponseData, LoginPayload } from '@/types';
import { axiosAuth } from '../axios/axios';
export const authService = {
login: async (payload: LoginPayload): Promise<AuthResponseData> => {
const response = await axiosAuth.post<AuthResponseData>('/auth/login', payload);
return response.data;
},
};

View File

@@ -1,5 +1,6 @@
export * from './project.service';
export * from './package.service';
export * from './auth.service';
export { chainageService } from './chainage.service';
export * from './video.service';
export * from './detection.service';

View File

@@ -1,25 +1,26 @@
import { ENV_CONSTANT } from '@/constants/secrect.constant';
import { tokenService } from '@/services/token.service';
import axios from 'axios';
import { signOut } from 'next-auth/react';
const BASE_URL = ENV_CONSTANT.BASE_API_URL;
const axiosClient = axios.create({
baseURL: BASE_URL,
headers: {
// "Content-Type": "application/json",
'Content-Type': 'application/json',
'ngrok-skip-browser-warning': 'true',
},
});
// Add request interceptor to inject access token
axiosClient.interceptors.request.use(
(config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
if (token && config.headers) {
config.headers['Authorization'] = `Bearer ${token}`;
}
async (config) => {
const token = await tokenService.getAccessToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
@@ -27,18 +28,23 @@ axiosClient.interceptors.request.use(
},
);
// Basic response interceptor
axiosClient.interceptors.response.use(
(response) => response,
(error) => {
// Standard error handling can be added here later
async (error) => {
if (error?.response?.status === 401 && typeof window !== 'undefined') {
await signOut({ callbackUrl: '/login' });
}
return Promise.reject(error);
},
);
export const axiosAuth = axios.create({
baseURL: BASE_URL,
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'ngrok-skip-browser-warning': 'true',
},
});
export default axiosClient;

View File

@@ -0,0 +1,16 @@
import { getSession } from 'next-auth/react';
export const tokenService = {
getAccessToken: async (): Promise<string | null> => {
if (typeof window === 'undefined') return null;
const session = await getSession();
return session?.accessToken ?? null;
},
getRefreshToken: async (): Promise<string | null> => {
if (typeof window === 'undefined') return null;
const session = await getSession();
return session?.refreshToken ?? null;
},
};

40
src/store/auth.store.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { UserProfile } from '@/types';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
user: UserProfile | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
setAuth: (user: UserProfile, accessToken?: string | null, refreshToken?: string | null) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
setAuth: (user, accessToken, refreshToken) =>
set({
user,
accessToken: accessToken ?? null,
refreshToken: refreshToken ?? null,
isAuthenticated: true,
}),
logout: () =>
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
}),
}),
{
name: 'auth-storage',
},
),
);

29
src/types/auth.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
import 'next-auth';
import 'next-auth/jwt';
declare module 'next-auth' {
interface Session {
accessToken?: string;
refreshToken?: string;
user: {
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
}
interface User {
id: string;
accessToken?: string;
refreshToken?: string;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id?: string;
accessToken?: string;
refreshToken?: string;
}
}

31
src/types/auth.type.ts Normal file
View File

@@ -0,0 +1,31 @@
export type AuthUser = {
id?: string | number;
name?: string | null;
email?: string | null;
role?: string | null;
};
export type UserProfile = {
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
role?: string | null;
};
export type LoginPayload = {
email: string;
password: string;
rememberMe?: boolean;
};
export type AuthResponseData = {
id?: string | number;
userId?: string | number;
name?: string | null;
email?: string | null;
token?: string;
accessToken?: string;
refreshToken?: string;
user?: AuthUser;
};

View File

@@ -6,3 +6,4 @@ export * from './video';
export * from './detection';
export * from './analysis';
export * from './session';
export * from './auth.type';

View File

@@ -1,4 +1,5 @@
export const ROUTES = {
LOGIN: '/login',
DASHBOARD: '/dashboard',
PROJECT: '/project',
PACKAGE: '/package',