import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; import { Form, useActionData, useLoaderData, useNavigation, Link } from "@remix-run/react"; import { useState, useEffect, useRef } from "react"; import { Card, CardContent, CardHeader } from "~/components/ui/card"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Alert, AlertDescription } from "~/components/ui/alert"; import { MessageSquare, ArrowLeft, ArrowRight, AlertCircle, Loader2, Clock, RefreshCw, CheckCircle, Smartphone, Shield } from "lucide-react"; import { getSession, commitSession } from "~/sessions.server"; import { validateOtp } from "~/utils/auth-utils"; import pengelolaAuthService from "~/services/auth/pengelola.service"; import type { AuthTokenData } from "~/types/auth.types"; // Progress Indicator Component untuk Login (3 steps) const LoginProgressIndicator = ({ currentStep = 2, totalSteps = 3 }) => { return (
{Array.from({ length: totalSteps }, (_, index) => { const stepNumber = index + 1; const isActive = stepNumber === currentStep; const isCompleted = stepNumber < currentStep; return (
{isCompleted ? : stepNumber}
{stepNumber < totalSteps && (
)}
); })}
); }; // Interfaces interface LoaderData { phone: string; deviceId: string; otpSentAt: string; expiryMinutes: number; } interface VerifyOTPLoginActionData { success?: boolean; message?: string; otpSentAt?: string; errors?: { otp?: string; general?: string; }; } export const loader = async ({ request }: LoaderFunctionArgs): Promise => { const session = await getSession(request); const phone = session.get("tempLoginPhone"); const deviceId = session.get("tempLoginDeviceId"); const otpSentAt = session.get("tempLoginOtpSentAt"); if (!phone || !deviceId) { return redirect("/authpengelola/requestotpforlogin"); } return json({ phone, deviceId, otpSentAt: otpSentAt || new Date().toISOString(), expiryMinutes: 5 }); }; export const action = async ({ request }: ActionFunctionArgs): Promise => { const formData = await request.formData(); const otp = formData.get("otp") as string; const actionType = formData.get("_action") as string; const session = await getSession(request); const phone = session.get("tempLoginPhone"); const deviceId = session.get("tempLoginDeviceId"); if (!phone || !deviceId) { return redirect("/authpengelola/requestotpforlogin"); } if (actionType === "resend") { try { // Resend OTP await pengelolaAuthService.requestOtpLogin({ phone, role_name: "pengelola" }); // Update OTP sent time session.set("tempLoginOtpSentAt", new Date().toISOString()); return json( { success: true, message: "Kode OTP baru telah dikirim ke WhatsApp Anda", otpSentAt: new Date().toISOString() }, { headers: { "Set-Cookie": await commitSession(session) } } ); } catch (error: any) { console.error("Resend OTP error:", error); const errorMessage = error.response?.data?.meta?.message || "Gagal mengirim ulang OTP. Silakan coba lagi."; return json( { errors: { general: errorMessage } }, { status: 500 } ); } } if (actionType === "verify") { // Validation const errors: { otp?: string; general?: string } = {}; if (!otp) { errors.otp = "Kode OTP wajib diisi"; } else if (!validateOtp(otp)) { errors.otp = "Kode OTP harus 4 digit angka"; } if (Object.keys(errors).length > 0) { return json({ errors }, { status: 400 }); } try { // Verify OTP untuk login const response = await pengelolaAuthService.verifyOtpLogin({ phone, otp, device_id: deviceId, role_name: "pengelola" }); const tokenData = response.data; // Check if tokenData exists if (!tokenData) { return json( { errors: { general: "Response data tidak valid dari server" } }, { status: 500 } ); } // Simpan data token ke session session.set("tempLoginTokenData", tokenData); session.set("tempLoginPhone", phone); session.set("tempLoginDeviceId", deviceId); // Check next step dari response if (tokenData.next_step === "verif_pin") { // Lanjut ke verifikasi PIN return redirect("/authpengelola/verifyexistingpin", { headers: { "Set-Cookie": await commitSession(session) } }); } else { // Jika sudah complete, langsung ke dashboard // (tidak seharusnya terjadi untuk login flow, tapi handle just in case) return redirect("/pengelola/dashboard", { headers: { "Set-Cookie": await commitSession(session) } }); } } catch (error: any) { console.error("Verify OTP login error:", error); // Handle specific API errors if (error.response?.status === 401) { return json( { errors: { otp: "Kode OTP tidak valid atau sudah kedaluwarsa" } }, { status: 401 } ); } if (error.response?.status === 429) { return json( { errors: { otp: "Terlalu banyak percobaan. Silakan tunggu beberapa menit." } }, { status: 429 } ); } // General error const errorMessage = error.response?.data?.meta?.message || "Gagal memverifikasi OTP. Silakan coba lagi."; return json( { errors: { general: errorMessage } }, { status: 500 } ); } } return json( { errors: { general: "Aksi tidak valid" } }, { status: 400 } ); }; export default function VerifyOTPToLogin() { const { phone, deviceId, otpSentAt, expiryMinutes } = useLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); const [otp, setOtp] = useState(["", "", "", ""]); const [timeLeft, setTimeLeft] = useState(expiryMinutes * 60); // 5 minutes in seconds const [canResend, setCanResend] = useState(false); const inputRefs = useRef<(HTMLInputElement | null)[]>([]); const isSubmitting = navigation.state === "submitting"; const isResending = navigation.formData?.get("_action") === "resend"; const isVerifying = navigation.formData?.get("_action") === "verify"; // Timer countdown useEffect(() => { const timer = setInterval(() => { setTimeLeft((prev) => { if (prev <= 1) { setCanResend(true); return 0; } return prev - 1; }); }, 1000); return () => clearInterval(timer); }, []); // Reset timer when OTP is resent useEffect(() => { if (actionData?.success && actionData?.otpSentAt) { setTimeLeft(expiryMinutes * 60); setCanResend(false); } }, [actionData, expiryMinutes]); // Handle OTP input change const handleOtpChange = (index: number, value: string) => { if (!/^\d*$/.test(value)) return; // Only allow digits const newOtp = [...otp]; newOtp[index] = value; setOtp(newOtp); // Auto-focus next input if (value && index < 3) { inputRefs.current[index + 1]?.focus(); } }; // Handle key down (backspace) const handleKeyDown = (index: number, e: React.KeyboardEvent) => { if (e.key === "Backspace" && !otp[index] && index > 0) { inputRefs.current[index - 1]?.focus(); } }; // Handle paste const handlePaste = (e: React.ClipboardEvent) => { e.preventDefault(); const pastedText = e.clipboardData.getData("text"); const digits = pastedText.replace(/\D/g, "").slice(0, 4); if (digits.length === 4) { const newOtp = digits.split(""); setOtp(newOtp); inputRefs.current[3]?.focus(); } }; // Format time const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, "0")}`; }; // Format phone display const formatPhone = (phoneNumber: string) => { if (phoneNumber.length <= 2) return phoneNumber; if (phoneNumber.length <= 5) return `${phoneNumber.substring(0, 2)} ${phoneNumber.substring(2)}`; if (phoneNumber.length <= 9) return `${phoneNumber.substring(0, 2)} ${phoneNumber.substring( 2, 5 )} ${phoneNumber.substring(5)}`; return `${phoneNumber.substring(0, 2)} ${phoneNumber.substring( 2, 5 )} ${phoneNumber.substring(5, 9)} ${phoneNumber.substring(9)}`; }; return (
{/* Progress Indicator */} {/* Main Card */}

Verifikasi Login

Masukkan kode OTP 4 digit yang dikirim ke

{formatPhone(phone)}

{/* Success Alert */} {actionData?.success && actionData?.message && ( {actionData.message} )} {/* Error Alert */} {(actionData?.errors?.otp || actionData?.errors?.general) && ( {actionData.errors.otp || actionData.errors.general} )} {/* OTP Input Form */}
{/* OTP Input Fields */}
{otp.map((digit, index) => ( (inputRefs.current[index] = el)} type="text" maxLength={1} value={digit} onChange={(e) => handleOtpChange(index, e.target.value)} onKeyDown={(e) => handleKeyDown(index, e)} onPaste={handlePaste} className={`w-14 h-14 text-center text-xl font-bold transition-all duration-200 ${ actionData?.errors?.otp ? "border-red-500 dark:border-red-400" : "" } focus:scale-105`} autoFocus={index === 0} /> ))}

Tempel kode OTP atau ketik manual

{/* Timer */}
{timeLeft > 0 ? (
Kode kedaluwarsa dalam {formatTime(timeLeft)}
) : (
Kode OTP telah kedaluwarsa
)}
{/* Verify Button */}
{/* Resend OTP */}

Tidak menerima kode?

{/* Info Box */}

Keamanan Login

Setelah verifikasi OTP, Anda akan diminta memasukkan PIN 6 digit untuk mengakses dashboard pengelola.

{/* Back Link */}
Ganti nomor WhatsApp
{/* Help Card */}
); }