Compare commits
	
		
			No commits in common. "9b65e38542cf90454b10e06a0da8d6f413fdbfc0" and "175b75961143524e711632a91dccd3a94767ebc3" have entirely different histories.
		
	
	
		
			9b65e38542
			...
			175b759611
		
	
		
							
								
								
									
										11
									
								
								.env.example
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								.env.example
									
									
									
									
									
								
							| @ -1,6 +1,11 @@ | |||||||
| FASTAPI_URL="http://localhost:8000" | NEXT_PUBLIC_API_BASE_URL=http://localhost:3005 | ||||||
| INTERNAL_API_BASE_URL="http://localhost:3005" |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| DATABASE_URL="postgresql://postgres:root@localhost:5432/rooftop?schema=public" | DATABASE_URL="postgresql://postgres:root@localhost:5432/rooftop?schema=public" | ||||||
| JWT_SECRET="secret_key" | JWT_SECRET="secret_key" | ||||||
|  | 
 | ||||||
|  | #SUNGROW | ||||||
|  | SUNGROW_SECRET_KEY= | ||||||
|  | SUNGROW_APP_KEY= | ||||||
|  | 
 | ||||||
|  | #CHINT | ||||||
|  | NEXT_PUBLIC_CHINT_TOKEN= | ||||||
|  | |||||||
| @ -27,8 +27,8 @@ jobs: | |||||||
|       - name: Build |       - name: Build | ||||||
|         run: npm run build |         run: npm run build | ||||||
|         env: |         env: | ||||||
|           NEXT_PUBLIC_URL: 'http://localhost:3005' |           NEXT_PUBLIC_URL: 'http://localhost:3000' | ||||||
|           NEXT_PUBLIC_FORECAST_URL: 'http://localhost:3005' |           NEXT_PUBLIC_FORECAST_URL: 'http://localhost:3001' | ||||||
|           DATABASE_URL: 'postgresql://dummy:dummy@localhost:5432/dummy' |           DATABASE_URL: 'postgresql://dummy:dummy@localhost:5432/dummy' | ||||||
|           SMTP_EMAIL: 'dummy@example.com' |           SMTP_EMAIL: 'dummy@example.com' | ||||||
|           SMTP_EMAIL_PASSWORD: 'dummy' |           SMTP_EMAIL_PASSWORD: 'dummy' | ||||||
|  | |||||||
							
								
								
									
										26
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -1,26 +0,0 @@ | |||||||
| FROM node:18 |  | ||||||
| 
 |  | ||||||
| WORKDIR /app |  | ||||||
| COPY . . |  | ||||||
| 
 |  | ||||||
| # Accept build-time arguments |  | ||||||
| ARG FASTAPI_URL |  | ||||||
| ARG INTERNAL_API_BASE_URL |  | ||||||
| ARG DATABASE_URL |  | ||||||
| ARG JWT_SECRET |  | ||||||
| 
 |  | ||||||
| # Assign them to environment variables inside the image |  | ||||||
| ENV FASTAPI_URL=$FASTAPI_URL |  | ||||||
| ENV INTERNAL_API_BASE_URL=$INTERNAL_API_BASE_URL |  | ||||||
| ENV DATABASE_URL=$DATABASE_URL |  | ||||||
| ENV JWT_SECRET=$JWT_SECRET |  | ||||||
| 
 |  | ||||||
| RUN npm install --force |  | ||||||
| 
 |  | ||||||
| # Build Next.js app |  | ||||||
| RUN npx prisma generate |  | ||||||
| RUN npm run build |  | ||||||
| 
 |  | ||||||
| EXPOSE 3005  |  | ||||||
| 
 |  | ||||||
| CMD ["npm", "start"] |  | ||||||
| @ -59,7 +59,6 @@ const AdminDashboard = () => { | |||||||
|   const router = useRouter(); |   const router = useRouter(); | ||||||
|   const pathname = usePathname(); |   const pathname = usePathname(); | ||||||
|   const searchParams = useSearchParams(); |   const searchParams = useSearchParams(); | ||||||
|   const [authChecked, setAuthChecked] = useState(false); |  | ||||||
| 
 | 
 | ||||||
|   // --- load CRM projects dynamically ---
 |   // --- load CRM projects dynamically ---
 | ||||||
|   const [sites, setSites] = useState<CrmProject[]>([]); |   const [sites, setSites] = useState<CrmProject[]>([]); | ||||||
| @ -68,42 +67,6 @@ const AdminDashboard = () => { | |||||||
|   // near other refs
 |   // near other refs
 | ||||||
|   const loggingRef = useRef<HTMLDivElement | null>(null); |   const loggingRef = useRef<HTMLDivElement | null>(null); | ||||||
| 
 | 
 | ||||||
|   const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000'; |  | ||||||
| 
 |  | ||||||
| useEffect(() => { |  | ||||||
|   let cancelled = false; |  | ||||||
| 
 |  | ||||||
|   const checkAuth = async () => { |  | ||||||
|     try { |  | ||||||
|       const res = await fetch(`${API}/auth/me`, { |  | ||||||
|         credentials: 'include', |  | ||||||
|         cache: 'no-store', |  | ||||||
|       }); |  | ||||||
| 
 |  | ||||||
|       if (!res.ok) { |  | ||||||
|         router.replace('/login'); |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       const user = await res.json().catch(() => null); |  | ||||||
|       if (!user?.id) { |  | ||||||
|         router.replace('/login'); |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|       // authenticated
 |  | ||||||
|     } catch { |  | ||||||
|       router.replace('/login'); |  | ||||||
|       return; |  | ||||||
|     } finally { |  | ||||||
|       if (!cancelled) setAuthChecked(true); |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   checkAuth(); |  | ||||||
|   return () => { cancelled = true; }; |  | ||||||
| }, [router, API]); |  | ||||||
| 
 |  | ||||||
|    |  | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     setSitesLoading(true); |     setSitesLoading(true); | ||||||
| @ -325,7 +288,7 @@ useEffect(() => { | |||||||
|             setHasTodayData(true);   // and today has data too
 |             setHasTodayData(true);   // and today has data too
 | ||||||
|             break; |             break; | ||||||
|           } |           } | ||||||
|   } catch { |         } catch { | ||||||
|           // ignore and keep polling
 |           // ignore and keep polling
 | ||||||
|         } |         } | ||||||
|         await new Promise(r => setTimeout(r, 3000)); |         await new Promise(r => setTimeout(r, 3000)); | ||||||
| @ -337,10 +300,6 @@ useEffect(() => { | |||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   // ---------- RENDER ----------
 |   // ---------- RENDER ----------
 | ||||||
|   if (!authChecked) { |  | ||||||
|     return <div>Checking authentication…</div>; |  | ||||||
|   } |  | ||||||
|    |  | ||||||
|   if (sitesLoading) { |   if (sitesLoading) { | ||||||
|     return ( |     return ( | ||||||
|       <DashboardLayout> |       <DashboardLayout> | ||||||
|  | |||||||
| @ -1,115 +1,73 @@ | |||||||
| // app/login/page.tsx
 |  | ||||||
| 'use client'; |  | ||||||
| 
 |  | ||||||
| import Link from 'next/link'; | import Link from 'next/link'; | ||||||
| import React, { useEffect, useState } from 'react'; | import { Metadata } from 'next'; | ||||||
| import { useRouter } from 'next/navigation'; | import React from 'react'; | ||||||
| import ComponentsAuthLoginForm from '@/components/auth/components-auth-login-form'; | import ComponentsAuthLoginForm from '@/components/auth/components-auth-login-form'; | ||||||
| 
 | 
 | ||||||
| export default function LoginPage() { | type Props = {} | ||||||
|   const router = useRouter(); |  | ||||||
|   const [ready, setReady] = useState(false); // gate to avoid UI flash
 |  | ||||||
| 
 | 
 | ||||||
|   // Use ONE client-exposed API env var everywhere
 | const LoginPage = (props: Props) => { | ||||||
|   const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000'; |     return ( | ||||||
| 
 |         <div className="relative min-h-screen overflow-hidden bg-[#060818] text-white"> | ||||||
|   useEffect(() => { |             {/* Background gradient layer */} | ||||||
|     let cancelled = false; |             <div className="absolute inset-0 -z-10"> | ||||||
|     const controller = new AbortController(); |                 <img | ||||||
| 
 |                     src="/assets/images/auth/bg-gradient.png" | ||||||
|     (async () => { |                     alt="background gradient" | ||||||
|       try { |                     className="h-full w-full object-cover" | ||||||
|         const res = await fetch(`${API}/auth/me`, { |                 /> | ||||||
|           credentials: 'include', |                 <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" /> | ||||||
|           cache: 'no-store',        // don't reuse a cached 401
 |             </div> | ||||||
|           signal: controller.signal, | 
 | ||||||
|         }); |             {/* Background decorative objects */} | ||||||
| 
 |             <img | ||||||
|         if (!res.ok) { |                 src="/assets/images/auth/coming-soon-object1.png" | ||||||
|           if (!cancelled) setReady(true); |                 alt="left decor" | ||||||
|           return; |                 className="absolute left-0 top-1/2 hidden h-full max-h-[893px] -translate-y-1/2 brightness-125 md:block" | ||||||
|         } |             /> | ||||||
| 
 |             <img | ||||||
|         const user = await res.json().catch(() => null); |                 src="/assets/images/auth/coming-soon-object3.png" | ||||||
|         if (user?.id) { |                 alt="right decor" | ||||||
|           // already logged in -> go straight to dashboard
 |                 className="absolute right-0 top-0 hidden h-[300px] brightness-125 md:block" | ||||||
|           router.replace('/adminDashboard'); |             /> | ||||||
|           return; | 
 | ||||||
|         } |             {/* Centered card wrapper */} | ||||||
| 
 |             <div className="relative flex min-h-screen items-center justify-center px-6 py-10 sm:px-16"> | ||||||
|         // not logged in -> show form
 |                 <div | ||||||
|         if (!cancelled) setReady(true); |                     className="relative w-full max-w-[870px] rounded-2xl p-1 | ||||||
|       } catch { |                                bg-[linear-gradient(45deg,#fffbe6_0%,rgba(255,251,230,0)_25%,rgba(255,251,230,0)_75%,#fffbe6_100%)] | ||||||
|         // network/error -> show form
 |                                dark:bg-[linear-gradient(52.22deg,#facc15_0%,rgba(250,204,21,0)_20%,rgba(250,204,21,0)_80%,#facc15_100%)]" | ||||||
|         if (!cancelled) setReady(true); |                 > | ||||||
|       } |                     {/* Inner card (glassmorphic effect) */} | ||||||
|     })(); |                     <div className="relative z-10 rounded-2xl bg-white/10 px-8 py-16 backdrop-blur-lg dark:bg-white/10 lg:min-h-[600px]"> | ||||||
| 
 |                         <div className="mx-auto w-full max-w-[440px] text-center"> | ||||||
|     return () => { |                             {/* Header */} | ||||||
|       cancelled = true; |                             <h1 className="text-4xl font-extrabold uppercase tracking-wide text-yellow-400 mb-2"> | ||||||
|       controller.abort(); |                                 Sign In | ||||||
|     }; |                             </h1> | ||||||
|   }, [router, API]); |                             <p className="text-base font-medium text-gray-200 dark:text-gray-300 mb-8"> | ||||||
| 
 |                                 Enter your email and password to access your account. | ||||||
|   if (!ready) return null; // or a spinner/skeleton
 |                             </p> | ||||||
| 
 | 
 | ||||||
|   return ( |                             {/* Login form */} | ||||||
|     <div className="relative min-h-screen overflow-hidden bg-[#060818] text-white"> |                             <ComponentsAuthLoginForm /> | ||||||
|       {/* Background gradient layer */} | 
 | ||||||
|       <div className="absolute inset-0 -z-10"> |                             {/* Footer link */} | ||||||
|         <img |                             <div className="mt-6 text-sm text-gray-200 dark:text-gray-300"> | ||||||
|           src="/assets/images/auth/bg-gradient.png" |                                 Don’t have an account?{" "} | ||||||
|           alt="background gradient" |                                 <Link | ||||||
|           className="h-full w-full object-cover" |                                     href="/register" | ||||||
|         /> |                                     className="text-yellow-400 font-semibold underline transition hover:text-white" | ||||||
|         <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" /> |                                 > | ||||||
|       </div> |                                     SIGN UP | ||||||
| 
 |                                 </Link> | ||||||
|       {/* Background decorative objects */} |                             </div> | ||||||
|       <img |                         </div> | ||||||
|         src="/assets/images/auth/coming-soon-object1.png" |                     </div> | ||||||
|         alt="left decor" |                 </div> | ||||||
|         className="absolute left-0 top-1/2 hidden h-full max-h-[893px] -translate-y-1/2 brightness-125 md:block" |  | ||||||
|       /> |  | ||||||
|       <img |  | ||||||
|         src="/assets/images/auth/coming-soon-object3.png" |  | ||||||
|         alt="right decor" |  | ||||||
|         className="absolute right-0 top-0 hidden h-[300px] brightness-125 md:block" |  | ||||||
|       /> |  | ||||||
| 
 |  | ||||||
|       {/* Centered card wrapper */} |  | ||||||
|       <div className="relative flex min-h-screen items-center justify-center px-6 py-10 sm:px-16"> |  | ||||||
|         <div |  | ||||||
|           className="relative w-full max-w-[870px] rounded-2xl p-1 |  | ||||||
|                      bg-[linear-gradient(45deg,#fffbe6_0%,rgba(255,251,230,0)_25%,rgba(255,251,230,0)_75%,#fffbe6_100%)] |  | ||||||
|                      dark:bg-[linear-gradient(52.22deg,#facc15_0%,rgba(250,204,21,0)_20%,rgba(250,204,21,0)_80%,#facc15_100%)]" |  | ||||||
|         > |  | ||||||
|           <div className="relative z-10 rounded-2xl bg-white/10 px-8 py-16 backdrop-blur-lg dark:bg-white/10 lg:min-h-[600px]"> |  | ||||||
|             <div className="mx-auto w-full max-w-[440px] text-center"> |  | ||||||
|               <h1 className="text-4xl font-extrabold uppercase tracking-wide text-yellow-400 mb-2"> |  | ||||||
|                 Sign In |  | ||||||
|               </h1> |  | ||||||
|               <p className="text-base font-medium text-gray-200 dark:text-gray-300 mb-8"> |  | ||||||
|                 Enter your email and password to access your account. |  | ||||||
|               </p> |  | ||||||
| 
 |  | ||||||
|               <ComponentsAuthLoginForm /> |  | ||||||
| 
 |  | ||||||
|               <div className="mt-6 text-sm text-gray-200 dark:text-gray-300"> |  | ||||||
|                 Don’t have an account?{' '} |  | ||||||
|                 <Link |  | ||||||
|                   href="/register" |  | ||||||
|                   className="text-yellow-400 font-semibold underline transition hover:text-white" |  | ||||||
|                 > |  | ||||||
|                   SIGN UP |  | ||||||
|                 </Link> |  | ||||||
|               </div> |  | ||||||
|             </div> |             </div> | ||||||
|           </div> |  | ||||||
|         </div> |         </div> | ||||||
|       </div> |     ); | ||||||
|     </div> | }; | ||||||
|   ); |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | export default LoginPage | ||||||
|  | |||||||
| @ -1,25 +1,30 @@ | |||||||
| // app/layout.tsx
 | 'use client'; | ||||||
| import type { Metadata } from "next"; | import ProviderComponent from '@/components/layouts/provider-component'; | ||||||
| import ProviderComponent from "@/components/layouts/provider-component"; | import 'react-perfect-scrollbar/dist/css/styles.css'; | ||||||
| import "react-perfect-scrollbar/dist/css/styles.css"; | import '../styles/tailwind.css'; | ||||||
| import "../styles/tailwind.css"; | import { Metadata } from 'next'; | ||||||
| import { Nunito } from "next/font/google"; | import { Nunito } from 'next/font/google'; | ||||||
| import { Exo_2 } from "next/font/google"; | import { Exo_2 } from "next/font/google"; | ||||||
| 
 | 
 | ||||||
| const exo2 = Exo_2({ subsets: ["latin"], variable: "--font-exo2", weight: ["200", "400"] }); | const exo2 = Exo_2({ | ||||||
| const nunito = Nunito({ weight: ["400","500","600","700","800"], subsets: ["latin"], display: "swap", variable: "--font-nunito" }); |   subsets: ["latin"], | ||||||
|  |   variable: "--font-exo2", | ||||||
|  |   weight: ["200", "400"], | ||||||
|  | }); | ||||||
| 
 | 
 | ||||||
| export const metadata: Metadata = { | const nunito = Nunito({ | ||||||
|   title: "Rooftop Meter", |     weight: ['400', '500', '600', '700', '800'], | ||||||
|   icons: { icon: "/favicon.png" }, // or "/favicon.ico"
 |     subsets: ['latin'], | ||||||
| }; |     display: 'swap', | ||||||
|  |     variable: '--font-nunito', | ||||||
|  | }); | ||||||
| 
 | 
 | ||||||
| export default function RootLayout({ children }: { children: React.ReactNode }) { | export default function RootLayout({ children }: { children: React.ReactNode }) { | ||||||
|   return ( |     return ( | ||||||
|     <html lang="en" className={`${exo2.variable} ${nunito.variable}`}> |         <html lang="en"> | ||||||
|       <body> |             <body className={exo2.variable}> | ||||||
|         <ProviderComponent>{children}</ProviderComponent> |                 <ProviderComponent>{children}</ProviderComponent> | ||||||
|       </body> |             </body> | ||||||
|     </html> |         </html> | ||||||
|   ); |     ) | ||||||
| } | } | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ export interface TimeSeriesResponse { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const API_BASE_URL = | const API_BASE_URL = | ||||||
|   process.env.FASTAPI_URL ?? "http://127.0.0.1:8000"; |   process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000"; | ||||||
| 
 | 
 | ||||||
| export const crmapi = { | export const crmapi = { | ||||||
|   getProjects: async () => { |   getProjects: async () => { | ||||||
|  | |||||||
| @ -3,46 +3,31 @@ import IconLockDots from '@/components/icon/icon-lock-dots'; | |||||||
| import IconMail from '@/components/icon/icon-mail'; | import IconMail from '@/components/icon/icon-mail'; | ||||||
| import { useRouter } from 'next/navigation'; | import { useRouter } from 'next/navigation'; | ||||||
| import { useState } from 'react'; | import { useState } from 'react'; | ||||||
|  | import axios from 'axios'; | ||||||
| import toast from 'react-hot-toast'; | import toast from 'react-hot-toast'; | ||||||
| 
 | 
 | ||||||
| type User = { id: string; email: string; is_active: boolean }; |  | ||||||
| 
 |  | ||||||
| const ComponentsAuthLoginForm = () => { | const ComponentsAuthLoginForm = () => { | ||||||
|   const [email, setEmail] = useState(''); |   const [email, setEmail] = useState(''); | ||||||
|   const [password, setPassword] = useState(''); |   const [password, setPassword] = useState(''); | ||||||
|   const [loading, setLoading] = useState(false); |   const [loading, setLoading] = useState(false); | ||||||
|   const router = useRouter(); |   const router = useRouter(); | ||||||
|   const API = process.env.NEXT_PUBLIC_FASTAPI_URL; // e.g. http://localhost:8000
 |  | ||||||
| 
 | 
 | ||||||
|   const submitForm = async (e: React.FormEvent<HTMLFormElement>) => { |   const submitForm = async (e: React.FormEvent) => { | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|     setLoading(true); |     setLoading(true); | ||||||
|     try { |     try { | ||||||
|       const res = await fetch(`${API}/auth/login`, { |       const res = await axios.post('/api/login', { email, password }); | ||||||
|         method: 'POST', |       toast.success(res.data?.message || 'Login successful!'); | ||||||
|         headers: { 'Content-Type': 'application/json' }, |  | ||||||
|         body: JSON.stringify({ email, password }), |  | ||||||
|         credentials: 'include', // cookie from FastAPI
 |  | ||||||
|       }); |  | ||||||
| 
 |  | ||||||
|       let data: any = null; |  | ||||||
|       try { |  | ||||||
|         data = await res.json(); |  | ||||||
|       } catch { |  | ||||||
|         // non-JSON error
 |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       if (!res.ok) { |  | ||||||
|         const msg = data?.detail || data?.message || 'Invalid credentials'; |  | ||||||
|         throw new Error(msg); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       const user: User = data; |  | ||||||
|       toast.success(`Welcome ${user.email}`); |  | ||||||
|       router.push('/adminDashboard'); |       router.push('/adminDashboard'); | ||||||
|       router.refresh(); |       router.refresh(); | ||||||
|  |       // token cookie is already set by the server:
 | ||||||
|     } catch (err: any) { |     } catch (err: any) { | ||||||
|       toast.error(err?.message ?? 'Login failed'); |       console.error('Login error:', err); | ||||||
|  |       const msg = | ||||||
|  |         err?.response?.data?.message || | ||||||
|  |         err?.message || | ||||||
|  |         'Invalid credentials'; | ||||||
|  |       toast.error(msg); | ||||||
|     } finally { |     } finally { | ||||||
|       setLoading(false); |       setLoading(false); | ||||||
|     } |     } | ||||||
| @ -67,7 +52,6 @@ const ComponentsAuthLoginForm = () => { | |||||||
|           </span> |           </span> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
| 
 |  | ||||||
|       <div className="pb-2"> |       <div className="pb-2"> | ||||||
|         <label htmlFor="Password" className="text-yellow-400 text-left">Password</label> |         <label htmlFor="Password" className="text-yellow-400 text-left">Password</label> | ||||||
|         <div className="relative text-white-dark"> |         <div className="relative text-white-dark"> | ||||||
| @ -86,7 +70,6 @@ const ComponentsAuthLoginForm = () => { | |||||||
|           </span> |           </span> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
| 
 |  | ||||||
|       <button |       <button | ||||||
|         type="submit" |         type="submit" | ||||||
|         disabled={loading} |         disabled={loading} | ||||||
| @ -100,4 +83,3 @@ const ComponentsAuthLoginForm = () => { | |||||||
| 
 | 
 | ||||||
| export default ComponentsAuthLoginForm; | export default ComponentsAuthLoginForm; | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|  | |||||||
| @ -33,14 +33,12 @@ export default function ComponentsAuthRegisterForm({ redirectTo = "/dashboard" } | |||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const API = process.env.NEXT_PUBLIC_FASTAPI_URL; // e.g. http://localhost:8000
 |  | ||||||
|     try { |     try { | ||||||
|       setLoading(true); |       setLoading(true); | ||||||
|       const res = await fetch(`${API}/auth/register`, { |       const res = await fetch("/api/register", { | ||||||
|         method: 'POST', |         method: "POST", | ||||||
|         headers: { 'Content-Type': 'application/json' }, |         headers: { "Content-Type": "application/json" }, | ||||||
|         body: JSON.stringify({ email, password }), |         body: JSON.stringify({ email, password }), | ||||||
|         credentials: 'include', |  | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|       const data = await res.json(); |       const data = await res.json(); | ||||||
|  | |||||||
| @ -3,18 +3,19 @@ import { useEffect, useState } from 'react'; | |||||||
| import { useDispatch, useSelector } from 'react-redux'; | import { useDispatch, useSelector } from 'react-redux'; | ||||||
| import Link from 'next/link'; | import Link from 'next/link'; | ||||||
| import { IRootState } from '@/store'; | import { IRootState } from '@/store'; | ||||||
| import { toggleTheme, toggleSidebar } from '@/store/themeConfigSlice'; | import { toggleTheme, toggleSidebar, toggleRTL } from '@/store/themeConfigSlice'; | ||||||
| import Image from 'next/image'; | import Image from 'next/image'; | ||||||
| import Dropdown from '@/components/dropdown'; | import Dropdown from '@/components/dropdown'; | ||||||
| import IconMenu from '@/components/icon/icon-menu'; | import IconMenu from '@/components/icon/icon-menu'; | ||||||
| import IconSun from '@/components/icon/icon-sun'; | import IconSun from '@/components/icon/icon-sun'; | ||||||
| import IconMoon from '@/components/icon/icon-moon'; | import IconMoon from '@/components/icon/icon-moon'; | ||||||
| import IconUser from '@/components/icon/icon-user'; | import IconUser from '@/components/icon/icon-user'; | ||||||
|  | import IconMail from '@/components/icon/icon-mail'; | ||||||
| import IconLockDots from '@/components/icon/icon-lock-dots'; | import IconLockDots from '@/components/icon/icon-lock-dots'; | ||||||
| import IconLogout from '@/components/icon/icon-logout'; | import IconLogout from '@/components/icon/icon-logout'; | ||||||
| import { usePathname, useRouter } from 'next/navigation'; | import { usePathname, useRouter } from 'next/navigation'; | ||||||
| 
 | 
 | ||||||
| type UserData = { id: string; email: string; is_active: boolean }; | type UserData = { id: string; email: string; createdAt: string }; | ||||||
| 
 | 
 | ||||||
| export default function Header() { | export default function Header() { | ||||||
|   const pathname = usePathname(); |   const pathname = usePathname(); | ||||||
| @ -26,16 +27,17 @@ export default function Header() { | |||||||
|   const [user, setUser] = useState<UserData | null>(null); |   const [user, setUser] = useState<UserData | null>(null); | ||||||
|   const [loadingUser, setLoadingUser] = useState(true); |   const [loadingUser, setLoadingUser] = useState(true); | ||||||
| 
 | 
 | ||||||
|   const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000'; |   // Highlight active menu (your original effect)
 | ||||||
| 
 |  | ||||||
|   // highlight active menu
 |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const selector = document.querySelector( |     const selector = document.querySelector( | ||||||
|       `ul.horizontal-menu a[href="${window.location.pathname}"]` |       'ul.horizontal-menu a[href="' + window.location.pathname + '"]' | ||||||
|     ); |     ); | ||||||
|     if (selector) { |     if (selector) { | ||||||
|       document |       document | ||||||
|         .querySelectorAll('ul.horizontal-menu .nav-link.active, ul.horizontal-menu a.active') |         .querySelectorAll('ul.horizontal-menu .nav-link.active') | ||||||
|  |         .forEach((el) => el.classList.remove('active')); | ||||||
|  |       document | ||||||
|  |         .querySelectorAll('ul.horizontal-menu a.active') | ||||||
|         .forEach((el) => el.classList.remove('active')); |         .forEach((el) => el.classList.remove('active')); | ||||||
|       selector.classList.add('active'); |       selector.classList.add('active'); | ||||||
|       const ul: any = selector.closest('ul.sub-menu'); |       const ul: any = selector.closest('ul.sub-menu'); | ||||||
| @ -46,16 +48,16 @@ export default function Header() { | |||||||
|     } |     } | ||||||
|   }, [pathname]); |   }, [pathname]); | ||||||
| 
 | 
 | ||||||
|   async function loadUser(signal?: AbortSignal) { |   async function loadUser() { | ||||||
|   try { |   try { | ||||||
|     const res = await fetch(`${API}/auth/me`, { |     const res = await fetch('/api/auth/me', { | ||||||
|       credentials: 'include', |       method: 'GET', | ||||||
|       cache: 'no-store', |       credentials: 'include', // send cookie
 | ||||||
|       signal, |       cache: 'no-store',      // avoid stale cached responses
 | ||||||
|     }); |     }); | ||||||
|     if (!res.ok) throw new Error(); |     if (!res.ok) throw new Error(); | ||||||
|     const data = await res.json().catch(() => null); |     const data = await res.json(); | ||||||
|     setUser(data?.id ? (data as UserData) : null); |     setUser(data.user); | ||||||
|   } catch { |   } catch { | ||||||
|     setUser(null); |     setUser(null); | ||||||
|   } finally { |   } finally { | ||||||
| @ -63,53 +65,44 @@ export default function Header() { | |||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { | useEffect(() => { | ||||||
|     setLoadingUser(true); |   setLoadingUser(true); | ||||||
|     const controller = new AbortController(); |   loadUser(); | ||||||
|     loadUser(controller.signal); |   // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||||
|     return () => controller.abort(); | }, [pathname]); // re-fetch on route change (after login redirect)
 | ||||||
|   }, [pathname, API]); |  | ||||||
| 
 | 
 | ||||||
|   const handleLogout = async () => { |   const handleLogout = async () => { | ||||||
|     try { |     await fetch('/api/auth/logout', { method: 'POST' }); | ||||||
|     await fetch(`${API}/auth/logout`, { |  | ||||||
|       method: 'POST', |  | ||||||
|       credentials: 'include', |  | ||||||
|       cache: 'no-store', |  | ||||||
|     }); |  | ||||||
|   } catch (_) { |  | ||||||
|     // ignore
 |  | ||||||
|   } finally { |  | ||||||
|     setUser(null); |     setUser(null); | ||||||
|     window.location.href = '/login'; |     router.push('/login'); // go to login
 | ||||||
|   } |  | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}> |     <header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}> | ||||||
|       <div className="shadow-sm"> |       <div className="shadow-sm"> | ||||||
|         <div className="relative flex w-full items-center bg-white px-5 py-2.5 dark:bg-rtgray-900"> |         <div className="relative flex w-full items-center bg-white px-5 py-2.5 dark:bg-rtgray-900"> | ||||||
|           {/* Logo + mobile toggler */} |           {/* Logo */} | ||||||
|           <div className="horizontal-logo flex items-center justify-between ltr:mr-2 rtl:ml-2 lg:hidden"> |           <div className="horizontal-logo flex items-center justify-between ltr:mr-2 rtl:ml-2 lg:hidden"> | ||||||
|             <div className="relative h-10 w-32 sm:h-11 sm:w-36 md:h-12 md:w-27 shrink-0 max-h-12"> |             <div className="relative h-10 w-32 sm:h-11 sm:w-36 md:h-12 md:w-27 shrink-0 max-h-12"> | ||||||
|               <Image |                 <Image | ||||||
|                 src="/assets/images/newfulllogo.png" |                 src="/assets/images/newfulllogo.png" | ||||||
|                 alt="logo" |                 alt="logo" | ||||||
|                 fill |                 fill | ||||||
|                 className="object-cover" |                 className="object-cover" | ||||||
|                 priority |                 priority | ||||||
|                 sizes="(max-width: 640px) 8rem, (max-width: 768px) 9rem, (max-width: 1024px) 10rem, 10rem" |                 sizes="(max-width: 640px) 8rem, (max-width: 768px) 9rem, (max-width: 1024px) 10rem, 10rem" | ||||||
|               /> |                 /> | ||||||
|             </div> |             </div> | ||||||
| 
 | 
 | ||||||
|             <button |             <button | ||||||
|               type="button" |                 type="button" | ||||||
|               onClick={() => dispatch(toggleSidebar())} |                 onClick={() => dispatch(toggleSidebar())} | ||||||
|               className="collapse-icon flex p-2 rounded-full hover:bg-rtgray-200 dark:text-white dark:hover:bg-rtgray-700" |                 className="collapse-icon flex p-2 rounded-full hover:bg-rtgray-200 dark:text-white dark:hover:bg-rtgray-700" | ||||||
|             > |             > | ||||||
|               <IconMenu className="h-6 w-6" /> |                 <IconMenu className="h-6 w-6" /> | ||||||
|             </button> |             </button> | ||||||
|           </div> |             </div> | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
|           {/* Right-side actions */} |           {/* Right-side actions */} | ||||||
|           <div className="flex items-center justify-end space-x-1.5 ltr:ml-auto rtl:mr-auto rtl:space-x-reverse dark:text-[#d0d2d6] lg:space-x-2"> |           <div className="flex items-center justify-end space-x-1.5 ltr:ml-auto rtl:mr-auto rtl:space-x-reverse dark:text-[#d0d2d6] lg:space-x-2"> | ||||||
| @ -131,21 +124,21 @@ export default function Header() { | |||||||
|             )} |             )} | ||||||
| 
 | 
 | ||||||
|             {/* User dropdown */} |             {/* User dropdown */} | ||||||
|             <div className="dropdown flex shrink-0"> |             <div className="dropdown flex shrink-0 "> | ||||||
|               {loadingUser ? ( |               {loadingUser ? ( | ||||||
|                 <div className="h-9 w-9 rounded-full animate-pulse bg-gray-300 dark:bg-rtgray-800" /> |                 <div className="h-9 w-9 rounded-full animate-pulse bg-gray-300 dark:bg-rtgray-800" /> | ||||||
|               ) : user ? ( |               ) : user ? ( | ||||||
|                 <Dropdown |             <Dropdown | ||||||
|                   placement={isRtl ? 'bottom-start' : 'bottom-end'} |                 placement={isRtl ? 'bottom-start' : 'bottom-end'} | ||||||
|                   btnClassName="relative group block" |                 btnClassName="relative group block" | ||||||
|                   panelClassName="rounded-lg shadow-lg border border-white/10 bg-rtgray-100 dark:bg-rtgray-800 p-2" |                 panelClassName="rounded-lg shadow-lg border border-white/10 bg-rtgray-100 dark:bg-rtgray-800 p-2" // ✅
 | ||||||
|                   button={ |                 button={ | ||||||
|                     <div className="h-9 w-9 rounded-full bg-rtgray-200 dark:bg-rtgray-800 flex items-center justify-center group-hover:bg-rtgray-300 dark:group-hover:bg-rtgray-700"> |                         <div className="h-9 w-9 rounded-full bg-rtgray-200 dark:bg-rtgray-800 flex items-center justify-center group-hover:bg-rtgray-300 dark:group-hover:bg-rtgray-700"> | ||||||
|                       <IconUser className="h-5 w-5 text-gray-600 dark:text-gray-300" /> |                         <IconUser className="h-5 w-5 text-gray-600 dark:text-gray-300" /> | ||||||
|                     </div> |                         </div> | ||||||
|                   } |                     } | ||||||
|                 > |                 > | ||||||
|                   <ul className="w-[230px] font-semibold text-dark"> |                 <ul className="w-[230px] font-semibold text-dark"> {/* make sure this stays transparent */} | ||||||
|                     <li className="px-4 py-4 flex items-center"> |                     <li className="px-4 py-4 flex items-center"> | ||||||
|                       <div className="truncate ltr:pl-1.5 rtl:pr-4"> |                       <div className="truncate ltr:pl-1.5 rtl:pr-4"> | ||||||
|                         <h4 className="text-sm text-left">{user.email}</h4> |                         <h4 className="text-sm text-left">{user.email}</h4> | ||||||
|  | |||||||
| @ -10,10 +10,10 @@ export function middleware(req: NextRequest) { | |||||||
| 
 | 
 | ||||||
|     try { |     try { | ||||||
|         jwt.verify(token, SECRET_KEY); |         jwt.verify(token, SECRET_KEY); | ||||||
|     return NextResponse.next(); |         return NextResponse.next(); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|         return NextResponse.redirect(new URL("/login", req.url)); |         return NextResponse.redirect(new URL("/login", req.url)); | ||||||
|     } |     } | ||||||
|   } | } | ||||||
| 
 | 
 | ||||||
| export const config = { matcher: ["/dashboard", "/profile"] }; | export const config = { matcher: ["/dashboard", "/profile"] }; | ||||||
|  | |||||||
							
								
								
									
										15
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										15
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -31,7 +31,6 @@ | |||||||
|                 "he": "^1.2.0", |                 "he": "^1.2.0", | ||||||
|                 "html2canvas": "^1.4.1", |                 "html2canvas": "^1.4.1", | ||||||
|                 "i18next": "^22.4.10", |                 "i18next": "^22.4.10", | ||||||
|                 "jose": "^6.0.12", |  | ||||||
|                 "jsonwebtoken": "^9.0.2", |                 "jsonwebtoken": "^9.0.2", | ||||||
|                 "jspdf": "^3.0.1", |                 "jspdf": "^3.0.1", | ||||||
|                 "next": "14.0.3", |                 "next": "14.0.3", | ||||||
| @ -7108,15 +7107,6 @@ | |||||||
|                 "jiti": "bin/jiti.js" |                 "jiti": "bin/jiti.js" | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|         "node_modules/jose": { |  | ||||||
|             "version": "6.0.12", |  | ||||||
|             "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz", |  | ||||||
|             "integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==", |  | ||||||
|             "license": "MIT", |  | ||||||
|             "funding": { |  | ||||||
|                 "url": "https://github.com/sponsors/panva" |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "node_modules/js-sdsl": { |         "node_modules/js-sdsl": { | ||||||
|             "version": "4.2.0", |             "version": "4.2.0", | ||||||
|             "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", |             "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", | ||||||
| @ -14703,11 +14693,6 @@ | |||||||
|             "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", |             "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", | ||||||
|             "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==" |             "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==" | ||||||
|         }, |         }, | ||||||
|         "jose": { |  | ||||||
|             "version": "6.0.12", |  | ||||||
|             "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz", |  | ||||||
|             "integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==" |  | ||||||
|         }, |  | ||||||
|         "js-sdsl": { |         "js-sdsl": { | ||||||
|             "version": "4.2.0", |             "version": "4.2.0", | ||||||
|             "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", |             "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", | ||||||
|  | |||||||
| @ -32,7 +32,6 @@ | |||||||
|         "he": "^1.2.0", |         "he": "^1.2.0", | ||||||
|         "html2canvas": "^1.4.1", |         "html2canvas": "^1.4.1", | ||||||
|         "i18next": "^22.4.10", |         "i18next": "^22.4.10", | ||||||
|         "jose": "^6.0.12", |  | ||||||
|         "jsonwebtoken": "^9.0.2", |         "jsonwebtoken": "^9.0.2", | ||||||
|         "jspdf": "^3.0.1", |         "jspdf": "^3.0.1", | ||||||
|         "next": "14.0.3", |         "next": "14.0.3", | ||||||
|  | |||||||
							
								
								
									
										46
									
								
								pages/api/auth/me.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								pages/api/auth/me.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | |||||||
|  | // pages/api/auth/me.ts
 | ||||||
|  | import type { NextApiRequest, NextApiResponse } from "next"; | ||||||
|  | import jwt, { JwtPayload } from "jsonwebtoken"; | ||||||
|  | import { PrismaClient } from "@prisma/client"; | ||||||
|  | 
 | ||||||
|  | const prisma = new PrismaClient(); | ||||||
|  | const SECRET_KEY = process.env.JWT_SECRET as string; | ||||||
|  | 
 | ||||||
|  | function readCookieToken(req: NextApiRequest) { | ||||||
|  |   const cookie = req.headers.cookie || ""; | ||||||
|  |   const match = cookie.split("; ").find((c) => c.startsWith("token=")); | ||||||
|  |   return match?.split("=")[1]; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function readAuthBearer(req: NextApiRequest) { | ||||||
|  |   const auth = req.headers.authorization; | ||||||
|  |   if (!auth?.startsWith("Bearer ")) return undefined; | ||||||
|  |   return auth.slice("Bearer ".length); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function hasEmail(payload: string | JwtPayload): payload is JwtPayload & { email: string } { | ||||||
|  |   return typeof payload === "object" && payload !== null && typeof (payload as any).email === "string"; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default async function handler(req: NextApiRequest, res: NextApiResponse) { | ||||||
|  |   if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" }); | ||||||
|  | 
 | ||||||
|  |   try { | ||||||
|  |     const token = readAuthBearer(req) ?? readCookieToken(req); | ||||||
|  |     if (!token) return res.status(401).json({ message: "Unauthorized" }); | ||||||
|  | 
 | ||||||
|  |     const decoded = jwt.verify(token, SECRET_KEY); | ||||||
|  |     if (!hasEmail(decoded)) return res.status(401).json({ message: "Invalid token" }); | ||||||
|  | 
 | ||||||
|  |     const user = await prisma.user.findUnique({ | ||||||
|  |       where: { email: decoded.email }, | ||||||
|  |       select: { id: true, email: true, createdAt: true }, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     if (!user) return res.status(401).json({ message: "User not found" }); | ||||||
|  |     return res.status(200).json({ user }); | ||||||
|  |   } catch { | ||||||
|  |     return res.status(401).json({ message: "Invalid token" }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										42
									
								
								pages/api/login.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								pages/api/login.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,42 @@ | |||||||
|  | // pages/api/login.ts
 | ||||||
|  | import type { NextApiRequest, NextApiResponse } from "next"; | ||||||
|  | import { PrismaClient } from "@prisma/client"; | ||||||
|  | import bcrypt from "bcrypt"; | ||||||
|  | import jwt from "jsonwebtoken"; | ||||||
|  | 
 | ||||||
|  | const prisma = new PrismaClient(); | ||||||
|  | const SECRET_KEY = process.env.JWT_SECRET as string; | ||||||
|  | 
 | ||||||
|  | export default async function handler(req: NextApiRequest, res: NextApiResponse) { | ||||||
|  |   if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" }); | ||||||
|  | 
 | ||||||
|  |   try { | ||||||
|  |     const { email, password } = req.body as { email?: string; password?: string }; | ||||||
|  |     if (!email || !password) return res.status(400).json({ message: "Email and password are required" }); | ||||||
|  | 
 | ||||||
|  |     const user = await prisma.user.findUnique({ where: { email } }); | ||||||
|  |     if (!user) return res.status(401).json({ message: "Invalid credentials" }); | ||||||
|  | 
 | ||||||
|  |     const isMatch = await bcrypt.compare(password, user.password); | ||||||
|  |     if (!isMatch) return res.status(401).json({ message: "Invalid credentials" }); | ||||||
|  | 
 | ||||||
|  |     const token = jwt.sign({ sub: String(user.id), email: user.email }, SECRET_KEY, { expiresIn: "1d" }); | ||||||
|  | 
 | ||||||
|  |     const isProd = process.env.NODE_ENV === "production"; | ||||||
|  |     const cookie = [ | ||||||
|  |       `token=${token}`, | ||||||
|  |       "HttpOnly", | ||||||
|  |       "Path=/", | ||||||
|  |       "SameSite=Strict", | ||||||
|  |       `Max-Age=${60 * 60 * 24}`, // 1 day
 | ||||||
|  |       isProd ? "Secure" : "",    // only secure in prod
 | ||||||
|  |     ].filter(Boolean).join("; "); | ||||||
|  | 
 | ||||||
|  |     res.setHeader("Set-Cookie", cookie); | ||||||
|  |     return res.status(200).json({ message: "Login successful" }); | ||||||
|  |   } catch (e) { | ||||||
|  |     console.error(e); | ||||||
|  |     return res.status(500).json({ message: "Something went wrong" }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										20
									
								
								pages/api/logout.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								pages/api/logout.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | |||||||
|  | // pages/api/auth/logout.ts
 | ||||||
|  | import type { NextApiRequest, NextApiResponse } from "next"; | ||||||
|  | 
 | ||||||
|  | export default function handler(req: NextApiRequest, res: NextApiResponse) { | ||||||
|  |   const isProd = process.env.NODE_ENV === "production"; | ||||||
|  |   res.setHeader( | ||||||
|  |     "Set-Cookie", | ||||||
|  |     [ | ||||||
|  |       "token=", // empty token
 | ||||||
|  |       "HttpOnly", | ||||||
|  |       "Path=/", | ||||||
|  |       "SameSite=Strict", | ||||||
|  |       "Max-Age=0", // expire immediately
 | ||||||
|  |       isProd ? "Secure" : "", | ||||||
|  |     ] | ||||||
|  |       .filter(Boolean) | ||||||
|  |       .join("; ") | ||||||
|  |   ); | ||||||
|  |   return res.status(200).json({ message: "Logged out" }); | ||||||
|  | } | ||||||
							
								
								
									
										42
									
								
								pages/api/register.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								pages/api/register.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,42 @@ | |||||||
|  | // pages/api/register.ts
 | ||||||
|  | import type { NextApiRequest, NextApiResponse } from "next"; | ||||||
|  | import { PrismaClient } from "@prisma/client"; | ||||||
|  | import bcrypt from "bcrypt"; | ||||||
|  | import jwt from "jsonwebtoken"; | ||||||
|  | 
 | ||||||
|  | const prisma = new PrismaClient(); | ||||||
|  | const SECRET_KEY = process.env.JWT_SECRET as string; | ||||||
|  | 
 | ||||||
|  | export default async function handler(req: NextApiRequest, res: NextApiResponse) { | ||||||
|  |   if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" }); | ||||||
|  | 
 | ||||||
|  |   try { | ||||||
|  |     const { email, password } = req.body as { email?: string; password?: string }; | ||||||
|  | 
 | ||||||
|  |     if (!email || !password) return res.status(400).json({ message: "Email and password are required" }); | ||||||
|  | 
 | ||||||
|  |     const existingUser = await prisma.user.findUnique({ where: { email } }); | ||||||
|  |     if (existingUser) return res.status(400).json({ message: "User already exists" }); | ||||||
|  | 
 | ||||||
|  |     const hashedPassword = await bcrypt.hash(password, 10); | ||||||
|  |     const user = await prisma.user.create({ | ||||||
|  |       data: { email, password: hashedPassword }, | ||||||
|  |       select: { id: true, email: true, createdAt: true }, // do NOT expose password
 | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const token = jwt.sign({ sub: String(user.id), email: user.email }, SECRET_KEY, { expiresIn: "1d" }); | ||||||
|  | 
 | ||||||
|  |     // Set a secure, httpOnly cookie
 | ||||||
|  |     const maxAge = 60 * 60 * 24; // 1 day
 | ||||||
|  |     res.setHeader( | ||||||
|  |       "Set-Cookie", | ||||||
|  |       `token=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict; Secure` | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     return res.status(201).json({ message: "User registered", user }); | ||||||
|  |   } catch (err) { | ||||||
|  |     console.error(err); | ||||||
|  |     return res.status(500).json({ message: "Something went wrong" }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| @ -1,18 +0,0 @@ | |||||||
| /* |  | ||||||
|   Warnings: |  | ||||||
| 
 |  | ||||||
|   - You are about to drop the `EnergyData` table. If the table is not empty, all the data it contains will be lost. |  | ||||||
|   - You are about to drop the `Site` table. If the table is not empty, all the data it contains will be lost. |  | ||||||
| 
 |  | ||||||
| */ |  | ||||||
| -- DropForeignKey |  | ||||||
| ALTER TABLE "public"."EnergyData" DROP CONSTRAINT "EnergyData_consumptionSiteId_fkey"; |  | ||||||
| 
 |  | ||||||
| -- DropForeignKey |  | ||||||
| ALTER TABLE "public"."EnergyData" DROP CONSTRAINT "EnergyData_generationSiteId_fkey"; |  | ||||||
| 
 |  | ||||||
| -- DropTable |  | ||||||
| DROP TABLE "public"."EnergyData"; |  | ||||||
| 
 |  | ||||||
| -- DropTable |  | ||||||
| DROP TABLE "public"."Site"; |  | ||||||
| @ -1,12 +0,0 @@ | |||||||
| /* |  | ||||||
|   Warnings: |  | ||||||
| 
 |  | ||||||
|   - You are about to drop the column `password` on the `User` table. All the data in the column will be lost. |  | ||||||
|   - Added the required column `passwordHash` to the `User` table without a default value. This is not possible if the table is not empty. |  | ||||||
| 
 |  | ||||||
| */ |  | ||||||
| -- AlterTable |  | ||||||
| ALTER TABLE "public"."User" DROP COLUMN "password", |  | ||||||
| ADD COLUMN     "name" TEXT, |  | ||||||
| ADD COLUMN     "passwordHash" TEXT NOT NULL, |  | ||||||
| ADD COLUMN     "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; |  | ||||||
| @ -1,14 +0,0 @@ | |||||||
| /* |  | ||||||
|   Warnings: |  | ||||||
| 
 |  | ||||||
|   - You are about to drop the column `name` on the `User` table. All the data in the column will be lost. |  | ||||||
|   - You are about to drop the column `passwordHash` on the `User` table. All the data in the column will be lost. |  | ||||||
|   - You are about to drop the column `updatedAt` on the `User` table. All the data in the column will be lost. |  | ||||||
|   - Added the required column `password` to the `User` table without a default value. This is not possible if the table is not empty. |  | ||||||
| 
 |  | ||||||
| */ |  | ||||||
| -- AlterTable |  | ||||||
| ALTER TABLE "public"."User" DROP COLUMN "name", |  | ||||||
| DROP COLUMN "passwordHash", |  | ||||||
| DROP COLUMN "updatedAt", |  | ||||||
| ADD COLUMN     "password" TEXT NOT NULL; |  | ||||||
| @ -1,3 +1,3 @@ | |||||||
| # Please do not edit this file manually | # Please do not edit this file manually | ||||||
| # It should be added in your version-control system (e.g., Git) | # It should be added in your version-control system (e.g., Git) | ||||||
| provider = "postgresql" | provider = "postgresql" | ||||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 3.2 KiB | 
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user