diff --git a/flowbit-backend/core/serializers.py b/flowbit-backend/core/serializers.py index 5410cac..2f79f14 100644 --- a/flowbit-backend/core/serializers.py +++ b/flowbit-backend/core/serializers.py @@ -1473,7 +1473,7 @@ class RegisterSerializer(serializers.Serializer): full_name = serializers.CharField(max_length=150) username = serializers.CharField(max_length=150) email = serializers.EmailField() - phone_number = serializers.CharField(max_length=50) + phone_number = serializers.CharField(max_length=50, allow_blank=True, required=False) password = serializers.CharField(write_only=True) confirm_password = serializers.CharField(write_only=True) @@ -1508,7 +1508,7 @@ def create(self, validated_data): is_active=False, ) profile, _ = Profile.objects.get_or_create(user=user) - profile.phone_number = validated_data['phone_number'].strip() + profile.phone_number = (validated_data.get('phone_number') or '').strip() profile.save(update_fields=['phone_number', 'updated_at']) user.refresh_from_db() return user diff --git a/flowbit-backend/core/tests.py b/flowbit-backend/core/tests.py index f223be8..b8956e9 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -344,6 +344,34 @@ def test_register_creates_user_profile_and_returns_user_payload(self): self.assertTrue(AuditLog.objects.filter(action='auth.register', target_id=created_user.id).exists()) self.assertTrue(AuditLog.objects.filter(action='auth.email_verification_requested', target_id=created_user.id).exists()) + def test_register_allows_missing_phone_number(self): + response = self.client.post('/api/auth/register/', { + 'full_name': 'No Phone User', + 'username': 'no_phone_user', + 'email': 'no-phone@example.com', + 'password': 'strong-pass-456', + 'confirm_password': 'strong-pass-456', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + created_user = User.objects.get(username='no_phone_user') + self.assertEqual(created_user.profile.phone_number, '') + self.assertEqual(response.data['user']['phone_number'], '') + + def test_username_availability_warns_when_username_is_taken(self): + response = self.client.get('/api/auth/username-availability/', {'username': 'AUTH_USER'}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.data['available']) + self.assertEqual(response.data['message'], 'This username is already taken.') + + def test_username_availability_allows_unused_username(self): + response = self.client.get('/api/auth/username-availability/', {'username': 'fresh_user'}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['available']) + self.assertEqual(response.data['message'], 'Username is available.') + def test_register_rejects_duplicate_email(self): response = self.client.post('/api/auth/register/', { 'full_name': 'Another User', diff --git a/flowbit-backend/core/urls.py b/flowbit-backend/core/urls.py index 8432206..c42407b 100644 --- a/flowbit-backend/core/urls.py +++ b/flowbit-backend/core/urls.py @@ -20,6 +20,7 @@ IdentifierCapacityReportView, LoginView, RegisterView, + UsernameAvailabilityView, GoogleLoginView, LogoutView, MeView, @@ -60,6 +61,7 @@ path('auth/login/', LoginView.as_view(), name='auth-login'), path('auth/register/', RegisterView.as_view(), name='auth-register'), + path('auth/username-availability/', UsernameAvailabilityView.as_view(), name='auth-username-availability'), path('auth/google/', GoogleLoginView.as_view(), name='auth-google-login'), path('auth/logout/', LogoutView.as_view(), name='auth-logout'), path('auth/me/', MeView.as_view(), name='auth-me'), diff --git a/flowbit-backend/core/views.py b/flowbit-backend/core/views.py index 3c44ace..a74aa87 100644 --- a/flowbit-backend/core/views.py +++ b/flowbit-backend/core/views.py @@ -5238,6 +5238,27 @@ def post(self, request): ) +class UsernameAvailabilityView(APIView): + permission_classes = [AllowAny] + + def get(self, request): + username = (request.query_params.get('username') or '').strip() + if not username: + return Response( + {'available': False, 'message': 'Choose a username.'}, + status=status.HTTP_200_OK, + ) + + is_available = not User.objects.filter(username__iexact=username).exists() + return Response( + { + 'available': is_available, + 'message': 'Username is available.' if is_available else 'This username is already taken.', + }, + status=status.HTTP_200_OK, + ) + + class LoginView(APIView): permission_classes = [AllowAny] diff --git a/flowbit-frontend/src/components/auth/login-help-form-card.tsx b/flowbit-frontend/src/components/auth/login-help-form-card.tsx index d29ad81..c927959 100644 --- a/flowbit-frontend/src/components/auth/login-help-form-card.tsx +++ b/flowbit-frontend/src/components/auth/login-help-form-card.tsx @@ -129,19 +129,21 @@ export function LoginHelpFormCard() { setFieldErrors((current) => ({ ...current, requester_name: undefined })); }} /> - { - setFormValues((current) => ({ ...current, requester_email: event.target.value })); - setFieldErrors((current) => ({ ...current, requester_email: undefined })); - }} - /> +
+ { + setFormValues((current) => ({ ...current, requester_email: event.target.value })); + setFieldErrors((current) => ({ ...current, requester_email: undefined })); + }} + /> +
diff --git a/flowbit-frontend/src/components/auth/sign-up-form-card.tsx b/flowbit-frontend/src/components/auth/sign-up-form-card.tsx index 264acd6..5c8b6c1 100644 --- a/flowbit-frontend/src/components/auth/sign-up-form-card.tsx +++ b/flowbit-frontend/src/components/auth/sign-up-form-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -8,7 +8,7 @@ import { faArrowLeft, faUserPlus } from "@fortawesome/free-solid-svg-icons"; import { AuthInput } from "./auth-input"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { registerAccount } from "@/lib/auth-client"; +import { checkUsernameAvailability, registerAccount } from "@/lib/auth-client"; const signUpNotes = [ "Fill in your account details carefully before submitting.", @@ -28,8 +28,42 @@ export function SignUpFormCard() { }); const [fieldErrors, setFieldErrors] = useState>>({}); const [errorMessage, setErrorMessage] = useState(""); + const [usernameHint, setUsernameHint] = useState(""); + const [isUsernameAvailable, setIsUsernameAvailable] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + useEffect(() => { + const username = formValues.username.trim(); + setIsUsernameAvailable(null); + + if (!username) { + setUsernameHint(""); + return; + } + + const timeoutId = window.setTimeout(() => { + checkUsernameAvailability(username) + .then((result) => { + if (formValues.username.trim() !== username) { + return; + } + setIsUsernameAvailable(result.available); + setUsernameHint(result.message); + setFieldErrors((current) => ({ + ...current, + username: result.available ? undefined : result.message, + })); + }) + .catch(() => { + if (formValues.username.trim() === username) { + setUsernameHint("Username availability could not be checked right now."); + } + }); + }, 450); + + return () => window.clearTimeout(timeoutId); + }, [formValues.username]); + function validateForm() { const nextErrors: Partial> = {}; @@ -38,15 +72,14 @@ export function SignUpFormCard() { } if (!formValues.username.trim()) { nextErrors.username = "Choose a username."; + } else if (isUsernameAvailable === false) { + nextErrors.username = usernameHint || "This username is already taken."; } if (!formValues.email.trim()) { nextErrors.email = "Enter your email address."; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formValues.email)) { nextErrors.email = "Enter a valid email address."; } - if (!formValues.phone_number.trim()) { - nextErrors.phone_number = "Enter your phone number."; - } if (!formValues.password) { nextErrors.password = "Create a password."; } else if (formValues.password.length < 8) { @@ -119,10 +152,12 @@ export function SignUpFormCard() { type="text" placeholder="Choose a username" error={fieldErrors.username} + hint={isUsernameAvailable ? usernameHint : undefined} value={formValues.username} onChange={(event) => { setFormValues((current) => ({ ...current, username: event.target.value })); setFieldErrors((current) => ({ ...current, username: undefined })); + setUsernameHint(""); }} />
{isAdmin && selectedCase.intake_type === "LOGIN_HELP" ? ( -