Adding login admin, fix ipv6 Vienamese version, add env example

This commit is contained in:
2026-05-15 19:52:34 +07:00
parent 31aebc40f0
commit c7f09d941e
15 changed files with 334 additions and 66 deletions
+2
View File
@@ -1 +1,3 @@
PORT=3000
# MongoDB — summit request queue (registration + feedback)
MONGODB_URI=mongodb://localhost:27017/ipv6_summit
+5
View File
@@ -0,0 +1,5 @@
import LoginPageClient from "@/app/components/forms/LoginPageClient";
export default function LoginPage() {
return <LoginPageClient />;
}
+48
View File
@@ -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 });
}
}
+13
View File
@@ -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;
}
+39 -10
View File
@@ -227,6 +227,16 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
const [detail, setDetail] = useState<AdminRequest | null>(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 (
<Link
key={item.href}
@@ -317,13 +329,7 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
);
})}
</nav>
<button
type="button"
className="group flex flex-col items-center gap-1 text-on-surface-variant hover:text-primary mt-auto"
>
<span className="material-symbols-outlined text-2xl transition-all duration-300 group-hover:rotate-45">settings</span>
<span className="font-[var(--font-label-caps)] text-[8px] uppercase tracking-widest">{lang === "vi" ? "Cài đặt" : "Settings"}</span>
</button>
{/* Settings button removed as per request */}
</aside>
<div className="flex-1 ml-20 md:ml-24 flex flex-col min-w-0">
@@ -382,10 +388,33 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
<span>VIE</span>
</button>
</div>
<div className="flex flex-col items-end">
<span className="font-[var(--font-label-caps)] text-[10px] text-primary uppercase tracking-widest">
<div className="relative">
<button
type="button"
onClick={() => setUserDropdown(!userDropdown)}
className="flex flex-col items-end group"
>
<span className="font-[var(--font-label-caps)] text-[10px] text-primary uppercase tracking-widest group-hover:text-primary/80 transition-colors">
{t.meta.user.role}
</span>
<span className="material-symbols-outlined text-outline group-hover:text-primary transition-colors text-xs">
{userDropdown ? "expand_less" : "expand_more"}
</span>
</button>
{userDropdown && (
<div className="absolute right-0 mt-2 w-48 glass-panel rounded-xl shadow-2xl border border-white/10 z-[60] overflow-hidden">
<button
onClick={handleLogout}
className="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-white/5 text-on-surface transition-colors border-b border-white/5"
>
<span className="material-symbols-outlined text-red-400 text-lg">logout</span>
<span className="text-xs font-[var(--font-label-caps)] uppercase tracking-wider">
{lang === "vi" ? "Đăng xuất" : "Log Out"}
</span>
</button>
</div>
)}
</div>
</div>
</header>
+119
View File
@@ -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 (
<main className="pt-[120px] pb-[80px] px-[var(--spacing-gutter)] flex justify-center min-h-screen">
<div className="w-full max-w-[1280px]">
<div className="max-w-[500px] mx-auto">
<div className="mb-[32px]">
<Link
className="group flex items-center gap-2 text-outline hover:text-primary transition-colors"
href="/"
>
<span className="material-symbols-outlined text-[20px] transition-transform group-hover:-translate-x-1">
arrow_back
</span>
{backLabel}
</Link>
</div>
<div className="mb-[32px]">
<h1 className="font-[var(--font-display-lg)] text-[clamp(1.75rem,3vw+1rem,3.25rem)] text-on-surface mb-2 leading-[1.1]">
{titleLabel}
</h1>
</div>
<div className="glass-panel p-[32px] md:p-12 rounded-xl shadow-2xl relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-error/10 border border-error/20 text-error px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="space-y-1">
<label className="text-outline uppercase tracking-wider text-xs">{emailLabel}</label>
<input
type="email"
required
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all focus:border-primary/50 outline-none"
placeholder=""
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-outline uppercase tracking-wider text-xs">{passwordLabel}</label>
<input
type="password"
required
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all focus:border-primary/50 outline-none"
placeholder=""
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="pt-4">
<button
type="submit"
disabled={loading}
className="gold-gradient-bg text-on-primary w-full px-6 py-4 rounded-xl font-[var(--font-display-lg)] text-lg uppercase tracking-wider hover:shadow-[0_0_30px_rgba(197,160,89,0.4)] transition-all duration-300 active:scale-[0.98] disabled:opacity-60"
>
{loading ? loadingLabel : buttonLabel}
</button>
</div>
</form>
</div>
</div>
</div>
</main>
);
}
@@ -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({
</button>
</div>
<Link
href="/login"
className="hidden sm:inline-flex gold-gradient-bg text-on-primary px-4 lg:px-6 py-2 lg:py-2.5 font-[var(--font-body-base)] text-xs lg:text-sm font-bold uppercase tracking-wider rounded-lg active:scale-95 transition-transform shadow-lg shadow-primary/10"
>
{loginLabel}
</Link>
<Link
href="/registration"
className="hidden sm:inline-flex gold-gradient-bg text-on-primary px-4 lg:px-6 py-2 lg:py-2.5 font-[var(--font-body-base)] text-xs lg:text-sm font-bold uppercase tracking-wider rounded-lg active:scale-95 transition-transform shadow-lg shadow-primary/10"
@@ -205,6 +213,13 @@ export default function HeaderClient({
</button>
</div>
<Link
href="/login"
className="gold-gradient-bg text-on-primary w-full py-5 font-[var(--font-body-base)] text-lg font-bold uppercase tracking-widest rounded-xl text-center shadow-2xl"
onClick={() => setMobileOpen(false)}
>
{loginNowLabel}
</Link>
<Link
href="/registration"
className="gold-gradient-bg text-on-primary w-full py-5 font-[var(--font-body-base)] text-lg font-bold uppercase tracking-widest rounded-xl text-center shadow-2xl"
+3 -3
View File
@@ -1,10 +1,10 @@
{
"title": "Phản Hồi Sự Kiện",
"subtitle": "Ý kiến của bạn định hướng tương lai hạ tầng IPv6. Hãy giúp chúng tôi cải thiện trải nghiệm năm 2027.",
"title": "Khảo sát Sự Kiện",
"subtitle": "Giúp chúng tôi nâng tầm trải nghiệm 2027, thúc đẩy tương lai hạ tầng IPv6 bằng đánh giá của bạn.",
"sections": [
{
"id": "overall",
"title": "1. TRẢI NGHIỆM TỔNG THỂ",
"title": "1. MỨC ĐỘ HÀI LÒNG TỔNG QUAN",
"subtitle": "Tổng quan về trải nghiệm hội nghị của bạn",
"questions": [
{
+2 -2
View File
@@ -2,8 +2,8 @@
"hero": {
"badgeIcon": "calendar_today",
"badgeText": "Tháng 6/2026 | TP. Hồ Chí Minh, Việt Nam",
"title": "Hội Nghị IPv6 cho AI & Trung Tâm Dữ Liệu 2026",
"subtitle": "Khai PTiềm Năng Hạ Tầng Thế HMới. Cùng các nhà lãnh đạo toàn cầu định hình lại kết nối số ASEAN trong kỷ nguyên AI.",
"title": "IPv6 for AI & Data Centre Summit 2026",
"subtitle": "Khám ptiềm năng Hạ tầng thế hmới. Chung sức định hình kỷ nguyên kết nối số tại ASEAN cùng doanh nghiệp hàng đầu quốc tế.",
"primaryCta": { "label": "Đăng Ký", "href": "/registration" },
"secondaryCta": { "label": "Xem Lịch Trình", "href": "#agenda" }
},
+8 -8
View File
@@ -1,6 +1,6 @@
{
"title": "Đăng Ký Tham Dự Hội Nghị",
"subtitle": "Đảm bảo sự hiện diện của bạn ở tuyến đầu đổi mới IPv6 cho AI và Trung Tâm DLiệu.",
"subtitle": "Nắm bắt cơ hội tiên phong thời kỳ đổi mới IPv6 cho AI và trung tâm dliệu.",
"sections": {
"personal": {
"title": "Thông Tin Cá Nhân",
@@ -9,13 +9,13 @@
"email": "Email Doanh Nghiệp"
},
"professional": {
"title": "Thông Tin Nghề Nghiệp",
"company": "Tên Công Ty",
"jobTitle": "Chức Danh"
"title": "Thông Tin Doanh Nghiệp",
"company": "Tên Doanh Nghiệp",
"jobTitle": "Vị Trí"
},
"industry": {
"title": "Ngành Nghề",
"label": "Chọn Ngành Nghề",
"title": "Lĩnh Vực",
"label": "Chọn Lĩnh Vực",
"placeholder": "Vui lòng chọn...",
"options": [
{ "value": "government", "label": "Chính Phủ" },
@@ -27,8 +27,8 @@
},
"additional": {
"title": "Thông Tin Bổ Sung",
"label": "Yêu cầu đặc biệt hoặc chế độ ăn kiêng",
"placeholder": "Tùy chọn ăn chay, nhu cầu tiếp cận, v.v."
"label": "Yêu cầu đặc biệt hoặc chế độ dinh dưỡng",
"placeholder": "Chế độ ăn chay, hỗ trợ người khuyết tật, v.v"
}
},
"submit": {
+2 -2
View File
@@ -103,12 +103,12 @@
"highlights": [
{
"eyebrow": "STANDARD BENEFIT",
"title": "Logo on backdrop, website, social media & LED",
"title": "Logo on backdrop, LED",
"icon": "branding_watermark"
},
{
"eyebrow": "ACCESS PACK",
"title": "01 VIP Pass + Networking Dinner",
"title": "02 VIP Pass",
"icon": "confirmation_number"
}
]
+6 -6
View File
@@ -103,12 +103,12 @@
"highlights": [
{
"eyebrow": "QUYỀN LỢI TIÊU CHUẨN",
"title": "Logo tại backdrop, website, social media & LED",
"title": "Logo nổi bật trên backdrop, màn LED",
"icon": "branding_watermark"
},
{
"eyebrow": "GÓI TIẾP CẬN",
"title": "01 vé VIP + tham dự Networking Dinner",
"eyebrow": "QUYỀN LỢI THAM DỰ",
"title": "Đã bao gồm 02 vé VIP đại biểu",
"icon": "confirmation_number"
}
]
@@ -116,16 +116,16 @@
"contact": {
"id": "contact",
"title": "Hợp Tác Cùng Chúng Tôi",
"description": "Nâng tầm thương hiệu của bạn trong hành lang công nghệ Ấn Độ - Thái Bình Dương. Hội nghị của chúng tôi mang đến khả năng tiếp cận vô song tới các nhà ra quyết định, nhà nghiên cứu và quan chức chính phủ đang thúc đẩy thế hệ hạ tầng trung tâm dữ liệu và phát triển AI.",
"description": "Nâng tầm thương hiệu doanh nghiệp trên khu vực công nghệ Ấn Độ - Thái Bình Dương. Hội thảo mang đến cơ hội hiếm có đến các tổ chức chính phủ, lãnh đạo chủ chốt, đơn vị nghiên cứu trực tiếp tham giao quá trình phát triển AI và Cơ sở hạ tầng dữ liệu số.",
"phone": {
"label": "ĐIỆN THOẠI",
"value": "+84 941 523 498"
},
"email": {
"label": "EMAIL TRỰC TIẾP",
"label": "EMAIL TƯ VẤN",
"value": "events@techvanguard.vn"
},
"cta": "Liên Hệ Để Tài Trợ",
"cta": "Kết nối nhà tài trợ",
"image": {
"src": "https://lh3.googleusercontent.com/aida-public/AB6AXuATSk4rhRYAo9_OfX0cJwIC7rUdUmzD9kO7cJM24ECjPHwglU_s0wjBn6gdK0d7tk4ExLvG9DUJQ1L-PbP5ucVH2THO05fev6bWgyvpkElf_94JXJNeWj0Mo4-5-l2ofY-kHE6kPLhitbRzarR7u41favHCt_V9WM0yYb7tcZZQM8eAN5jIVT5OVKSGvfpZNYe05dJm_h3pW0qkWhX1XrUtg_xhhjdvTi-Mei7zHDUkwwRrydg7BHmO17W6ScfALH5rMXojTHub7UDN",
"alt": "Cuộc họp kinh doanh chuyên nghiệp trong phòng hội thảo công nghệ cao"
+6 -33
View File
@@ -1,41 +1,14 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
function unauthorized() {
return new NextResponse("Authentication required", {
status: 401,
headers: {
"WWW-Authenticate": 'Basic realm="IPv6 Admin"',
},
});
}
export function middleware(request: NextRequest) {
const user = process.env.ADMIN_BASIC_AUTH_USER;
const pass = process.env.ADMIN_BASIC_AUTH_PASS;
const session = request.cookies.get("admin_session");
if (!user || !pass) {
return NextResponse.next();
}
const auth = request.headers.get("authorization");
if (!auth?.startsWith("Basic ")) {
return unauthorized();
}
let decoded: string;
try {
decoded = atob(auth.slice(6));
} catch {
return unauthorized();
}
const colon = decoded.indexOf(":");
const u = colon >= 0 ? decoded.slice(0, colon) : "";
const p = colon >= 0 ? decoded.slice(colon + 1) : "";
if (u !== user || p !== pass) {
return new NextResponse("Unauthorized", { status: 401 });
if (!session || session.value !== "true") {
// If not authenticated and trying to access admin, redirect to login
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return NextResponse.next();
+18
View File
@@ -0,0 +1,18 @@
import mongoose, { Schema, Document } from "mongoose";
export interface IUser extends Document {
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
const UserSchema: Schema = new Schema(
{
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
},
{ timestamps: true }
);
export default mongoose.models.User || mongoose.model<IUser>("User", UserSchema);
+46
View File
@@ -0,0 +1,46 @@
import mongoose from "mongoose";
import User from "../models/User";
import * as dotenv from "dotenv";
import path from "path";
import crypto from "crypto";
dotenv.config({ path: path.join(__dirname, "../.env") });
function hashPassword(password: string) {
return crypto.createHash("sha256").update(password).digest("hex");
}
async function seedAdmin() {
const MONGODB_URI = process.env.MONGODB_URI;
if (!MONGODB_URI) {
console.error("MONGODB_URI not found");
process.exit(1);
}
try {
await mongoose.connect(MONGODB_URI);
console.log("Connected to MongoDB");
const adminEmail = "adminipv6@mail.com".toLowerCase().trim();
const adminPassword = "adminipv6A@a";
const hashedPassword = hashPassword(adminPassword);
// Xóa sạch user cũ để tránh xung đột
await User.deleteMany({ email: adminEmail });
await User.create({
email: adminEmail,
password: hashedPassword,
});
console.log("SUCCESS: Admin account created/refreshed with hashed password.");
console.log(`Email: ${adminEmail}`);
} catch (error) {
console.error("Error:", error);
} finally {
await mongoose.disconnect();
}
}
seedAdmin();