diff --git a/.env.example b/.env.example index 0150039..b062194 100644 --- a/.env.example +++ b/.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" JWT_SECRET="secret_key" - -#SUNGROW -SUNGROW_SECRET_KEY= -SUNGROW_APP_KEY= - -#CHINT -NEXT_PUBLIC_CHINT_TOKEN= diff --git a/.gitea/workflows/pr-build-check.yml b/.gitea/workflows/pr-build-check.yml index 7673ebe..5022fc2 100644 --- a/.gitea/workflows/pr-build-check.yml +++ b/.gitea/workflows/pr-build-check.yml @@ -27,8 +27,8 @@ jobs: - name: Build run: npm run build env: - NEXT_PUBLIC_URL: 'http://localhost:3000' - NEXT_PUBLIC_FORECAST_URL: 'http://localhost:3001' + NEXT_PUBLIC_URL: 'http://localhost:3005' + NEXT_PUBLIC_FORECAST_URL: 'http://localhost:3005' DATABASE_URL: 'postgresql://dummy:dummy@localhost:5432/dummy' SMTP_EMAIL: 'dummy@example.com' SMTP_EMAIL_PASSWORD: 'dummy' diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..11139cc --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/app/(admin)/adminDashboard/page.tsx b/app/(admin)/adminDashboard/page.tsx index bc97fce..83c287a 100644 --- a/app/(admin)/adminDashboard/page.tsx +++ b/app/(admin)/adminDashboard/page.tsx @@ -59,6 +59,7 @@ const AdminDashboard = () => { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const [authChecked, setAuthChecked] = useState(false); // --- load CRM projects dynamically --- const [sites, setSites] = useState([]); @@ -67,6 +68,42 @@ const AdminDashboard = () => { // near other refs const loggingRef = useRef(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(() => { setSitesLoading(true); @@ -288,7 +325,7 @@ const AdminDashboard = () => { setHasTodayData(true); // and today has data too break; } - } catch { + } catch { // ignore and keep polling } await new Promise(r => setTimeout(r, 3000)); @@ -300,6 +337,10 @@ const AdminDashboard = () => { }; // ---------- RENDER ---------- + if (!authChecked) { + return
Checking authentication…
; + } + if (sitesLoading) { return ( diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index ef76b0c..b1dcf64 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,73 +1,115 @@ +// app/login/page.tsx +'use client'; + import Link from 'next/link'; -import { Metadata } from 'next'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; 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) => { - return ( -
- {/* Background gradient layer */} -
- background gradient -
-
+ // Use ONE client-exposed API env var everywhere + const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000'; - {/* Background decorative objects */} - left decor - right decor + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); - {/* Centered card wrapper */} -
-
{ + try { + const res = await fetch(`${API}/auth/me`, { + credentials: 'include', + cache: 'no-store', // don't reuse a cached 401 + 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 ( +
+ {/* Background gradient layer */} +
+ background gradient +
+
+ + {/* Background decorative objects */} + left decor + right decor + + {/* Centered card wrapper */} +
+
+
+
+

+ Sign In +

+

+ Enter your email and password to access your account. +

+ + + +
+ Don’t have an account?{' '} + - {/* Inner card (glassmorphic effect) */} -
-
- {/* Header */} -

- Sign In -

-

- Enter your email and password to access your account. -

- - {/* Login form */} - - - {/* Footer link */} -
- Don’t have an account?{" "} - - SIGN UP - -
-
-
-
+ SIGN UP + +
+
- ); -}; +
+
+ ); +} -export default LoginPage diff --git a/app/layout.tsx b/app/layout.tsx index ce1fc5c..0dc96eb 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,30 +1,25 @@ -'use client'; -import ProviderComponent from '@/components/layouts/provider-component'; -import 'react-perfect-scrollbar/dist/css/styles.css'; -import '../styles/tailwind.css'; -import { Metadata } from 'next'; -import { Nunito } from 'next/font/google'; +// app/layout.tsx +import type { Metadata } from "next"; +import ProviderComponent from "@/components/layouts/provider-component"; +import "react-perfect-scrollbar/dist/css/styles.css"; +import "../styles/tailwind.css"; +import { Nunito } 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({ subsets: ["latin"], variable: "--font-exo2", weight: ["200", "400"] }); +const nunito = Nunito({ weight: ["400","500","600","700","800"], subsets: ["latin"], display: "swap", variable: "--font-nunito" }); -const nunito = Nunito({ - weight: ['400', '500', '600', '700', '800'], - subsets: ['latin'], - display: 'swap', - variable: '--font-nunito', -}); +export const metadata: Metadata = { + title: "Rooftop Meter", + icons: { icon: "/favicon.png" }, // or "/favicon.ico" +}; export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ) + return ( + + + {children} + + + ); } diff --git a/app/utils/api.ts b/app/utils/api.ts index 09773e1..67b9cbf 100644 --- a/app/utils/api.ts +++ b/app/utils/api.ts @@ -10,7 +10,7 @@ export interface TimeSeriesResponse { } 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 = { getProjects: async () => { diff --git a/components/auth/components-auth-login-form.tsx b/components/auth/components-auth-login-form.tsx index b478c44..8f9789a 100644 --- a/components/auth/components-auth-login-form.tsx +++ b/components/auth/components-auth-login-form.tsx @@ -3,31 +3,46 @@ import IconLockDots from '@/components/icon/icon-lock-dots'; import IconMail from '@/components/icon/icon-mail'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; -import axios from 'axios'; import toast from 'react-hot-toast'; +type User = { id: string; email: string; is_active: boolean }; + const ComponentsAuthLoginForm = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); 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) => { e.preventDefault(); setLoading(true); try { - const res = await axios.post('/api/login', { email, password }); - toast.success(res.data?.message || 'Login successful!'); + const res = await fetch(`${API}/auth/login`, { + 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.refresh(); - // token cookie is already set by the server: } catch (err: any) { - console.error('Login error:', err); - const msg = - err?.response?.data?.message || - err?.message || - 'Invalid credentials'; - toast.error(msg); + toast.error(err?.message ?? 'Login failed'); } finally { setLoading(false); } @@ -52,6 +67,7 @@ const ComponentsAuthLoginForm = () => {
+
@@ -70,6 +86,7 @@ const ComponentsAuthLoginForm = () => {
+ -
- +
{/* Right-side actions */}
@@ -124,21 +131,21 @@ useEffect(() => { )} {/* User dropdown */} -
+
{loadingUser ? (
) : user ? ( - - -
- } + + +
+ } > -
    {/* make sure this stays transparent */} +
    • {user.email}

      diff --git a/middleware.ts b/middleware.ts index c499d26..e9b2947 100644 --- a/middleware.ts +++ b/middleware.ts @@ -10,10 +10,10 @@ export function middleware(req: NextRequest) { try { jwt.verify(token, SECRET_KEY); - return NextResponse.next(); + return NextResponse.next(); } catch (error) { return NextResponse.redirect(new URL("/login", req.url)); } -} + } export const config = { matcher: ["/dashboard", "/profile"] }; diff --git a/package-lock.json b/package-lock.json index dba53c1..98ba282 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "he": "^1.2.0", "html2canvas": "^1.4.1", "i18next": "^22.4.10", + "jose": "^6.0.12", "jsonwebtoken": "^9.0.2", "jspdf": "^3.0.1", "next": "14.0.3", @@ -7107,6 +7108,15 @@ "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": { "version": "4.2.0", "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", "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": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", diff --git a/package.json b/package.json index 83a132d..f1be8f8 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "he": "^1.2.0", "html2canvas": "^1.4.1", "i18next": "^22.4.10", + "jose": "^6.0.12", "jsonwebtoken": "^9.0.2", "jspdf": "^3.0.1", "next": "14.0.3", diff --git a/pages/api/auth/me.ts b/pages/api/auth/me.ts deleted file mode 100644 index e5d94be..0000000 --- a/pages/api/auth/me.ts +++ /dev/null @@ -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" }); - } -} - diff --git a/pages/api/login.ts b/pages/api/login.ts deleted file mode 100644 index 633e4dd..0000000 --- a/pages/api/login.ts +++ /dev/null @@ -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" }); - } -} - diff --git a/pages/api/logout.ts b/pages/api/logout.ts deleted file mode 100644 index f341c0f..0000000 --- a/pages/api/logout.ts +++ /dev/null @@ -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" }); -} diff --git a/pages/api/register.ts b/pages/api/register.ts deleted file mode 100644 index fc570f8..0000000 --- a/pages/api/register.ts +++ /dev/null @@ -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" }); - } -} - diff --git a/prisma/migrations/20250815012144_init_users/migration.sql b/prisma/migrations/20250815012144_init_users/migration.sql new file mode 100644 index 0000000..c0bc57d --- /dev/null +++ b/prisma/migrations/20250815012144_init_users/migration.sql @@ -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"; diff --git a/prisma/migrations/20250815014502_add_password_hash_and_updated_at/migration.sql b/prisma/migrations/20250815014502_add_password_hash_and_updated_at/migration.sql new file mode 100644 index 0000000..069d8fe --- /dev/null +++ b/prisma/migrations/20250815014502_add_password_hash_and_updated_at/migration.sql @@ -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; diff --git a/prisma/migrations/20250815022446_add_user/migration.sql b/prisma/migrations/20250815022446_add_user/migration.sql new file mode 100644 index 0000000..67a1167 --- /dev/null +++ b/prisma/migrations/20250815022446_add_user/migration.sql @@ -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; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 648c57f..044d57c 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "postgresql" \ No newline at end of file +provider = "postgresql" diff --git a/public/favicon.png b/public/favicon.png index 9ee75c5..ca9cec2 100644 Binary files a/public/favicon.png and b/public/favicon.png differ