import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; import { Form, useActionData, useLoaderData, useNavigation, Link } from "@remix-run/react"; import { useState, useRef } from "react"; import { Card, CardContent, CardHeader } from "~/components/ui/card"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Alert, AlertDescription } from "~/components/ui/alert"; import { Shield, CheckCircle, AlertCircle, Loader2, Lock, Eye, EyeOff, ArrowLeft, LogIn, KeyRound, Fingerprint } from "lucide-react"; import { getSession, commitSession, createUserSession } from "~/sessions.server"; import { validatePin } from "~/utils/auth-utils"; import pengelolaAuthService from "~/services/auth/pengelola.service"; import commonAuthService from "~/services/auth/common.service"; import type { AuthTokenData } from "~/types/auth.types"; // Progress Indicator Component untuk Login (3 steps) const LoginProgressIndicator = ({ currentStep = 3, 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; tokenData: AuthTokenData; } interface VerifyPINActionData { errors?: { pin?: string; general?: string; }; success?: boolean; } export const loader = async ({ request }: LoaderFunctionArgs): Promise => { const session = await getSession(request); const phone = session.get("tempLoginPhone"); const deviceId = session.get("tempLoginDeviceId"); const tokenData = session.get("tempLoginTokenData"); if (!phone || !deviceId || !tokenData) { return redirect("/authpengelola/requestotpforlogin"); } // Set auth token for API calls commonAuthService.setAuthToken(tokenData.access_token); return json({ phone, deviceId, tokenData }); }; export const action = async ({ request }: ActionFunctionArgs): Promise => { const formData = await request.formData(); const pin = formData.get("pin") as string; const session = await getSession(request); const phone = session.get("tempLoginPhone"); const deviceId = session.get("tempLoginDeviceId"); const tokenData = session.get("tempLoginTokenData"); if (!phone || !deviceId || !tokenData) { return redirect("/authpengelola/requestotpforlogin"); } // Validation const errors: { pin?: string; general?: string } = {}; if (!pin) { errors.pin = "PIN wajib diisi"; } else if (!validatePin(pin)) { errors.pin = "PIN harus 6 digit angka"; } if (Object.keys(errors).length > 0) { return json({ errors }, { status: 400 }); } // Set auth token for API calls commonAuthService.setAuthToken(tokenData.access_token); try { // Verify PIN const response = await pengelolaAuthService.verifyPin({ userpin: pin }); const finalTokenData = response.data; // Check if finalTokenData exists if (!finalTokenData) { return json( { errors: { general: "Response data tidak valid dari server" } }, { status: 500 } ); } // Validate required fields if ( !finalTokenData.access_token || !finalTokenData.refresh_token || !finalTokenData.session_id ) { return json( { errors: { general: "Data token tidak lengkap dari server" } }, { status: 500 } ); } // Create user session dengan semua data yang diperlukan const sessionData = { accessToken: finalTokenData.access_token, refreshToken: finalTokenData.refresh_token, sessionId: finalTokenData.session_id, role: "pengelola" as const, deviceId, phone, tokenType: finalTokenData.token_type, registrationStatus: finalTokenData.registration_status, nextStep: finalTokenData.next_step }; // Clear temporary session data session.unset("tempLoginPhone"); session.unset("tempLoginDeviceId"); session.unset("tempLoginTokenData"); session.unset("tempLoginOtpSentAt"); // Create user session and redirect to dashboard return createUserSession({ request, sessionData, redirectTo: "/pengelola/dashboard" }); } catch (error: any) { console.error("Verify PIN error:", error); // Handle specific API errors if (error.response?.status === 401) { return json( { errors: { pin: "PIN yang Anda masukkan salah. Silakan coba lagi." } }, { status: 401 } ); } if (error.response?.status === 429) { return json( { errors: { pin: "Terlalu banyak percobaan. Silakan tunggu beberapa menit." } }, { status: 429 } ); } if (error.response?.status === 403) { return json( { errors: { general: "Akun Anda dikunci sementara. Hubungi administrator." } }, { status: 403 } ); } // General error const errorMessage = error.response?.data?.meta?.message || "Gagal memverifikasi PIN. Silakan coba lagi."; return json( { errors: { general: errorMessage } }, { status: 500 } ); } }; export default function VerifyExistingPIN() { const { phone, deviceId, tokenData } = useLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); const [pin, setPin] = useState(["", "", "", "", "", ""]); const [showPin, setShowPin] = useState(false); const pinRefs = useRef<(HTMLInputElement | null)[]>([]); const isSubmitting = navigation.state === "submitting"; // Handle PIN input change const handlePinChange = (index: number, value: string) => { if (!/^\d*$/.test(value)) return; // Only allow digits const newPin = [...pin]; newPin[index] = value; setPin(newPin); // Auto-focus next input if (value && index < 5) { pinRefs.current[index + 1]?.focus(); } }; // Handle key down (backspace) const handleKeyDown = (index: number, e: React.KeyboardEvent) => { if (e.key === "Backspace" && !pin[index] && index > 0) { pinRefs.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, 6); if (digits.length === 6) { const newPin = digits.split(""); setPin(newPin); pinRefs.current[5]?.focus(); } }; // 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)}`; }; const fullPin = pin.join(""); return (
{/* Progress Indicator */} {/* Welcome Back Card */}

Selamat Datang Kembali!

Langkah terakhir untuk mengakses dashboard

{/* Main Card */}

Masukkan PIN Anda

Akun: {formatPhone(phone)}

{/* Error Alert */} {(actionData?.errors?.general || actionData?.errors?.pin) && ( {actionData.errors.general || actionData.errors.pin} )} {/* Form */}
{/* PIN Input */}
{pin.map((digit, index) => ( (pinRefs.current[index] = el)} type={showPin ? "text" : "password"} maxLength={1} value={digit} onChange={(e) => handlePinChange(index, e.target.value)} onKeyDown={(e) => handleKeyDown(index, e)} onPaste={handlePaste} className={`w-12 h-12 text-center text-lg font-bold transition-all duration-200 ${ actionData?.errors?.pin ? "border-red-500 dark:border-red-400" : "" } focus:scale-105`} autoFocus={index === 0} /> ))}

Tempel PIN atau ketik manual

{/* Security Info */}

Keamanan Terjamin

PIN Anda dienkripsi dengan standar keamanan tinggi. Jangan bagikan PIN kepada siapapun.

{/* Submit Button */}
{/* Forgot PIN */}
Lupa PIN?

Hubungi Customer Support untuk reset PIN

{/* Back Link */}
Kembali ke verifikasi OTP
{/* Security Notice */}

Tips Keamanan

  • • Jangan bagikan PIN kepada siapapun
  • • Logout dari perangkat yang bukan milik Anda
  • • Ganti PIN secara berkala untuk keamanan
  • • Laporkan aktivitas mencurigakan ke admin
); }