NextJS

Hướng dẫn Xây dựng Hệ thống Xác thực OTP (Phần 3): UI Components & Trải nghiệm Người dùng

Chào mừng trở lại với series Xây dựng hệ thống xác thực OTP qua Email với Next.js.Ở Phần 2, chúng ta đã hoàn thiện phần "xương sống" (Backend API) để ...

Hướng dẫn Xây dựng Hệ thống Xác thực OTP (Phần 3): UI Components & Trải nghiệm Người dùng

Chào mừng trở lại với series Xây dựng hệ thống xác thực OTP qua Email với Next.js.

Phần 2, chúng ta đã hoàn thiện phần "xương sống" (Backend API) để gửi và kiểm tra mã OTP. Trong Phần 3 này, chúng ta sẽ khoác lên nó một "lớp áo" (Frontend UI) hiện đại, tập trung tối đa vào trải nghiệm người dùng (UX) để việc nhập mã xác thực trở nên dễ dàng nhất.

Mục tiêu của phần này

  1. Tạo component nhập OTP thông minh: Tự động chuyển ô, hỗ trợ dán (paste), và xử lý đếm ngược.

  2. Tích hợp vào quy trình Đăng ký: Hiển thị màn hình nhập OTP ngay sau khi đăng ký thành công.

  3. Cập nhật quy trình Đăng nhập: Chặn người dùng chưa xác thực và hiển thị thông báo phù hợp.


1. Xây dựng Component OTPVerification

Đây là trái tim của phần giao diện. Một form nhập OTP tốt cần nhiều hơn là 6 ô input đơn giản; nó cần sự thông minh để người dùng không cảm thấy phiền phức.

Hãy tạo file src/components/auth/OTPVerification.tsx:

typescript
"use client";

import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, Mail, CheckCircle2 } from "lucide-react";

interface OTPVerificationProps {
  email: string;
  onVerified: () => void;
  onBack?: () => void;
}

export default function OTPVerification({
  email,
  onVerified,
  onBack,
}: OTPVerificationProps) {
  // State quản lý 6 chữ số OTP
  const [otp, setOtp] = useState<string[]>(["", "", "", "", "", ""]);
  
  // State quản lý trạng thái
  const [isVerifying, setIsVerifying] = useState(false);
  const [isSending, setIsSending] = useState(false);
  const [error, setError] = useState("");
  const [success, setSuccess] = useState("");
  const [countdown, setCountdown] = useState(60);
  const [canResend, setCanResend] = useState(false);

  // Refs để điều khiển focus và tránh gửi lặp
  const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
  const hasSentOTP = useRef(false);

  // Effect: Đếm ngược 60s
  useEffect(() => {
    if (countdown > 0) {
      const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
      return () => clearTimeout(timer);
    } else {
      setCanResend(true);
    }
  }, [countdown]);

  // Effect: Tự động gửi OTP khi component mount (chỉ 1 lần)
  useEffect(() => {
    if (!hasSentOTP.current) {
      hasSentOTP.current = true;
      sendOTP();
    }
  }, []);

  const sendOTP = async () => {
    setIsSending(true);
    setError("");
    setSuccess("");

    try {
      const response = await fetch("/api/auth/send-otp", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email }),
      });

      const data = await response.json();

      if (data.success) {
        setSuccess("Mã OTP đã được gửi đến email của bạn");
        setCountdown(60);
        setCanResend(false);
      } else {
        setError(data.error || "Không thể gửi mã OTP");
      }
    } catch (err) {
      setError("Đã xảy ra lỗi khi gửi mã OTP");
    } finally {
      setIsSending(false);
    }
  };

  const handleOTPChange = (index: number, value: string) => {
    // Chỉ cho phép nhập số
    if (value && !/^\d$/.test(value)) return;

    const newOtp = [...otp];
    newOtp[index] = value;
    setOtp(newOtp);

    // Auto-focus: Tự động nhảy sang ô tiếp theo
    if (value && index < 5) {
      inputRefs.current[index + 1]?.focus();
    }

    // Auto-submit: Tự động verify khi nhập đủ 6 số
    if (index === 5 && value) {
      const fullOtp = [...newOtp.slice(0, 5), value].join("");
      verifyOTP(fullOtp);
    }
  };

  const handleKeyDown = (
    index: number,
    e: React.KeyboardEvent<HTMLInputElement>
  ) => {
    // Backspace: Xóa và lùi về ô trước đó
    if (e.key === "Backspace" && !otp[index] && index > 0) {
      inputRefs.current[index - 1]?.focus();
    }
  };

  const handlePaste = (e: React.ClipboardEvent) => {
    e.preventDefault();
    const pastedData = e.clipboardData.getData("text").trim();

    // Paste support: Tự động điền nếu copy đúng 6 số
    if (/^\d{6}$/.test(pastedData)) {
      const newOtp = pastedData.split("");
      setOtp(newOtp);
      inputRefs.current[5]?.focus();
      verifyOTP(pastedData);
    }
  };

  const verifyOTP = async (otpString: string) => {
    setIsVerifying(true);
    setError("");

    try {
      const response = await fetch("/api/auth/verify-otp", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, otp: otpString }),
      });

      const data = await response.json();

      if (data.success) {
        setSuccess("Xác thực thành công!");
        setTimeout(() => {
          onVerified(); // Callback để component cha xử lý tiếp
        }, 1000);
      } else {
        setError(data.error || "Mã OTP không chính xác");
        // Reset input để nhập lại
        setOtp(["", "", "", "", "", ""]);
        inputRefs.current[0]?.focus();
      }
    } catch (err) {
      setError("Đã xảy ra lỗi khi xác thực mã OTP");
      setOtp(["", "", "", "", "", ""]);
      inputRefs.current[0]?.focus();
    } finally {
      setIsVerifying(false);
    }
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const otpString = otp.join("");
    if (otpString.length === 6) {
      verifyOTP(otpString);
    } else {
      setError("Vui lòng nhập đầy đủ 6 chữ số");
    }
  };

  return (
    <div className="w-full max-w-md space-y-6">
      {/* Header UI */}
      <div className="space-y-2 text-center">
        <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
          <Mail className="h-6 w-6 text-primary" />
        </div>
        <h2 className="text-2xl font-bold">Xác thực Email</h2>
        <p className="text-sm text-muted-foreground">
          Nhập mã OTP đã được gửi đến <br />
          <span className="font-medium text-foreground">{email}</span>
        </p>
      </div>

      <form onSubmit={handleSubmit} className="space-y-6">
        {/* OTP Inputs Grid */}
        <div className="flex justify-center gap-2">
          {otp.map((digit, index) => (
            <Input
              key={index}
              ref={(el) => { inputRefs.current[index] = el; }}
              type="text"
              inputMode="numeric"
              maxLength={1}
              value={digit}
              onChange={(e) => handleOTPChange(index, e.target.value)}
              onKeyDown={(e) => handleKeyDown(index, e)}
              onPaste={handlePaste}
              disabled={isVerifying}
              className="h-12 w-12 text-center text-lg font-semibold"
              autoFocus={index === 0}
            />
          ))}
        </div>

        {/* Alerts */}
        {error && (
          <Alert variant="destructive">
            <AlertDescription>{error}</AlertDescription>
          </Alert>
        )}
        {success && (
          <Alert className="border-green-500 bg-green-50 text-green-900">
            <CheckCircle2 className="h-4 w-4" />
            <AlertDescription>{success}</AlertDescription>
          </Alert>
        )}

        {/* Resend Logic */}
        <div className="flex flex-col items-center gap-2 text-sm">
          <p className="text-muted-foreground">Không nhận được mã?</p>
          <Button
            type="button"
            variant="link"
            onClick={sendOTP}
            disabled={!canResend || isSending}
            className="h-auto p-0"
          >
            {isSending ? (
              <>
                <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Đang gửi...
              </>
            ) : canResend ? (
              "Gửi lại mã OTP"
            ) : (
              `Gửi lại sau ${countdown}s`
            )}
          </Button>
        </div>

        {/* Submit Button */}
        <Button
          type="submit"
          className="w-full"
          disabled={isVerifying || otp.join("").length !== 6}
        >
          {isVerifying ? (
            <>
              <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Đang xác thực...
            </>
          ) : (
            "Xác thực"
          )}
        </Button>

        {/* Back Button */}
        {onBack && (
          <Button type="button" variant="outline" className="w-full" onClick={onBack} disabled={isVerifying}>
            Quay lại
          </Button>
        )}
      </form>
    </div>
  );
}

Điểm nhấn UX trong Component này:

  • Auto-focus: Người dùng nhập xong ô 1, con trỏ tự nhảy sang ô 2.

  • Auto-submit: Nhập đủ 6 số, hệ thống tự động gửi request verify, không cần bấm nút.

  • Paste thông minh: Copy mã 123456 từ email và paste vào, nó sẽ tự điền vào 6 ô.

  • Điều hướng mượt mà: Bấm Backspace khi ô trống sẽ tự lùi về ô trước để sửa.

  • Chống Spam: Countdown timer 60s ngăn người dùng spam nút gửi lại email.


2. Tích hợp vào Trang Đăng Ký

Chúng ta sẽ sửa src/app/register/page.tsx. Thay vì redirect ngay sau khi đăng ký, chúng ta sẽ kiểm tra xem server có yêu cầu verify không. Nếu có, hiển thị component OTPVerification.

typescript
'use client';

// ... các imports
import OTPVerification from '@/components/auth/OTPVerification'; 

export default function RegisterPage() {
  // ... các state cũ
  
  // State mới cho flow OTP
  const [showOTPVerification, setShowOTPVerification] = useState(false);
  const [registeredEmail, setRegisteredEmail] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    // ... logic validation cũ

      const response = await fetch('/api/auth/register', {
        // ... config
      });

      const data = await response.json();

      if (!response.ok) {
        setError(data.error);
        return;
      }

      setSuccess(true);

      // LOGIC MỚI: Kiểm tra requireVerification
      if (data.requireVerification) {
        setRegisteredEmail(formData.email);
        // Đợi 1s để người dùng thấy thông báo thành công rồi chuyển màn hình
        setTimeout(() => {
          setShowOTPVerification(true);
        }, 1000);
      } else {
        // Fallback cho trường hợp không cần verify (ví dụ OAuth)
        router.push('/login?registered=true');
      }
    // ... catch error
  };

  // Render màn hình OTP nếu biến showOTPVerification = true
  if (showOTPVerification) {
    return (
      <div className="flex min-h-screen items-center justify-center px-4 py-12">
        <OTPVerification
          email={registeredEmail}
          onVerified={() => {
            // Verify xong thì đẩy về login
            router.push('/login?verified=true');
          }}
          onBack={() => {
            setShowOTPVerification(false);
            setSuccess(false);
          }}
        />
      </div>
    );
  }

  // ... Return phần form đăng ký cũ
  return ( ... );
}

3. Cập nhật Trang Đăng Nhập & NextAuth

Ngay cả khi có giao diện verify, chúng ta cần đảm bảo ở tầng logic rằng user không thể đăng nhập nếu chưa verify email.

Cập nhật src/lib/auth-options.ts

Thêm logic kiểm tra emailVerified vào trong hàm authorize:

typescript
// ... imports

export const authOptions = {
  providers: [
    CredentialsProvider({
      // ...
      async authorize(credentials: any) {
        // ... kết nối DB và tìm user

        // Logic MỚI: Chặn đăng nhập nếu chưa verify
        if (user.provider === 'credentials' && !user.emailVerified) {
           // Throw error code đặc biệt để frontend nhận biết
           throw new Error('EMAIL_NOT_VERIFIED');
        }

        // ... check password
      },
    }),
  ],
  // ...
};

Cập nhật src/app/login/page.tsx

Bắt lỗi EMAIL_NOT_VERIFIED và hiển thị hướng dẫn cho người dùng.

typescript
// ... trong hàm handleSubmit

const result = await signIn('credentials', {
  email,
  password,
  redirect: false,
});

if (result?.error) {
  // Kiểm tra lỗi trả về từ NextAuth
  if (result.error.includes('EMAIL_NOT_VERIFIED')) {
    setError('Email chưa được xác thực. Vui lòng kiểm tra email của bạn.');
    // Mở rộng: Tại đây bạn có thể thêm nút "Gửi lại OTP" nếu muốn
  } else {
    setError('Email hoặc mật khẩu không chính xác');
  }
}

Tổng kết Flow người dùng

Sau khi hoàn tất Phần 3, luồng đi của người dùng sẽ như sau:

  1. Đăng ký: Người dùng nhập thông tin -> Bấm Đăng ký.

  2. Chuyển tiếp: Màn hình chuyển sang giao diện nhập 6 số OTP.

  3. Email: Hệ thống tự động gửi email chứa mã.

  4. Xác thực:

    • Người dùng copy mã từ email -> Paste vào giao diện.

    • Hệ thống tự động verify.

  5. Hoàn tất: Chuyển hướng về trang đăng nhập với thông báo thành công.

  6. Đăng nhập: Người dùng đăng nhập bình thường. Nếu họ cố tình bỏ qua bước verify (ví dụ tắt tab rồi quay lại), hệ thống sẽ chặn đăng nhập.

Vậy là chúng ta đã hoàn thành việc tích hợp toàn bộ tính năng xác thực OTP từ Backend đến Frontend.

👉 Phần tiếp theo (Phần 4): Chúng ta sẽ đi vào các vấn đề về Bảo mật, Rate Limiting và Triển khai thực tế để đảm bảo hệ thống không bị tấn công spam SMS/Email.

👁️8 lượt xem
❤️0 lượt thích