feature/syasya/testlayout #8
							
								
								
									
										11
									
								
								.env.example
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								.env.example
									
									
									
									
									
								
							@ -1,11 +1,6 @@
 | 
				
			|||||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:3005
 | 
					FASTAPI_URL="http://localhost:8000"
 | 
				
			||||||
 | 
					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:3000'
 | 
					          NEXT_PUBLIC_URL: 'http://localhost:3005'
 | 
				
			||||||
          NEXT_PUBLIC_FORECAST_URL: 'http://localhost:3001'
 | 
					          NEXT_PUBLIC_FORECAST_URL: 'http://localhost:3005'
 | 
				
			||||||
          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
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					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,6 +59,7 @@ 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[]>([]);
 | 
				
			||||||
@ -67,6 +68,42 @@ 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);
 | 
				
			||||||
@ -288,7 +325,7 @@ const AdminDashboard = () => {
 | 
				
			|||||||
            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));
 | 
				
			||||||
@ -300,6 +337,10 @@ const AdminDashboard = () => {
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // ---------- RENDER ----------
 | 
					  // ---------- RENDER ----------
 | 
				
			||||||
 | 
					  if (!authChecked) {
 | 
				
			||||||
 | 
					    return <div>Checking authentication…</div>;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
  if (sitesLoading) {
 | 
					  if (sitesLoading) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <DashboardLayout>
 | 
					      <DashboardLayout>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,73 +1,115 @@
 | 
				
			|||||||
 | 
					// app/login/page.tsx
 | 
				
			||||||
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import Link from 'next/link';
 | 
					import Link from 'next/link';
 | 
				
			||||||
import { Metadata } from 'next';
 | 
					import React, { useEffect, useState } from 'react';
 | 
				
			||||||
import React from 'react';
 | 
					import { useRouter } from 'next/navigation';
 | 
				
			||||||
import ComponentsAuthLoginForm from '@/components/auth/components-auth-login-form';
 | 
					import ComponentsAuthLoginForm from '@/components/auth/components-auth-login-form';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Props = {}
 | 
					export default function LoginPage() {
 | 
				
			||||||
 | 
					  const router = useRouter();
 | 
				
			||||||
 | 
					  const [ready, setReady] = useState(false); // gate to avoid UI flash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const LoginPage = (props: Props) => {
 | 
					  // Use ONE client-exposed API env var everywhere
 | 
				
			||||||
    return (
 | 
					  const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000';
 | 
				
			||||||
        <div className="relative min-h-screen overflow-hidden bg-[#060818] text-white">
 | 
					 | 
				
			||||||
            {/* Background gradient layer */}
 | 
					 | 
				
			||||||
            <div className="absolute inset-0 -z-10">
 | 
					 | 
				
			||||||
                <img
 | 
					 | 
				
			||||||
                    src="/assets/images/auth/bg-gradient.png"
 | 
					 | 
				
			||||||
                    alt="background gradient"
 | 
					 | 
				
			||||||
                    className="h-full w-full object-cover"
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            {/* Background decorative objects */}
 | 
					  useEffect(() => {
 | 
				
			||||||
            <img
 | 
					    let cancelled = false;
 | 
				
			||||||
                src="/assets/images/auth/coming-soon-object1.png"
 | 
					    const controller = new AbortController();
 | 
				
			||||||
                alt="left decor"
 | 
					 | 
				
			||||||
                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 */}
 | 
					    (async () => {
 | 
				
			||||||
            <div className="relative flex min-h-screen items-center justify-center px-6 py-10 sm:px-16">
 | 
					      try {
 | 
				
			||||||
                <div
 | 
					        const res = await fetch(`${API}/auth/me`, {
 | 
				
			||||||
                    className="relative w-full max-w-[870px] rounded-2xl p-1
 | 
					          credentials: 'include',
 | 
				
			||||||
                               bg-[linear-gradient(45deg,#fffbe6_0%,rgba(255,251,230,0)_25%,rgba(255,251,230,0)_75%,#fffbe6_100%)]
 | 
					          cache: 'no-store',        // don't reuse a cached 401
 | 
				
			||||||
                               dark:bg-[linear-gradient(52.22deg,#facc15_0%,rgba(250,204,21,0)_20%,rgba(250,204,21,0)_80%,#facc15_100%)]"
 | 
					          signal: controller.signal,
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!res.ok) {
 | 
				
			||||||
 | 
					          if (!cancelled) setReady(true);
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const user = await res.json().catch(() => null);
 | 
				
			||||||
 | 
					        if (user?.id) {
 | 
				
			||||||
 | 
					          // already logged in -> go straight to dashboard
 | 
				
			||||||
 | 
					          router.replace('/adminDashboard');
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // not logged in -> show form
 | 
				
			||||||
 | 
					        if (!cancelled) setReady(true);
 | 
				
			||||||
 | 
					      } catch {
 | 
				
			||||||
 | 
					        // network/error -> show form
 | 
				
			||||||
 | 
					        if (!cancelled) setReady(true);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return () => {
 | 
				
			||||||
 | 
					      cancelled = true;
 | 
				
			||||||
 | 
					      controller.abort();
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }, [router, API]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!ready) return null; // or a spinner/skeleton
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="relative min-h-screen overflow-hidden bg-[#060818] text-white">
 | 
				
			||||||
 | 
					      {/* Background gradient layer */}
 | 
				
			||||||
 | 
					      <div className="absolute inset-0 -z-10">
 | 
				
			||||||
 | 
					        <img
 | 
				
			||||||
 | 
					          src="/assets/images/auth/bg-gradient.png"
 | 
				
			||||||
 | 
					          alt="background gradient"
 | 
				
			||||||
 | 
					          className="h-full w-full object-cover"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Background decorative objects */}
 | 
				
			||||||
 | 
					      <img
 | 
				
			||||||
 | 
					        src="/assets/images/auth/coming-soon-object1.png"
 | 
				
			||||||
 | 
					        alt="left decor"
 | 
				
			||||||
 | 
					        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"
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                    {/* Inner card (glassmorphic effect) */}
 | 
					                  SIGN UP
 | 
				
			||||||
                    <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]">
 | 
					                </Link>
 | 
				
			||||||
                        <div className="mx-auto w-full max-w-[440px] text-center">
 | 
					              </div>
 | 
				
			||||||
                            {/* Header */}
 | 
					 | 
				
			||||||
                            <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>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                            {/* Login form */}
 | 
					 | 
				
			||||||
                            <ComponentsAuthLoginForm />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                            {/* Footer link */}
 | 
					 | 
				
			||||||
                            <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>
 | 
					        </div>
 | 
				
			||||||
    );
 | 
					      </div>
 | 
				
			||||||
};
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default LoginPage
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -1,30 +1,25 @@
 | 
				
			|||||||
'use client';
 | 
					// app/layout.tsx
 | 
				
			||||||
import ProviderComponent from '@/components/layouts/provider-component';
 | 
					import type { Metadata } from "next";
 | 
				
			||||||
import 'react-perfect-scrollbar/dist/css/styles.css';
 | 
					import ProviderComponent from "@/components/layouts/provider-component";
 | 
				
			||||||
import '../styles/tailwind.css';
 | 
					import "react-perfect-scrollbar/dist/css/styles.css";
 | 
				
			||||||
import { Metadata } from 'next';
 | 
					import "../styles/tailwind.css";
 | 
				
			||||||
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({
 | 
					const exo2 = Exo_2({ subsets: ["latin"], variable: "--font-exo2", weight: ["200", "400"] });
 | 
				
			||||||
  subsets: ["latin"],
 | 
					const nunito = Nunito({ weight: ["400","500","600","700","800"], subsets: ["latin"], display: "swap", variable: "--font-nunito" });
 | 
				
			||||||
  variable: "--font-exo2",
 | 
					 | 
				
			||||||
  weight: ["200", "400"],
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const nunito = Nunito({
 | 
					export const metadata: Metadata = {
 | 
				
			||||||
    weight: ['400', '500', '600', '700', '800'],
 | 
					  title: "Rooftop Meter",
 | 
				
			||||||
    subsets: ['latin'],
 | 
					  icons: { icon: "/favicon.png" }, // or "/favicon.ico"
 | 
				
			||||||
    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">
 | 
					    <html lang="en" className={`${exo2.variable} ${nunito.variable}`}>
 | 
				
			||||||
            <body className={exo2.variable}>
 | 
					      <body>
 | 
				
			||||||
                <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.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000";
 | 
					  process.env.FASTAPI_URL ?? "http://127.0.0.1:8000";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const crmapi = {
 | 
					export const crmapi = {
 | 
				
			||||||
  getProjects: async () => {
 | 
					  getProjects: async () => {
 | 
				
			||||||
 | 
				
			|||||||
@ -3,31 +3,46 @@ 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) => {
 | 
					  const submitForm = async (e: React.FormEvent<HTMLFormElement>) => {
 | 
				
			||||||
    e.preventDefault();
 | 
					    e.preventDefault();
 | 
				
			||||||
    setLoading(true);
 | 
					    setLoading(true);
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const res = await axios.post('/api/login', { email, password });
 | 
					      const res = await fetch(`${API}/auth/login`, {
 | 
				
			||||||
      toast.success(res.data?.message || 'Login successful!');
 | 
					        method: 'POST',
 | 
				
			||||||
 | 
					        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) {
 | 
				
			||||||
      console.error('Login error:', err);
 | 
					      toast.error(err?.message ?? 'Login failed');
 | 
				
			||||||
      const msg =
 | 
					 | 
				
			||||||
        err?.response?.data?.message ||
 | 
					 | 
				
			||||||
        err?.message ||
 | 
					 | 
				
			||||||
        'Invalid credentials';
 | 
					 | 
				
			||||||
      toast.error(msg);
 | 
					 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
      setLoading(false);
 | 
					      setLoading(false);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -52,6 +67,7 @@ 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">
 | 
				
			||||||
@ -70,6 +86,7 @@ const ComponentsAuthLoginForm = () => {
 | 
				
			|||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <button
 | 
					      <button
 | 
				
			||||||
        type="submit"
 | 
					        type="submit"
 | 
				
			||||||
        disabled={loading}
 | 
					        disabled={loading}
 | 
				
			||||||
@ -83,3 +100,4 @@ const ComponentsAuthLoginForm = () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export default ComponentsAuthLoginForm;
 | 
					export default ComponentsAuthLoginForm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -33,12 +33,14 @@ 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/register", {
 | 
					      const res = await fetch(`${API}/auth/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,19 +3,18 @@ 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, toggleRTL } from '@/store/themeConfigSlice';
 | 
					import { toggleTheme, toggleSidebar } 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; createdAt: string };
 | 
					type UserData = { id: string; email: string; is_active: boolean };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Header() {
 | 
					export default function Header() {
 | 
				
			||||||
  const pathname = usePathname();
 | 
					  const pathname = usePathname();
 | 
				
			||||||
@ -27,17 +26,16 @@ 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);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Highlight active menu (your original effect)
 | 
					  const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 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')
 | 
					        .querySelectorAll('ul.horizontal-menu .nav-link.active, ul.horizontal-menu a.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');
 | 
				
			||||||
@ -48,16 +46,16 @@ export default function Header() {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }, [pathname]);
 | 
					  }, [pathname]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async function loadUser() {
 | 
					  async function loadUser(signal?: AbortSignal) {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    const res = await fetch('/api/auth/me', {
 | 
					    const res = await fetch(`${API}/auth/me`, {
 | 
				
			||||||
      method: 'GET',
 | 
					      credentials: 'include',
 | 
				
			||||||
      credentials: 'include', // send cookie
 | 
					      cache: 'no-store',
 | 
				
			||||||
      cache: 'no-store',      // avoid stale cached responses
 | 
					      signal,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    if (!res.ok) throw new Error();
 | 
					    if (!res.ok) throw new Error();
 | 
				
			||||||
    const data = await res.json();
 | 
					    const data = await res.json().catch(() => null);
 | 
				
			||||||
    setUser(data.user);
 | 
					    setUser(data?.id ? (data as UserData) : null);
 | 
				
			||||||
  } catch {
 | 
					  } catch {
 | 
				
			||||||
    setUser(null);
 | 
					    setUser(null);
 | 
				
			||||||
  } finally {
 | 
					  } finally {
 | 
				
			||||||
@ -65,44 +63,53 @@ export default function Header() {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
  setLoadingUser(true);
 | 
					    setLoadingUser(true);
 | 
				
			||||||
  loadUser();
 | 
					    const controller = new AbortController();
 | 
				
			||||||
  // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
					    loadUser(controller.signal);
 | 
				
			||||||
}, [pathname]); // re-fetch on route change (after login redirect)
 | 
					    return () => controller.abort();
 | 
				
			||||||
 | 
					  }, [pathname, API]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleLogout = async () => {
 | 
					  const handleLogout = async () => {
 | 
				
			||||||
    await fetch('/api/auth/logout', { method: 'POST' });
 | 
					    try {
 | 
				
			||||||
 | 
					    await fetch(`${API}/auth/logout`, {
 | 
				
			||||||
 | 
					      method: 'POST',
 | 
				
			||||||
 | 
					      credentials: 'include',
 | 
				
			||||||
 | 
					      cache: 'no-store',
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  } catch (_) {
 | 
				
			||||||
 | 
					    // ignore
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
    setUser(null);
 | 
					    setUser(null);
 | 
				
			||||||
    router.push('/login'); // go to login
 | 
					    window.location.href = '/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 */}
 | 
					          {/* Logo + mobile toggler */}
 | 
				
			||||||
          <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">
 | 
				
			||||||
@ -124,21 +131,21 @@ useEffect(() => {
 | 
				
			|||||||
            )}
 | 
					            )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            {/* 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"> {/* make sure this stays transparent */}
 | 
					                  <ul className="w-[230px] font-semibold text-dark">
 | 
				
			||||||
                    <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,6 +31,7 @@
 | 
				
			|||||||
                "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",
 | 
				
			||||||
@ -7107,6 +7108,15 @@
 | 
				
			|||||||
                "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",
 | 
				
			||||||
@ -14693,6 +14703,11 @@
 | 
				
			|||||||
            "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,6 +32,7 @@
 | 
				
			|||||||
        "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",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,46 +0,0 @@
 | 
				
			|||||||
// 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" });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ -1,42 +0,0 @@
 | 
				
			|||||||
// 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" });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ -1,20 +0,0 @@
 | 
				
			|||||||
// 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" });
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,42 +0,0 @@
 | 
				
			|||||||
// 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" });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
							
								
								
									
										18
									
								
								prisma/migrations/20250815012144_init_users/migration.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								prisma/migrations/20250815012144_init_users/migration.sql
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					  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";
 | 
				
			||||||
@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					  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;
 | 
				
			||||||
							
								
								
									
										14
									
								
								prisma/migrations/20250815022446_add_user/migration.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								prisma/migrations/20250815022446_add_user/migration.sql
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					  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;
 | 
				
			||||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 78 KiB  | 
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user