diff --git a/package-lock.json b/package-lock.json index 55e13d6..39086c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,14 +27,17 @@ "lucide-react": "^0.548.0", "motion": "^12.38.0", "next": "16.0.10", + "next-auth": "^4.24.14", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "19.2.0", "react-dom": "19.2.0", + "react-hook-form": "^7.79.0", "react-leaflet": "^5.0.0", "recharts": "2.15.4", "sonner": "^1.7.4", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.14" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.9", @@ -1297,6 +1300,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -4875,6 +4887,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7065,6 +7086,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7871,6 +7901,38 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.14", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.14.tgz", + "integrity": "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -7961,6 +8023,12 @@ "dev": true, "license": "MIT" }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7970,6 +8038,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -8083,6 +8160,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -8099,6 +8185,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8272,6 +8391,28 @@ "dev": true, "license": "MIT" }, + "node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8311,6 +8452,12 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8578,6 +8725,22 @@ "react": "^19.2.0" } }, + "node_modules/react-hook-form": { + "version": "7.79.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz", + "integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -9914,6 +10077,16 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -10166,6 +10339,35 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/@next/swc-win32-x64-msvc": { "version": "16.0.10", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz", diff --git a/package.json b/package.json index 30f7a84..0d9a60f 100644 --- a/package.json +++ b/package.json @@ -39,14 +39,17 @@ "lucide-react": "^0.548.0", "motion": "^12.38.0", "next": "16.0.10", + "next-auth": "^4.24.14", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "19.2.0", "react-dom": "19.2.0", + "react-hook-form": "^7.79.0", "react-leaflet": "^5.0.0", "recharts": "2.15.4", "sonner": "^1.7.4", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.14" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.9", diff --git a/src/app/(full-page)/login/page.tsx b/src/app/(full-page)/login/page.tsx new file mode 100644 index 0000000..dcffe62 --- /dev/null +++ b/src/app/(full-page)/login/page.tsx @@ -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 ( +
+
+ +
+
+
+

+ VisionRoad +

+

+ Road intelligence for faster inspection decisions +

+

+ Review projects, uploads, detections, and chainage insights from one secure + workspace. +

+
+
+
+ +
+
+
+

VisionRoad

+

Sign in

+

+ Use your email and password to continue. +

+
+ +
+
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+ + +
+
+
+
+ ); +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..198dec6 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -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 }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6ff243b..02d1c83 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 > -
- - {children} -
- + + +
+ + {children} +
+ +
+
diff --git a/src/hooks/useLoginForm.ts b/src/hooks/useLoginForm.ts new file mode 100644 index 0000000..b6f391b --- /dev/null +++ b/src/hooks/useLoginForm.ts @@ -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({ + 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, + }; +}; diff --git a/src/lib/auth/authOptions.ts b/src/lib/auth/authOptions.ts new file mode 100644 index 0000000..d59f715 --- /dev/null +++ b/src/lib/auth/authOptions.ts @@ -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, +}; diff --git a/src/providers/AuthProvider.tsx b/src/providers/AuthProvider.tsx new file mode 100644 index 0000000..be51c14 --- /dev/null +++ b/src/providers/AuthProvider.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { SessionProvider } from 'next-auth/react'; + +export default function AuthProvider({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..2a721d5 --- /dev/null +++ b/src/proxy.ts @@ -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*', + ], +}; diff --git a/src/services/api/auth.service.ts b/src/services/api/auth.service.ts new file mode 100644 index 0000000..36a670a --- /dev/null +++ b/src/services/api/auth.service.ts @@ -0,0 +1,9 @@ +import type { AuthResponseData, LoginPayload } from '@/types'; +import { axiosAuth } from '../axios/axios'; + +export const authService = { + login: async (payload: LoginPayload): Promise => { + const response = await axiosAuth.post('/auth/login', payload); + return response.data; + }, +}; diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 3d3b8d3..74f0d44 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -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'; diff --git a/src/services/axios/axios.ts b/src/services/axios/axios.ts index 5cff09a..1d025c2 100644 --- a/src/services/axios/axios.ts +++ b/src/services/axios/axios.ts @@ -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; diff --git a/src/services/token.service.ts b/src/services/token.service.ts new file mode 100644 index 0000000..2760a96 --- /dev/null +++ b/src/services/token.service.ts @@ -0,0 +1,16 @@ +import { getSession } from 'next-auth/react'; + +export const tokenService = { + getAccessToken: async (): Promise => { + if (typeof window === 'undefined') return null; + + const session = await getSession(); + return session?.accessToken ?? null; + }, + getRefreshToken: async (): Promise => { + if (typeof window === 'undefined') return null; + + const session = await getSession(); + return session?.refreshToken ?? null; + }, +}; diff --git a/src/store/auth.store.ts b/src/store/auth.store.ts new file mode 100644 index 0000000..432b760 --- /dev/null +++ b/src/store/auth.store.ts @@ -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()( + 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', + }, + ), +); diff --git a/src/types/auth.d.ts b/src/types/auth.d.ts new file mode 100644 index 0000000..0de3e25 --- /dev/null +++ b/src/types/auth.d.ts @@ -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; + } +} diff --git a/src/types/auth.type.ts b/src/types/auth.type.ts new file mode 100644 index 0000000..64d07cb --- /dev/null +++ b/src/types/auth.type.ts @@ -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; +}; diff --git a/src/types/index.ts b/src/types/index.ts index ee8123e..3fabacb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,3 +6,4 @@ export * from './video'; export * from './detection'; export * from './analysis'; export * from './session'; +export * from './auth.type'; diff --git a/src/utils/routes.ts b/src/utils/routes.ts index d2b4188..2b0d8be 100644 --- a/src/utils/routes.ts +++ b/src/utils/routes.ts @@ -1,4 +1,5 @@ export const ROUTES = { + LOGIN: '/login', DASHBOARD: '/dashboard', PROJECT: '/project', PACKAGE: '/package',