Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions flowbit-backend/core/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions flowbit-backend/core/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions flowbit-backend/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
IdentifierCapacityReportView,
LoginView,
RegisterView,
UsernameAvailabilityView,
GoogleLoginView,
LogoutView,
MeView,
Expand Down Expand Up @@ -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'),
Expand Down
21 changes: 21 additions & 0 deletions flowbit-backend/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
28 changes: 15 additions & 13 deletions flowbit-frontend/src/components/auth/login-help-form-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,19 +129,21 @@ export function LoginHelpFormCard() {
setFieldErrors((current) => ({ ...current, requester_name: undefined }));
}}
/>
<AuthInput
label="Reply email"
type="email"
placeholder="Enter the email address for admin replies"
name="requester_email"
autoComplete="email"
error={fieldErrors.requester_email}
value={formValues.requester_email}
onChange={(event) => {
setFormValues((current) => ({ ...current, requester_email: event.target.value }));
setFieldErrors((current) => ({ ...current, requester_email: undefined }));
}}
/>
<div className="md:col-span-2">
<AuthInput
label="Reply email"
type="email"
placeholder="Enter the email address for admin replies"
name="requester_email"
autoComplete="email"
error={fieldErrors.requester_email}
value={formValues.requester_email}
onChange={(event) => {
setFormValues((current) => ({ ...current, requester_email: event.target.value }));
setFieldErrors((current) => ({ ...current, requester_email: undefined }));
}}
/>
</div>
</div>

<div className="mt-4 space-y-4">
Expand Down
47 changes: 41 additions & 6 deletions flowbit-frontend/src/components/auth/sign-up-form-card.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"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";
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.",
Expand All @@ -28,8 +28,42 @@ export function SignUpFormCard() {
});
const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof typeof formValues, string>>>({});
const [errorMessage, setErrorMessage] = useState("");
const [usernameHint, setUsernameHint] = useState("");
const [isUsernameAvailable, setIsUsernameAvailable] = useState<boolean | null>(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<Record<keyof typeof formValues, string>> = {};

Expand All @@ -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) {
Expand Down Expand Up @@ -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("");
}}
/>
<AuthInput
Expand All @@ -138,7 +173,7 @@ export function SignUpFormCard() {
}}
/>
<AuthInput
label="Phone number"
label="Phone number (optional)"
type="tel"
placeholder="Enter your phone number"
autoComplete="tel"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,17 +552,17 @@ export function CustomerServicePage() {
<div className="mt-4 rounded-[22px] border border-stone-900/8 bg-[#f8f6f2] p-3">
<div className="space-y-3">
{isAdmin && selectedCase.intake_type === "LOGIN_HELP" ? (
<label className="block">
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-stone-500">
<label className="flex w-full flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-stone-500">
Reply email
</span>
<input
type="email"
value={loginHelpReplyEmail}
onChange={(event) => setLoginHelpReplyEmail(event.target.value)}
placeholder="requester@example.com"
disabled={selectedCase.status === "CLOSED"}
className="mt-2 h-11 w-full rounded-[16px] 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"
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"
/>
</label>
) : null}
Expand Down
29 changes: 29 additions & 0 deletions flowbit-frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${getApiBaseUrl()}${path}`, {
...init,
Expand All @@ -24,6 +50,9 @@ export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T
: fieldError
? String((fieldError[1] as unknown[])[0])
: "Request failed.";
if (isSessionAuthError(response.status, detail)) {
clearStaleClientSession();
}
throw new Error(detail);
}

Expand Down
9 changes: 9 additions & 0 deletions flowbit-frontend/src/lib/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ export async function registerAccount(payload: RegisterPayload) {
});
}

export async function checkUsernameAvailability(username: string) {
return apiRequest<{ available: boolean; message: string }>(
`/auth/username-availability/?username=${encodeURIComponent(username)}`,
{
method: "GET",
},
);
}

export async function verifyEmailAddress(payload: EmailVerificationPayload) {
return apiRequest<{ message: string }>("/auth/verify-email/", {
method: "POST",
Expand Down
Loading