import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; import { Form, useActionData, useLoaderData, useNavigation, useSearchParams, Link } from "@remix-run/react"; import { useState, useEffect, useRef } from "react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Input } from "~/components/ui/input"; import { Alert, AlertDescription } from "~/components/ui/alert"; import { Shield, Mail, Clock, CheckCircle, AlertCircle, ArrowLeft, RefreshCw, Loader2, KeyRound } from "lucide-react"; import { Boxes } from "~/components/ui/background-boxes"; import { ThemeFloatingDock } from "~/components/ui/floatingthemeswitch"; // ✅ Import services and utils import adminAuthService from "~/services/auth/admin.service"; import { validateOtp } from "~/utils/auth-utils"; import { createUserSession, getUserSession } from "~/sessions.server"; interface LoaderData { email: string; deviceId: string; remainingTime: string; expiryMinutes: number; } interface OTPActionData { success: boolean; message?: string; otpSentAt?: string; errors?: { otp?: string; general?: string; }; } // ✅ Proper loader with URL params validation export const loader = async ({ request }: LoaderFunctionArgs): Promise => { const userSession = await getUserSession(request); // Redirect if already logged in if (userSession && userSession.role === "administrator") { return redirect("/sys-rijig-adminpanel/dashboard"); } const url = new URL(request.url); const email = url.searchParams.get("email"); const deviceId = url.searchParams.get("device_id"); const remainingTime = url.searchParams.get("remaining_time"); if (!email || !deviceId) { return redirect("/sys-rijig-administrator/sign-infirst"); } return json({ email, deviceId, remainingTime: remainingTime || "5:00", expiryMinutes: 5 }); }; // ✅ Action integrated with API service export const action = async ({ request }: ActionFunctionArgs): Promise => { const formData = await request.formData(); const otp = formData.get("otp") as string; const email = formData.get("email") as string; const deviceId = formData.get("device_id") as string; const action = formData.get("_action") as string; if (action === "resend") { try { // ✅ Call login API again to resend OTP const response = await adminAuthService.login({ device_id: deviceId, email, password: "temp" // We don't have password here, but API might handle resend differently }); return json({ success: true, message: "Kode OTP baru telah dikirim ke email Anda", otpSentAt: new Date().toISOString() }); } catch (error: any) { console.error("Resend OTP error:", error); return json( { errors: { general: "Gagal mengirim ulang OTP. Silakan coba lagi." }, success: false }, { status: 500 } ); } } if (action === "verify") { // ✅ Validation using utils 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 (!email || !deviceId) { errors.general = "Data session tidak valid. Silakan login ulang."; } if (Object.keys(errors).length > 0) { return json({ errors, success: false }, { status: 400 }); } try { // ✅ Call API service for OTP verification const response = await adminAuthService.verifyOtp({ device_id: deviceId, email, otp }); if (response.meta.status === 200 && response.data) { // ✅ Create user session after successful verification return createUserSession({ request, sessionData: { accessToken: response.data.access_token, refreshToken: response.data.refresh_token, sessionId: response.data.session_id, role: "administrator", deviceId, email, registrationStatus: response.data.registration_status || "complete", nextStep: response.data.next_step || "completed" }, redirectTo: "/sys-rijig-adminpanel/dashboard" }); } return json( { errors: { otp: "Verifikasi OTP gagal" }, success: false }, { status: 401 } ); } catch (error: any) { console.error("OTP verification error:", error); // ✅ Handle specific API errors if (error.response?.data?.meta?.message) { return json( { errors: { otp: error.response.data.meta.message }, success: false }, { status: error.response.status || 401 } ); } return json( { errors: { general: "Terjadi kesalahan server. Silakan coba lagi." }, success: false }, { status: 500 } ); } } return json( { errors: { general: "Aksi tidak valid" }, success: false }, { status: 400 } ); }; export default function AdminVerifyOTP() { const { email, deviceId, remainingTime, expiryMinutes } = useLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); const [searchParams] = useSearchParams(); const [otp, setOtp] = useState(["", "", "", ""]); const [timeLeft, setTimeLeft] = useState(() => { // ✅ Parse remaining time from API response const [minutes, seconds] = remainingTime.split(":").map(Number); return minutes * 60 + (seconds || 0); }); 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(() => { if (timeLeft <= 0) { setCanResend(true); return; } const timer = setInterval(() => { setTimeLeft((prev) => { if (prev <= 1) { setCanResend(true); return 0; } return prev - 1; }); }, 1000); return () => clearInterval(timer); }, [timeLeft]); // Reset timer when OTP is resent useEffect(() => { if (actionData?.success && actionData?.otpSentAt) { setTimeLeft(expiryMinutes * 60); setCanResend(false); } }, [actionData, expiryMinutes]); const handleOtpChange = (index: number, value: string) => { if (!/^\d*$/.test(value)) return; const newOtp = [...otp]; newOtp[index] = value; setOtp(newOtp); if (value && index < 3) { inputRefs.current[index + 1]?.focus(); } }; const handleKeyDown = (index: number, e: React.KeyboardEvent) => { if (e.key === "Backspace" && !otp[index] && index > 0) { inputRefs.current[index - 1]?.focus(); } }; 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(); } }; const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, "0")}`; }; const maskedEmail = email.replace(/(.{2})(.*)(@.*)/, "$1***$3"); return (
{/* Background overlay with theme-aware gradient */}
{/* Animated background boxes */} {/* Theme Toggle - Positioned at top-right */} {/* Main content container */}
Verifikasi Email

Masukkan kode OTP 4 digit yang telah dikirim ke

{maskedEmail}

{/* Success Alert */} {actionData?.success && actionData?.message && ( {actionData.message} )} {/* Error Alert */} {actionData?.errors?.otp && ( {actionData.errors.otp} )} {/* General Error Alert */} {actionData?.errors?.general && ( {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={cn( "w-14 h-14 text-center text-xl font-bold bg-background border-input transition-all duration-200 focus:scale-105", actionData?.errors?.otp && "border-red-500 dark:border-red-400" )} 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?

{/* Security Info */}

Keamanan Email

Kode OTP dikirim melalui email terenkripsi untuk memastikan keamanan akun administrator Anda.

{/* Back to Login */}
Kembali ke Login
{/* Footer */}

Portal Administrator - Sistem Pengelolaan Sampah Terpadu

); }