diff --git a/flowbit-backend/core/tests.py b/flowbit-backend/core/tests.py index 2bc44f3..723be71 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -367,6 +367,27 @@ def test_register_rejects_password_mismatch(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('confirm_password', response.data) + @patch('core.views.send_mail', side_effect=Exception('smtp down')) + def test_register_returns_operational_error_when_verification_email_fails(self, mock_send_mail): + response = self.client.post('/api/auth/register/', { + 'full_name': 'Delivery Failure User', + 'username': 'delivery_failure_user', + 'email': 'delivery-failure@example.com', + 'phone_number': '+44-7000-000009', + 'password': 'strong-pass-456', + 'confirm_password': 'strong-pass-456', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + self.assertEqual( + response.data['detail'], + 'Account created, but we could not send the verification email right now. Please try resending verification shortly.', + ) + created_user = User.objects.get(username='delivery_failure_user') + self.assertFalse(created_user.is_active) + self.assertTrue(AuditLog.objects.filter(action='auth.register', target_id=created_user.id).exists()) + self.assertTrue(AuditLog.objects.filter(action='auth.email_delivery_failed', target_id=created_user.id).exists()) + def test_login_returns_token_and_user_payload(self): response = self.client.post('/api/auth/login/', { 'username': 'auth_user', @@ -446,6 +467,7 @@ def test_verify_email_rejects_invalid_token(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.data['detail'], 'Verification token is invalid or expired.') + @override_settings(EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=0) def test_resend_verification_sends_new_email_for_inactive_user(self): self.client.post('/api/auth/register/', { 'full_name': 'Resend User', @@ -467,6 +489,61 @@ def test_resend_verification_sends_new_email_for_inactive_user(self): self.assertIsNotNone(first_token.used_at) self.assertEqual(EmailVerificationToken.objects.filter(user__username='resend_user').count(), 2) + def test_resend_verification_returns_generic_message_for_unknown_email(self): + response = self.client.post('/api/auth/resend-verification/', { + 'email': 'missing-verify@example.com', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['message'], 'If the email exists, a verification message has been sent.') + self.assertEqual(len(mail.outbox), 0) + + @override_settings(EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=60) + def test_resend_verification_is_rate_limited_when_requested_too_soon(self): + self.client.post('/api/auth/register/', { + 'full_name': 'Resend Limited User', + 'username': 'resend_limited_user', + 'email': 'resend-limited@example.com', + 'phone_number': '+44-7000-000010', + 'password': 'strong-pass-456', + 'confirm_password': 'strong-pass-456', + }, format='json') + + response = self.client.post('/api/auth/resend-verification/', { + 'email': 'resend-limited@example.com', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + self.assertIn('Please wait', response.data['detail']) + self.assertTrue( + AuditLog.objects.filter(action='auth.email_verification_resend_rate_limited').exists() + ) + + @patch('core.views.send_mail', side_effect=Exception('smtp down')) + @override_settings(EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=0) + def test_resend_verification_returns_operational_error_when_email_fails(self, mock_send_mail): + self.client.post('/api/auth/register/', { + 'full_name': 'Resend Failure User', + 'username': 'resend_failure_user', + 'email': 'resend-failure@example.com', + 'phone_number': '+44-7000-000011', + 'password': 'strong-pass-456', + 'confirm_password': 'strong-pass-456', + }, format='json') + + response = self.client.post('/api/auth/resend-verification/', { + 'email': 'resend-failure@example.com', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + self.assertEqual( + response.data['detail'], + 'We could not send the verification email right now. Please try again shortly.', + ) + self.assertTrue( + AuditLog.objects.filter(action='auth.email_delivery_failed', target_id=User.objects.get(username='resend_failure_user').id).exists() + ) + def test_login_accepts_email_address(self): response = self.client.post('/api/auth/login/', { 'username': 'auth@example.com', @@ -783,6 +860,24 @@ def test_forgot_password_returns_generic_message_for_unknown_email(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(mail.outbox), 0) + @patch('core.views.send_mail', side_effect=Exception('smtp down')) + def test_forgot_password_returns_operational_error_when_email_fails(self, mock_send_mail): + response = self.client.post('/api/auth/forgot-password/', { + 'email': 'auth@example.com', + }, format='json') + + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + self.assertEqual( + response.data['detail'], + 'We could not send the password reset email right now. Please try again shortly.', + ) + self.assertTrue( + AuditLog.objects.filter(action='auth.email_delivery_failed', target_id=self.user.id).exists() + ) + self.assertFalse( + AuditLog.objects.filter(action='auth.password_reset_requested', target_id=self.user.id).exists() + ) + def test_reset_password_completes_with_valid_token(self): self.client.post('/api/auth/forgot-password/', { 'email': 'auth@example.com', diff --git a/flowbit-backend/core/views.py b/flowbit-backend/core/views.py index afd059f..173e90b 100644 --- a/flowbit-backend/core/views.py +++ b/flowbit-backend/core/views.py @@ -1,4 +1,11 @@ # All imports organized in ONE place at the top +import csv +import logging +from datetime import datetime, time +from decimal import Decimal, InvalidOperation +from io import BytesIO +from itertools import permutations + from rest_framework import viewsets, generics, status, mixins from rest_framework.permissions import AllowAny from rest_framework.exceptions import ValidationError as DRFValidationError @@ -19,11 +26,6 @@ from django.db.models.functions import Coalesce, Greatest from django.core.exceptions import ValidationError from rest_framework.authtoken.models import Token -from decimal import Decimal, InvalidOperation -from datetime import datetime, time -import csv -from io import BytesIO -from itertools import permutations from reportlab.lib.pagesizes import letter from reportlab.lib import colors from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle @@ -117,6 +119,8 @@ TicketReceiptPdfSerializer, ) +logger = logging.getLogger(__name__) + def parse_period_value(value): if not value: @@ -236,15 +240,40 @@ def build_email_verification_email_body(verification_token, raw_token): return "\n".join(body_lines) +class AuthEmailDeliveryError(Exception): + pass + + +def send_auth_email(*, request, user, subject, message, audit_action): + try: + send_mail( + subject=subject, + message=message, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@flowbit.local'), + recipient_list=[user.email], + fail_silently=False, + ) + except Exception as exc: + logger.exception("Failed to send auth email '%s' to %s", audit_action, user.email) + record_audit_log( + request, + 'auth.email_delivery_failed', + target=user, + details=f"Auth email delivery failed for '{user.username}'", + changes={'email': user.email, 'category': audit_action, 'error': str(exc)}, + ) + raise AuthEmailDeliveryError from exc + + 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( + send_auth_email( + request=request, + user=user, 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, + audit_action='email_verification', ) record_audit_log( request, @@ -256,6 +285,19 @@ def issue_and_send_email_verification(*, request, user): return verification_token +def get_email_verification_resend_wait_seconds(user): + cooldown_seconds = max(getattr(settings, 'EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS', 60), 0) + if cooldown_seconds == 0: + return 0 + + latest_token = EmailVerificationToken.objects.filter(user=user).order_by('-created_at').first() + if latest_token is None: + return 0 + + elapsed_seconds = (timezone.now() - latest_token.created_at).total_seconds() + return max(int(cooldown_seconds - elapsed_seconds), 0) + + def collaborator_snapshot(user): return { 'id': user.id, @@ -4720,13 +4762,19 @@ def post(self, request): if user and user.has_usable_password(): expiry_hours = getattr(settings, 'PASSWORD_RESET_TOKEN_EXPIRY_HOURS', 2) reset_token, raw_token = PasswordResetToken.issue_for_user(user, expiry_hours=expiry_hours) - send_mail( - subject='FlowBit password reset', - message=build_password_reset_email_body(reset_token, raw_token), - from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@flowbit.local'), - recipient_list=[user.email], - fail_silently=True, - ) + try: + send_auth_email( + request=request, + user=user, + subject='FlowBit password reset', + message=build_password_reset_email_body(reset_token, raw_token), + audit_action='password_reset', + ) + except AuthEmailDeliveryError: + return Response( + {'detail': 'We could not send the password reset email right now. Please try again shortly.'}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) record_audit_log( request, 'auth.password_reset_requested', @@ -4787,7 +4835,27 @@ def post(self, request): 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) + wait_seconds = get_email_verification_resend_wait_seconds(user) + if wait_seconds > 0: + record_audit_log( + request, + 'auth.email_verification_resend_rate_limited', + target=user, + details=f"Verification resend blocked for '{user.username}'", + changes={'email': user.email, 'wait_seconds': wait_seconds}, + ) + return Response( + {'detail': f'Please wait {wait_seconds} seconds before requesting another verification email.'}, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + try: + issue_and_send_email_verification(request=request, user=user) + except AuthEmailDeliveryError: + return Response( + {'detail': 'We could not send the verification email right now. Please try again shortly.'}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) return Response( {'message': 'If the email exists, a verification message has been sent.'}, @@ -4843,7 +4911,6 @@ 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, @@ -4857,6 +4924,17 @@ def post(self, request): }, ) + try: + issue_and_send_email_verification(request=request, user=user) + except AuthEmailDeliveryError: + return Response( + { + 'detail': 'Account created, but we could not send the verification email right now. Please try resending verification shortly.', + 'user': UserProfileSerializer(user).data, + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + return Response( { 'message': 'Account created successfully. Check your email to verify your account before logging in.', diff --git a/flowbit-backend/flowbit_backend/settings.py b/flowbit-backend/flowbit_backend/settings.py index 5f2e048..56e2ba9 100644 --- a/flowbit-backend/flowbit_backend/settings.py +++ b/flowbit-backend/flowbit_backend/settings.py @@ -186,11 +186,19 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" GOOGLE_OAUTH_CLIENT_ID = config('GOOGLE_OAUTH_CLIENT_ID', default='') +EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend') +EMAIL_HOST = config('EMAIL_HOST', default='localhost') +EMAIL_PORT = config('EMAIL_PORT', cast=int, default=25) +EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='') +EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='') +EMAIL_USE_TLS = config('EMAIL_USE_TLS', cast=bool, default=False) +EMAIL_USE_SSL = config('EMAIL_USE_SSL', cast=bool, default=False) 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) +EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS = config('EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS', cast=int, default=60) SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') REST_FRAMEWORK = { diff --git a/flowbit-frontend/src/components/auth/auth-input.tsx b/flowbit-frontend/src/components/auth/auth-input.tsx index 978c210..432507f 100644 --- a/flowbit-frontend/src/components/auth/auth-input.tsx +++ b/flowbit-frontend/src/components/auth/auth-input.tsx @@ -1,6 +1,6 @@ "use client"; -import { useId, useState, type ChangeEventHandler, type HTMLAttributes, type KeyboardEventHandler } from "react"; +import { useState, type ChangeEventHandler, type HTMLAttributes, type KeyboardEventHandler } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons"; import { Input } from "@/components/ui/input"; @@ -19,6 +19,8 @@ type AuthInputProps = { inputMode?: HTMLAttributes["inputMode"]; disabled?: boolean; onKeyDown?: KeyboardEventHandler; + hideErrorMessage?: boolean; + id?: string; }; export function AuthInput({ @@ -27,10 +29,16 @@ export function AuthInput({ placeholder, error, hint, + hideErrorMessage = false, className, + id, ...props }: AuthInputProps & { className?: string }) { - const inputId = useId(); + const inputId = + id || + (props.name + ? `auth-input-${props.name}` + : `auth-input-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`); const descriptionId = `${inputId}-description`; const [showPassword, setShowPassword] = useState(false); const isPassword = type === "password"; @@ -60,7 +68,7 @@ export function AuthInput({ ) : null} - {error ? ( + {error && !hideErrorMessage ? (

{error}

diff --git a/flowbit-frontend/src/components/auth/forgot-password-form-card.tsx b/flowbit-frontend/src/components/auth/forgot-password-form-card.tsx index 6457295..b00050c 100644 --- a/flowbit-frontend/src/components/auth/forgot-password-form-card.tsx +++ b/flowbit-frontend/src/components/auth/forgot-password-form-card.tsx @@ -12,6 +12,7 @@ import { requestPasswordReset } from "@/lib/auth-client"; const recoveryNotes = [ "Enter the email address linked to your account.", "If the address is recognized, you will receive instructions to reset your password.", + "If the email does not arrive, check your spam or junk folder before trying again.", "After resetting your password, sign in again to continue to your workspace.", ]; diff --git a/flowbit-frontend/src/components/auth/login-form-card.tsx b/flowbit-frontend/src/components/auth/login-form-card.tsx index a04a659..c2ce307 100644 --- a/flowbit-frontend/src/components/auth/login-form-card.tsx +++ b/flowbit-frontend/src/components/auth/login-form-card.tsx @@ -24,6 +24,7 @@ export function LoginFormCard() { const [keepSignedIn, setKeepSignedIn] = useState(false); const [showSignUpSuccess, setShowSignUpSuccess] = useState(false); const [showVerifyEmailNotice, setShowVerifyEmailNotice] = useState(false); + const [showDeliveryFailureNotice, setShowDeliveryFailureNotice] = useState(false); const [credentials, setCredentials] = useState({ username: "", password: "" }); const [verificationEmail, setVerificationEmail] = useState(""); const [fieldErrors, setFieldErrors] = useState<{ username?: string; password?: string }>({}); @@ -42,9 +43,15 @@ export function LoginFormCard() { const params = new URLSearchParams(window.location.search); const signupState = params.get("signup"); const signupEmail = params.get("email") || ""; + const deliveryState = params.get("delivery"); + const deliveryMessage = params.get("message") || ""; setShowSignUpSuccess(signupState === "success"); setShowVerifyEmailNotice(signupState === "verify-email"); + setShowDeliveryFailureNotice(deliveryState === "failed"); setVerificationEmail(signupEmail); + if (deliveryState === "failed" && deliveryMessage) { + setErrorMessage(deliveryMessage); + } }, []); const showVerificationHelp = showVerifyEmailNotice || errorMessage === "Verify your email before logging in."; @@ -177,6 +184,12 @@ export function LoginFormCard() { ) : null} + {showDeliveryFailureNotice ? ( +
+ The account was created, but FlowBit could not send the verification email yet. Use resend below after checking your sender setup. +
+ ) : null} + {errorMessage ? (
{errorMessage} @@ -186,8 +199,8 @@ export function LoginFormCard() { {showVerificationHelp ? (

Need another verification email?

-

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

-
+

Enter the email address for this account and FlowBit will send a fresh verification link. Also check your spam or junk folder.

+
{ setVerificationEmail(event.target.value); @@ -202,11 +216,16 @@ export function LoginFormCard() { }} />
-
- {resendMessage ?

{resendMessage}

: null} + {resendError ?

{resendError}

: null} + {resendMessage ? ( +

+ {resendMessage} If it does not arrive, check spam or try again shortly. +

+ ) : null}
) : null} 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 b1814f5..264acd6 100644 --- a/flowbit-frontend/src/components/auth/sign-up-form-card.tsx +++ b/flowbit-frontend/src/components/auth/sign-up-form-card.tsx @@ -73,7 +73,14 @@ export function SignUpFormCard() { await registerAccount(formValues); router.push(`/login?signup=verify-email&email=${encodeURIComponent(formValues.email.trim())}`); } catch (error) { - setErrorMessage(error instanceof Error ? error.message : "Unable to create your account."); + const message = error instanceof Error ? error.message : "Unable to create your account."; + if (message.startsWith("Account created,")) { + router.push( + `/login?signup=verify-email&delivery=failed&email=${encodeURIComponent(formValues.email.trim())}&message=${encodeURIComponent(message)}`, + ); + return; + } + setErrorMessage(message); } finally { setIsSubmitting(false); } diff --git a/flowbit-frontend/src/proxy.ts b/flowbit-frontend/src/proxy.ts index 5425e1b..479f7d5 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", "/verify-email"]); +const authRoutes = new Set(["/login", "/sign-in", "/forgot-password", "/reset-password", "/sign-up", "/verify-email"]); export function proxy(request: NextRequest) { const { pathname } = request.nextUrl;