diff --git a/.env.example b/.env.example index 2fc80e3..9480ce3 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,3 @@ PORT=3000 +# MongoDB — summit request queue (registration + feedback) +MONGODB_URI=mongodb://localhost:27017/ipv6_summit \ No newline at end of file diff --git a/app/(site)/login/page.tsx b/app/(site)/login/page.tsx new file mode 100644 index 0000000..2e1b175 --- /dev/null +++ b/app/(site)/login/page.tsx @@ -0,0 +1,5 @@ +import LoginPageClient from "@/app/components/forms/LoginPageClient"; + +export default function LoginPage() { + return ; +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..f00b00f --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import dbConnect from "@/lib/mongodb"; +import User from "@/models/User"; +import crypto from "crypto"; + +function hashPassword(password: string) { + return crypto.createHash("sha256").update(password).digest("hex"); +} + +export async function POST(request: Request) { + try { + await dbConnect(); + const { email, password } = await request.json(); + + // Tìm user và chuẩn hóa email về chữ thường + const user = await User.findOne({ email: email.toLowerCase().trim() }); + + if (!user) { + console.log(`Login failed: User not found -> ${email}`); + return NextResponse.json({ error: "Invalid email or password" }, { status: 401 }); + } + + const inputHash = hashPassword(password); + + // Kiểm tra mật khẩu (hỗ trợ cả mật khẩu chưa hash nếu lỡ có dữ liệu cũ - nhưng ưu tiên hash) + const isMatch = user.password === inputHash || user.password === password; + + if (!isMatch) { + console.log(`Login failed: Password mismatch for -> ${email}`); + return NextResponse.json({ error: "Invalid email or password" }, { status: 401 }); + } + + const response = NextResponse.json({ success: true }); + + response.cookies.set("admin_session", "true", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24, + }); + + return response; + } catch (error) { + console.error("Login error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..7f45a9e --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; + +export async function POST() { + const response = NextResponse.json({ success: true }); + + // Xóa cookie bằng cách set maxAge = 0 + response.cookies.set("admin_session", "", { + path: "/", + maxAge: 0, + }); + + return response; +} diff --git a/app/components/admin/AdminConsole.tsx b/app/components/admin/AdminConsole.tsx index 8be6a5f..1d84c2c 100644 --- a/app/components/admin/AdminConsole.tsx +++ b/app/components/admin/AdminConsole.tsx @@ -227,6 +227,16 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData; const [detail, setDetail] = useState(null); const { lang, setLang } = useLanguage(); const { admin: t } = useTranslation(); + const [userDropdown, setUserDropdown] = useState(false); + + const handleLogout = async () => { + try { + await fetch("/ipv6/api/auth/logout", { method: "POST" }); + router.push("/login"); + } catch (error) { + console.error("Logout failed", error); + } + }; // Merge real requests from server prop with translated UI labels const tData = { @@ -295,7 +305,9 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData; { href: "/admin/rejected", icon: "cancel", label: lang === "vi" ? "Từ chối" : "Rejected" }, ] as const ).map((item) => { - const active = pathname === item.href; + const cleanPathname = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; + const cleanHref = item.href.endsWith("/") ? item.href.slice(0, -1) : item.href; + const active = cleanPathname === cleanHref; return ( - + {/* Settings button removed as per request */}
@@ -382,10 +388,33 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData; VIE
-
- - {t.meta.user.role} - +
+ + + {userDropdown && ( +
+ +
+ )}
diff --git a/app/components/forms/LoginPageClient.tsx b/app/components/forms/LoginPageClient.tsx new file mode 100644 index 0000000..456e923 --- /dev/null +++ b/app/components/forms/LoginPageClient.tsx @@ -0,0 +1,119 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useLanguage } from "@/app/context/LanguageContext"; + +export default function LoginPageClient() { + const { lang } = useLanguage(); + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const backLabel = lang === "vi" ? "Quay Về Trang Chủ" : "Back to Home"; + const titleLabel = lang === "vi" ? "Đăng Nhập Quản Trị" : "Admin Login"; + const emailLabel = lang === "vi" ? "Email" : "Email Address"; + const passwordLabel = lang === "vi" ? "Mật Khẩu" : "Password"; + const buttonLabel = lang === "vi" ? "Đăng Nhập" : "Log In"; + const loadingLabel = lang === "vi" ? "Đang xử lý..." : "Processing..."; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + + try { + const res = await fetch("/ipv6/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (res.ok) { + router.push("/admin"); + } else { + const data = await res.json(); + setError(data.error || (lang === "vi" ? "Đăng nhập thất bại" : "Login failed")); + } + } catch (err) { + setError(lang === "vi" ? "Đã xảy ra lỗi" : "An error occurred"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ + + arrow_back + + {backLabel} + +
+ +
+

+ {titleLabel} +

+
+ +
+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + /> +
+ +
+ + setPassword(e.target.value)} + /> +
+ +
+ +
+
+
+
+
+
+ ); +} diff --git a/app/components/home/HomePageClient.tsx b/app/components/home/HomePageClient.tsx index 8cd89e1..ef236b2 100644 --- a/app/components/home/HomePageClient.tsx +++ b/app/components/home/HomePageClient.tsx @@ -5,6 +5,7 @@ import HeroSection from "@/app/components/home/HeroSection"; import VisionSection from "@/app/components/home/VisionSection"; import StatsSection from "@/app/components/home/StatsSection"; import AgendaSection from "@/app/components/home/AgendaSection"; +import PartnersCarousel from "@/app/components/home/PartnersCarousel"; export default function HomePageClient() { const { home } = useTranslation(); @@ -15,6 +16,7 @@ export default function HomePageClient() { + ); } diff --git a/app/components/home/PartnersCarousel.tsx b/app/components/home/PartnersCarousel.tsx new file mode 100644 index 0000000..2092c72 --- /dev/null +++ b/app/components/home/PartnersCarousel.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useEffect, useState } from "react"; + +// Tên file chính xác trong public/assets/img/carousel/ +const ROW_1 = [ + { name: "NVIDIA", file: "Nvidia_logo.svg.png" }, + { name: "Maxis", file: "Maxis-logo.png" }, + { name: "Singtel", file: "Singtel_logo.svg.png" }, + { name: "NIDA", file: "nida.png" }, + { name: "VNNIC", file: "vnnic.png" }, + { name: "Netflix", file: "Logonetflix.png" }, + { name: "Microsoft",file: "Microsoft-Logo.png" }, + { name: "Alibaba", file: "Alibaba-Logo.png" }, +]; + +const ROW_2 = [ + { name: "Tencent", file: "tencent.png" }, + { name: "AirTrunk", file: "airtrunk.png" }, + { name: "Celcomdigi",file: "celcomdigi.png" }, + { name: "Grab", file: "grab.png" }, + { name: "Logo 1", file: "logo_transparent.png" }, + { name: "Logo 2", file: "Equinix_logo.svg.png" }, + { name: "Logo 3", file: "XL_Axiata-Logo.wine.png" }, + { name: "Logo 4", file: "Huawei_Standard_logo.svg.png" }, +]; + +function CarouselRow({ + items, + base, + reverse = false, +}: { + items: { name: string; file: string }[]; + base: string; + reverse?: boolean; +}) { + // Lặp 3 lần để đảm bảo không bao giờ thấy khoảng trống + const tripled = [...items, ...items, ...items]; + // Mỗi item: w=150px, gap=20px → 1 set = items.length * 170 - 20 + const setWidth = items.length * 150 + (items.length - 1) * 20; + + return ( +
+
+ {tripled.map((item, i) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {item.name} +
+ ))} +
+
+ ); +} + +export default function PartnersCarousel() { + const [base, setBase] = useState(""); + + useEffect(() => { + const match = window.location.pathname.match(/^(\/ipv6)/); + setBase(match ? match[1] : ""); + }, []); + + return ( +
+
+

+ Partners & Sponsors +

+

+ Trusted by Industry Leaders +

+
+ +
+ + +
+
+ ); +} diff --git a/app/components/layout/Header/HeaderClient.tsx b/app/components/layout/Header/HeaderClient.tsx index 7598299..4ea3173 100644 --- a/app/components/layout/Header/HeaderClient.tsx +++ b/app/components/layout/Header/HeaderClient.tsx @@ -29,7 +29,9 @@ export default function HeaderClient({ }; const registerLabel = lang === "vi" ? "Đăng Ký" : "Register"; + const loginLabel = lang === "vi" ? "Đăng Nhập" : "Log In"; const registerNowLabel = lang === "vi" ? "Đăng Ký Ngay" : "Register Now"; + const loginNowLabel = lang === "vi" ? "Đăng Nhập" : "Log In"; const backLabel = lang === "vi" ? "Trang Chủ" : "Home"; useEffect(() => { @@ -106,6 +108,12 @@ export default function HeaderClient({ + + {loginLabel} + + setMobileOpen(false)} + > + {loginNowLabel} + {/* Left Content */} -
+
-

+

{data.title}

-

+

{data.description}

-
+
{/* Phone */}
-
-
- call +
+
+ call
-
-

+

+

{data.phone.label}

-

+

{data.phone.value}

@@ -49,15 +49,15 @@ export default function SponsorContact({ data }: { data: SponsorContactData }) { {/* Email */}
-
-
- mail +
+
+ mail
-
-

+

+

{data.email.label}

-

+

{data.email.value}

@@ -67,7 +67,7 @@ export default function SponsorContact({ data }: { data: SponsorContactData }) {