Commit 859f6c2c authored by ThinhNC's avatar ThinhNC

feat(auth): implement Auth & RBAC system with Next.js BFF and HttpOnly cookies

parent 5a27bdf6
# Backend API Base URL
NEXT_PUBLIC_API_URL=http://localhost:9898/api/v1
# Backend API Base URL (Internal Server-to-Server for Next.js BFF)
BACKEND_API_URL=http://localhost:9898/api/v1
# App Settings
NEXT_PUBLIC_APP_NAME="Data Crawler System"
......
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
KeyRound,
Mail,
Loader2,
ArrowLeft,
CheckCircle2,
Send,
RotateCcw,
} from "lucide-react";
import { forgotPasswordSchema, ForgotPasswordInput } from "@/schemas/auth.schema";
import { authService } from "@/services/auth.service";
import { useLanguage } from "@/providers/language-provider";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function ForgotPasswordPage() {
const { t } = useLanguage();
const [authError, setAuthError] = useState<string | null>(null);
const [isSent, setIsSent] = useState(false);
const [submittedEmail, setSubmittedEmail] = useState("");
const [countdown, setCountdown] = useState(0);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ForgotPasswordInput>({
resolver: zodResolver(forgotPasswordSchema),
defaultValues: {
email: "",
},
});
// Countdown timer for resending
useEffect(() => {
if (countdown <= 0) return;
const timer = setTimeout(() => {
setCountdown((prev) => prev - 1);
}, 1000);
return () => clearTimeout(timer);
}, [countdown]);
const onSubmit = async (data: ForgotPasswordInput) => {
setAuthError(null);
try {
await authService.forgotPassword(data);
setSubmittedEmail(data.email);
setIsSent(true);
setCountdown(60);
} catch (err: unknown) {
if (err instanceof Error) {
setAuthError(err.message);
} else {
setAuthError("Không thể gửi yêu cầu khôi phục. Vui lòng thử lại sau.");
}
}
};
const handleResend = async () => {
if (countdown > 0 || !submittedEmail) return;
setAuthError(null);
try {
await authService.forgotPassword({ email: submittedEmail });
setCountdown(60);
} catch (err: unknown) {
if (err instanceof Error) {
setAuthError(err.message);
}
}
};
return (
<div className="rounded-3xl border border-emerald-500/15 bg-card/90 p-6 sm:p-8 shadow-xl shadow-emerald-950/5 backdrop-blur-xl transition-all duration-300">
{/* Header */}
<div className="text-center mb-6">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-700 shadow-md shadow-emerald-500/20 text-white mb-3">
<KeyRound className="h-6 w-6" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{t.auth.forgot.title}
</h1>
<p className="mt-1 text-xs sm:text-sm text-muted-foreground">
{t.auth.forgot.subtitle}
</p>
</div>
{/* Auth Error Banner */}
{authError && (
<div className="mb-5 rounded-2xl border border-red-500/20 bg-red-500/10 p-3.5 text-xs text-red-600 dark:text-red-400">
<p className="font-medium">{authError}</p>
</div>
)}
{isSent ? (
/* Success State */
<div className="text-center space-y-4">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<CheckCircle2 className="h-7 w-7" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
{t.auth.forgot.successTitle}
</h3>
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
{t.auth.forgot.successDesc}
</p>
<div className="mt-2 inline-block rounded-xl border border-border bg-muted/50 px-3 py-1 text-xs font-medium text-foreground">
{submittedEmail}
</div>
</div>
<div className="pt-2 flex flex-col gap-2">
<Button
type="button"
variant="outline"
disabled={countdown > 0}
onClick={handleResend}
className="w-full rounded-2xl border-border cursor-pointer hover:bg-muted text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{countdown > 0
? `Gửi lại sau ${countdown}s`
: t.auth.forgot.resendBtn}
</Button>
<Link href="/login">
<Button
variant="ghost"
className="w-full rounded-2xl text-xs text-muted-foreground hover:text-foreground cursor-pointer"
>
<ArrowLeft className="mr-2 h-3.5 w-3.5" />
{t.auth.forgot.backToLogin}
</Button>
</Link>
</div>
</div>
) : (
/* Form */
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email" className="text-xs font-semibold text-foreground">
{t.auth.forgot.email}
</Label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder={t.auth.forgot.emailPlaceholder}
autoComplete="email"
className="rounded-2xl pl-10 border-border bg-background/50 focus-visible:ring-emerald-500"
{...register("email")}
/>
</div>
{errors.email && (
<p className="text-[11px] font-medium text-destructive">
{errors.email.message}
</p>
)}
</div>
<Button
type="submit"
disabled={isSubmitting}
className="w-full rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 shadow-md shadow-emerald-600/20 transition-all duration-200 cursor-pointer hover:scale-[1.01]"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.auth.forgot.submitting}
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
{t.auth.forgot.submit}
</>
)}
</Button>
<div className="pt-2 text-center">
<Link
href="/login"
className="inline-flex items-center text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
{t.auth.forgot.backToLogin}
</Link>
</div>
</form>
)}
</div>
);
}
import React from "react";
import Link from "next/link";
import { Bot } from "lucide-react";
import { ThemeToggle } from "@/components/common/theme-toggle";
import { LanguageToggle } from "@/components/common/language-toggle";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="relative flex min-h-screen flex-col justify-between p-4 sm:p-6 overflow-hidden">
{/* Decorative Organic Biophilic Background Glows */}
<div className="pointer-events-none absolute -top-24 left-1/2 -z-10 h-[500px] w-[500px] -translate-x-1/2 rounded-full bg-emerald-500/10 blur-3xl dark:bg-emerald-500/15" />
<div className="pointer-events-none absolute -bottom-24 right-1/4 -z-10 h-96 w-96 rounded-full bg-teal-500/10 blur-3xl dark:bg-teal-500/10" />
{/* Auth Minimal Header */}
<header className="mx-auto flex w-full max-w-7xl items-center justify-between">
<Link
href="/"
className="flex items-center gap-2.5 transition-opacity hover:opacity-90"
>
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-700 shadow-md shadow-emerald-500/20 text-white">
<Bot className="h-5 w-5" />
</div>
<span className="font-bold text-base tracking-tight text-foreground">
DataCrawler{" "}
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
v1.0
</span>
</span>
</Link>
{/* Language & Theme Controls */}
<div className="flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
</div>
</header>
{/* Main Form Center */}
<div className="my-auto flex items-center justify-center py-6">
<div className="w-full max-w-md">{children}</div>
</div>
{/* Minimal Clean Auth Footer */}
<footer className="text-center text-[11px] text-muted-foreground/80 py-2">
Data Crawler © {new Date().getFullYear()} - Code by @hnihTyoB
</footer>
</div>
);
}
"use client";
import React, { Suspense, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { LogIn, Eye, EyeOff, Loader2, Mail, Lock } from "lucide-react";
import { loginSchema, LoginInput } from "@/schemas/auth.schema";
import { useAuth } from "@/hooks/use-auth";
import { useLanguage } from "@/providers/language-provider";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function LoginForm() {
const { t } = useLanguage();
const { login } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const rawRedirect = searchParams.get("redirect") || "/";
// Never redirect back to auth pages to prevent loops
const redirectUrl =
rawRedirect.startsWith("/login") || rawRedirect.startsWith("/register")
? "/"
: rawRedirect;
const [showPassword, setShowPassword] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
rememberMe: true,
},
});
const onSubmit = async (data: LoginInput) => {
setAuthError(null);
try {
await login(data, data.rememberMe ?? true);
// Force hard reload navigation so Next.js server components and middleware receive fresh HttpOnly cookies
window.location.href = redirectUrl;
} catch (err: unknown) {
if (err instanceof Error) {
setAuthError(err.message);
} else {
setAuthError("Đã có lỗi xảy ra. Vui lòng kiểm tra lại thông tin đăng nhập.");
}
}
};
return (
<div className="rounded-3xl border border-emerald-500/15 bg-card/90 p-6 sm:p-8 shadow-xl shadow-emerald-950/5 backdrop-blur-xl transition-all duration-300">
{/* Header */}
<div className="text-center mb-6">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-700 shadow-md shadow-emerald-500/20 text-white mb-3">
<LogIn className="h-6 w-6" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{t.auth.login.title}
</h1>
<p className="mt-1 text-xs sm:text-sm text-muted-foreground">
{t.auth.login.subtitle}
</p>
</div>
{/* Auth Error Banner */}
{authError && (
<div className="mb-5 rounded-2xl border border-red-500/20 bg-red-500/10 p-3.5 text-xs text-red-600 dark:text-red-400">
<p className="font-medium">{authError}</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Email Field */}
<div className="space-y-1.5">
<Label htmlFor="email" className="text-xs font-semibold text-foreground">
{t.auth.login.email}
</Label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder={t.auth.login.emailPlaceholder}
autoComplete="email"
className="rounded-2xl pl-10 border-border bg-background/50 focus-visible:ring-emerald-500"
{...register("email")}
/>
</div>
{errors.email && (
<p className="text-[11px] font-medium text-destructive">
{errors.email.message}
</p>
)}
</div>
{/* Password Field */}
<div className="space-y-1.5">
<Label
htmlFor="password"
className="text-xs font-semibold text-foreground"
>
{t.auth.login.password}
</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={t.auth.login.passwordPlaceholder}
autoComplete="current-password"
className="rounded-2xl pl-10 pr-10 border-border bg-background/50 focus-visible:ring-emerald-500"
{...register("password")}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{errors.password && (
<p className="text-[11px] font-medium text-destructive">
{errors.password.message}
</p>
)}
</div>
{/* Remember Me & Forgot Password Controls */}
<div className="flex items-center justify-between pt-1">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="rememberMe"
className="h-4 w-4 rounded-md border-border text-emerald-600 focus:ring-emerald-500 accent-emerald-600 cursor-pointer"
{...register("rememberMe")}
/>
<Label
htmlFor="rememberMe"
className="text-xs font-medium text-muted-foreground cursor-pointer select-none"
>
{t.auth.login.rememberMe}
</Label>
</div>
<Link
href="/forgot-password"
className="text-[11px] font-medium text-emerald-600 hover:text-emerald-500 dark:text-emerald-400 transition-colors"
>
{t.auth.login.forgotPassword}
</Link>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={isSubmitting}
className="w-full rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 shadow-md shadow-emerald-600/20 transition-all duration-200 cursor-pointer hover:scale-[1.01]"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.auth.login.submitting}
</>
) : (
t.auth.login.submit
)}
</Button>
</form>
{/* Footer / Switch to Register */}
<div className="mt-6 border-t border-border/70 pt-4 text-center text-xs text-muted-foreground">
<span>{t.auth.login.noAccount} </span>
<Link
href="/register"
className="font-semibold text-emerald-600 hover:text-emerald-500 dark:text-emerald-400 hover:underline transition-colors"
>
{t.auth.login.registerNow}
</Link>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense
fallback={
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
</div>
}
>
<LoginForm />
</Suspense>
);
}
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
UserPlus,
Eye,
EyeOff,
Loader2,
Mail,
Lock,
User,
CheckCircle2,
XCircle,
ArrowRight,
} from "lucide-react";
import { registerSchema, RegisterInput } from "@/schemas/auth.schema";
import { useAuth } from "@/hooks/use-auth";
import { useLanguage } from "@/providers/language-provider";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function RegisterPage() {
const { t } = useLanguage();
const { register: registerUser } = useAuth();
const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [isSuccess, setIsSuccess] = useState(false);
const [registeredEmail, setRegisteredEmail] = useState("");
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm<RegisterInput>({
resolver: zodResolver(registerSchema),
defaultValues: {
fullName: "",
email: "",
password: "",
confirmPassword: "",
},
});
const passwordValue = watch("password", "");
// Real-time password strength validation rules
const passwordCriteria = [
{ label: t.auth.register.passwordRules.min, valid: passwordValue.length >= 8 },
{ label: t.auth.register.passwordRules.upper, valid: /[A-Z]/.test(passwordValue) },
{ label: t.auth.register.passwordRules.lower, valid: /[a-z]/.test(passwordValue) },
{ label: t.auth.register.passwordRules.number, valid: /[0-9]/.test(passwordValue) },
{
label: t.auth.register.passwordRules.special,
valid: /[^A-Za-z0-9]/.test(passwordValue),
},
];
const onSubmit = async (data: RegisterInput) => {
setAuthError(null);
try {
await registerUser(data);
setRegisteredEmail(data.email);
setIsSuccess(true);
} catch (err: unknown) {
if (err instanceof Error) {
setAuthError(err.message);
} else {
setAuthError("Không thể hoàn tất đăng ký tài khoản. Vui lòng thử lại.");
}
}
};
if (isSuccess) {
return (
<div className="rounded-3xl border border-emerald-500/20 bg-card/90 p-6 sm:p-8 shadow-xl shadow-emerald-950/5 text-center backdrop-blur-xl">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<CheckCircle2 className="h-8 w-8" />
</div>
<h2 className="text-2xl font-bold text-foreground">
{t.auth.register.success}
</h2>
<p className="mt-2 text-xs sm:text-sm text-muted-foreground leading-relaxed">
Chúng tôi đã gửi đường dẫn kích hoạt tài khoản đến địa chỉ:{" "}
<strong className="text-foreground">{registeredEmail}</strong>. Vui
lòng kiểm tra hòm thư của bạn để kích hoạt.
</p>
<div className="mt-6 flex flex-col gap-2.5">
<Button
onClick={() => router.push("/login")}
className="w-full rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 shadow-md shadow-emerald-600/20 cursor-pointer"
>
<ArrowRight className="mr-2 h-4 w-4" />
{t.auth.register.loginNow}
</Button>
<Link
href={`/verify-email`}
className="text-xs font-medium text-emerald-600 hover:underline dark:text-emerald-400"
>
Nhập mã kích hoạt thủ công
</Link>
</div>
</div>
);
}
return (
<div className="rounded-3xl border border-emerald-500/15 bg-card/90 p-6 sm:p-8 shadow-xl shadow-emerald-950/5 backdrop-blur-xl transition-all duration-300">
{/* Header */}
<div className="text-center mb-6">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-700 shadow-md shadow-emerald-500/20 text-white mb-3">
<UserPlus className="h-6 w-6" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{t.auth.register.title}
</h1>
<p className="mt-1 text-xs sm:text-sm text-muted-foreground">
{t.auth.register.subtitle}
</p>
</div>
{/* Auth Error Banner */}
{authError && (
<div className="mb-5 rounded-2xl border border-red-500/20 bg-red-500/10 p-3.5 text-xs text-red-600 dark:text-red-400">
<p className="font-medium">{authError}</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Full Name */}
<div className="space-y-1.5">
<Label htmlFor="fullName" className="text-xs font-semibold text-foreground">
{t.auth.register.fullName}
</Label>
<div className="relative">
<User className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="fullName"
type="text"
placeholder={t.auth.register.fullNamePlaceholder}
autoComplete="name"
className="rounded-2xl pl-10 border-border bg-background/50 focus-visible:ring-emerald-500"
{...register("fullName")}
/>
</div>
{errors.fullName && (
<p className="text-[11px] font-medium text-destructive">
{errors.fullName.message}
</p>
)}
</div>
{/* Email */}
<div className="space-y-1.5">
<Label htmlFor="email" className="text-xs font-semibold text-foreground">
{t.auth.register.email}
</Label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder={t.auth.register.emailPlaceholder}
autoComplete="email"
className="rounded-2xl pl-10 border-border bg-background/50 focus-visible:ring-emerald-500"
{...register("email")}
/>
</div>
{errors.email && (
<p className="text-[11px] font-medium text-destructive">
{errors.email.message}
</p>
)}
</div>
{/* Password */}
<div className="space-y-1.5">
<Label htmlFor="password" className="text-xs font-semibold text-foreground">
{t.auth.register.password}
</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={t.auth.register.passwordPlaceholder}
autoComplete="new-password"
className="rounded-2xl pl-10 pr-10 border-border bg-background/50 focus-visible:ring-emerald-500"
{...register("password")}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{errors.password && (
<p className="text-[11px] font-medium text-destructive">
{errors.password.message}
</p>
)}
</div>
{/* Confirm Password */}
<div className="space-y-1.5">
<Label
htmlFor="confirmPassword"
className="text-xs font-semibold text-foreground"
>
{t.auth.register.confirmPassword}
</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder={t.auth.register.confirmPasswordPlaceholder}
autoComplete="new-password"
className="rounded-2xl pl-10 pr-10 border-border bg-background/50 focus-visible:ring-emerald-500"
{...register("confirmPassword")}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
tabIndex={-1}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{errors.confirmPassword && (
<p className="text-[11px] font-medium text-destructive">
{errors.confirmPassword.message}
</p>
)}
</div>
{/* Real-time Password Rules Checklist */}
{passwordValue && (
<div className="rounded-2xl border border-border/80 bg-muted/40 p-3 text-[11px] space-y-1.5 transition-all">
<span className="font-semibold text-muted-foreground">
{t.auth.register.passwordRules.title}
</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1 pt-1">
{passwordCriteria.map((item, idx) => (
<div
key={idx}
className={`flex items-center gap-1.5 ${
item.valid
? "text-emerald-600 dark:text-emerald-400 font-medium"
: "text-muted-foreground"
}`}
>
{item.valid ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
) : (
<XCircle className="h-3.5 w-3.5 shrink-0 opacity-40" />
)}
<span>{item.label}</span>
</div>
))}
</div>
</div>
)}
{/* Terms Agreement */}
<div className="pt-1 text-xs text-muted-foreground">
<p className="leading-relaxed">
{t.auth.register.agreeTerms}
</p>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={isSubmitting}
className="w-full rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 shadow-md shadow-emerald-600/20 transition-all duration-200 cursor-pointer hover:scale-[1.01]"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.auth.register.submitting}
</>
) : (
t.auth.register.submit
)}
</Button>
</form>
{/* Footer / Switch to Login */}
<div className="mt-6 border-t border-border/70 pt-4 text-center text-xs text-muted-foreground">
<span>{t.auth.register.hasAccount} </span>
<Link
href="/login"
className="font-semibold text-emerald-600 hover:text-emerald-500 dark:text-emerald-400 hover:underline transition-colors"
>
{t.auth.register.loginNow}
</Link>
</div>
</div>
);
}
"use client";
import React, { Suspense, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
KeyRound,
Lock,
Eye,
EyeOff,
Loader2,
CheckCircle2,
AlertTriangle,
ArrowRight,
ShieldCheck,
} from "lucide-react";
import { resetPasswordSchema, ResetPasswordInput } from "@/schemas/auth.schema";
import { authService } from "@/services/auth.service";
import { useLanguage } from "@/providers/language-provider";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function ResetPasswordForm() {
const { t } = useLanguage();
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") || "";
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [isSuccess, setIsSuccess] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ResetPasswordInput>({
resolver: zodResolver(resetPasswordSchema),
defaultValues: {
token: token,
password: "",
confirmPassword: "",
},
});
const onSubmit = async (data: ResetPasswordInput) => {
setAuthError(null);
try {
await authService.resetPassword({
token: token || data.token,
password: data.password,
confirmPassword: data.confirmPassword,
});
setIsSuccess(true);
} catch (err: unknown) {
if (err instanceof Error) {
setAuthError(err.message);
} else {
setAuthError("Không thể đặt lại mật khẩu. Mã xác thực có thể đã hết hạn.");
}
}
};
if (!token) {
return (
<div className="rounded-3xl border border-amber-500/20 bg-card/90 p-6 sm:p-8 shadow-xl shadow-amber-950/5 text-center backdrop-blur-xl">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
<AlertTriangle className="h-7 w-7" />
</div>
<h2 className="text-xl font-bold text-foreground">
Thiếu Mã Xác Thực
</h2>
<p className="mt-2 text-xs sm:text-sm text-muted-foreground leading-relaxed">
{t.auth.reset.missingToken}
</p>
<div className="mt-6 flex flex-col gap-2.5">
<Link href="/forgot-password">
<Button className="w-full rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium cursor-pointer">
Yêu cầu gửi lại liên kết mới
</Button>
</Link>
<Link href="/login">
<Button
variant="outline"
className="w-full rounded-2xl border-border cursor-pointer hover:bg-muted"
>
Quay lại Đăng nhập
</Button>
</Link>
</div>
</div>
);
}
if (isSuccess) {
return (
<div className="rounded-3xl border border-emerald-500/20 bg-card/90 p-6 sm:p-8 shadow-xl shadow-emerald-950/5 text-center backdrop-blur-xl">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<CheckCircle2 className="h-8 w-8" />
</div>
<h2 className="text-2xl font-bold text-foreground">
{t.auth.reset.successTitle}
</h2>
<p className="mt-2 text-xs sm:text-sm text-muted-foreground leading-relaxed">
{t.auth.reset.successDesc}
</p>
<div className="mt-6">
<Button
onClick={() => router.push("/login")}
className="w-full rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 shadow-md shadow-emerald-600/20 cursor-pointer"
>
<ArrowRight className="mr-2 h-4 w-4" />
{t.auth.reset.backToLogin}
</Button>
</div>
</div>
);
}
return (
<div className="rounded-3xl border border-emerald-500/15 bg-card/90 p-6 sm:p-8 shadow-xl shadow-emerald-950/5 backdrop-blur-xl transition-all duration-300">
{/* Header */}
<div className="text-center mb-6">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-700 shadow-md shadow-emerald-500/20 text-white mb-3">
<KeyRound className="h-6 w-6" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{t.auth.reset.title}
</h1>
<p className="mt-1 text-xs sm:text-sm text-muted-foreground">
{t.auth.reset.subtitle}
</p>
</div>
{/* Auth Error Banner */}
{authError && (
<div className="mb-5 rounded-2xl border border-red-500/20 bg-red-500/10 p-3.5 text-xs text-red-600 dark:text-red-400">
<p className="font-medium">{authError}</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<input type="hidden" value={token} {...register("token")} />
{/* New Password */}
<div className="space-y-1.5">
<Label
htmlFor="password"
className="text-xs font-semibold text-foreground"
>
{t.auth.reset.newPassword}
</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={t.auth.reset.newPasswordPlaceholder}
autoComplete="new-password"
className="rounded-2xl pl-10 pr-10 border-border bg-background/50 focus-visible:ring-emerald-500"
{...register("password")}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{errors.password && (
<p className="text-[11px] font-medium text-destructive">
{errors.password.message}
</p>
)}
</div>
{/* Confirm New Password */}
<div className="space-y-1.5">
<Label
htmlFor="confirmPassword"
className="text-xs font-semibold text-foreground"
>
{t.auth.reset.confirmPassword}
</Label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder={t.auth.reset.confirmPasswordPlaceholder}
autoComplete="new-password"
className="rounded-2xl pl-10 pr-10 border-border bg-background/50 focus-visible:ring-emerald-500"
{...register("confirmPassword")}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
tabIndex={-1}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{errors.confirmPassword && (
<p className="text-[11px] font-medium text-destructive">
{errors.confirmPassword.message}
</p>
)}
</div>
<Button
type="submit"
disabled={isSubmitting}
className="w-full rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 shadow-md shadow-emerald-600/20 transition-all duration-200 cursor-pointer hover:scale-[1.01]"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.auth.reset.submitting}
</>
) : (
<>
<ShieldCheck className="mr-2 h-4 w-4" />
{t.auth.reset.submit}
</>
)}
</Button>
</form>
<div className="mt-6 border-t border-border/70 pt-4 text-center text-xs text-muted-foreground">
<Link
href="/login"
className="font-medium text-emerald-600 hover:underline dark:text-emerald-400"
>
{t.auth.reset.backToLogin}
</Link>
</div>
</div>
);
}
export default function ResetPasswordPage() {
return (
<Suspense
fallback={
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
</div>
}
>
<ResetPasswordForm />
</Suspense>
);
}
"use client";
import React, { Suspense, useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import {
CheckCircle2,
XCircle,
Loader2,
MailCheck,
ArrowRight,
Send,
Sparkles,
} from "lucide-react";
import { authService } from "@/services/auth.service";
import { useLanguage } from "@/providers/language-provider";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type VerifyStatus = "idle" | "verifying" | "success" | "error";
function VerifyEmailContent() {
const { t } = useLanguage();
const router = useRouter();
const searchParams = useSearchParams();
const tokenParam = searchParams.get("token");
const [status, setStatus] = useState<VerifyStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string>("");
const [manualToken, setManualToken] = useState("");
const [resendEmail, setResendEmail] = useState("");
const [isResending, setIsResending] = useState(false);
const [resendSuccess, setResendSuccess] = useState(false);
const handleVerify = useCallback(async (tokenToVerify: string) => {
if (!tokenToVerify) return;
setStatus("verifying");
setErrorMessage("");
try {
await authService.verifyEmail(tokenToVerify);
setStatus("success");
} catch (err: unknown) {
setStatus("error");
if (err instanceof Error) {
setErrorMessage(err.message);
} else {
setErrorMessage("Mã kích hoạt không hợp lệ hoặc đã hết hạn.");
}
}
}, []);
useEffect(() => {
if (tokenParam) {
handleVerify(tokenParam);
}
}, [tokenParam, handleVerify]);
const handleManualSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (manualToken.trim()) {
handleVerify(manualToken.trim());
}
};
const handleResendVerification = async (e: React.FormEvent) => {
e.preventDefault();
if (!resendEmail.trim()) return;
setIsResending(true);
setResendSuccess(false);
try {
await authService.resendVerification(resendEmail.trim());
setResendSuccess(true);
} catch (err: unknown) {
if (err instanceof Error) {
setErrorMessage(err.message);
}
} finally {
setIsResending(false);
}
};
// State 1: Verifying in progress
if (status === "verifying") {
return (
<div className="rounded-3xl border border-emerald-500/15 bg-card/90 p-8 shadow-xl shadow-emerald-950/5 text-center backdrop-blur-xl">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
<h2 className="text-xl font-bold text-foreground">
{t.auth.verify.verifying}
</h2>
<p className="mt-2 text-xs text-muted-foreground">
Hệ thống đang kết nối và xác thực tài khoản của bạn, vui lòng đợi trong giây lát...
</p>
</div>
);
}
// State 2: Verification Success
if (status === "success") {
return (
<div className="rounded-3xl border border-emerald-500/20 bg-card/90 p-6 sm:p-8 shadow-xl shadow-emerald-950/5 text-center backdrop-blur-xl">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 shadow-sm shadow-emerald-600/10">
<CheckCircle2 className="h-8 w-8" />
</div>
<h2 className="text-2xl font-bold text-foreground">
{t.auth.verify.successTitle}
</h2>
<p className="mt-2 text-xs sm:text-sm text-muted-foreground leading-relaxed">
{t.auth.verify.successDesc}
</p>
<div className="mt-6">
<Button
onClick={() => router.push("/login")}
className="w-full rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 shadow-md shadow-emerald-600/20 cursor-pointer"
>
<ArrowRight className="mr-2 h-4 w-4" />
{t.auth.verify.goToLogin}
</Button>
</div>
</div>
);
}
// State 3: Verification Error
if (status === "error") {
return (
<div className="rounded-3xl border border-red-500/20 bg-card/90 p-6 sm:p-8 shadow-xl shadow-red-950/5 backdrop-blur-xl">
<div className="text-center mb-6">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20">
<XCircle className="h-7 w-7" />
</div>
<h2 className="text-xl font-bold text-foreground">
{t.auth.verify.errorTitle}
</h2>
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
{errorMessage || t.auth.verify.errorDesc}
</p>
</div>
{/* Resend Verification Form */}
<div className="rounded-2xl border border-border/80 bg-background/50 p-4">
<h3 className="text-xs font-semibold text-foreground mb-2">
{t.auth.verify.resendTitle}
</h3>
{resendSuccess ? (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-3 text-xs text-emerald-600 dark:text-emerald-400">
<Sparkles className="mr-1.5 inline-block h-3.5 w-3.5" />
{t.auth.verify.resendSuccess}
</div>
) : (
<form onSubmit={handleResendVerification} className="space-y-3">
<Input
type="email"
required
value={resendEmail}
onChange={(e) => setResendEmail(e.target.value)}
placeholder={t.auth.verify.resendEmailPlaceholder}
className="rounded-xl text-xs border-border bg-background/80"
/>
<Button
type="submit"
disabled={isResending}
className="w-full rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium text-xs py-2 cursor-pointer"
>
{isResending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
{t.auth.verify.resendBtn}
</Button>
</form>
)}
</div>
<div className="mt-5 text-center">
<Link
href="/login"
className="text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Quay lại Đăng nhập
</Link>
</div>
</div>
);
}
// State 4: Idle (No token provided in URL, manual entry or resend)
return (
<div className="rounded-3xl border border-emerald-500/15 bg-card/90 p-6 sm:p-8 shadow-xl shadow-emerald-950/5 backdrop-blur-xl transition-all duration-300">
<div className="text-center mb-6">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-700 shadow-md shadow-emerald-500/20 text-white mb-3">
<MailCheck className="h-6 w-6" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
{t.auth.verify.title}
</h1>
<p className="mt-1 text-xs sm:text-sm text-muted-foreground">
{t.auth.verify.subtitle}
</p>
</div>
{/* Manual Token Form */}
<form onSubmit={handleManualSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label
htmlFor="manualToken"
className="text-xs font-semibold text-foreground"
>
{t.auth.verify.tokenLabel}
</Label>
<Input
id="manualToken"
type="text"
required
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
placeholder={t.auth.verify.tokenPlaceholder}
className="rounded-2xl border-border bg-background/50 focus-visible:ring-emerald-500 font-mono text-xs"
/>
</div>
<Button
type="submit"
className="w-full rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 shadow-md shadow-emerald-600/20 cursor-pointer"
>
{t.auth.verify.submitToken}
</Button>
</form>
{/* Resend Section */}
<div className="mt-6 border-t border-border/70 pt-4">
<h3 className="text-xs font-semibold text-foreground mb-2 text-center">
{t.auth.verify.resendTitle}
</h3>
{resendSuccess ? (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-3 text-xs text-center text-emerald-600 dark:text-emerald-400">
{t.auth.verify.resendSuccess}
</div>
) : (
<form onSubmit={handleResendVerification} className="flex gap-2">
<Input
type="email"
required
value={resendEmail}
onChange={(e) => setResendEmail(e.target.value)}
placeholder={t.auth.verify.resendEmailPlaceholder}
className="rounded-xl text-xs flex-1 border-border bg-background/80"
/>
<Button
type="submit"
disabled={isResending}
variant="outline"
className="rounded-xl text-xs border-border shrink-0 cursor-pointer"
>
{isResending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5" />
)}
</Button>
</form>
)}
</div>
<div className="mt-5 text-center text-xs text-muted-foreground">
<Link
href="/login"
className="font-medium text-emerald-600 hover:underline dark:text-emerald-400"
>
Quay lại Đăng nhập
</Link>
</div>
</div>
);
}
export default function VerifyEmailPage() {
return (
<Suspense
fallback={
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
</div>
}
>
<VerifyEmailContent />
</Suspense>
);
}
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const BACKEND_URL =
process.env.BACKEND_API_URL || "http://localhost:9898/api/v1";
const isProduction = process.env.NODE_ENV === "production";
/**
* Cookie options for secure HttpOnly session storage
* Only enforces Secure when actually served over HTTPS (preventing cookie drop on localhost)
*/
function getCookieOptions(request: NextRequest, maxAgeSeconds: number) {
const isHttps =
request.nextUrl.protocol === "https:" ||
request.headers.get("x-forwarded-proto") === "https";
return {
httpOnly: true,
secure: isHttps,
sameSite: "lax" as const,
path: "/",
maxAge: maxAgeSeconds,
};
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ action: string }> }
) {
const { action } = await context.params;
const cookieStore = await cookies();
try {
// 1. LOGIN ACTION
if (action === "login") {
const body = await request.json();
const rememberMe = body.rememberMe ?? true;
const backendRes = await fetch(`${BACKEND_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: body.email,
password: body.password,
}),
});
const data = await backendRes.json();
if (!backendRes.ok || !data.success) {
return NextResponse.json(
{ message: data.message || "Đăng nhập thất bại" },
{ status: backendRes.status }
);
}
const { accessToken, refreshToken, user } = data.data;
// Access Token cookie (e.g. 1 day, or 7 days)
const accessMaxAge = rememberMe ? 7 * 24 * 60 * 60 : 24 * 60 * 60;
// Refresh Token cookie (e.g. 30 days)
const refreshMaxAge = rememberMe ? 30 * 24 * 60 * 60 : 7 * 24 * 60 * 60;
const accessOpts = getCookieOptions(request, accessMaxAge);
const refreshOpts = getCookieOptions(request, refreshMaxAge);
const response = NextResponse.json({
success: true,
data: {
user,
},
});
// Set directly on the response object to guarantee Set-Cookie headers in the response
response.cookies.set("auth_token", accessToken, accessOpts);
cookieStore.set("auth_token", accessToken, accessOpts);
if (refreshToken) {
response.cookies.set("refresh_token", refreshToken, refreshOpts);
cookieStore.set("refresh_token", refreshToken, refreshOpts);
}
if (user?.role) {
response.cookies.set("user_role", user.role, accessOpts);
cookieStore.set("user_role", user.role, accessOpts);
}
// Return ONLY the user profile, NEVER expose tokens to client-side JS
return response;
}
// 2. LOGOUT ACTION
if (action === "logout") {
const refreshToken = cookieStore.get("refresh_token")?.value;
try {
if (refreshToken) {
await fetch(`${BACKEND_URL}/auth/logout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
}
} catch {
// Ignore upstream logout failures
}
const response = NextResponse.json({
success: true,
message: "Đã đăng xuất thành công",
});
// Delete all auth HttpOnly cookies on both response and store
response.cookies.delete("auth_token");
response.cookies.delete("refresh_token");
response.cookies.delete("user_role");
cookieStore.delete("auth_token");
cookieStore.delete("refresh_token");
cookieStore.delete("user_role");
return response;
}
// 3. REFRESH ACTION (Internal silent refresh)
if (action === "refresh") {
const refreshToken = cookieStore.get("refresh_token")?.value;
if (!refreshToken) {
return NextResponse.json(
{ message: "Không tìm thấy refresh token" },
{ status: 401 }
);
}
const backendRes = await fetch(`${BACKEND_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
const data = await backendRes.json();
if (!backendRes.ok || !data.success) {
cookieStore.delete("auth_token");
cookieStore.delete("refresh_token");
cookieStore.delete("user_role");
return NextResponse.json(
{ message: "Phiên đăng nhập đã hết hạn" },
{ status: 401 }
);
}
const newAccessToken = data.data?.accessToken;
const newRefreshToken = data.data?.refreshToken || refreshToken;
cookieStore.set(
"auth_token",
newAccessToken,
getCookieOptions(request, 7 * 24 * 60 * 60)
);
cookieStore.set(
"refresh_token",
newRefreshToken,
getCookieOptions(request, 30 * 24 * 60 * 60)
);
return NextResponse.json({ success: true });
}
// 4. FORWARD OTHER AUTH ACTIONS: register, forgot-password, reset-password, verify-email, resend-verification
const allowedForwardActions = [
"register",
"forgot-password",
"reset-password",
"verify-email",
"resend-verification",
];
if (allowedForwardActions.includes(action)) {
const body = await request.json().catch(() => ({}));
const backendRes = await fetch(`${BACKEND_URL}/auth/${action}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await backendRes.json().catch(() => ({}));
return NextResponse.json(data, { status: backendRes.status });
}
return NextResponse.json(
{ message: "Hành động không hợp lệ" },
{ status: 400 }
);
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Đã có lỗi hệ thống xảy ra";
return NextResponse.json({ message }, { status: 500 });
}
}
export async function GET(
request: NextRequest,
context: { params: Promise<{ action: string }> }
) {
const { action } = await context.params;
const cookieStore = await cookies();
try {
// 1. GET ME / SESSION
if (action === "me" || action === "session") {
let authToken = cookieStore.get("auth_token")?.value;
const refreshToken = cookieStore.get("refresh_token")?.value;
if (!authToken && !refreshToken) {
return NextResponse.json(
{ message: "Chưa đăng nhập" },
{ status: 401 }
);
}
// If auth_token is missing but refresh_token exists, attempt server-side silent refresh
if (!authToken && refreshToken) {
const refreshRes = await fetch(`${BACKEND_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
const refreshData = await refreshRes.json();
const newAccessToken = refreshData.data?.accessToken;
if (refreshRes.ok && newAccessToken && typeof newAccessToken === "string") {
authToken = newAccessToken;
cookieStore.set(
"auth_token",
newAccessToken,
getCookieOptions(request, 7 * 24 * 60 * 60)
);
if (refreshData.data.refreshToken) {
cookieStore.set(
"refresh_token",
refreshData.data.refreshToken,
getCookieOptions(request, 30 * 24 * 60 * 60)
);
}
} else {
cookieStore.delete("auth_token");
cookieStore.delete("refresh_token");
cookieStore.delete("user_role");
return NextResponse.json(
{ message: "Phiên đăng nhập đã hết hạn" },
{ status: 401 }
);
}
}
// Fetch user profile from backend with Bearer token
let backendRes = await fetch(`${BACKEND_URL}/auth/me`, {
headers: {
Authorization: `Bearer ${authToken || ""}`,
},
});
// If backend returns 401 and we have a refresh_token, try refreshing once
if (backendRes.status === 401 && refreshToken) {
const refreshRes = await fetch(`${BACKEND_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
const refreshData = await refreshRes.json();
const refreshedAccessToken = refreshData.data?.accessToken;
if (
refreshRes.ok &&
refreshedAccessToken &&
typeof refreshedAccessToken === "string"
) {
authToken = refreshedAccessToken;
cookieStore.set(
"auth_token",
refreshedAccessToken,
getCookieOptions(request, 7 * 24 * 60 * 60)
);
backendRes = await fetch(`${BACKEND_URL}/auth/me`, {
headers: {
Authorization: `Bearer ${refreshedAccessToken}`,
},
});
}
}
const data = await backendRes.json();
if (!backendRes.ok) {
cookieStore.delete("auth_token");
cookieStore.delete("refresh_token");
cookieStore.delete("user_role");
return NextResponse.json(data, { status: backendRes.status });
}
const response = NextResponse.json(data);
// Update role cookie if returned
if (data.data?.role) {
const roleOpts = getCookieOptions(request, 7 * 24 * 60 * 60);
cookieStore.set("user_role", data.data.role, roleOpts);
response.cookies.set("user_role", data.data.role, roleOpts);
}
return response;
}
return NextResponse.json(
{ message: "Endpoint không hỗ trợ GET" },
{ status: 405 }
);
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Đã có lỗi hệ thống xảy ra";
return NextResponse.json({ message }, { status: 500 });
}
}
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const BACKEND_URL =
process.env.BACKEND_API_URL || "http://localhost:9898/api/v1";
function getCookieOptions(request: NextRequest, maxAgeSeconds: number) {
const isHttps =
request.nextUrl.protocol === "https:" ||
request.headers.get("x-forwarded-proto") === "https";
return {
httpOnly: true,
secure: isHttps,
sameSite: "lax" as const,
path: "/",
maxAge: maxAgeSeconds,
};
}
async function handleProxy(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
const { path } = await context.params;
const cookieStore = await cookies();
let authToken = cookieStore.get("auth_token")?.value;
const refreshToken = cookieStore.get("refresh_token")?.value;
const targetPath = path.join("/");
const targetUrl = `${BACKEND_URL}/${targetPath}${request.nextUrl.search}`;
const headers: Record<string, string> = {};
const contentType = request.headers.get("content-type");
if (contentType) {
headers["content-type"] = contentType;
}
if (authToken) {
headers["authorization"] = `Bearer ${authToken}`;
}
let body: BodyInit | undefined = undefined;
if (["POST", "PUT", "PATCH"].includes(request.method)) {
const rawBody = await request.arrayBuffer();
if (rawBody.byteLength > 0) {
body = Buffer.from(rawBody);
}
}
let backendRes = await fetch(targetUrl, {
method: request.method,
headers,
body,
});
// If 401 and refresh_token exists, attempt server-side silent refresh
if (backendRes.status === 401 && refreshToken) {
try {
const refreshRes = await fetch(`${BACKEND_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
const refreshData = await refreshRes.json();
const newAccessToken = refreshData.data?.accessToken;
if (refreshRes.ok && newAccessToken && typeof newAccessToken === "string") {
authToken = newAccessToken;
cookieStore.set(
"auth_token",
newAccessToken,
getCookieOptions(request, 7 * 24 * 60 * 60)
);
if (refreshData.data.refreshToken) {
cookieStore.set(
"refresh_token",
refreshData.data.refreshToken,
getCookieOptions(request, 30 * 24 * 60 * 60)
);
}
// Retry the original request with the new access token
headers["authorization"] = `Bearer ${authToken}`;
backendRes = await fetch(targetUrl, {
method: request.method,
headers,
body,
});
} else {
cookieStore.delete("auth_token");
cookieStore.delete("refresh_token");
cookieStore.delete("user_role");
}
} catch {
// Refresh error ignored
}
}
const responseContentType = backendRes.headers.get("content-type");
if (responseContentType && responseContentType.includes("application/json")) {
const data = await backendRes.json();
return NextResponse.json(data, { status: backendRes.status });
}
const data = await backendRes.arrayBuffer();
return new NextResponse(data, {
status: backendRes.status,
headers: {
"content-type": responseContentType || "application/octet-stream",
},
});
}
export async function GET(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
return handleProxy(request, context);
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
return handleProxy(request, context);
}
export async function PUT(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
return handleProxy(request, context);
}
export async function PATCH(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
return handleProxy(request, context);
}
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ path: string[] }> }
) {
return handleProxy(request, context);
}
"use client";
import React from "react";
import Link from "next/link";
import { ShieldAlert, ArrowLeft, LogOut } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
export default function ForbiddenPage() {
const { t } = useLanguage();
const { role, logout } = useAuth();
return (
<div className="flex min-h-[70vh] flex-col items-center justify-center text-center px-4">
<div className="relative mb-6">
<div className="absolute -inset-4 rounded-full bg-emerald-500/10 blur-xl"></div>
<div className="relative flex h-20 w-20 items-center justify-center rounded-3xl border border-emerald-500/30 bg-card shadow-lg shadow-emerald-950/10">
<ShieldAlert className="h-10 w-10 text-emerald-600 dark:text-emerald-400" />
</div>
</div>
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 mb-4">
<span>RBAC 403</span>
<span></span>
<span>{t.auth.forbidden.title}</span>
</div>
<h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl mb-3">
{t.auth.forbidden.title}
</h1>
<p className="max-w-md text-sm text-muted-foreground mb-8 leading-relaxed">
{t.auth.forbidden.subtitle}
</p>
{role && (
<div className="mb-6 rounded-2xl border border-border bg-card/60 px-4 py-2 text-xs text-muted-foreground">
Vai trò hiện tại của bạn:{" "}
<span className="font-semibold text-foreground">{role}</span>
</div>
)}
<div className="flex flex-wrap items-center justify-center gap-3">
<Link href="/">
<Button
variant="default"
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium cursor-pointer shadow-sm shadow-emerald-600/20"
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t.auth.forbidden.backHome}
</Button>
</Link>
<Button
variant="outline"
onClick={() => logout()}
className="rounded-2xl border-border cursor-pointer hover:bg-muted"
>
<LogOut className="mr-2 h-4 w-4" />
{t.auth.userMenu.logout}
</Button>
</div>
</div>
);
}
......@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/providers/providers";
import { Navbar } from "@/components/common/navbar";
import { AppShell } from "@/components/common/app-shell";
const geistSans = Geist({
variable: "--font-geist-sans",
......@@ -32,13 +32,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col bg-background text-foreground selection:bg-emerald-500/30 selection:text-emerald-400 transition-colors duration-200`}
>
<Providers>
<Navbar />
<main className="flex-1 container mx-auto max-w-7xl px-4 py-8 sm:px-6">
{children}
</main>
<footer className="border-t border-border/60 py-6 text-center text-xs text-muted-foreground">
<p>Data Crawler Architecture © {new Date().getFullYear()} — Powered by Next.js & TanStack Query</p>
</footer>
<AppShell>{children}</AppShell>
</Providers>
</body>
</html>
......
"use client";
import React from "react";
import { usePathname } from "next/navigation";
import { Navbar } from "@/components/common/navbar";
const AUTH_PATHS = [
"/login",
"/register",
"/forgot-password",
"/reset-password",
"/verify-email",
];
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
const pathname = usePathname();
const isAuthPage = AUTH_PATHS.some((path) => pathname.startsWith(path));
// If on an auth page, hide the main Navbar and default footer completely
if (isAuthPage) {
return <main className="flex-1 flex flex-col min-h-screen">{children}</main>;
}
// On standard application pages, display full Navbar, centered container, and footer
return (
<>
<Navbar />
<main className="flex-1 container mx-auto max-w-7xl px-4 py-8 sm:px-6">
{children}
</main>
<footer className="border-t border-border/60 py-6 text-center text-xs text-muted-foreground">
<p>
Data Crawler © {new Date().getFullYear()} - Code by @hnihTyoB
</p>
</footer>
</>
);
}
......@@ -5,6 +5,7 @@ import Link from "next/link";
import { Activity, Bot, Database, Globe, RefreshCw } from "lucide-react";
import { ThemeToggle } from "./theme-toggle";
import { LanguageToggle } from "./language-toggle";
import { UserMenu } from "./user-menu";
import { useLanguage } from "@/providers/language-provider";
export function Navbar() {
......@@ -22,7 +23,7 @@ export function Navbar() {
<div className="flex items-center gap-2">
<span className="font-bold text-lg tracking-tight text-foreground">DataCrawler</span>
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
PRO v1.0
v1.0
</span>
</div>
<p className="text-xs text-muted-foreground hidden sm:block">{t.nav.brandSub}</p>
......@@ -54,7 +55,7 @@ export function Navbar() {
</Link>
</nav>
{/* Right Controls: Theme Toggle, Language Toggle, Status & Reload */}
{/* Right Controls: Theme Toggle, Language Toggle, Status, Reload & User Menu */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Active Status Badge */}
<div className="hidden lg:flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/5 px-3 py-1 text-xs text-emerald-700 dark:text-emerald-300">
......@@ -79,6 +80,9 @@ export function Navbar() {
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
{/* User Profile & Auth Menu */}
<UserMenu />
</div>
</div>
</header>
......
"use client";
import React, { useState, useRef, useEffect } from "react";
import Link from "next/link";
import {
User,
LogOut,
Shield,
LayoutDashboard,
ChevronDown,
} from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useLanguage } from "@/providers/language-provider";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
export function UserMenu() {
const { user, isAuthenticated, isLoading, role, isAdmin, logout } = useAuth();
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
if (isLoading) {
return (
<div className="h-8 w-24 animate-pulse rounded-xl bg-muted/60" />
);
}
if (!isAuthenticated || !user) {
return (
<Link href="/login">
<Button
size="sm"
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-3.5 shadow-sm shadow-emerald-600/20 cursor-pointer transition-all hover:scale-[1.02]"
>
{t.auth.login.title}
</Button>
</Link>
);
}
// Get initial letters for avatar fallback
const displayName = user.fullName || user.email;
const initials = displayName
.split(" ")
.map((n) => n[0])
.join("")
.substring(0, 2)
.toUpperCase();
const getRoleBadge = () => {
switch (role) {
case "ADMIN":
return (
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
{t.auth.userMenu.roleAdmin}
</span>
);
case "CRAWLER_USER":
return (
<span className="rounded-full bg-blue-500/10 px-2 py-0.5 text-[10px] font-semibold text-blue-600 dark:text-blue-400 border border-blue-500/20">
{t.auth.userMenu.roleUser}
</span>
);
default:
return (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground border border-border">
{t.auth.userMenu.roleViewer}
</span>
);
}
};
return (
<div className="relative" ref={menuRef}>
{/* Trigger Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 rounded-2xl border border-border/80 bg-card/60 p-1.5 pl-2 pr-2.5 text-xs text-foreground hover:bg-muted/60 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-emerald-500/30"
>
{/* Avatar */}
<div className="flex h-7 w-7 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-700 font-semibold text-white text-[11px] shadow-sm shadow-emerald-600/20">
{initials}
</div>
<div className="hidden sm:flex flex-col text-left max-w-[120px]">
<span className="truncate font-semibold text-xs leading-none">
{user.fullName || user.email.split("@")[0]}
</span>
<span className="text-[10px] text-muted-foreground truncate leading-tight">
{role || "USER"}
</span>
</div>
<ChevronDown
className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${
isOpen ? "rotate-180" : ""
}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute right-0 mt-2 w-64 rounded-3xl border border-emerald-500/15 bg-card/95 p-3 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150">
{/* User Header */}
<div className="rounded-2xl border border-border/60 bg-muted/40 p-3 mb-2">
<p className="font-semibold text-xs text-foreground truncate">
{user.fullName || "Người dùng"}
</p>
<p className="text-[11px] text-muted-foreground truncate">
{user.email}
</p>
<div className="mt-2 flex items-center justify-between">
{getRoleBadge()}
{user.isActive && (
<span className="flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Đang hoạt động
</span>
)}
</div>
</div>
{/* Actions List */}
<div className="space-y-1">
<Link
href="/"
onClick={() => setIsOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<LayoutDashboard className="h-3.5 w-3.5 text-emerald-500" />
<span>{t.nav.dashboard}</span>
</Link>
{isAdmin && (
<div className="flex items-center justify-between rounded-xl px-3 py-2 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-500/5 border border-emerald-500/10">
<div className="flex items-center gap-2.5">
<Shield className="h-3.5 w-3.5 text-emerald-500" />
<span>{t.auth.userMenu.admin}</span>
</div>
<Badge variant="outline" className="text-[9px] border-emerald-500/30">
ADMIN
</Badge>
</div>
)}
</div>
{/* Divider */}
<div className="my-2 border-t border-border/60" />
{/* Logout Action */}
<button
onClick={() => {
setIsOpen(false);
logout();
}}
className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-500/10 dark:text-red-400 transition-colors cursor-pointer"
>
<LogOut className="h-3.5 w-3.5" />
<span>{t.auth.userMenu.logout}</span>
</button>
</div>
)}
</div>
);
}
......@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-slate-700 bg-slate-900/80 px-3 py-1 text-sm text-slate-100 shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-slate-100 placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-emerald-500 focus-visible:border-emerald-500 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm text-foreground shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-emerald-500 focus-visible:border-emerald-500 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
......
export { useAuth } from "@/providers/auth-provider";
export type { AuthContextType } from "@/providers/auth-provider";
import axios, {
AxiosError,
AxiosInstance,
InternalAxiosRequestConfig,
} from "axios";
import axios, { AxiosError, AxiosInstance } from "axios";
export interface ApiErrorResponse {
message: string;
......@@ -26,15 +22,13 @@ export class ApiError extends Error {
}
}
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
const baseURL =
process.env.NEXT_PUBLIC_API_URL || "http://localhost:9898/api/v1";
/**
* Next.js BFF API Client
* Calls internal Next.js Proxy (/api/proxy) with automatic HttpOnly Cookie transmission.
* Zero token storage in localStorage, eliminating XSS token theft risks.
*/
export const apiClient: AxiosInstance = axios.create({
baseURL,
baseURL: "/api/proxy",
timeout: 30000,
withCredentials: true,
headers: {
......@@ -43,138 +37,16 @@ export const apiClient: AxiosInstance = axios.create({
},
});
// Queue management for silent token refresh
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: unknown) => void;
}> = [];
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else if (token) {
prom.resolve(token);
}
});
failedQueue = [];
};
// Request Interceptor: Attach bearer token from localStorage if available
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
if (typeof window !== "undefined") {
const token = localStorage.getItem("auth_token");
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => Promise.reject(error)
);
// Response Interceptor: Handle 401 and perform Silent Refresh Token
// Response Interceptor: Handle 401 unauthorized notifications
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError<ApiErrorResponse>) => {
const originalRequest = error.config as CustomAxiosRequestConfig | undefined;
if (!originalRequest) {
return Promise.reject(
new ApiError(error.message || "Đã xảy ra lỗi mạng không xác định")
);
}
(error: AxiosError<ApiErrorResponse>) => {
const statusCode = error.response?.status;
const isAuthEndpoint =
originalRequest.url?.includes("/auth/login") ||
originalRequest.url?.includes("/auth/refresh") ||
originalRequest.url?.includes("/auth/register");
// If 401 Unauthorized occurs on an authenticated endpoint and has not yet been retried
if (statusCode === 401 && !originalRequest._retry && !isAuthEndpoint) {
if (isRefreshing) {
return new Promise<unknown>((resolve, reject) => {
failedQueue.push({
resolve: (token: string) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
resolve(apiClient(originalRequest));
},
reject: (err: unknown) => {
reject(err);
},
});
});
}
originalRequest._retry = true;
isRefreshing = true;
const storedRefreshToken =
typeof window !== "undefined"
? localStorage.getItem("refresh_token")
: null;
try {
const refreshResponse = await axios.post<{
success: boolean;
message?: string;
data: {
accessToken: string;
refreshToken?: string;
};
}>(
`${baseURL}/auth/refresh`,
{ refreshToken: storedRefreshToken },
{ withCredentials: true }
);
const newAccessToken = refreshResponse.data?.data?.accessToken;
const newRefreshToken = refreshResponse.data?.data?.refreshToken;
if (!newAccessToken) {
throw new Error("Không nhận được Access Token mới.");
}
if (typeof window !== "undefined") {
localStorage.setItem("auth_token", newAccessToken);
if (newRefreshToken) {
localStorage.setItem("refresh_token", newRefreshToken);
}
}
apiClient.defaults.headers.common.Authorization = `Bearer ${newAccessToken}`;
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
processQueue(null, newAccessToken);
return apiClient(originalRequest);
} catch (refreshErr) {
processQueue(refreshErr, null);
if (typeof window !== "undefined") {
localStorage.removeItem("auth_token");
localStorage.removeItem("refresh_token");
if (statusCode === 401 && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:unauthorized"));
}
const refreshMessage =
(refreshErr as AxiosError<ApiErrorResponse>)?.response?.data
?.message || "Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.";
return Promise.reject(
new ApiError(refreshMessage, 401, error.response?.data?.errors)
);
} finally {
isRefreshing = false;
}
}
const message =
error.response?.data?.message ||
error.message ||
......
/**
* Auth Storage Constants
* Note: Access & Refresh tokens are stored exclusively in HttpOnly Cookies
* managed by Next.js Server Route Handlers for maximum security against XSS.
*/
export const AUTH_TOKEN_KEY = "auth_token";
export const REFRESH_TOKEN_KEY = "refresh_token";
export const USER_ROLE_KEY = "user_role";
/**
* Safely decodes standard JWT payload in browser or Node/Edge runtime
*/
export function parseJwt<T = Record<string, unknown>>(token: string): T | null {
try {
const base64Url = token.split(".")[1];
if (!base64Url) return null;
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent(
atob(base64)
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join("")
);
return JSON.parse(jsonPayload) as T;
} catch {
return null;
}
}
......@@ -10,7 +10,7 @@ export const translations = {
storage: "Kho Dữ Liệu",
statusActive: "Động cơ Crawler Đang Chạy",
reload: "Tải lại trang",
copyright: "Data Crawler Architecture © 2026 — Được hỗ trợ bởi Next.js & TanStack Query",
copyright: "Data Crawler © 2026 - Code by @hnihTyoB",
},
// Theme & Language
theme: {
......@@ -84,6 +84,108 @@ export const translations = {
failed: "Thất bại",
paused: "Tạm dừng",
},
// Authentication & RBAC
auth: {
login: {
title: "Đăng Nhập",
subtitle: "Truy cập nền tảng thu thập và giám sát Data Crawler",
email: "Địa chỉ Email",
emailPlaceholder: "name@organization.com",
password: "Mật khẩu",
passwordPlaceholder: "••••••••",
rememberMe: "Ghi nhớ đăng nhập",
forgotPassword: "Quên mật khẩu?",
submit: "Đăng Nhập Ngay",
submitting: "Đang xác thực...",
noAccount: "Chưa có tài khoản?",
registerNow: "Đăng ký tài khoản mới",
success: "Đăng nhập thành công!",
},
register: {
title: "Đăng Ký Tài Khoản",
subtitle: "Tham gia nền tảng cào dữ liệu thông minh và bền vững",
fullName: "Họ và Tên",
fullNamePlaceholder: "Nguyễn Văn A",
email: "Địa chỉ Email",
emailPlaceholder: "name@organization.com",
password: "Mật khẩu bảo mật",
passwordPlaceholder: "••••••••",
confirmPassword: "Xác nhận mật khẩu",
confirmPasswordPlaceholder: "••••••••",
agreeTerms: "Tôi đồng ý với Điều khoản dịch vụ & Chính sách bảo mật",
termsError: "Vui lòng đồng ý với điều khoản sử dụng",
submit: "Tạo Tài Khoản",
submitting: "Đang khởi tạo...",
hasAccount: "Đã có tài khoản?",
loginNow: "Đăng nhập ngay",
success: "Tạo tài khoản thành công!",
passwordRules: {
title: "Yêu cầu mật khẩu:",
min: "Tối thiểu 8 ký tự",
upper: "Ít nhất 1 chữ hoa",
lower: "Ít nhất 1 chữ thường",
number: "Ít nhất 1 chữ số",
special: "Ít nhất 1 ký tự đặc biệt (!@#$...)",
},
},
forgot: {
title: "Khôi Phục Mật Khẩu",
subtitle: "Nhập email liên kết với tài khoản để nhận liên kết đặt lại mật khẩu",
email: "Địa chỉ Email",
emailPlaceholder: "name@organization.com",
submit: "Gửi Yêu Cầu Khôi Phục",
submitting: "Đang gửi...",
backToLogin: "Quay lại Đăng nhập",
successTitle: "Đã gửi liên kết khôi phục!",
successDesc: "Nếu email tồn tại trong hệ thống, bạn sẽ nhận được đường dẫn đặt lại mật khẩu trong ít phút.",
resendBtn: "Gửi lại email",
},
reset: {
title: "Đặt Lại Mật Khẩu",
subtitle: "Nhập mật khẩu mới cho tài khoản của bạn",
newPassword: "Mật khẩu mới",
newPasswordPlaceholder: "••••••••",
confirmPassword: "Xác nhận mật khẩu mới",
confirmPasswordPlaceholder: "••••••••",
submit: "Cập Nhật Mật Khẩu",
submitting: "Đang cập nhật...",
missingToken: "Thiếu mã xác thực (Token). Vui lòng sử dụng đường link gửi qua email.",
successTitle: "Cập Nhật Mật Khẩu Thành Công!",
successDesc: "Mật khẩu của bạn đã được thay đổi. Hãy đăng nhập lại bằng mật khẩu mới.",
backToLogin: "Chuyển đến trang Đăng nhập",
},
verify: {
title: "Xác Thực & Kích Hoạt Tài Khoản",
subtitle: "Đang hoàn tất quá trình xác thực tài khoản của bạn",
verifying: "Đang kiểm tra mã kích hoạt...",
successTitle: "Kích Hoạt Tài Khoản Thành Công!",
successDesc: "Tài khoản của bạn đã được kích hoạt thành công. Hãy đăng nhập để trải nghiệm hệ thống.",
errorTitle: "Xác Thực Thất Bại",
errorDesc: "Mã kích hoạt không đúng hoặc đã hết hạn.",
goToLogin: "Đăng nhập ngay",
manualTitle: "Hoặc nhập mã kích hoạt thủ công:",
tokenLabel: "Mã Token Xác Thực",
tokenPlaceholder: "Dán mã token từ email...",
submitToken: "Kích Hoạt Ngay",
resendTitle: "Chưa nhận được mã xác thực?",
resendEmailPlaceholder: "Nhập email của bạn...",
resendBtn: "Gửi lại email kích hoạt",
resendSuccess: "Đã gửi lại email kích hoạt!",
},
userMenu: {
profile: "Hồ sơ cá nhân",
admin: "Bảng Quản Trị",
logout: "Đăng xuất",
roleAdmin: "Quản trị viên",
roleUser: "Người dùng Crawler",
roleViewer: "Người xem",
},
forbidden: {
title: "Từ Chối Truy Cập (403)",
subtitle: "Bạn không có quyền truy cập trang quản trị này. Vui lòng liên hệ Quản trị viên (ADMIN) để được cấp quyền.",
backHome: "Quay lại Bảng điều khiển",
},
},
},
en: {
// Navigation & Common
......@@ -94,7 +196,7 @@ export const translations = {
storage: "Data Lake",
statusActive: "Crawler Engine Active",
reload: "Reload page",
copyright: "Data Crawler Architecture © 2026 — Powered by Next.js & TanStack Query",
copyright: "Data Crawler © 2026 - Code by @hnihTyoB",
},
// Theme & Language
theme: {
......@@ -168,6 +270,108 @@ export const translations = {
failed: "Failed",
paused: "Paused",
},
// Authentication & RBAC
auth: {
login: {
title: "Sign In",
subtitle: "Access the Data Crawler scraping and monitoring platform",
email: "Email Address",
emailPlaceholder: "name@organization.com",
password: "Password",
passwordPlaceholder: "••••••••",
rememberMe: "Remember me",
forgotPassword: "Forgot password?",
submit: "Sign In Now",
submitting: "Authenticating...",
noAccount: "Don't have an account?",
registerNow: "Create an account",
success: "Login successful!",
},
register: {
title: "Create Account",
subtitle: "Join the intelligent, eco-friendly data scraping platform",
fullName: "Full Name",
fullNamePlaceholder: "John Doe",
email: "Email Address",
emailPlaceholder: "name@organization.com",
password: "Secure Password",
passwordPlaceholder: "••••••••",
confirmPassword: "Confirm Password",
confirmPasswordPlaceholder: "••••••••",
agreeTerms: "I agree to the Terms of Service and Eco-friendly Privacy Policy",
termsError: "You must agree to the terms of service",
submit: "Create Account",
submitting: "Creating account...",
hasAccount: "Already have an account?",
loginNow: "Sign in now",
success: "Account created successfully!",
passwordRules: {
title: "Password requirements:",
min: "At least 8 characters",
upper: "At least 1 uppercase letter",
lower: "At least 1 lowercase letter",
number: "At least 1 digit",
special: "At least 1 special character (!@#$...)",
},
},
forgot: {
title: "Reset Password",
subtitle: "Enter your registered email to receive a password reset link",
email: "Email Address",
emailPlaceholder: "name@organization.com",
submit: "Send Reset Link",
submitting: "Sending...",
backToLogin: "Back to Sign In",
successTitle: "Reset Link Sent!",
successDesc: "If the email exists in our system, you will receive a password reset link shortly.",
resendBtn: "Resend email",
},
reset: {
title: "Set New Password",
subtitle: "Enter a new secure password for your account",
newPassword: "New Password",
newPasswordPlaceholder: "••••••••",
confirmPassword: "Confirm New Password",
confirmPasswordPlaceholder: "••••••••",
submit: "Update Password",
submitting: "Updating...",
missingToken: "Missing verification token. Please use the link provided in your email.",
successTitle: "Password Updated Successfully!",
successDesc: "Your password has been changed. Please sign in with your new password.",
backToLogin: "Go to Sign In page",
},
verify: {
title: "Verify & Activate Account",
subtitle: "Completing your email verification process",
verifying: "Verifying your activation token...",
successTitle: "Account Activated Successfully!",
successDesc: "Your account is now activated. Please sign in to access all features.",
errorTitle: "Verification Failed",
errorDesc: "The activation token is invalid or has expired.",
goToLogin: "Sign In Now",
manualTitle: "Or enter activation token manually:",
tokenLabel: "Verification Token",
tokenPlaceholder: "Paste token from email...",
submitToken: "Activate Now",
resendTitle: "Didn't receive verification email?",
resendEmailPlaceholder: "Enter your email...",
resendBtn: "Resend verification email",
resendSuccess: "Verification email resent!",
},
userMenu: {
profile: "My Profile",
admin: "Admin Console",
logout: "Sign Out",
roleAdmin: "Administrator",
roleUser: "Crawler User",
roleViewer: "Viewer",
},
forbidden: {
title: "Access Denied (403)",
subtitle: "You do not have permission to access this administration page. Please contact an Administrator (ADMIN).",
backHome: "Back to Dashboard",
},
},
},
};
......
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Public auth routes that do NOT require authentication
const AUTH_ROUTES = [
"/login",
"/register",
"/forgot-password",
"/reset-password",
"/verify-email",
];
// Routes strictly requiring ADMIN role (RBAC)
const ADMIN_ROUTES = [
"/admin",
"/users",
"/audit-logs",
"/roles",
];
// Open public pages that do NOT require login (e.g. 403 forbidden page)
const OPEN_ROUTES = [
"/forbidden",
];
/**
* Safely decodes JWT payload in Edge runtime without external packages
*/
function decodeJwtPayload(token: string): { role?: string; exp?: number } | null {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const base64Url = parts[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = atob(base64);
return JSON.parse(jsonPayload);
} catch {
return null;
}
}
export function middleware(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const authToken = request.cookies.get("auth_token")?.value;
const refreshToken = request.cookies.get("refresh_token")?.value;
const userRoleCookie = request.cookies.get("user_role")?.value;
// Determine user role and token expiration
let role = userRoleCookie;
let isTokenExpired = false;
if (authToken) {
const payload = decodeJwtPayload(authToken);
if (payload) {
if (payload.role && !role) {
role = payload.role;
}
if (payload.exp && payload.exp * 1000 < Date.now()) {
isTokenExpired = true;
}
}
}
// User is authenticated if they have a valid auth_token or an active refresh_token for silent refresh
const isAuthenticated =
(Boolean(authToken) && !isTokenExpired) || Boolean(refreshToken);
const isAuthRoute = AUTH_ROUTES.some((route) => pathname.startsWith(route));
const isOpenRoute = OPEN_ROUTES.some((route) => pathname.startsWith(route));
const isAdminRoute = ADMIN_ROUTES.some((route) => pathname.startsWith(route));
// 1. If user is already authenticated and visits /login, /register, etc. -> redirect to home or returnUrl
if (isAuthRoute && isAuthenticated) {
let redirectUrl = request.nextUrl.searchParams.get("redirect") || "/";
if (
redirectUrl.startsWith("/login") ||
redirectUrl.startsWith("/register")
) {
redirectUrl = "/";
}
return NextResponse.redirect(new URL(redirectUrl, request.url));
}
// 2. Allow open non-authenticated routes (/login, /register, /forbidden, etc.)
if (isAuthRoute || isOpenRoute) {
return NextResponse.next();
}
// 3. If user is NOT authenticated and trying to access protected routes -> redirect to /login
if (!isAuthenticated) {
const loginUrl = new URL("/login", request.url);
const callbackUrl = pathname + search;
if (
callbackUrl &&
callbackUrl !== "/" &&
!AUTH_ROUTES.some((r) => callbackUrl.startsWith(r))
) {
loginUrl.searchParams.set("redirect", callbackUrl);
}
return NextResponse.redirect(loginUrl);
}
// 4. Role-Based Access Control (RBAC): restrict ADMIN routes
if (isAdminRoute && role !== "ADMIN") {
// Redirect unauthorized role to /forbidden
return NextResponse.redirect(new URL("/forbidden", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - api routes
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico and common static file extensions
*/
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
"use client";
import React, {
createContext,
useContext,
useEffect,
useCallback,
ReactNode,
} from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { authService, BffLoginResponse } from "@/services/auth.service";
import { LoginInput, RegisterInput } from "@/schemas/auth.schema";
import { User, UserRole } from "@/types/auth";
import { useLanguage } from "./language-provider";
export const AUTH_QUERY_KEYS = {
me: ["auth", "me"] as const,
};
export interface AuthContextType {
user: User | null;
role: UserRole | null;
isAuthenticated: boolean;
isLoading: boolean;
isAdmin: boolean;
hasRole: (allowedRoles: UserRole[]) => boolean;
login: (input: LoginInput, rememberMe?: boolean) => Promise<BffLoginResponse>;
register: (input: RegisterInput) => Promise<User>;
logout: () => Promise<void>;
refreshProfile: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const router = useRouter();
const { t } = useLanguage();
// Load session from Next.js BFF (/api/auth/me) with HttpOnly cookie
const {
data: user,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: AUTH_QUERY_KEYS.me,
queryFn: () => authService.getMe(),
retry: false,
staleTime: 5 * 60 * 1000,
});
const isAuthenticated = !!user && !isError;
const role: UserRole | null = (user?.role as UserRole) || null;
const isAdmin = role === "ADMIN";
const hasRole = useCallback(
(allowedRoles: UserRole[]): boolean => {
if (!role) return false;
return allowedRoles.includes(role);
},
[role]
);
const login = async (
input: LoginInput,
rememberMe: boolean = true
): Promise<BffLoginResponse> => {
try {
const response = await authService.login({ ...input, rememberMe });
// Proactively update query cache with logged in user data
if (response.user) {
queryClient.setQueryData(AUTH_QUERY_KEYS.me, response.user);
}
await queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.me });
toast.success(t.auth.login.success);
return response;
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Đăng nhập không thành công";
toast.error(message);
throw error;
}
};
const register = async (input: RegisterInput): Promise<User> => {
try {
const newUser = await authService.register(input);
toast.success(t.auth.register.success);
return newUser;
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Đăng ký không thành công";
toast.error(message);
throw error;
}
};
const logout = useCallback(async (): Promise<void> => {
try {
await authService.logout();
} catch {
// Ignore network errors during logout
} finally {
queryClient.clear();
toast.info(t.auth.userMenu.logout);
router.push("/login");
}
}, [queryClient, router, t]);
const refreshProfile = async (): Promise<void> => {
await refetch();
};
// Listen for unauthorized 401 events emitted by apiClient
useEffect(() => {
const handleUnauthorized = () => {
queryClient.clear();
router.push("/login");
};
window.addEventListener("auth:unauthorized", handleUnauthorized);
return () => {
window.removeEventListener("auth:unauthorized", handleUnauthorized);
};
}, [queryClient, router]);
const contextValue: AuthContextType = {
user: user || null,
role,
isAuthenticated,
isLoading,
isAdmin,
hasRole,
login,
register,
logout,
refreshProfile,
};
return (
<AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>
);
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
......@@ -7,6 +7,7 @@ import { getQueryClient } from "@/lib/query-client";
import { Toaster } from "sonner";
import { ThemeProvider } from "./theme-provider";
import { LanguageProvider } from "./language-provider";
import { AuthProvider } from "./auth-provider";
interface ProvidersProps {
children: ReactNode;
......@@ -24,9 +25,11 @@ export function Providers({ children }: ProvidersProps) {
>
<LanguageProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
{children}
<Toaster position="top-right" richColors closeButton />
<ReactQueryDevtools initialIsOpen={false} />
</AuthProvider>
</QueryClientProvider>
</LanguageProvider>
</ThemeProvider>
......
......@@ -9,6 +9,7 @@ export const loginSchema = z.object({
password: z
.string()
.min(6, "Mật khẩu phải có ít nhất 6 ký tự"),
rememberMe: z.boolean().optional(),
});
export const registerSchema = z
......@@ -90,9 +91,24 @@ export const resetPasswordSchema = z
path: ["confirmPassword"],
});
export const verifyEmailSchema = z.object({
token: z.string().trim().min(1, "Mã token xác thực không được để trống"),
});
export const resendVerificationSchema = z.object({
email: z
.string()
.trim()
.min(1, "Email không được để trống")
.email("Email không đúng định dạng"),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>;
export type ResendVerificationInput = z.infer<typeof resendVerificationSchema>;
import axios, { AxiosError } from "axios";
import {
LoginInput,
RegisterInput,
ForgotPasswordInput,
ResetPasswordInput,
} from "@/schemas/auth.schema";
import { User, AuthUserSummary } from "@/types/auth";
import { ApiResponse } from "@/types/api";
const authClient = axios.create({
baseURL: "/api/auth",
timeout: 15000,
withCredentials: true,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
// Helper for extracting API error messages
function extractErrorMessage(error: unknown, defaultMsg: string): string {
if (error instanceof AxiosError) {
return error.response?.data?.message || error.message || defaultMsg;
}
if (error instanceof Error) {
return error.message;
}
return defaultMsg;
}
export interface BffLoginResponse {
user: AuthUserSummary;
}
export class AuthService {
async login(input: LoginInput): Promise<BffLoginResponse> {
try {
const response = await authClient.post<ApiResponse<BffLoginResponse>>(
"/login",
{
email: input.email,
password: input.password,
rememberMe: input.rememberMe ?? true,
}
);
return response.data.data;
} catch (err) {
throw new Error(extractErrorMessage(err, "Đăng nhập không thành công"));
}
}
async register(input: RegisterInput): Promise<User> {
try {
const response = await authClient.post<ApiResponse<User>>("/register", {
email: input.email,
password: input.password,
fullName: input.fullName || undefined,
});
return response.data.data;
} catch (err) {
throw new Error(extractErrorMessage(err, "Đăng ký không thành công"));
}
}
async logout(): Promise<{ success: boolean; message?: string }> {
try {
const response = await authClient.post<ApiResponse<null>>("/logout");
return { success: true, message: response.data.message };
} catch {
return { success: true };
}
}
async getMe(): Promise<User> {
const response = await authClient.get<ApiResponse<User>>("/me");
return response.data.data;
}
async forgotPassword(input: ForgotPasswordInput): Promise<{ message: string }> {
try {
const response = await authClient.post<ApiResponse<null>>(
"/forgot-password",
{ email: input.email }
);
return { message: response.data.message || "Đã gửi liên kết khôi phục mật khẩu" };
} catch (err) {
throw new Error(extractErrorMessage(err, "Không thể gửi yêu cầu khôi phục"));
}
}
async resetPassword(input: ResetPasswordInput): Promise<{ message: string }> {
try {
const response = await authClient.post<ApiResponse<null>>("/reset-password", {
token: input.token,
password: input.password,
});
return { message: response.data.message || "Đặt lại mật khẩu thành công" };
} catch (err) {
throw new Error(extractErrorMessage(err, "Không thể đặt lại mật khẩu"));
}
}
async verifyEmail(token: string): Promise<{ message: string }> {
try {
const response = await authClient.post<ApiResponse<null>>("/verify-email", {
token,
});
return { message: response.data.message || "Kích hoạt tài khoản thành công" };
} catch (err) {
throw new Error(extractErrorMessage(err, "Xác thực token thất bại"));
}
}
async resendVerification(email: string): Promise<{ message: string }> {
try {
const response = await authClient.post<ApiResponse<null>>(
"/resend-verification",
{ email }
);
return { message: response.data.message || "Đã gửi lại email kích hoạt" };
} catch (err) {
throw new Error(extractErrorMessage(err, "Không thể gửi lại email"));
}
}
}
export const authService = new AuthService();
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment