feat(auth): add real-time username/email availability validation
Backend changes: - Added checkAvailability endpoint (GET /api/auth/check-availability) - Checks username and email availability in database - Returns availability status for both fields Frontend changes: - Added real-time validation for username (3+ characters) and email - Debounced API calls (500ms) to avoid excessive requests - Visual feedback with loading spinner, success checkmark, and error icons - Improved UX by showing availability before form submission This prevents users from submitting forms with already-taken credentials and provides immediate feedback during registration.
This commit is contained in:
@@ -660,6 +660,50 @@ async function resetPassword(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check username/email availability (for real-time validation)
|
||||||
|
async function checkAvailability(req, res, next) {
|
||||||
|
try {
|
||||||
|
const { username, email } = req.query;
|
||||||
|
|
||||||
|
if (!username && !email) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Username or email is required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
usernameAvailable: true,
|
||||||
|
emailAvailable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check username availability
|
||||||
|
if (username) {
|
||||||
|
const existingUsername = await prisma.user.findUnique({
|
||||||
|
where: { username },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
result.usernameAvailable = !existingUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check email availability
|
||||||
|
if (email) {
|
||||||
|
const existingEmail = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
result.emailAvailable = !existingEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
register,
|
register,
|
||||||
login,
|
login,
|
||||||
@@ -668,4 +712,5 @@ module.exports = {
|
|||||||
resendVerification,
|
resendVerification,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
|
checkAvailability,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ const {
|
|||||||
verifyEmailByCode,
|
verifyEmailByCode,
|
||||||
resendVerification,
|
resendVerification,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
resetPassword
|
resetPassword,
|
||||||
|
checkAvailability
|
||||||
} = require('../controllers/auth');
|
} = require('../controllers/auth');
|
||||||
const {
|
const {
|
||||||
registerValidation,
|
registerValidation,
|
||||||
@@ -39,4 +40,7 @@ router.post('/request-password-reset', emailLimiter, requestPasswordReset);
|
|||||||
// POST /api/auth/reset-password - Reset password with token
|
// POST /api/auth/reset-password - Reset password with token
|
||||||
router.post('/reset-password', passwordResetValidation, resetPassword);
|
router.post('/reset-password', passwordResetValidation, resetPassword);
|
||||||
|
|
||||||
|
// GET /api/auth/check-availability?username=xxx&email=xxx - Check username/email availability
|
||||||
|
router.get('/check-availability', checkAvailability);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { wsdcAPI } from '../services/api';
|
import { wsdcAPI, authAPI } from '../services/api';
|
||||||
import { Video, Mail, Lock, User, Hash, ArrowRight, ArrowLeft, Loader2, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
import { Video, Mail, Lock, User, Hash, ArrowRight, ArrowLeft, Loader2, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||||
import PasswordStrengthIndicator from '../components/common/PasswordStrengthIndicator';
|
import PasswordStrengthIndicator from '../components/common/PasswordStrengthIndicator';
|
||||||
import FormInput from '../components/common/FormInput';
|
import FormInput from '../components/common/FormInput';
|
||||||
@@ -36,6 +36,12 @@ const RegisterPage = () => {
|
|||||||
const [turnstileToken, setTurnstileToken] = useState('');
|
const [turnstileToken, setTurnstileToken] = useState('');
|
||||||
const turnstileRef = useRef(null);
|
const turnstileRef = useRef(null);
|
||||||
|
|
||||||
|
// Availability validation state
|
||||||
|
const [usernameAvailable, setUsernameAvailable] = useState(null);
|
||||||
|
const [emailAvailable, setEmailAvailable] = useState(null);
|
||||||
|
const [checkingUsername, setCheckingUsername] = useState(false);
|
||||||
|
const [checkingEmail, setCheckingEmail] = useState(false);
|
||||||
|
|
||||||
const { register } = useAuth();
|
const { register } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -133,6 +139,50 @@ const RegisterPage = () => {
|
|||||||
};
|
};
|
||||||
}, [step]);
|
}, [step]);
|
||||||
|
|
||||||
|
// Check username availability
|
||||||
|
useEffect(() => {
|
||||||
|
const checkUsername = async () => {
|
||||||
|
if (formData.username.length >= 3) {
|
||||||
|
setCheckingUsername(true);
|
||||||
|
try {
|
||||||
|
const result = await authAPI.checkAvailability(formData.username, null);
|
||||||
|
setUsernameAvailable(result.usernameAvailable);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking username:', error);
|
||||||
|
} finally {
|
||||||
|
setCheckingUsername(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUsernameAvailable(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(checkUsername, 500); // Debounce 500ms
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [formData.username]);
|
||||||
|
|
||||||
|
// Check email availability
|
||||||
|
useEffect(() => {
|
||||||
|
const checkEmail = async () => {
|
||||||
|
if (formData.email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||||
|
setCheckingEmail(true);
|
||||||
|
try {
|
||||||
|
const result = await authAPI.checkAvailability(null, formData.email);
|
||||||
|
setEmailAvailable(result.emailAvailable);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking email:', error);
|
||||||
|
} finally {
|
||||||
|
setCheckingEmail(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setEmailAvailable(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(checkEmail, 500); // Debounce 500ms
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [formData.email]);
|
||||||
|
|
||||||
// Handle WSDC ID confirmation and continue to registration
|
// Handle WSDC ID confirmation and continue to registration
|
||||||
const handleWsdcContinue = () => {
|
const handleWsdcContinue = () => {
|
||||||
if (!wsdcPreview) {
|
if (!wsdcPreview) {
|
||||||
@@ -414,27 +464,71 @@ const RegisterPage = () => {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormInput
|
<div>
|
||||||
label="Username"
|
<FormInput
|
||||||
name="username"
|
label="Username"
|
||||||
type="text"
|
name="username"
|
||||||
value={formData.username}
|
type="text"
|
||||||
onChange={handleInputChange}
|
value={formData.username}
|
||||||
icon={User}
|
onChange={handleInputChange}
|
||||||
placeholder="john_doe"
|
icon={User}
|
||||||
required
|
placeholder="john_doe"
|
||||||
/>
|
required
|
||||||
|
/>
|
||||||
|
{formData.username.length >= 3 && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-sm">
|
||||||
|
{checkingUsername ? (
|
||||||
|
<span className="text-gray-500 flex items-center gap-1">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Checking availability...
|
||||||
|
</span>
|
||||||
|
) : usernameAvailable === false ? (
|
||||||
|
<span className="text-red-600 flex items-center gap-1">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
Username is already taken
|
||||||
|
</span>
|
||||||
|
) : usernameAvailable === true ? (
|
||||||
|
<span className="text-green-600 flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Username is available
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormInput
|
<div>
|
||||||
label="Email"
|
<FormInput
|
||||||
name="email"
|
label="Email"
|
||||||
type="email"
|
name="email"
|
||||||
value={formData.email}
|
type="email"
|
||||||
onChange={handleInputChange}
|
value={formData.email}
|
||||||
icon={Mail}
|
onChange={handleInputChange}
|
||||||
placeholder="john@example.com"
|
icon={Mail}
|
||||||
required
|
placeholder="john@example.com"
|
||||||
/>
|
required
|
||||||
|
/>
|
||||||
|
{formData.email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-sm">
|
||||||
|
{checkingEmail ? (
|
||||||
|
<span className="text-gray-500 flex items-center gap-1">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Checking availability...
|
||||||
|
</span>
|
||||||
|
) : emailAvailable === false ? (
|
||||||
|
<span className="text-red-600 flex items-center gap-1">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
Email is already registered
|
||||||
|
</span>
|
||||||
|
) : emailAvailable === true ? (
|
||||||
|
<span className="text-green-600 flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Email is available
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FormInput
|
<FormInput
|
||||||
|
|||||||
@@ -182,6 +182,15 @@ export const authAPI = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async checkAvailability(username = null, email = null) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (username) params.append('username', username);
|
||||||
|
if (email) params.append('email', email);
|
||||||
|
|
||||||
|
const data = await fetchAPI(`/auth/check-availability?${params.toString()}`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
async updateProfile(profileData) {
|
async updateProfile(profileData) {
|
||||||
const data = await fetchAPI('/users/me', {
|
const data = await fetchAPI('/users/me', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
Reference in New Issue
Block a user