CRITICAL SECURITY FIX: All admin and member routes now require authentication. ## Changes: - Created ProtectedRoute component to enforce authentication - Protected all member routes (/dashboard, /access, /orders, /profile) - Protected all admin routes (/admin/*) with admin role check - Added redirect-after-login functionality using sessionStorage - Non-authenticated users accessing protected pages are redirected to /auth - Non-admin users accessing admin pages are redirected to /dashboard ## Security Impact: - Prevents unauthorized access to admin panel and member areas - Users must login to access any protected functionality - Admin routes additionally verify user role is 'admin' - After login, users are redirected back to their intended page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useNavigate, Link, useLocation } from 'react-router-dom';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { toast } from '@/hooks/use-toast';
|
|
import { z } from 'zod';
|
|
import { ArrowLeft, Mail } from 'lucide-react';
|
|
|
|
const emailSchema = z.string().email('Invalid email address');
|
|
const passwordSchema = z.string().min(6, 'Password must be at least 6 characters');
|
|
|
|
export default function Auth() {
|
|
const [isLogin, setIsLogin] = useState(true);
|
|
const [showOTP, setShowOTP] = useState(false);
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [name, setName] = useState('');
|
|
const [otpCode, setOtpCode] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [pendingUserId, setPendingUserId] = useState<string | null>(null);
|
|
const [resendCountdown, setResendCountdown] = useState(0);
|
|
const [isResendOTP, setIsResendOTP] = useState(false); // Track if this is resend OTP for existing user
|
|
const { signIn, signUp, user, sendAuthOTP, verifyAuthOTP, getUserByEmail } = useAuth();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
|
|
useEffect(() => {
|
|
if (user) {
|
|
// Check if there's a saved redirect path
|
|
const savedRedirect = sessionStorage.getItem('redirectAfterLogin');
|
|
if (savedRedirect) {
|
|
sessionStorage.removeItem('redirectAfterLogin');
|
|
navigate(savedRedirect);
|
|
} else {
|
|
navigate('/dashboard');
|
|
}
|
|
}
|
|
}, [user, navigate]);
|
|
|
|
// Countdown timer for resend OTP
|
|
useEffect(() => {
|
|
if (resendCountdown > 0) {
|
|
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [resendCountdown]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
try {
|
|
emailSchema.parse(email);
|
|
passwordSchema.parse(password);
|
|
} catch (err) {
|
|
if (err instanceof z.ZodError) {
|
|
toast({ title: 'Validation Error', description: err.errors[0].message, variant: 'destructive' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
if (isLogin) {
|
|
const { error } = await signIn(email, password);
|
|
if (error) {
|
|
console.log('Login error:', error.message);
|
|
|
|
// Check if error is due to unconfirmed email
|
|
// Supabase returns various error messages for unconfirmed email
|
|
const isUnconfirmedEmail =
|
|
error.message.includes('Email not confirmed') ||
|
|
error.message.includes('Email not verified') ||
|
|
error.message.includes('Email not confirmed') ||
|
|
error.message.toLowerCase().includes('email') && error.message.toLowerCase().includes('not confirmed') ||
|
|
error.message.toLowerCase().includes('unconfirmed');
|
|
|
|
console.log('Is unconfirmed email?', isUnconfirmedEmail);
|
|
|
|
if (isUnconfirmedEmail) {
|
|
// Get user by email to fetch user_id
|
|
console.log('Fetching user by email for OTP resend...');
|
|
const userResult = await getUserByEmail(email);
|
|
|
|
console.log('User lookup result:', userResult);
|
|
|
|
if (userResult.success && userResult.user_id) {
|
|
setPendingUserId(userResult.user_id);
|
|
setIsResendOTP(true);
|
|
setShowOTP(true);
|
|
setResendCountdown(0); // Allow immediate resend on first attempt
|
|
toast({
|
|
title: 'Email Belum Dikonfirmasi',
|
|
description: 'Silakan verifikasi email Anda. Kami akan mengirimkan kode OTP.',
|
|
});
|
|
} else {
|
|
toast({
|
|
title: 'Error',
|
|
description: 'User tidak ditemukan. Silakan daftar terlebih dahulu.',
|
|
variant: 'destructive'
|
|
});
|
|
}
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
|
setLoading(false);
|
|
} else {
|
|
// Get redirect from sessionStorage or use default
|
|
const savedRedirect = sessionStorage.getItem('redirectAfterLogin');
|
|
const redirectTo = savedRedirect || '/dashboard';
|
|
if (savedRedirect) {
|
|
sessionStorage.removeItem('redirectAfterLogin');
|
|
}
|
|
navigate(redirectTo);
|
|
setLoading(false);
|
|
}
|
|
} else {
|
|
if (!name.trim()) {
|
|
toast({ title: 'Error', description: 'Name is required', variant: 'destructive' });
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const { error, data } = await signUp(email, password, name);
|
|
|
|
console.log('SignUp result:', { error, data, hasUser: !!data?.user, hasSession: !!data?.session });
|
|
|
|
if (error) {
|
|
if (error.message.includes('already registered')) {
|
|
toast({ title: 'Error', description: 'This email is already registered. Please login instead.', variant: 'destructive' });
|
|
} else {
|
|
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
|
}
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (!data?.user) {
|
|
toast({ title: 'Error', description: 'Failed to create user account. Please try again.', variant: 'destructive' });
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// User created, now send OTP
|
|
const userId = data.user.id;
|
|
console.log('User created successfully:', { userId, email, session: data.session });
|
|
|
|
const result = await sendAuthOTP(userId, email);
|
|
|
|
console.log('OTP send result:', result);
|
|
|
|
if (result.success) {
|
|
setPendingUserId(userId);
|
|
setShowOTP(true);
|
|
setResendCountdown(60); // 60 seconds cooldown
|
|
toast({
|
|
title: 'OTP Terkirim',
|
|
description: 'Kode verifikasi telah dikirim ke email Anda. Silakan cek inbox.',
|
|
});
|
|
} else {
|
|
toast({ title: 'Error', description: result.message, variant: 'destructive' });
|
|
}
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleOTPSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!pendingUserId) {
|
|
toast({ title: 'Error', description: 'Session expired. Please try again.', variant: 'destructive' });
|
|
setShowOTP(false);
|
|
return;
|
|
}
|
|
|
|
if (otpCode.length !== 6) {
|
|
toast({ title: 'Error', description: 'OTP harus 6 digit', variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
const result = await verifyAuthOTP(pendingUserId, otpCode);
|
|
|
|
if (result.success) {
|
|
toast({
|
|
title: 'Verifikasi Berhasil',
|
|
description: 'Email Anda telah terverifikasi. Silakan login.',
|
|
});
|
|
setShowOTP(false);
|
|
setIsLogin(true);
|
|
// Reset form
|
|
setName('');
|
|
setOtpCode('');
|
|
setPendingUserId(null);
|
|
} else {
|
|
toast({ title: 'Error', description: result.message, variant: 'destructive' });
|
|
}
|
|
|
|
setLoading(false);
|
|
};
|
|
|
|
const handleResendOTP = async () => {
|
|
if (resendCountdown > 0 || !pendingUserId) return;
|
|
|
|
setLoading(true);
|
|
|
|
const result = await sendAuthOTP(pendingUserId, email);
|
|
|
|
if (result.success) {
|
|
setResendCountdown(60);
|
|
toast({ title: 'OTP Terkirim Ulang', description: 'Kode verifikasi baru telah dikirim ke email Anda.' });
|
|
} else {
|
|
toast({ title: 'Error', description: result.message, variant: 'destructive' });
|
|
}
|
|
|
|
setLoading(false);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
|
<div className="w-full max-w-md space-y-4">
|
|
{/* Back to Home Button */}
|
|
<Link to="/">
|
|
<Button variant="ghost" className="gap-2">
|
|
<ArrowLeft className="w-4 h-4" />
|
|
Kembali ke Beranda
|
|
</Button>
|
|
</Link>
|
|
|
|
{!showOTP ? (
|
|
<Card className="border-2 border-border shadow-md">
|
|
<CardHeader>
|
|
<CardTitle className="text-2xl">{isLogin ? 'Login' : 'Daftar'}</CardTitle>
|
|
<CardDescription>
|
|
{isLogin ? 'Masuk untuk mengakses akun Anda' : 'Buat akun baru untuk memulai'}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{!isLogin && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Nama</Label>
|
|
<Input
|
|
id="name"
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Nama lengkap"
|
|
className="border-2"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">Email</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="email@anda.com"
|
|
className="border-2"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">Password</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="••••••••"
|
|
className="border-2"
|
|
/>
|
|
</div>
|
|
<Button type="submit" className="w-full shadow-sm" disabled={loading}>
|
|
{loading ? 'Memuat...' : isLogin ? 'Masuk' : 'Daftar'}
|
|
</Button>
|
|
</form>
|
|
|
|
<div className="mt-4 text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsLogin(!isLogin)}
|
|
className="text-sm text-muted-foreground hover:underline"
|
|
>
|
|
{isLogin ? 'Belum punya akun? Daftar' : 'Sudah punya akun? Masuk'}
|
|
</button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card className="border-2 border-border shadow-md">
|
|
<CardHeader>
|
|
<CardTitle className="text-2xl flex items-center gap-2">
|
|
<Mail className="w-6 h-6" />
|
|
Verifikasi Email
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Masukkan kode 6 digit yang telah dikirim ke <strong>{email}</strong>
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleOTPSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="otp">Kode OTP</Label>
|
|
<Input
|
|
id="otp"
|
|
type="text"
|
|
value={otpCode}
|
|
onChange={(e) => {
|
|
// Only allow numbers, max 6 digits
|
|
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
|
setOtpCode(value);
|
|
}}
|
|
placeholder="123456"
|
|
className="border-2 text-center text-2xl tracking-widest font-mono"
|
|
maxLength={6}
|
|
autoFocus
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Masukkan 6 digit kode dari email Anda
|
|
</p>
|
|
</div>
|
|
|
|
<Button type="submit" className="w-full shadow-sm" disabled={loading || otpCode.length !== 6}>
|
|
{loading ? 'Memverifikasi...' : 'Verifikasi'}
|
|
</Button>
|
|
|
|
<div className="text-center space-y-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
Tidak menerima kode?
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="link"
|
|
onClick={handleResendOTP}
|
|
disabled={resendCountdown > 0 || loading}
|
|
className="text-sm"
|
|
>
|
|
{resendCountdown > 0
|
|
? `Kirim ulang dalam ${resendCountdown} detik`
|
|
: 'Kirim ulang kode'}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setShowOTP(false);
|
|
setOtpCode('');
|
|
setPendingUserId(null);
|
|
setResendCountdown(0);
|
|
}}
|
|
className="w-full text-sm"
|
|
>
|
|
Kembali ke form pendaftaran
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|