feat: add auth setup and login flow
This commit is contained in:
103
src/app/(full-page)/login/page.tsx
Normal file
103
src/app/(full-page)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
@@ -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
63
src/hooks/useLoginForm.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
83
src/lib/auth/authOptions.ts
Normal file
83
src/lib/auth/authOptions.ts
Normal 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,
|
||||
};
|
||||
7
src/providers/AuthProvider.tsx
Normal file
7
src/providers/AuthProvider.tsx
Normal 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
38
src/proxy.ts
Normal 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*',
|
||||
],
|
||||
};
|
||||
9
src/services/api/auth.service.ts
Normal file
9
src/services/api/auth.service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
src/services/token.service.ts
Normal file
16
src/services/token.service.ts
Normal 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
40
src/store/auth.store.ts
Normal 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
29
src/types/auth.d.ts
vendored
Normal 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
31
src/types/auth.type.ts
Normal 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;
|
||||
};
|
||||
@@ -6,3 +6,4 @@ export * from './video';
|
||||
export * from './detection';
|
||||
export * from './analysis';
|
||||
export * from './session';
|
||||
export * from './auth.type';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const ROUTES = {
|
||||
LOGIN: '/login',
|
||||
DASHBOARD: '/dashboard',
|
||||
PROJECT: '/project',
|
||||
PACKAGE: '/package',
|
||||
|
||||
Reference in New Issue
Block a user