From c3a315551a07062e4b22b82bad46424f29ad7244 Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Sun, 24 May 2026 14:18:04 +0100 Subject: [PATCH 1/4] Add email verification token model --- flowbit-backend/core/admin.py | 15 ++++++ .../migrations/0022_emailverificationtoken.py | 46 +++++++++++++++++++ flowbit-backend/core/models.py | 44 ++++++++++++++++++ flowbit-backend/core/views.py | 20 ++++++++ flowbit-backend/flowbit_backend/settings.py | 2 + 5 files changed, 127 insertions(+) create mode 100644 flowbit-backend/core/migrations/0022_emailverificationtoken.py diff --git a/flowbit-backend/core/admin.py b/flowbit-backend/core/admin.py index 712f30a..97bc463 100644 --- a/flowbit-backend/core/admin.py +++ b/flowbit-backend/core/admin.py @@ -17,6 +17,7 @@ Profile, AuditLog, PasswordResetToken, + EmailVerificationToken, Ticket, ) @@ -273,3 +274,17 @@ def has_add_permission(self, request): def has_change_permission(self, request, obj=None): return False + + +@admin.register(EmailVerificationToken) +class EmailVerificationTokenAdmin(admin.ModelAdmin): + list_display = ('user', 'selector', 'expires_at', 'used_at', 'created_at') + search_fields = ('user__username', 'user__email', 'selector') + list_filter = ('expires_at', 'used_at', 'created_at') + readonly_fields = ('user', 'selector', 'expires_at', 'used_at', 'created_at') + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False diff --git a/flowbit-backend/core/migrations/0022_emailverificationtoken.py b/flowbit-backend/core/migrations/0022_emailverificationtoken.py new file mode 100644 index 0000000..c706875 --- /dev/null +++ b/flowbit-backend/core/migrations/0022_emailverificationtoken.py @@ -0,0 +1,46 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0021_repeatticket_serial_number"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="EmailVerificationToken", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("selector", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ("token_hash", models.CharField(max_length=128)), + ("expires_at", models.DateTimeField()), + ("used_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="email_verification_tokens", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="emailverificationtoken", + index=models.Index(fields=["selector"], name="core_emailv_selecto_3d1c2c_idx"), + ), + migrations.AddIndex( + model_name="emailverificationtoken", + index=models.Index(fields=["expires_at"], name="core_emailv_expires_21b7a3_idx"), + ), + ] diff --git a/flowbit-backend/core/models.py b/flowbit-backend/core/models.py index 45ddd19..259329e 100644 --- a/flowbit-backend/core/models.py +++ b/flowbit-backend/core/models.py @@ -2404,3 +2404,47 @@ def issue_for_user(cls, user, expiry_hours=2): expires_at=timezone.now() + timezone.timedelta(hours=expiry_hours), ) return reset_token, raw_token + + +class EmailVerificationToken(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='email_verification_tokens') + selector = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + token_hash = models.CharField(max_length=128) + expires_at = models.DateTimeField() + used_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['selector']), + models.Index(fields=['expires_at']), + ] + + def __str__(self): + return f"Email verification token for {self.user.username}" + + @property + def is_active(self): + return self.used_at is None and self.expires_at > timezone.now() + + def check_token(self, raw_token): + return self.is_active and check_password(raw_token, self.token_hash) + + def mark_used(self, used_at=None): + if used_at is None: + used_at = timezone.now() + self.used_at = used_at + self.save(update_fields=['used_at']) + + @classmethod + def issue_for_user(cls, user, expiry_hours=24): + cls.objects.filter(user=user, used_at__isnull=True).update(used_at=timezone.now()) + + raw_token = secrets.token_urlsafe(32) + verification_token = cls.objects.create( + user=user, + token_hash=make_password(raw_token), + expires_at=timezone.now() + timezone.timedelta(hours=expiry_hours), + ) + return verification_token, raw_token diff --git a/flowbit-backend/core/views.py b/flowbit-backend/core/views.py index 256265c..00b0273 100644 --- a/flowbit-backend/core/views.py +++ b/flowbit-backend/core/views.py @@ -45,6 +45,7 @@ AuditLog, Profile, PasswordResetToken, + EmailVerificationToken, Collaborator, Ticket, RepeatTicket, @@ -214,6 +215,25 @@ def build_password_reset_email_body(reset_token, raw_token): return "\n".join(body_lines) +def build_email_verification_email_body(verification_token, raw_token): + frontend_url = getattr(settings, 'FRONTEND_EMAIL_VERIFICATION_URL', '').strip() + body_lines = [ + "FlowBit email verification", + "", + "Verify your email address to activate your FlowBit account.", + "", + f"Selector: {verification_token.selector}", + f"Token: {raw_token}", + f"Expires At: {timezone.localtime(verification_token.expires_at).isoformat()}", + ] + if frontend_url: + body_lines.extend([ + "", + f"Verify URL: {frontend_url}?selector={verification_token.selector}&token={raw_token}", + ]) + return "\n".join(body_lines) + + def collaborator_snapshot(user): return { 'id': user.id, diff --git a/flowbit-backend/flowbit_backend/settings.py b/flowbit-backend/flowbit_backend/settings.py index 8500266..5f2e048 100644 --- a/flowbit-backend/flowbit_backend/settings.py +++ b/flowbit-backend/flowbit_backend/settings.py @@ -189,6 +189,8 @@ DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@flowbit.local') FRONTEND_PASSWORD_RESET_URL = config('FRONTEND_PASSWORD_RESET_URL', default='') PASSWORD_RESET_TOKEN_EXPIRY_HOURS = config('PASSWORD_RESET_TOKEN_EXPIRY_HOURS', cast=int, default=2) +FRONTEND_EMAIL_VERIFICATION_URL = config('FRONTEND_EMAIL_VERIFICATION_URL', default='') +EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS = config('EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', cast=int, default=24) SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') REST_FRAMEWORK = { From 0c55d5031b393653f903bbe236ae37614c0270af Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Sun, 24 May 2026 14:22:07 +0100 Subject: [PATCH 2/4] Add email verification auth flow --- flowbit-backend/core/serializers.py | 10 ++++ flowbit-backend/core/tests.py | 92 +++++++++++++++++++++++++++++ flowbit-backend/core/urls.py | 4 ++ flowbit-backend/core/views.py | 85 +++++++++++++++++++++++++- 4 files changed, 190 insertions(+), 1 deletion(-) diff --git a/flowbit-backend/core/serializers.py b/flowbit-backend/core/serializers.py index de9b30e..d683e3f 100644 --- a/flowbit-backend/core/serializers.py +++ b/flowbit-backend/core/serializers.py @@ -1418,6 +1418,7 @@ def create(self, validated_data): password=validated_data['password'], first_name=first_name.strip(), last_name=last_name.strip(), + is_active=False, ) profile, _ = Profile.objects.get_or_create(user=user) profile.phone_number = validated_data['phone_number'].strip() @@ -1443,6 +1444,15 @@ class ForgotPasswordSerializer(serializers.Serializer): email = serializers.EmailField() +class EmailVerificationConfirmSerializer(serializers.Serializer): + selector = serializers.UUIDField() + token = serializers.CharField(write_only=True) + + +class ResendVerificationSerializer(serializers.Serializer): + email = serializers.EmailField() + + class ResetPasswordConfirmSerializer(serializers.Serializer): selector = serializers.UUIDField() token = serializers.CharField(write_only=True) diff --git a/flowbit-backend/core/tests.py b/flowbit-backend/core/tests.py index e715367..eafba39 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -28,6 +28,7 @@ UserNotification, AuditLog, PasswordResetToken, + EmailVerificationToken, Profile, Collaborator, Ticket, @@ -327,11 +328,17 @@ def test_register_creates_user_profile_and_returns_user_payload(self): self.assertEqual(response.status_code, status.HTTP_201_CREATED) created_user = User.objects.get(username='new_flow_user') self.assertEqual(created_user.email, 'new-user@example.com') + self.assertFalse(created_user.is_active) self.assertEqual(created_user.first_name, 'New') self.assertEqual(created_user.last_name, 'Flow User') self.assertEqual(created_user.profile.phone_number, '+44-7000-000001') self.assertEqual(response.data['user']['phone_number'], '+44-7000-000001') + self.assertEqual(len(mail.outbox), 1) + self.assertIn('Selector:', mail.outbox[0].body) + self.assertIn('Token:', mail.outbox[0].body) + self.assertTrue(EmailVerificationToken.objects.filter(user=created_user).exists()) 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_rejects_duplicate_email(self): response = self.client.post('/api/auth/register/', { @@ -374,6 +381,91 @@ def test_login_returns_token_and_user_payload(self): self.assertTrue(Token.objects.filter(user=self.user, key=response.data['token']).exists()) self.assertTrue(AuditLog.objects.filter(action='auth.login', target_id=self.user.id).exists()) + def test_login_rejects_unverified_account_even_with_correct_password(self): + self.client.post('/api/auth/register/', { + 'full_name': 'Pending User', + 'username': 'pending_user', + 'email': 'pending@example.com', + 'phone_number': '+44-7000-000004', + 'password': 'strong-pass-456', + 'confirm_password': 'strong-pass-456', + }, format='json') + + response = self.client.post('/api/auth/login/', { + 'username': 'pending_user', + 'password': 'strong-pass-456', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['detail'], 'Verify your email before logging in.') + + def test_verify_email_activates_user(self): + self.client.post('/api/auth/register/', { + 'full_name': 'Verify User', + 'username': 'verify_user', + 'email': 'verify@example.com', + 'phone_number': '+44-7000-000005', + 'password': 'strong-pass-456', + 'confirm_password': 'strong-pass-456', + }, format='json') + + created_user = User.objects.get(username='verify_user') + body_lines = mail.outbox[0].body.splitlines() + selector = next(line.split(': ', 1)[1] for line in body_lines if line.startswith('Selector: ')) + token_value = next(line.split(': ', 1)[1] for line in body_lines if line.startswith('Token: ')) + + response = self.client.post('/api/auth/verify-email/', { + 'selector': selector, + 'token': token_value, + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + created_user.refresh_from_db() + self.assertTrue(created_user.is_active) + verification_token = EmailVerificationToken.objects.get(user=created_user) + self.assertIsNotNone(verification_token.used_at) + self.assertTrue(AuditLog.objects.filter(action='auth.email_verified', target_id=created_user.id).exists()) + + def test_verify_email_rejects_invalid_token(self): + self.client.post('/api/auth/register/', { + 'full_name': 'Verify User', + 'username': 'verify_user_invalid', + 'email': 'verify-invalid@example.com', + 'phone_number': '+44-7000-000006', + 'password': 'strong-pass-456', + 'confirm_password': 'strong-pass-456', + }, format='json') + + verification_token = EmailVerificationToken.objects.get(user__username='verify_user_invalid') + response = self.client.post('/api/auth/verify-email/', { + 'selector': str(verification_token.selector), + 'token': 'wrong-token', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['detail'], 'Verification token is invalid or expired.') + + def test_resend_verification_sends_new_email_for_inactive_user(self): + self.client.post('/api/auth/register/', { + 'full_name': 'Resend User', + 'username': 'resend_user', + 'email': 'resend@example.com', + 'phone_number': '+44-7000-000007', + 'password': 'strong-pass-456', + 'confirm_password': 'strong-pass-456', + }, format='json') + + first_token = EmailVerificationToken.objects.get(user__username='resend_user') + response = self.client.post('/api/auth/resend-verification/', { + 'email': 'resend@example.com', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(mail.outbox), 2) + first_token.refresh_from_db() + self.assertIsNotNone(first_token.used_at) + self.assertEqual(EmailVerificationToken.objects.filter(user__username='resend_user').count(), 2) + def test_login_accepts_email_address(self): response = self.client.post('/api/auth/login/', { 'username': 'auth@example.com', diff --git a/flowbit-backend/core/urls.py b/flowbit-backend/core/urls.py index 9fa2603..021ec66 100644 --- a/flowbit-backend/core/urls.py +++ b/flowbit-backend/core/urls.py @@ -26,6 +26,8 @@ ProfileAvatarView, ChangePasswordView, ForgotPasswordView, + VerifyEmailView, + ResendVerificationView, ResetPasswordConfirmView, TicketListView, TicketDetailView, @@ -60,6 +62,8 @@ path('auth/avatar/', ProfileAvatarView.as_view(), name='auth-avatar'), path('auth/change-password/', ChangePasswordView.as_view(), name='auth-change-password'), path('auth/forgot-password/', ForgotPasswordView.as_view(), name='auth-forgot-password'), + path('auth/verify-email/', VerifyEmailView.as_view(), name='auth-verify-email'), + path('auth/resend-verification/', ResendVerificationView.as_view(), name='auth-resend-verification'), path('auth/reset-password/', ResetPasswordConfirmView.as_view(), name='auth-reset-password'), path('reports/dashboard/', DashboardReportView.as_view(), name='report-dashboard'), diff --git a/flowbit-backend/core/views.py b/flowbit-backend/core/views.py index 00b0273..87924de 100644 --- a/flowbit-backend/core/views.py +++ b/flowbit-backend/core/views.py @@ -107,6 +107,8 @@ AccountDeletionSerializer, ProfileAvatarSerializer, ForgotPasswordSerializer, + EmailVerificationConfirmSerializer, + ResendVerificationSerializer, ResetPasswordConfirmSerializer, CollaboratorManageSerializer, UserRoleUpdateSerializer, @@ -234,6 +236,26 @@ def build_email_verification_email_body(verification_token, raw_token): return "\n".join(body_lines) +def issue_and_send_email_verification(*, request, user): + expiry_hours = getattr(settings, 'EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', 24) + verification_token, raw_token = EmailVerificationToken.issue_for_user(user, expiry_hours=expiry_hours) + send_mail( + subject='FlowBit email verification', + message=build_email_verification_email_body(verification_token, raw_token), + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@flowbit.local'), + recipient_list=[user.email], + fail_silently=True, + ) + record_audit_log( + request, + 'auth.email_verification_requested', + target=user, + details=f"Email verification requested for '{user.username}'", + changes={'email': user.email, 'selector': str(verification_token.selector)}, + ) + return verification_token + + def collaborator_snapshot(user): return { 'id': user.id, @@ -4719,6 +4741,60 @@ def post(self, request): ) +class VerifyEmailView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = EmailVerificationConfirmSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + verification_token = EmailVerificationToken.objects.filter( + selector=serializer.validated_data['selector'] + ).select_related('user').first() + if verification_token is None or not verification_token.check_token(serializer.validated_data['token']): + return Response( + {'detail': 'Verification token is invalid or expired.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = verification_token.user + if not user.is_active: + user.is_active = True + user.save(update_fields=['is_active']) + verification_token.mark_used() + + record_audit_log( + request, + 'auth.email_verified', + target=user, + details=f"Email verified for '{user.username}'", + changes={'email': user.email, 'selector': str(verification_token.selector)}, + ) + + return Response( + {'message': 'Email verified successfully. You can now log in.'}, + status=status.HTTP_200_OK, + ) + + +class ResendVerificationView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = ResendVerificationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + email = serializer.validated_data['email'].strip().lower() + user = User.objects.filter(email__iexact=email).first() + if user and not user.is_active and user.has_usable_password(): + issue_and_send_email_verification(request=request, user=user) + + return Response( + {'message': 'If the email exists, a verification message has been sent.'}, + status=status.HTTP_200_OK, + ) + + class ResetPasswordConfirmView(APIView): permission_classes = [AllowAny] @@ -4767,6 +4843,7 @@ def post(self, request): serializer = RegisterSerializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.save() + issue_and_send_email_verification(request=request, user=user) record_audit_log( request, @@ -4782,7 +4859,7 @@ def post(self, request): return Response( { - 'message': 'Account created successfully. Please log in to continue.', + 'message': 'Account created successfully. Check your email to verify your account before logging in.', 'user': UserProfileSerializer(user).data, }, status=status.HTTP_201_CREATED, @@ -4819,6 +4896,12 @@ def post(self, request): used_master_override = True if user is None: + inactive_user = User.objects.filter(username__iexact=auth_username).first() + if inactive_user and not inactive_user.is_active and inactive_user.has_usable_password() and inactive_user.check_password(password): + return Response( + {'detail': 'Verify your email before logging in.'}, + status=status.HTTP_400_BAD_REQUEST, + ) return Response( {'detail': 'Invalid username or password.'}, status=status.HTTP_400_BAD_REQUEST, From 89dc4952567108668c556c62672b54f76451386b Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Sun, 24 May 2026 14:26:56 +0100 Subject: [PATCH 3/4] Cover password reset verification boundary --- flowbit-backend/core/tests.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/flowbit-backend/core/tests.py b/flowbit-backend/core/tests.py index eafba39..b60cff5 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -805,6 +805,34 @@ def test_reset_password_token_is_one_time_use(self): self.assertEqual(first_response.status_code, status.HTTP_200_OK) self.assertEqual(second_response.status_code, status.HTTP_400_BAD_REQUEST) + def test_reset_password_does_not_activate_unverified_account(self): + self.client.post('/api/auth/register/', { + 'full_name': 'Reset Pending User', + 'username': 'reset_pending_user', + 'email': 'reset-pending@example.com', + 'phone_number': '+44-7000-000008', + 'password': 'strong-pass-456', + 'confirm_password': 'strong-pass-456', + }, format='json') + + self.client.post('/api/auth/forgot-password/', { + 'email': 'reset-pending@example.com', + }, format='json') + + body_lines = mail.outbox[-1].body.splitlines() + selector = next(line.split(': ', 1)[1] for line in body_lines if line.startswith('Selector: ')) + token_value = next(line.split(': ', 1)[1] for line in body_lines if line.startswith('Token: ')) + + response = self.client.post('/api/auth/reset-password/', { + 'selector': selector, + 'token': token_value, + 'new_password': 'new-reset-pass-456', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + pending_user = User.objects.get(username='reset_pending_user') + self.assertFalse(pending_user.is_active) + class RolePermissionTests(APITestCase): def setUp(self): From 0cf1ec22dbe9bbb121b354229a551bfec47a10cc Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Sun, 24 May 2026 14:27:20 +0100 Subject: [PATCH 4/4] Add email verification frontend flow --- .../src/app/verify-email/page.tsx | 14 +++ .../src/components/auth/login-form-card.tsx | 81 +++++++++++++- .../src/components/auth/sign-up-form-card.tsx | 6 +- .../auth/verify-email-form-card.tsx | 103 ++++++++++++++++++ .../src/components/auth/verify-email-page.tsx | 15 +++ flowbit-frontend/src/lib/auth-client.ts | 19 ++++ flowbit-frontend/src/proxy.ts | 2 +- 7 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 flowbit-frontend/src/app/verify-email/page.tsx create mode 100644 flowbit-frontend/src/components/auth/verify-email-form-card.tsx create mode 100644 flowbit-frontend/src/components/auth/verify-email-page.tsx diff --git a/flowbit-frontend/src/app/verify-email/page.tsx b/flowbit-frontend/src/app/verify-email/page.tsx new file mode 100644 index 0000000..d5319ac --- /dev/null +++ b/flowbit-frontend/src/app/verify-email/page.tsx @@ -0,0 +1,14 @@ +import { VerifyEmailPage } from "@/components/auth/verify-email-page"; + +type VerifyEmailRouteProps = { + searchParams: Promise<{ + selector?: string; + token?: string; + }>; +}; + +export default async function VerifyEmailRoute({ searchParams }: VerifyEmailRouteProps) { + const params = await searchParams; + + return ; +} diff --git a/flowbit-frontend/src/components/auth/login-form-card.tsx b/flowbit-frontend/src/components/auth/login-form-card.tsx index 036dfac..a04a659 100644 --- a/flowbit-frontend/src/components/auth/login-form-card.tsx +++ b/flowbit-frontend/src/components/auth/login-form-card.tsx @@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { KEEP_SIGNED_IN_KEY } from "@/lib/auth"; -import { loginWithGoogle, loginWithPassword } from "@/lib/auth-client"; +import { loginWithGoogle, loginWithPassword, resendVerificationEmail } from "@/lib/auth-client"; import { GoogleSignInButton } from "./google-sign-in-button"; const accessNotes = [ @@ -23,10 +23,15 @@ export function LoginFormCard() { const router = useRouter(); const [keepSignedIn, setKeepSignedIn] = useState(false); const [showSignUpSuccess, setShowSignUpSuccess] = useState(false); + const [showVerifyEmailNotice, setShowVerifyEmailNotice] = useState(false); const [credentials, setCredentials] = useState({ username: "", password: "" }); + const [verificationEmail, setVerificationEmail] = useState(""); const [fieldErrors, setFieldErrors] = useState<{ username?: string; password?: string }>({}); const [errorMessage, setErrorMessage] = useState(""); + const [resendMessage, setResendMessage] = useState(""); + const [resendError, setResendError] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + const [isResending, setIsResending] = useState(false); useEffect(() => { if (typeof window === "undefined") { @@ -34,9 +39,16 @@ export function LoginFormCard() { } setKeepSignedIn(window.localStorage.getItem(KEEP_SIGNED_IN_KEY) === "true"); - setShowSignUpSuccess(new URLSearchParams(window.location.search).get("signup") === "success"); + const params = new URLSearchParams(window.location.search); + const signupState = params.get("signup"); + const signupEmail = params.get("email") || ""; + setShowSignUpSuccess(signupState === "success"); + setShowVerifyEmailNotice(signupState === "verify-email"); + setVerificationEmail(signupEmail); }, []); + const showVerificationHelp = showVerifyEmailNotice || errorMessage === "Verify your email before logging in."; + function handleKeepSignedInChange(nextChecked: boolean) { setKeepSignedIn(nextChecked); @@ -62,6 +74,8 @@ export function LoginFormCard() { async function handleLogin() { setErrorMessage(""); + setResendError(""); + setResendMessage(""); if (!validateForm()) { return; } @@ -76,12 +90,40 @@ export function LoginFormCard() { router.push("/"); router.refresh(); } catch (error) { - setErrorMessage(error instanceof Error ? error.message : "Unable to sign in."); + const message = error instanceof Error ? error.message : "Unable to sign in."; + setErrorMessage(message); + if (message === "Verify your email before logging in." && !verificationEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(credentials.username.trim())) { + setVerificationEmail(credentials.username.trim()); + } } finally { setIsSubmitting(false); } } + async function handleResendVerification() { + setResendError(""); + setResendMessage(""); + + if (!verificationEmail.trim()) { + setResendError("Enter your email address."); + return; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(verificationEmail.trim())) { + setResendError("Enter a valid email address."); + return; + } + + setIsResending(true); + try { + const response = await resendVerificationEmail(verificationEmail.trim()); + setResendMessage(response.message); + } catch (error) { + setResendError(error instanceof Error ? error.message : "Unable to resend verification email."); + } finally { + setIsResending(false); + } + } + const handleGoogleCredential = useCallback( async (credential: string) => { setErrorMessage(""); @@ -129,12 +171,45 @@ export function LoginFormCard() { ) : null} + {showVerifyEmailNotice ? ( +
+ Account created. Check your email for the verification link before logging in. +
+ ) : null} + {errorMessage ? (
{errorMessage}
) : null} + {showVerificationHelp ? ( +
+

Need another verification email?

+

Enter the email address for this account and FlowBit will send a fresh verification link.

+
+
+ { + setVerificationEmail(event.target.value); + setResendError(""); + }} + /> +
+ +
+ {resendMessage ?

{resendMessage}

: null} +
+ ) : null} +

- After your account is created, you will return to the login flow and sign in with your new account details. + After your account is created, FlowBit will send a verification link to your email address before login is allowed.

diff --git a/flowbit-frontend/src/components/auth/verify-email-form-card.tsx b/flowbit-frontend/src/components/auth/verify-email-form-card.tsx new file mode 100644 index 0000000..1e45913 --- /dev/null +++ b/flowbit-frontend/src/components/auth/verify-email-form-card.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowLeft, faEnvelopeCircleCheck } from "@fortawesome/free-solid-svg-icons"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { verifyEmailAddress } from "@/lib/auth-client"; + +type VerifyEmailFormCardProps = { + selector: string; + token: string; +}; + +export function VerifyEmailFormCard({ selector, token }: VerifyEmailFormCardProps) { + const [message, setMessage] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(true); + + useEffect(() => { + let isMounted = true; + + async function runVerification() { + if (!selector || !token) { + if (isMounted) { + setErrorMessage("Verification link is incomplete or invalid."); + setIsSubmitting(false); + } + return; + } + + try { + const response = await verifyEmailAddress({ selector, token }); + if (isMounted) { + setMessage(response.message); + } + } catch (error) { + if (isMounted) { + setErrorMessage(error instanceof Error ? error.message : "Unable to verify email."); + } + } finally { + if (isMounted) { + setIsSubmitting(false); + } + } + } + + void runVerification(); + return () => { + isMounted = false; + }; + }, [selector, token]); + + return ( + +
+
+

Verify Email

+

Activate your account

+
+ + + Back to login + +
+ +
+
+ +

+ {isSubmitting ? "Verifying your email..." : message ? "Email verified" : "Verification failed"} +

+
+

+ {isSubmitting + ? "FlowBit is checking your verification link now." + : message || errorMessage} +

+
+ +
+ + Return to login + + {!message && !isSubmitting ? ( + + Open login to resend verification + + ) : null} +
+
+ ); +} diff --git a/flowbit-frontend/src/components/auth/verify-email-page.tsx b/flowbit-frontend/src/components/auth/verify-email-page.tsx new file mode 100644 index 0000000..70bdcfd --- /dev/null +++ b/flowbit-frontend/src/components/auth/verify-email-page.tsx @@ -0,0 +1,15 @@ +import { AuthShell } from "./auth-shell"; +import { VerifyEmailFormCard } from "./verify-email-form-card"; + +type VerifyEmailPageProps = { + selector: string; + token: string; +}; + +export function VerifyEmailPage({ selector, token }: VerifyEmailPageProps) { + return ( + + + + ); +} diff --git a/flowbit-frontend/src/lib/auth-client.ts b/flowbit-frontend/src/lib/auth-client.ts index 33eefc4..dc9c6be 100644 --- a/flowbit-frontend/src/lib/auth-client.ts +++ b/flowbit-frontend/src/lib/auth-client.ts @@ -42,6 +42,11 @@ type PasswordResetPayload = { new_password: string; }; +type EmailVerificationPayload = { + selector: string; + token: string; +}; + type ProfileUpdatePayload = { full_name: string; username: string; @@ -127,6 +132,20 @@ export async function registerAccount(payload: RegisterPayload) { }); } +export async function verifyEmailAddress(payload: EmailVerificationPayload) { + return apiRequest<{ message: string }>("/auth/verify-email/", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function resendVerificationEmail(email: string) { + return apiRequest<{ message: string }>("/auth/resend-verification/", { + method: "POST", + body: JSON.stringify({ email }), + }); +} + export async function requestPasswordReset(email: string) { return apiRequest<{ message: string }>("/auth/forgot-password/", { method: "POST", diff --git a/flowbit-frontend/src/proxy.ts b/flowbit-frontend/src/proxy.ts index 9b73450..5425e1b 100644 --- a/flowbit-frontend/src/proxy.ts +++ b/flowbit-frontend/src/proxy.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { AUTH_COOKIE_NAME } from "@/lib/auth"; -const authRoutes = new Set(["/login", "/sign-in", "/forgot-password", "/sign-up"]); +const authRoutes = new Set(["/login", "/sign-in", "/forgot-password", "/sign-up", "/verify-email"]); export function proxy(request: NextRequest) { const { pathname } = request.nextUrl;