fix auth flow

This commit is contained in:
Syasya 2025-08-20 12:41:52 +08:00
parent b8c67992cb
commit e689384977
9 changed files with 163 additions and 94 deletions

View File

@ -1,11 +1,6 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:3005 NEXT_PUBLIC_API_BASE_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=

View File

@ -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'

View File

@ -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,30 @@ const AdminDashboard = () => {
// near other refs // near other refs
const loggingRef = useRef<HTMLDivElement | null>(null); const loggingRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const checkAuth = async () => {
try {
const res = await fetch('/api/auth/me', { credentials: 'include' });
if (!res.ok) {
router.replace('/login');
return;
}
const data = await res.json();
if (!data.user) {
router.replace('/login');
return;
}
} catch {
router.replace('/login');
} finally {
setAuthChecked(true);
}
};
checkAuth();
}, [router]);
useEffect(() => { useEffect(() => {
setSitesLoading(true); setSitesLoading(true);
@ -288,7 +313,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 +325,10 @@ const AdminDashboard = () => {
}; };
// ---------- RENDER ---------- // ---------- RENDER ----------
if (!authChecked) {
return <div>Checking authentication</div>;
}
if (sitesLoading) { if (sitesLoading) {
return ( return (
<DashboardLayout> <DashboardLayout>

View File

@ -1,73 +1,100 @@
// 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) => { useEffect(() => {
return ( let cancelled = false;
<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 */} (async () => {
<img try {
src="/assets/images/auth/coming-soon-object1.png" const res = await fetch('/api/auth/me', {
alt="left decor" method: 'GET',
className="absolute left-0 top-1/2 hidden h-full max-h-[893px] -translate-y-1/2 brightness-125 md:block" cache: 'no-store',
/> credentials: 'include', // safe even if same-origin
<img });
src="/assets/images/auth/coming-soon-object3.png" if (!cancelled && res.ok) {
alt="right decor" router.replace('/adminDashboard');
className="absolute right-0 top-0 hidden h-[300px] brightness-125 md:block" return;
/> }
} catch {
// ignore errors; just show the form
}
if (!cancelled) setReady(true);
})();
{/* Centered card wrapper */} return () => { cancelled = true; };
<div className="relative flex min-h-screen items-center justify-center px-6 py-10 sm:px-16"> }, [router]);
<div
className="relative w-full max-w-[870px] rounded-2xl p-1 if (!ready) return null; // or a small spinner if you prefer
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%)]" 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%)]"
>
{/* 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">
{/* 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">
Dont 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">
Dont 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

View File

@ -72,7 +72,7 @@ useEffect(() => {
}, [pathname]); // re-fetch on route change (after login redirect) }, [pathname]); // re-fetch on route change (after login redirect)
const handleLogout = async () => { const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' }); await fetch('/api/logout', { method: 'POST' });
setUser(null); setUser(null);
router.push('/login'); // go to login router.push('/login'); // go to login
}; };

View File

@ -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
View File

@ -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",

View File

@ -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",

View File

@ -1,20 +1,22 @@
// pages/api/auth/logout.ts // pages/api/logout.ts -> /api/logout
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from 'next';
import { serialize } from 'cookie';
export default function handler(req: NextApiRequest, res: NextApiResponse) { export default function handler(req: NextApiRequest, res: NextApiResponse) {
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env.NODE_ENV === 'production';
res.setHeader(
"Set-Cookie", const setCookie = serialize('token', '', {
[ httpOnly: true,
"token=", // empty token secure: isProd,
"HttpOnly", sameSite: 'strict', // matches login
"Path=/", path: '/', // matches login
"SameSite=Strict", maxAge: 0,
"Max-Age=0", // expire immediately expires: new Date(0),
isProd ? "Secure" : "", });
]
.filter(Boolean) res.setHeader('Set-Cookie', setCookie);
.join("; ") res.setHeader('Cache-Control', 'no-store');
); return res.status(200).json({ message: 'Logged out' });
return res.status(200).json({ message: "Logged out" });
} }