From dc25d9c6760989d01b61e6845a6e93db944876d5 Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Sat, 30 May 2026 13:15:03 +0100 Subject: [PATCH 1/6] Clear stale sessions on invalid token --- flowbit-frontend/src/lib/api.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/flowbit-frontend/src/lib/api.ts b/flowbit-frontend/src/lib/api.ts index 7c29cce..36a3ef3 100644 --- a/flowbit-frontend/src/lib/api.ts +++ b/flowbit-frontend/src/lib/api.ts @@ -1,9 +1,35 @@ +import { AUTH_TOKEN_STORAGE_KEY, AUTH_USER_STORAGE_KEY, clearAuthCookie } from "@/lib/auth"; + const DEFAULT_API_BASE_URL = "http://127.0.0.1:8000/api"; export function getApiBaseUrl() { return (process.env.NEXT_PUBLIC_API_BASE_URL || DEFAULT_API_BASE_URL).replace(/\/$/, ""); } +function isSessionAuthError(status: number, detail: string) { + if (status !== 401 && status !== 403) { + return false; + } + const normalizedDetail = detail.toLowerCase(); + return ( + normalizedDetail.includes("invalid token") || + normalizedDetail.includes("authentication credentials were not provided") + ); +} + +function clearStaleClientSession() { + if (typeof window === "undefined") { + return; + } + + window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); + window.localStorage.removeItem(AUTH_USER_STORAGE_KEY); + document.cookie = clearAuthCookie(); + if (window.location.pathname !== "/login") { + window.location.assign("/login"); + } +} + export async function apiRequest(path: string, init?: RequestInit): Promise { const response = await fetch(`${getApiBaseUrl()}${path}`, { ...init, @@ -24,6 +50,9 @@ export async function apiRequest(path: string, init?: RequestInit): Promise Date: Sat, 30 May 2026 13:17:06 +0100 Subject: [PATCH 2/6] Match login help reply email field sizing --- .../src/components/support/customer-service-page.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flowbit-frontend/src/components/support/customer-service-page.tsx b/flowbit-frontend/src/components/support/customer-service-page.tsx index 4c4b767..feeb1b0 100644 --- a/flowbit-frontend/src/components/support/customer-service-page.tsx +++ b/flowbit-frontend/src/components/support/customer-service-page.tsx @@ -552,8 +552,8 @@ export function CustomerServicePage() {
{isAdmin && selectedCase.intake_type === "LOGIN_HELP" ? ( -
From 7e99941dcfaa1deb517551982ceb04867342692f Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Sat, 30 May 2026 13:21:24 +0100 Subject: [PATCH 4/6] Lock saved login help reply email --- .../src/components/support/customer-service-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flowbit-frontend/src/components/support/customer-service-page.tsx b/flowbit-frontend/src/components/support/customer-service-page.tsx index feeb1b0..5074d89 100644 --- a/flowbit-frontend/src/components/support/customer-service-page.tsx +++ b/flowbit-frontend/src/components/support/customer-service-page.tsx @@ -561,7 +561,7 @@ export function CustomerServicePage() { value={loginHelpReplyEmail} onChange={(event) => setLoginHelpReplyEmail(event.target.value)} placeholder="requester@example.com" - disabled={selectedCase.status === "CLOSED"} + disabled={selectedCase.status === "CLOSED" || Boolean(selectedCase.requester_email)} className="h-12 w-full rounded-2xl border border-stone-900/10 bg-white px-4 text-sm text-stone-900 outline-none transition placeholder:text-stone-400 focus:border-stone-400 disabled:cursor-not-allowed disabled:opacity-60" /> From 2bc1a4b3ca3ee917d5cb052773a5a0e707833806 Mon Sep 17 00:00:00 2001 From: Dana Khaing Date: Sat, 30 May 2026 13:24:55 +0100 Subject: [PATCH 5/6] Make sign-up phone number optional --- flowbit-backend/core/serializers.py | 4 ++-- flowbit-backend/core/tests.py | 14 ++++++++++++++ .../src/components/auth/sign-up-form-card.tsx | 5 +---- 3 files changed, 17 insertions(+), 6 deletions(-) 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..6ae0f44 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -344,6 +344,20 @@ 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_register_rejects_duplicate_email(self): response = self.client.post('/api/auth/register/', { 'full_name': 'Another User', 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..5dbe691 100644 --- a/flowbit-frontend/src/components/auth/sign-up-form-card.tsx +++ b/flowbit-frontend/src/components/auth/sign-up-form-card.tsx @@ -44,9 +44,6 @@ export function SignUpFormCard() { } 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) { @@ -138,7 +135,7 @@ export function SignUpFormCard() { }} /> Date: Sat, 30 May 2026 13:29:00 +0100 Subject: [PATCH 6/6] Warn when sign-up username is taken Co-authored-by: Khant Zayar --- flowbit-backend/core/tests.py | 14 +++++++ flowbit-backend/core/urls.py | 2 + flowbit-backend/core/views.py | 21 ++++++++++ .../src/components/auth/sign-up-form-card.tsx | 42 ++++++++++++++++++- flowbit-frontend/src/lib/auth-client.ts | 9 ++++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/flowbit-backend/core/tests.py b/flowbit-backend/core/tests.py index 6ae0f44..b8956e9 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -358,6 +358,20 @@ def test_register_allows_missing_phone_number(self): 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/sign-up-form-card.tsx b/flowbit-frontend/src/components/auth/sign-up-form-card.tsx index 5dbe691..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,6 +72,8 @@ 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."; @@ -116,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(""); }} /> ( + `/auth/username-availability/?username=${encodeURIComponent(username)}`, + { + method: "GET", + }, + ); +} + export async function verifyEmailAddress(payload: EmailVerificationPayload) { return apiRequest<{ message: string }>("/auth/verify-email/", { method: "POST",