Internal
Public Access
Implement comprehensive email verification and admin approval system for user registration. Users must verify their email before logging in, and admins must approve new users before they can access the system. Major features: - Email verification with UUID tokens (24hr expiry, one-time use) - Admin approval workflow via Django admin interface - Custom authentication backend enforcing verification and approval - Password change functionality for authenticated users - Enhanced profile page with proper styling and theme support - Collect first name, last name, and timezone during registration - Profile link added to header navigation Email notifications: - Verification email sent after registration - Admin notification when users need approval - Approval notification sent to users Security features: - Cryptographically secure UUID tokens - Token expiration and one-time use enforcement - Email enumeration protection - Custom JWT token validation for API access - Existing users auto-approved via data migration Templates added: - Email templates (verification, approval, admin notification) - Web templates (password change, verification pages) - Enhanced profile page with dark/light mode support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
525 lines
17 KiB
Python
525 lines
17 KiB
Python
"""
|
|
User views for KeepItGoing.
|
|
"""
|
|
|
|
from django.contrib.auth import get_user_model, login, logout
|
|
from django.shortcuts import render, redirect
|
|
from django.utils import timezone
|
|
from django.views import View
|
|
from rest_framework import generics, permissions, status
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
from rest_framework.exceptions import AuthenticationFailed
|
|
from rest_framework_simplejwt.tokens import RefreshToken
|
|
from rest_framework_simplejwt.views import TokenObtainPairView as BaseTokenObtainPairView
|
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
|
|
|
from .serializers import (
|
|
UserSerializer,
|
|
UserRegistrationSerializer,
|
|
ChangePasswordSerializer,
|
|
DeviceTokenSerializer,
|
|
)
|
|
from .models import DeviceToken, EmailVerificationToken
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
# =============================================================================
|
|
# API Views
|
|
# =============================================================================
|
|
|
|
class RegisterAPIView(generics.CreateAPIView):
|
|
"""API endpoint for user registration."""
|
|
|
|
queryset = User.objects.all()
|
|
serializer_class = UserRegistrationSerializer
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
def create(self, request, *args, **kwargs):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
user = serializer.save()
|
|
|
|
# Send verification email
|
|
from .utils import send_verification_email
|
|
send_verification_email(user, request)
|
|
|
|
# DO NOT generate tokens - user must verify email first
|
|
return Response({
|
|
'message': 'Registration successful! Please check your email to verify your account.',
|
|
'email': user.email,
|
|
'email_verified': False,
|
|
'is_approved': False,
|
|
}, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
class UserProfileAPIView(generics.RetrieveUpdateAPIView):
|
|
"""API endpoint for user profile."""
|
|
|
|
serializer_class = UserSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_object(self):
|
|
return self.request.user
|
|
|
|
|
|
class ChangePasswordAPIView(APIView):
|
|
"""API endpoint for changing password."""
|
|
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def post(self, request):
|
|
serializer = ChangePasswordSerializer(
|
|
data=request.data,
|
|
context={'request': request}
|
|
)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
request.user.set_password(serializer.validated_data['new_password'])
|
|
request.user.save()
|
|
|
|
return Response({'message': 'Password changed successfully.'})
|
|
|
|
|
|
class DeviceTokenAPIView(generics.ListCreateAPIView):
|
|
"""API endpoint for managing device tokens."""
|
|
|
|
serializer_class = DeviceTokenSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
return DeviceToken.objects.filter(user=self.request.user)
|
|
|
|
|
|
class DeviceTokenDeleteAPIView(generics.DestroyAPIView):
|
|
"""API endpoint for deleting a device token."""
|
|
|
|
serializer_class = DeviceTokenSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
|
|
def get_queryset(self):
|
|
return DeviceToken.objects.filter(user=self.request.user)
|
|
|
|
|
|
class VerifyEmailAPIView(APIView):
|
|
"""API endpoint for email verification."""
|
|
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
def post(self, request):
|
|
token_value = request.data.get('token')
|
|
|
|
if not token_value:
|
|
return Response(
|
|
{'error': 'Token is required.'},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
token = EmailVerificationToken.objects.get(token=token_value)
|
|
except EmailVerificationToken.DoesNotExist:
|
|
return Response(
|
|
{'error': 'Invalid verification token.'},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if token.is_used:
|
|
return Response(
|
|
{'error': 'This verification link has already been used.'},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
if token.is_expired():
|
|
return Response(
|
|
{'error': 'This verification link has expired. Please request a new one.'},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
# Mark email as verified
|
|
user = token.user
|
|
user.email_verified = True
|
|
user.email_verified_at = timezone.now()
|
|
user.save(update_fields=['email_verified', 'email_verified_at'])
|
|
|
|
# Mark token as used
|
|
token.is_used = True
|
|
token.used_at = timezone.now()
|
|
token.save(update_fields=['is_used', 'used_at'])
|
|
|
|
# Notify admins if not yet approved
|
|
if not user.is_approved:
|
|
from .utils import notify_admins_new_user
|
|
notify_admins_new_user(user)
|
|
|
|
return Response({
|
|
'message': 'Email verified successfully.',
|
|
'email_verified': True,
|
|
'is_approved': user.is_approved,
|
|
})
|
|
|
|
|
|
class ResendVerificationEmailAPIView(APIView):
|
|
"""API endpoint to resend verification email."""
|
|
|
|
permission_classes = [permissions.AllowAny]
|
|
|
|
def post(self, request):
|
|
email = request.data.get('email')
|
|
|
|
if not email:
|
|
return Response(
|
|
{'error': 'Email is required.'},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
try:
|
|
user = User.objects.get(email=email)
|
|
except User.DoesNotExist:
|
|
# Don't reveal if email exists
|
|
return Response({
|
|
'message': 'If that email is registered, a verification link has been sent.'
|
|
})
|
|
|
|
if user.email_verified:
|
|
return Response(
|
|
{'error': 'Email is already verified.'},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
from .utils import send_verification_email
|
|
send_verification_email(user, request)
|
|
|
|
return Response({
|
|
'message': 'Verification email sent.'
|
|
})
|
|
|
|
|
|
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
|
"""Custom JWT token serializer that checks verification and approval."""
|
|
|
|
def validate(self, attrs):
|
|
# Authenticate user
|
|
data = super().validate(attrs)
|
|
|
|
# Check email verification
|
|
if not self.user.email_verified:
|
|
raise AuthenticationFailed({
|
|
'error': 'email_not_verified',
|
|
'message': 'Please verify your email address before logging in.',
|
|
'email': self.user.email,
|
|
})
|
|
|
|
# Check admin approval
|
|
if not self.user.is_approved:
|
|
raise AuthenticationFailed({
|
|
'error': 'not_approved',
|
|
'message': 'Your account is pending admin approval.',
|
|
'email': self.user.email,
|
|
})
|
|
|
|
return data
|
|
|
|
|
|
class TokenObtainPairView(BaseTokenObtainPairView):
|
|
"""Custom JWT token view using our custom serializer."""
|
|
|
|
serializer_class = CustomTokenObtainPairSerializer
|
|
|
|
|
|
# =============================================================================
|
|
# Web Views (Django Templates)
|
|
# =============================================================================
|
|
|
|
class LoginView(View):
|
|
"""Web login page."""
|
|
|
|
def get(self, request):
|
|
if request.user.is_authenticated:
|
|
return redirect('dashboard')
|
|
|
|
# Check for special error conditions from authentication backend
|
|
error = None
|
|
email = None
|
|
can_resend = False
|
|
|
|
if 'login_error' in request.session:
|
|
error_type = request.session.pop('login_error')
|
|
email = request.session.pop('login_email', None)
|
|
|
|
if error_type == 'email_not_verified':
|
|
error = 'Please verify your email address before logging in.'
|
|
can_resend = True
|
|
elif error_type == 'not_approved':
|
|
error = 'Your account is pending admin approval.'
|
|
|
|
return render(request, 'users/login.html', {
|
|
'error': error,
|
|
'email': email,
|
|
'can_resend': can_resend,
|
|
})
|
|
|
|
def post(self, request):
|
|
from django.contrib.auth import authenticate
|
|
|
|
email = request.POST.get('email')
|
|
password = request.POST.get('password')
|
|
|
|
user = authenticate(request, username=email, password=password)
|
|
|
|
if user is not None:
|
|
login(request, user)
|
|
next_url = request.GET.get('next', 'dashboard')
|
|
return redirect(next_url)
|
|
|
|
# Check if error info was set by authentication backend
|
|
error = 'Invalid email or password.'
|
|
can_resend = False
|
|
|
|
if 'login_error' in request.session:
|
|
error_type = request.session.pop('login_error')
|
|
email_from_session = request.session.pop('login_email', None)
|
|
|
|
if error_type == 'email_not_verified':
|
|
error = 'Please verify your email address before logging in.'
|
|
can_resend = True
|
|
email = email_from_session
|
|
elif error_type == 'not_approved':
|
|
error = 'Your account is pending admin approval.'
|
|
email = email_from_session
|
|
|
|
return render(request, 'users/login.html', {
|
|
'error': error,
|
|
'email': email,
|
|
'can_resend': can_resend,
|
|
})
|
|
|
|
|
|
class LogoutView(View):
|
|
"""Web logout."""
|
|
|
|
def get(self, request):
|
|
logout(request)
|
|
return redirect('login')
|
|
|
|
def post(self, request):
|
|
logout(request)
|
|
return redirect('login')
|
|
|
|
|
|
class RegisterView(View):
|
|
"""Web registration page."""
|
|
|
|
def get(self, request):
|
|
if request.user.is_authenticated:
|
|
return redirect('dashboard')
|
|
return render(request, 'users/register.html')
|
|
|
|
def post(self, request):
|
|
email = request.POST.get('email')
|
|
username = request.POST.get('username')
|
|
password = request.POST.get('password')
|
|
password_confirm = request.POST.get('password_confirm')
|
|
first_name = request.POST.get('first_name', '')
|
|
last_name = request.POST.get('last_name', '')
|
|
timezone = request.POST.get('timezone', 'UTC')
|
|
|
|
errors = {}
|
|
|
|
if password != password_confirm:
|
|
errors['password_confirm'] = 'Passwords do not match.'
|
|
|
|
if User.objects.filter(email=email).exists():
|
|
errors['email'] = 'Email already registered.'
|
|
|
|
if User.objects.filter(username=username).exists():
|
|
errors['username'] = 'Username already taken.'
|
|
|
|
if errors:
|
|
return render(request, 'users/register.html', {
|
|
'errors': errors,
|
|
'email': email,
|
|
'username': username,
|
|
'first_name': first_name,
|
|
'last_name': last_name,
|
|
'timezone': timezone,
|
|
})
|
|
|
|
user = User.objects.create_user(
|
|
email=email,
|
|
username=username,
|
|
password=password,
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
timezone=timezone
|
|
)
|
|
|
|
# Send verification email
|
|
from .utils import send_verification_email
|
|
send_verification_email(user, request)
|
|
|
|
# DO NOT auto-login
|
|
return render(request, 'users/register_success.html', {
|
|
'email': user.email
|
|
})
|
|
|
|
|
|
class ProfileView(View):
|
|
"""Web profile page."""
|
|
|
|
def get(self, request):
|
|
if not request.user.is_authenticated:
|
|
return redirect('login')
|
|
return render(request, 'users/profile.html')
|
|
|
|
def post(self, request):
|
|
if not request.user.is_authenticated:
|
|
return redirect('login')
|
|
|
|
user = request.user
|
|
user.first_name = request.POST.get('first_name', '')
|
|
user.last_name = request.POST.get('last_name', '')
|
|
user.timezone = request.POST.get('timezone', 'UTC')
|
|
user.default_reminder_minutes = int(request.POST.get('default_reminder_minutes', 30))
|
|
user.email_notifications = request.POST.get('email_notifications') == 'on'
|
|
user.push_notifications = request.POST.get('push_notifications') == 'on'
|
|
user.save()
|
|
|
|
return render(request, 'users/profile.html', {
|
|
'success': 'Profile updated successfully.'
|
|
})
|
|
|
|
|
|
class VerifyEmailWebView(View):
|
|
"""Web view for email verification via link."""
|
|
|
|
def get(self, request, token):
|
|
try:
|
|
token_obj = EmailVerificationToken.objects.get(token=token)
|
|
except EmailVerificationToken.DoesNotExist:
|
|
return render(request, 'users/verify_email.html', {
|
|
'success': False,
|
|
'error': 'Invalid verification link.'
|
|
})
|
|
|
|
if token_obj.is_used:
|
|
return render(request, 'users/verify_email.html', {
|
|
'success': False,
|
|
'error': 'This verification link has already been used.'
|
|
})
|
|
|
|
if token_obj.is_expired():
|
|
return render(request, 'users/verify_email.html', {
|
|
'success': False,
|
|
'error': 'This verification link has expired.',
|
|
'can_resend': True,
|
|
'email': token_obj.user.email,
|
|
})
|
|
|
|
# Verify email
|
|
user = token_obj.user
|
|
user.email_verified = True
|
|
user.email_verified_at = timezone.now()
|
|
user.save(update_fields=['email_verified', 'email_verified_at'])
|
|
|
|
token_obj.is_used = True
|
|
token_obj.used_at = timezone.now()
|
|
token_obj.save(update_fields=['is_used', 'used_at'])
|
|
|
|
# Notify admins
|
|
if not user.is_approved:
|
|
from .utils import notify_admins_new_user
|
|
notify_admins_new_user(user)
|
|
|
|
return render(request, 'users/verify_email.html', {
|
|
'success': True,
|
|
'is_approved': user.is_approved,
|
|
})
|
|
|
|
|
|
class ResendVerificationWebView(View):
|
|
"""Web view for resending verification email."""
|
|
|
|
def get(self, request):
|
|
return render(request, 'users/resend_verification.html')
|
|
|
|
def post(self, request):
|
|
email = request.POST.get('email')
|
|
|
|
if not email:
|
|
return render(request, 'users/resend_verification.html', {
|
|
'error': 'Email is required.'
|
|
})
|
|
|
|
try:
|
|
user = User.objects.get(email=email)
|
|
except User.DoesNotExist:
|
|
# Don't reveal if email exists
|
|
return render(request, 'users/resend_verification.html', {
|
|
'success': True,
|
|
'message': 'If that email is registered, a verification link has been sent.'
|
|
})
|
|
|
|
if user.email_verified:
|
|
return render(request, 'users/resend_verification.html', {
|
|
'error': 'Email is already verified.'
|
|
})
|
|
|
|
from .utils import send_verification_email
|
|
send_verification_email(user, request)
|
|
|
|
return render(request, 'users/resend_verification.html', {
|
|
'success': True,
|
|
'message': 'Verification email sent. Please check your inbox.'
|
|
})
|
|
|
|
|
|
class ChangePasswordWebView(View):
|
|
"""Web view for changing password."""
|
|
|
|
def get(self, request):
|
|
if not request.user.is_authenticated:
|
|
return redirect('login')
|
|
return render(request, 'users/change_password.html')
|
|
|
|
def post(self, request):
|
|
if not request.user.is_authenticated:
|
|
return redirect('login')
|
|
|
|
old_password = request.POST.get('old_password')
|
|
new_password = request.POST.get('new_password')
|
|
confirm_password = request.POST.get('confirm_password')
|
|
|
|
errors = {}
|
|
|
|
# Validate old password
|
|
if not request.user.check_password(old_password):
|
|
errors['old_password'] = 'Current password is incorrect.'
|
|
|
|
# Validate new passwords match
|
|
if new_password != confirm_password:
|
|
errors['confirm_password'] = 'New passwords do not match.'
|
|
|
|
# Validate password length
|
|
if len(new_password) < 8:
|
|
errors['new_password'] = 'Password must be at least 8 characters long.'
|
|
|
|
# Validate new password is different from old
|
|
if old_password == new_password:
|
|
errors['new_password'] = 'New password must be different from current password.'
|
|
|
|
if errors:
|
|
return render(request, 'users/change_password.html', {'errors': errors})
|
|
|
|
# Change the password
|
|
request.user.set_password(new_password)
|
|
request.user.save()
|
|
|
|
# Re-login the user so their session isn't invalidated
|
|
from django.contrib.auth import update_session_auth_hash
|
|
update_session_auth_hash(request, request.user)
|
|
|
|
return render(request, 'users/change_password.html', {
|
|
'success': 'Password changed successfully!'
|
|
})
|