MIF_E31222379_WEB/app/routes/authpengelola.verifyexistin...

524 lines
17 KiB
TypeScript

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 (
<div className="flex items-center justify-center space-x-2 mb-8">
{Array.from({ length: totalSteps }, (_, index) => {
const stepNumber = index + 1;
const isActive = stepNumber === currentStep;
const isCompleted = stepNumber < currentStep;
return (
<div key={stepNumber} className="flex items-center">
<div
className={`
w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-all duration-300
${
isActive
? "bg-gradient-to-r from-green-600 to-blue-600 text-white shadow-lg scale-105"
: isCompleted
? "bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 border-2 border-green-200 dark:border-green-700"
: "bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border-2 border-gray-200 dark:border-gray-700"
}
`}
>
{isCompleted ? <CheckCircle className="h-5 w-5" /> : stepNumber}
</div>
{stepNumber < totalSteps && (
<div
className={`w-8 h-0.5 mx-2 transition-all duration-300 ${
stepNumber < currentStep
? "bg-green-400 dark:bg-green-500"
: "bg-gray-200 dark:bg-gray-700"
}`}
/>
)}
</div>
);
})}
</div>
);
};
// 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<Response> => {
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<LoaderData>({
phone,
deviceId,
tokenData
});
};
export const action = async ({
request
}: ActionFunctionArgs): Promise<Response> => {
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<VerifyPINActionData>({ 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<VerifyPINActionData>(
{
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<VerifyPINActionData>(
{
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<VerifyPINActionData>(
{
errors: { pin: "PIN yang Anda masukkan salah. Silakan coba lagi." }
},
{ status: 401 }
);
}
if (error.response?.status === 429) {
return json<VerifyPINActionData>(
{
errors: {
pin: "Terlalu banyak percobaan. Silakan tunggu beberapa menit."
}
},
{ status: 429 }
);
}
if (error.response?.status === 403) {
return json<VerifyPINActionData>(
{
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<VerifyPINActionData>(
{
errors: { general: errorMessage }
},
{ status: 500 }
);
}
};
export default function VerifyExistingPIN() {
const { phone, deviceId, tokenData } = useLoaderData<LoaderData>();
const actionData = useActionData<VerifyPINActionData>();
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 (
<div className="space-y-6">
{/* Progress Indicator */}
<LoginProgressIndicator currentStep={3} totalSteps={3} />
{/* Welcome Back Card */}
<div className="p-4 bg-gradient-to-r from-green-50 to-blue-50 dark:from-green-950/30 dark:to-blue-950/30 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex items-center space-x-3">
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
<div>
<p className="font-medium text-green-800 dark:text-green-300">
Selamat Datang Kembali!
</p>
<p className="text-sm text-green-700 dark:text-green-400">
Langkah terakhir untuk mengakses dashboard
</p>
</div>
</div>
</div>
{/* Main Card */}
<Card className="border-0 shadow-2xl bg-background/80 backdrop-blur-sm">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 p-3 bg-gradient-to-br from-green-100 to-blue-100 dark:from-green-900/30 dark:to-blue-900/30 rounded-full w-fit">
<KeyRound className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<h1 className="text-2xl font-bold text-foreground">
Masukkan PIN Anda
</h1>
<p className="text-muted-foreground mt-2">
Akun: {formatPhone(phone)}
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Error Alert */}
{(actionData?.errors?.general || actionData?.errors?.pin) && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{actionData.errors.general || actionData.errors.pin}
</AlertDescription>
</Alert>
)}
{/* Form */}
<Form method="post" className="space-y-6">
<input type="hidden" name="pin" value={fullPin} />
{/* PIN Input */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">PIN Keamanan</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowPin(!showPin)}
className="text-muted-foreground hover:text-foreground"
>
{showPin ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex justify-center space-x-3">
{pin.map((digit, index) => (
<Input
key={index}
ref={(el) => (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}
/>
))}
</div>
<p className="text-center text-xs text-muted-foreground">
Tempel PIN atau ketik manual
</p>
</div>
{/* Security Info */}
<div className="p-4 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start space-x-3">
<Fingerprint className="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-800 dark:text-blue-300">
Keamanan Terjamin
</p>
<p className="text-xs text-blue-700 dark:text-blue-400 mt-1">
PIN Anda dienkripsi dengan standar keamanan tinggi. Jangan
bagikan PIN kepada siapapun.
</p>
</div>
</div>
</div>
{/* Submit Button */}
<Button
type="submit"
className="w-full h-12 bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700 text-white shadow-lg"
disabled={isSubmitting || fullPin.length !== 6}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Memverifikasi PIN...
</>
) : (
<>
<LogIn className="mr-2 h-5 w-5" />
Masuk ke Dashboard
</>
)}
</Button>
</Form>
{/* Forgot PIN */}
<div className="text-center space-y-3">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Lupa PIN?
</span>
</div>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Hubungi Customer Support untuk reset PIN
</p>
<div className="flex items-center justify-center space-x-4">
<a
href="tel:+6281234567890"
className="text-sm text-primary hover:text-primary/80 font-medium"
>
📞 Call Support
</a>
<span className="text-muted-foreground"></span>
<a
href={`https://wa.me/6281234567890?text=Halo%20saya%20lupa%20PIN%20untuk%20nomor%20${phone}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:text-primary/80 font-medium"
>
💬 WhatsApp
</a>
</div>
</div>
</div>
{/* Back Link */}
<div className="text-center">
<Link
to="/authpengelola/verifyotptologin"
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Kembali ke verifikasi OTP
</Link>
</div>
</CardContent>
</Card>
{/* Security Notice */}
<Card className="border border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-950/20 backdrop-blur-sm">
<CardContent className="p-4">
<div className="flex items-start space-x-3">
<Shield className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
Tips Keamanan
</p>
<ul className="text-xs text-amber-700 dark:text-amber-400 mt-1 space-y-1">
<li> Jangan bagikan PIN kepada siapapun</li>
<li> Logout dari perangkat yang bukan milik Anda</li>
<li> Ganti PIN secara berkala untuk keamanan</li>
<li> Laporkan aktivitas mencurigakan ke admin</li>
</ul>
</div>
</div>
</CardContent>
</Card>
</div>
);
}