feat(auth): Convert login system to use email as username

- Backend login now accepts 'email' field (with backward compatibility)
- User creation (signup, invitation, customer) uses email as username
- Frontend login form updated with email input and validation
- Updated test users to use email addresses as usernames
- Updated all translation files (en, es, fr, de)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-04 10:38:53 -05:00
parent a2f74ee769
commit dbe91ec2ff
10 changed files with 71 additions and 86 deletions

View File

@@ -5,7 +5,7 @@
import apiClient from './client'; import apiClient from './client';
export interface LoginCredentials { export interface LoginCredentials {
username: string; email: string;
password: string; password: string;
} }

View File

@@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { getBaseDomain, buildSubdomainUrl } from '../utils/domain'; import { getBaseDomain, buildSubdomainUrl } from '../utils/domain';
export interface TestUser { export interface TestUser {
username: string; email: string;
password: string; password: string;
role: string; role: string;
label: string; label: string;
@@ -14,56 +14,56 @@ export interface TestUser {
const testUsers: TestUser[] = [ const testUsers: TestUser[] = [
{ {
username: 'superuser', email: 'superuser@platform.com',
password: 'test123', password: 'test123',
role: 'SUPERUSER', role: 'SUPERUSER',
label: 'Platform Superuser', label: 'Platform Superuser',
color: 'bg-purple-600 hover:bg-purple-700', color: 'bg-purple-600 hover:bg-purple-700',
}, },
{ {
username: 'platform_manager', email: 'manager@platform.com',
password: 'test123', password: 'test123',
role: 'PLATFORM_MANAGER', role: 'PLATFORM_MANAGER',
label: 'Platform Manager', label: 'Platform Manager',
color: 'bg-blue-600 hover:bg-blue-700', color: 'bg-blue-600 hover:bg-blue-700',
}, },
{ {
username: 'platform_sales', email: 'sales@platform.com',
password: 'test123', password: 'test123',
role: 'PLATFORM_SALES', role: 'PLATFORM_SALES',
label: 'Platform Sales', label: 'Platform Sales',
color: 'bg-green-600 hover:bg-green-700', color: 'bg-green-600 hover:bg-green-700',
}, },
{ {
username: 'platform_support', email: 'support@platform.com',
password: 'test123', password: 'test123',
role: 'PLATFORM_SUPPORT', role: 'PLATFORM_SUPPORT',
label: 'Platform Support', label: 'Platform Support',
color: 'bg-yellow-600 hover:bg-yellow-700', color: 'bg-yellow-600 hover:bg-yellow-700',
}, },
{ {
username: 'tenant_owner', email: 'owner@demo.com',
password: 'test123', password: 'test123',
role: 'TENANT_OWNER', role: 'TENANT_OWNER',
label: 'Business Owner', label: 'Business Owner',
color: 'bg-indigo-600 hover:bg-indigo-700', color: 'bg-indigo-600 hover:bg-indigo-700',
}, },
{ {
username: 'tenant_manager', email: 'manager@demo.com',
password: 'test123', password: 'test123',
role: 'TENANT_MANAGER', role: 'TENANT_MANAGER',
label: 'Business Manager', label: 'Business Manager',
color: 'bg-pink-600 hover:bg-pink-700', color: 'bg-pink-600 hover:bg-pink-700',
}, },
{ {
username: 'tenant_staff', email: 'staff@demo.com',
password: 'test123', password: 'test123',
role: 'TENANT_STAFF', role: 'TENANT_STAFF',
label: 'Staff Member', label: 'Staff Member',
color: 'bg-teal-600 hover:bg-teal-700', color: 'bg-teal-600 hover:bg-teal-700',
}, },
{ {
username: 'customer', email: 'customer@demo.com',
password: 'test123', password: 'test123',
role: 'CUSTOMER', role: 'CUSTOMER',
label: 'Customer', label: 'Customer',
@@ -86,11 +86,11 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
} }
const handleQuickLogin = async (user: TestUser) => { const handleQuickLogin = async (user: TestUser) => {
setLoading(user.username); setLoading(user.email);
try { try {
// Call token auth API // Call token auth API - username field contains email since we use email as username
const response = await apiClient.post('/auth-token/', { const response = await apiClient.post('/auth-token/', {
username: user.username, username: user.email,
password: user.password, password: user.password,
}); });
@@ -176,12 +176,12 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{testUsers.map((user) => ( {testUsers.map((user) => (
<button <button
key={user.username} key={user.email}
onClick={() => handleQuickLogin(user)} onClick={() => handleQuickLogin(user)}
disabled={loading !== null} disabled={loading !== null}
className={`${user.color} text-white px-3 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed`} className={`${user.color} text-white px-3 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
> >
{loading === user.username ? ( {loading === user.email ? (
<span className="flex items-center justify-center"> <span className="flex items-center justify-center">
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24"> <svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
<circle <circle

View File

@@ -31,12 +31,12 @@
"signIn": "Anmelden", "signIn": "Anmelden",
"signOut": "Abmelden", "signOut": "Abmelden",
"signingIn": "Anmeldung läuft...", "signingIn": "Anmeldung läuft...",
"username": "Benutzername", "email": "E-Mail",
"password": "Passwort", "password": "Passwort",
"enterUsername": "Geben Sie Ihren Benutzernamen ein", "enterEmail": "Geben Sie Ihre E-Mail-Adresse ein",
"enterPassword": "Geben Sie Ihr Passwort ein", "enterPassword": "Geben Sie Ihr Passwort ein",
"welcomeBack": "Willkommen zurück", "welcomeBack": "Willkommen zurück",
"pleaseEnterDetails": "Bitte geben Sie Ihre Daten ein, um sich anzumelden.", "pleaseEnterDetails": "Bitte geben Sie Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden.",
"authError": "Authentifizierungsfehler", "authError": "Authentifizierungsfehler",
"invalidCredentials": "Ungültige Anmeldedaten", "invalidCredentials": "Ungültige Anmeldedaten",
"orContinueWith": "Oder fortfahren mit", "orContinueWith": "Oder fortfahren mit",

View File

@@ -56,12 +56,12 @@
"signIn": "Sign in", "signIn": "Sign in",
"signOut": "Sign Out", "signOut": "Sign Out",
"signingIn": "Signing in...", "signingIn": "Signing in...",
"username": "Username", "email": "Email",
"password": "Password", "password": "Password",
"enterUsername": "Enter your username", "enterEmail": "Enter your email",
"enterPassword": "Enter your password", "enterPassword": "Enter your password",
"welcomeBack": "Welcome back", "welcomeBack": "Welcome back",
"pleaseEnterDetails": "Please enter your details to sign in.", "pleaseEnterDetails": "Please enter your email and password to sign in.",
"authError": "Authentication Error", "authError": "Authentication Error",
"invalidCredentials": "Invalid credentials", "invalidCredentials": "Invalid credentials",
"orContinueWith": "Or continue with", "orContinueWith": "Or continue with",

View File

@@ -31,12 +31,12 @@
"signIn": "Iniciar sesión", "signIn": "Iniciar sesión",
"signOut": "Cerrar Sesión", "signOut": "Cerrar Sesión",
"signingIn": "Iniciando sesión...", "signingIn": "Iniciando sesión...",
"username": "Nombre de usuario", "email": "Correo electrónico",
"password": "Contraseña", "password": "Contraseña",
"enterUsername": "Ingresa tu nombre de usuario", "enterEmail": "Ingresa tu correo electrónico",
"enterPassword": "Ingresa tu contraseña", "enterPassword": "Ingresa tu contraseña",
"welcomeBack": "Bienvenido de nuevo", "welcomeBack": "Bienvenido de nuevo",
"pleaseEnterDetails": "Por favor ingresa tus datos para iniciar sesión.", "pleaseEnterDetails": "Por favor ingresa tu correo electrónico y contraseña para iniciar sesión.",
"authError": "Error de Autenticación", "authError": "Error de Autenticación",
"invalidCredentials": "Credenciales inválidas", "invalidCredentials": "Credenciales inválidas",
"orContinueWith": "O continuar con", "orContinueWith": "O continuar con",

View File

@@ -31,12 +31,12 @@
"signIn": "Se connecter", "signIn": "Se connecter",
"signOut": "Déconnexion", "signOut": "Déconnexion",
"signingIn": "Connexion en cours...", "signingIn": "Connexion en cours...",
"username": "Nom d'utilisateur", "email": "E-mail",
"password": "Mot de passe", "password": "Mot de passe",
"enterUsername": "Entrez votre nom d'utilisateur", "enterEmail": "Entrez votre e-mail",
"enterPassword": "Entrez votre mot de passe", "enterPassword": "Entrez votre mot de passe",
"welcomeBack": "Bon retour", "welcomeBack": "Bon retour",
"pleaseEnterDetails": "Veuillez entrer vos informations pour vous connecter.", "pleaseEnterDetails": "Veuillez entrer votre e-mail et mot de passe pour vous connecter.",
"authError": "Erreur d'Authentification", "authError": "Erreur d'Authentification",
"invalidCredentials": "Identifiants invalides", "invalidCredentials": "Identifiants invalides",
"orContinueWith": "Ou continuer avec", "orContinueWith": "Ou continuer avec",

View File

@@ -11,11 +11,11 @@ import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
import OAuthButtons from '../components/OAuthButtons'; import OAuthButtons from '../components/OAuthButtons';
import LanguageSelector from '../components/LanguageSelector'; import LanguageSelector from '../components/LanguageSelector';
import { DevQuickLogin } from '../components/DevQuickLogin'; import { DevQuickLogin } from '../components/DevQuickLogin';
import { AlertCircle, Loader2, User, Lock, ArrowRight } from 'lucide-react'; import { AlertCircle, Loader2, Mail, Lock, ArrowRight } from 'lucide-react';
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [username, setUsername] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -27,7 +27,7 @@ const LoginPage: React.FC = () => {
setError(''); setError('');
loginMutation.mutate( loginMutation.mutate(
{ username, password }, { email, password },
{ {
onSuccess: (data) => { onSuccess: (data) => {
// Check if MFA is required // Check if MFA is required
@@ -202,25 +202,25 @@ const LoginPage: React.FC = () => {
<form className="space-y-6" onSubmit={handleSubmit}> <form className="space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4"> <div className="space-y-4">
{/* Username */} {/* Email */}
<div> <div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('auth.username')} {t('auth.email')}
</label> </label>
<div className="relative rounded-md shadow-sm"> <div className="relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" aria-hidden="true" /> <Mail className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div> </div>
<input <input
id="username" id="email"
name="username" name="email"
type="text" type="email"
autoComplete="username" autoComplete="email"
required required
className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 sm:text-sm border-gray-300 dark:border-gray-700 rounded-lg py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-colors" className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 sm:text-sm border-gray-300 dark:border-gray-700 rounded-lg py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-colors"
placeholder={t('auth.enterUsername')} placeholder={t('auth.enterEmail')}
value={username} value={email}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setEmail(e.target.value)}
/> />
</div> </div>
</div> </div>

View File

@@ -61,21 +61,15 @@ class CustomerSerializer(serializers.ModelSerializer):
read_only_fields = ['id'] read_only_fields = ['id']
def create(self, validated_data): def create(self, validated_data):
"""Create a customer with auto-generated username""" """Create a customer with email as username"""
import uuid import uuid
email = validated_data.get('email', '') email = validated_data.get('email', '')
# Generate username from email or use a UUID if no email # Use email as username, or generate a UUID-based username if no email
if email: if email:
base_username = email.split('@')[0] validated_data['username'] = email.lower()
username = base_username
counter = 1
while User.objects.filter(username=username).exists():
username = f"{base_username}{counter}"
counter += 1
else: else:
username = f"customer_{uuid.uuid4().hex[:8]}" validated_data['username'] = f"customer_{uuid.uuid4().hex[:8]}"
validated_data['username'] = username
return super().create(validated_data) return super().create(validated_data)
def get_name(self, obj): def get_name(self, obj):

View File

@@ -26,7 +26,7 @@ from django_tenants.utils import schema_context
@permission_classes([AllowAny]) @permission_classes([AllowAny])
def login_view(request): def login_view(request):
""" """
Login user with username/email and password. Login user with email and password.
POST /api/auth/login/ POST /api/auth/login/
If MFA is enabled: If MFA is enabled:
@@ -36,25 +36,24 @@ def login_view(request):
If MFA is not enabled or device is trusted: If MFA is not enabled or device is trusted:
- Returns access/refresh tokens and user data - Returns access/refresh tokens and user data
""" """
username = request.data.get('username', '').strip() # Accept both 'email' and 'username' fields for backward compatibility
email = request.data.get('email', '') or request.data.get('username', '')
email = email.strip().lower()
password = request.data.get('password', '') password = request.data.get('password', '')
if not username or not password: if not email or not password:
return Response( return Response(
{'error': 'Username and password are required'}, {'error': 'Email and password are required'},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Authenticate user (supports username or email) # Look up user by email and authenticate
user = authenticate(request, username=username, password=password) user = None
try:
# If authentication with username failed, try email user_by_email = User.objects.get(email__iexact=email)
if user is None: user = authenticate(request, username=user_by_email.username, password=password)
try: except User.DoesNotExist:
user_by_email = User.objects.get(email__iexact=username) pass
user = authenticate(request, username=user_by_email.username, password=password)
except User.DoesNotExist:
pass
if user is None: if user is None:
return Response( return Response(
@@ -732,20 +731,12 @@ def accept_invitation_view(request, token):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Create the user # Create the user with email as username
username = invitation.email.split('@')[0]
# Ensure username is unique
base_username = username
counter = 1
while User.objects.filter(username=username).exists():
username = f"{base_username}{counter}"
counter += 1
# Determine sandbox mode from request (set by middleware) # Determine sandbox mode from request (set by middleware)
is_sandbox = getattr(request, 'sandbox_mode', False) is_sandbox = getattr(request, 'sandbox_mode', False)
user = User.objects.create_user( user = User.objects.create_user(
username=username, username=invitation.email.lower(), # Use email as username
email=invitation.email, email=invitation.email,
password=password, password=password,
first_name=first_name, first_name=first_name,
@@ -1010,7 +1001,7 @@ def signup_view(request):
# 5. Create User (Owner) # 5. Create User (Owner)
user = User.objects.create_user( user = User.objects.create_user(
username=email.split('@')[0], # Fallback username username=email, # Use email as username
email=email, email=email,
password=password, password=password,
first_name=data.get('first_name', ''), first_name=data.get('first_name', ''),

View File

@@ -21,9 +21,9 @@ class Command(BaseCommand):
) )
test_users = [ test_users = [
# Platform users (no tenant) # Platform users (no tenant) - username is set to email
{ {
'username': 'superuser', 'username': 'superuser@platform.com',
'email': 'superuser@platform.com', 'email': 'superuser@platform.com',
'password': 'test123', 'password': 'test123',
'role': User.Role.SUPERUSER, 'role': User.Role.SUPERUSER,
@@ -32,7 +32,7 @@ class Command(BaseCommand):
'tenant': None, 'tenant': None,
}, },
{ {
'username': 'platform_manager', 'username': 'manager@platform.com',
'email': 'manager@platform.com', 'email': 'manager@platform.com',
'password': 'test123', 'password': 'test123',
'role': User.Role.PLATFORM_MANAGER, 'role': User.Role.PLATFORM_MANAGER,
@@ -41,7 +41,7 @@ class Command(BaseCommand):
'tenant': None, 'tenant': None,
}, },
{ {
'username': 'platform_sales', 'username': 'sales@platform.com',
'email': 'sales@platform.com', 'email': 'sales@platform.com',
'password': 'test123', 'password': 'test123',
'role': User.Role.PLATFORM_SALES, 'role': User.Role.PLATFORM_SALES,
@@ -50,7 +50,7 @@ class Command(BaseCommand):
'tenant': None, 'tenant': None,
}, },
{ {
'username': 'platform_support', 'username': 'support@platform.com',
'email': 'support@platform.com', 'email': 'support@platform.com',
'password': 'test123', 'password': 'test123',
'role': User.Role.PLATFORM_SUPPORT, 'role': User.Role.PLATFORM_SUPPORT,
@@ -58,9 +58,9 @@ class Command(BaseCommand):
'last_name': 'Agent', 'last_name': 'Agent',
'tenant': None, 'tenant': None,
}, },
# Tenant users (with tenant) # Tenant users (with tenant) - username is set to email
{ {
'username': 'tenant_owner', 'username': 'owner@demo.com',
'email': 'owner@demo.com', 'email': 'owner@demo.com',
'password': 'test123', 'password': 'test123',
'role': User.Role.TENANT_OWNER, 'role': User.Role.TENANT_OWNER,
@@ -69,7 +69,7 @@ class Command(BaseCommand):
'tenant': demo_tenant, 'tenant': demo_tenant,
}, },
{ {
'username': 'tenant_manager', 'username': 'manager@demo.com',
'email': 'manager@demo.com', 'email': 'manager@demo.com',
'password': 'test123', 'password': 'test123',
'role': User.Role.TENANT_MANAGER, 'role': User.Role.TENANT_MANAGER,
@@ -78,7 +78,7 @@ class Command(BaseCommand):
'tenant': demo_tenant, 'tenant': demo_tenant,
}, },
{ {
'username': 'tenant_staff', 'username': 'staff@demo.com',
'email': 'staff@demo.com', 'email': 'staff@demo.com',
'password': 'test123', 'password': 'test123',
'role': User.Role.TENANT_STAFF, 'role': User.Role.TENANT_STAFF,
@@ -87,7 +87,7 @@ class Command(BaseCommand):
'tenant': demo_tenant, 'tenant': demo_tenant,
}, },
{ {
'username': 'customer', 'username': 'customer@demo.com',
'email': 'customer@demo.com', 'email': 'customer@demo.com',
'password': 'test123', 'password': 'test123',
'role': User.Role.CUSTOMER, 'role': User.Role.CUSTOMER,