From d36cf9a79d046766629274bc022b88e75c6115db Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Jul 2025 13:11:06 +0530 Subject: [PATCH] Fix email URLs to use Railway domain instead of localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SITE_URL configuration that auto-detects Railway environment - Update forgot password view to use correct site URL in emails - Ensure password reset links work on both local and Railway deployments ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.email.example | 13 + .../migrations/0003_passwordresettoken.py | 48 ++ authentication/models.py | 29 + authentication/urls.py | 2 + authentication/views.py | 93 ++- debug_email.py | 107 +++ docs/WALLET_STRIPE_IMPLEMENTATION.md | 676 ++++++++++++++++++ netcop_hub/settings.py | 16 + templates/authentication/forgot_password.html | 289 ++++++++ templates/authentication/login.html | 1 + templates/authentication/reset_password.html | 365 ++++++++++ test_email.py | 45 ++ test_gmail_reset.py | 79 ++ test_user_reset.py | 77 ++ test_web_form.py | 72 ++ 15 files changed, 1910 insertions(+), 2 deletions(-) create mode 100644 .env.email.example create mode 100644 authentication/migrations/0003_passwordresettoken.py create mode 100644 debug_email.py create mode 100644 docs/WALLET_STRIPE_IMPLEMENTATION.md create mode 100644 templates/authentication/forgot_password.html create mode 100644 templates/authentication/reset_password.html create mode 100644 test_email.py create mode 100644 test_gmail_reset.py create mode 100644 test_user_reset.py create mode 100644 test_web_form.py diff --git a/.env.email.example b/.env.email.example new file mode 100644 index 0000000..51db4d2 --- /dev/null +++ b/.env.email.example @@ -0,0 +1,13 @@ +# Email Configuration +EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST=smtp.gmail.com +EMAIL_PORT=587 +EMAIL_USE_TLS=True +EMAIL_HOST_USER=your-email@gmail.com +EMAIL_HOST_PASSWORD=your-app-password +DEFAULT_FROM_EMAIL=NetCop + +# Other settings (copy from existing if you have them) +SECRET_KEY=your-secret-key-here +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 \ No newline at end of file diff --git a/authentication/migrations/0003_passwordresettoken.py b/authentication/migrations/0003_passwordresettoken.py new file mode 100644 index 0000000..8e83789 --- /dev/null +++ b/authentication/migrations/0003_passwordresettoken.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.4 on 2025-07-16 06:48 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentication", "0002_alter_user_options_alter_user_created_at_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="PasswordResetToken", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "token", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("expires_at", models.DateTimeField()), + ("is_used", models.BooleanField(default=False)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="password_reset_tokens", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/authentication/models.py b/authentication/models.py index 9d7ae63..882284b 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -1,6 +1,9 @@ from django.contrib.auth.models import AbstractUser from django.db import models from decimal import Decimal +import uuid +from django.utils import timezone +from datetime import timedelta class User(AbstractUser): @@ -55,3 +58,29 @@ class User(AbstractUser): description=description, stripe_session_id=stripe_session_id ) + + +class PasswordResetToken(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='password_reset_tokens') + token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + is_used = models.BooleanField(default=False) + + def save(self, *args, **kwargs): + if not self.expires_at: + self.expires_at = timezone.now() + timedelta(hours=1) + super().save(*args, **kwargs) + + def is_valid(self): + return not self.is_used and timezone.now() < self.expires_at + + def mark_as_used(self): + self.is_used = True + self.save() + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"Password reset token for {self.user.email}" diff --git a/authentication/urls.py b/authentication/urls.py index 679d259..7e3832e 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -8,4 +8,6 @@ urlpatterns = [ path('register/', views.register_view, name='register'), path('logout/', views.logout_view, name='logout'), path('profile/', views.profile_view, name='profile'), + path('forgot-password/', views.forgot_password_view, name='forgot_password'), + path('reset-password//', views.reset_password_view, name='reset_password'), ] \ No newline at end of file diff --git a/authentication/views.py b/authentication/views.py index a8f6202..3f85af9 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -1,10 +1,13 @@ -from django.shortcuts import render, redirect +from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth import login, authenticate, logout from django.contrib.auth.decorators import login_required from django.contrib import messages from django.contrib.auth.forms import UserCreationForm from django.http import JsonResponse -from .models import User +from django.core.mail import send_mail +from django.conf import settings +from django.urls import reverse +from .models import User, PasswordResetToken def login_view(request): @@ -105,3 +108,89 @@ def profile_view(request): } return render(request, 'authentication/profile.html', context) + + +def forgot_password_view(request): + """Forgot password view - request password reset""" + if request.method == 'POST': + email = request.POST.get('email') + + try: + user = User.objects.get(email=email) + + # Create password reset token + reset_token = PasswordResetToken.objects.create(user=user) + + # Build reset URL using correct site URL + reset_path = reverse('authentication:reset_password', kwargs={'token': reset_token.token}) + reset_url = f"{settings.SITE_URL}{reset_path}" + + # Send email + subject = 'Password Reset Request' + message = f''' +Hello {user.username}, + +You requested a password reset for your NetCop account. + +Click the link below to reset your password: +{reset_url} + +This link will expire in 1 hour. + +If you didn't request this reset, please ignore this email. + +Best regards, +NetCop Team + ''' + + try: + send_mail( + subject, + message, + settings.DEFAULT_FROM_EMAIL, + [email], + fail_silently=False, + ) + messages.success(request, 'Password reset instructions have been sent to your email.') + except Exception as e: + messages.error(request, 'Failed to send reset email. Please try again later.') + + except User.DoesNotExist: + # Don't reveal if email exists or not for security + messages.success(request, 'If an account with that email exists, password reset instructions have been sent.') + + return render(request, 'authentication/forgot_password.html') + + +def reset_password_view(request, token): + """Reset password view - using token from email""" + reset_token = get_object_or_404(PasswordResetToken, token=token) + + if not reset_token.is_valid(): + messages.error(request, 'This password reset link has expired or is invalid.') + return redirect('authentication:forgot_password') + + if request.method == 'POST': + password1 = request.POST.get('password1') + password2 = request.POST.get('password2') + + if password1 != password2: + messages.error(request, 'Passwords do not match.') + return render(request, 'authentication/reset_password.html', {'token': token}) + + if len(password1) < 8: + messages.error(request, 'Password must be at least 8 characters long.') + return render(request, 'authentication/reset_password.html', {'token': token}) + + # Reset password + user = reset_token.user + user.set_password(password1) + user.save() + + # Mark token as used + reset_token.mark_as_used() + + messages.success(request, 'Your password has been reset successfully. You can now log in.') + return redirect('authentication:login') + + return render(request, 'authentication/reset_password.html', {'token': token}) diff --git a/debug_email.py b/debug_email.py new file mode 100644 index 0000000..eba08db --- /dev/null +++ b/debug_email.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +import os +import django +from django.conf import settings + +# Setup Django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'netcop_hub.settings') +django.setup() + +from django.core.mail import send_mail +from authentication.models import User, PasswordResetToken +import traceback + +def debug_email_sending(): + print("=" * 60) + print("๐Ÿ” DEBUGGING EMAIL CONFIGURATION") + print("=" * 60) + + # Check Django settings + print(f"EMAIL_BACKEND: {settings.EMAIL_BACKEND}") + print(f"EMAIL_HOST: {settings.EMAIL_HOST}") + print(f"EMAIL_PORT: {settings.EMAIL_PORT}") + print(f"EMAIL_USE_TLS: {settings.EMAIL_USE_TLS}") + print(f"EMAIL_HOST_USER: {settings.EMAIL_HOST_USER}") + print(f"EMAIL_HOST_PASSWORD: {'*' * len(settings.EMAIL_HOST_PASSWORD) if settings.EMAIL_HOST_PASSWORD else 'NOT SET'}") + print(f"DEFAULT_FROM_EMAIL: {settings.DEFAULT_FROM_EMAIL}") + print() + + # Test basic email sending + print("๐Ÿ“ง Testing basic email sending...") + try: + send_mail( + 'Test Email from NetCop', + 'This is a test email to verify email configuration.', + settings.DEFAULT_FROM_EMAIL, + [settings.EMAIL_HOST_USER], # Send to yourself + fail_silently=False, + ) + print("โœ… Basic email test PASSED") + except Exception as e: + print(f"โŒ Basic email test FAILED: {e}") + print(f"Error details: {traceback.format_exc()}") + return False + + # Test forgot password flow + print("\n๐Ÿ”‘ Testing forgot password flow...") + try: + # Get a test user + user = User.objects.filter(email=settings.EMAIL_HOST_USER).first() + if not user: + user = User.objects.first() + + if not user: + print("โŒ No users found in database") + return False + + print(f"Using test user: {user.email}") + + # Create password reset token + reset_token = PasswordResetToken.objects.create(user=user) + print(f"โœ… Password reset token created: {reset_token.token}") + + # Build reset URL + reset_url = f"http://localhost:8000/auth/reset-password/{reset_token.token}/" + + # Send reset email + subject = 'Password Reset Request - DEBUG TEST' + message = f''' +Hello {user.username}, + +This is a DEBUG TEST of the password reset functionality. + +Click the link below to reset your password: +{reset_url} + +This link will expire in 1 hour. + +If you didn't request this reset, please ignore this email. + +Best regards, +NetCop Team (DEBUG MODE) + ''' + + send_mail( + subject, + message, + settings.DEFAULT_FROM_EMAIL, + [user.email], + fail_silently=False, + ) + + print("โœ… Password reset email sent successfully!") + print(f"๐Ÿ“ง Email sent to: {user.email}") + print(f"๐Ÿ”— Reset URL: {reset_url}") + + except Exception as e: + print(f"โŒ Password reset test FAILED: {e}") + print(f"Error details: {traceback.format_exc()}") + return False + + print("\n" + "=" * 60) + print("โœ… ALL TESTS PASSED - Check your Gmail inbox!") + print("=" * 60) + return True + +if __name__ == "__main__": + debug_email_sending() \ No newline at end of file diff --git a/docs/WALLET_STRIPE_IMPLEMENTATION.md b/docs/WALLET_STRIPE_IMPLEMENTATION.md new file mode 100644 index 0000000..53159a5 --- /dev/null +++ b/docs/WALLET_STRIPE_IMPLEMENTATION.md @@ -0,0 +1,676 @@ +# ๐Ÿ’ณ NetCop Wallet/Stripe Implementation Guide + +## ๐ŸŽฏ Overview +This guide documents how to implement a professional wallet system with Stripe Payment Intents API in the NetCop Django project. The system provides real-time payment processing **without requiring webhooks** for basic functionality. + +## โœจ Key Features +- **Professional wallet topup interface** with Stripe Elements +- **Real-time payment processing** with Payment Intents API +- **Loading states and progress indicators** for better UX +- **Webhook-free operation** for development and testing +- **AED currency support** matching NetCop pricing +- **Balance checking** before agent usage +- **Transaction history** with copy/download functionality + +## ๐Ÿšซ No Webhooks Required + +### Why No Webhooks Needed: +- **Payment Intents API** provides immediate payment status +- **Frontend confirmation** happens in real-time after card processing +- **Direct database updates** via confirmed payment status +- **Duplicate prevention** through payment metadata checking + +### Payment Flow (Webhook-Free): +1. User selects topup amount โ†’ Frontend creates Payment Intent +2. Stripe Elements processes card securely โ†’ Returns success/failure +3. Frontend confirms payment status โ†’ Backend updates wallet immediately +4. User sees updated balance โ†’ Can use agents with sufficient funds + +--- + +## ๐Ÿ—๏ธ Implementation Steps + +### 1. Environment Configuration + +Add to `.env` file: +```bash +# Stripe Configuration (No webhook secret required for basic functionality) +STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here +STRIPE_SECRET_KEY=sk_test_your_secret_key_here +# STRIPE_WEBHOOK_SECRET=whsec_... (optional for production) +``` + +Add to `netcop_hub/settings.py`: +```python +# Stripe Configuration +STRIPE_SECRET_KEY = config('STRIPE_SECRET_KEY', default='') +STRIPE_PUBLISHABLE_KEY = config('STRIPE_PUBLISHABLE_KEY', default='') +STRIPE_WEBHOOK_SECRET = config('STRIPE_WEBHOOK_SECRET', default='') +``` + +### 2. User Model Enhancement + +Update `authentication/models.py` to add wallet balance: +```python +from django.contrib.auth.models import AbstractUser +from django.db import models +from decimal import Decimal + +class User(AbstractUser): + email = models.EmailField(unique=True) + wallet_balance = models.DecimalField( + max_digits=10, + decimal_places=2, + default=Decimal('0.00'), + help_text="User wallet balance in AED" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['username'] + + def has_sufficient_balance(self, amount): + """Check if user has sufficient balance for a transaction""" + return self.wallet_balance >= Decimal(str(amount)) + + def deduct_balance(self, amount, description=""): + """Deduct amount from wallet balance""" + if self.has_sufficient_balance(amount): + self.wallet_balance -= Decimal(str(amount)) + self.save() + + # Create transaction record + from wallet.models import WalletTransaction + WalletTransaction.objects.create( + user=self, + amount=-Decimal(str(amount)), + type='agent_usage', + description=description + ) + return True + return False + + def add_balance(self, amount, description=""): + """Add amount to wallet balance""" + self.wallet_balance += Decimal(str(amount)) + self.save() + + # Create transaction record + from wallet.models import WalletTransaction + WalletTransaction.objects.create( + user=self, + amount=Decimal(str(amount)), + type='top_up', + description=description + ) +``` + +### 3. Wallet Models + +Update `wallet/models.py`: +```python +from django.db import models +from django.contrib.auth import get_user_model +import uuid + +User = get_user_model() + +class WalletTransaction(models.Model): + TRANSACTION_TYPES = [ + ('top_up', 'Top Up'), + ('agent_usage', 'Agent Usage'), + ('refund', 'Refund'), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='wallet_transactions') + amount = models.DecimalField(max_digits=10, decimal_places=2) + type = models.CharField(max_length=20, choices=TRANSACTION_TYPES) + description = models.TextField() + stripe_payment_intent_id = models.CharField(max_length=200, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"{self.user.email} - {self.amount} AED ({self.type})" +``` + +### 4. Wallet Views (Payment Intents API) + +Create `wallet/views.py`: +```python +import stripe +import json +from django.conf import settings +from django.shortcuts import render +from django.views.decorators.csrf import csrf_exempt +from django.http import JsonResponse +from django.contrib.auth.decorators import login_required +from django.utils import timezone +from .models import WalletTransaction +from decimal import Decimal +from django.contrib.auth import get_user_model + +User = get_user_model() +stripe.api_key = settings.STRIPE_SECRET_KEY + +@login_required +def topup(request): + """Professional wallet topup page""" + return render(request, "wallet/topup.html", { + 'stripe_publishable_key': settings.STRIPE_PUBLISHABLE_KEY, + 'user_balance': request.user.wallet_balance + }) + +@login_required +@csrf_exempt +def create_payment_intent(request): + """Create Stripe Payment Intent for wallet topup""" + if request.method == "POST": + try: + data = json.loads(request.body) + amount = int(data.get("amount")) + + if amount < 1: + return JsonResponse({"error": "Amount must be at least 1 AED"}, status=400) + + # Create Payment Intent + intent = stripe.PaymentIntent.create( + amount=amount * 100, # Convert to fils (AED cents) + currency='aed', + metadata={ + 'user_id': request.user.id, + 'amount': amount, + 'email': request.user.email + }, + description=f"NetCop wallet top-up for {request.user.email}" + ) + + return JsonResponse({ + 'client_secret': intent.client_secret, + 'amount': amount + }) + + except Exception as e: + return JsonResponse({"error": str(e)}, status=400) + + return JsonResponse({"error": "Invalid request method"}, status=405) + +@login_required +@csrf_exempt +def confirm_payment(request): + """Confirm payment and update wallet balance""" + if request.method == "POST": + try: + data = json.loads(request.body) + payment_intent_id = data.get("payment_intent_id") + + # Retrieve payment intent from Stripe + intent = stripe.PaymentIntent.retrieve(payment_intent_id) + + if intent.status == 'succeeded': + user_id = int(intent.metadata['user_id']) + amount = Decimal(intent.metadata['amount']) + + # Verify this is the correct user + if user_id != request.user.id: + return JsonResponse({"error": "Unauthorized"}, status=403) + + # Check for duplicate processing + existing_transaction = WalletTransaction.objects.filter( + stripe_payment_intent_id=payment_intent_id + ).first() + + if not existing_transaction: + # Update user balance using model method + request.user.add_balance( + amount=amount, + description=f"Wallet top-up via Stripe - {amount} AED" + ) + + # Update the transaction with Stripe ID + latest_transaction = WalletTransaction.objects.filter( + user=request.user, + type='top_up', + amount=amount + ).first() + if latest_transaction: + latest_transaction.stripe_payment_intent_id = payment_intent_id + latest_transaction.save() + + return JsonResponse({ + "success": True, + "message": f"Successfully added {amount} AED to your wallet", + "new_balance": str(request.user.wallet_balance) + }) + else: + return JsonResponse({"error": "Payment not completed"}, status=400) + + except Exception as e: + return JsonResponse({"error": str(e)}, status=400) + + return JsonResponse({"error": "Invalid request method"}, status=405) + +@login_required +def transaction_history(request): + """View transaction history""" + transactions = request.user.wallet_transactions.all()[:50] + return render(request, "wallet/history.html", { + 'transactions': transactions, + 'current_balance': request.user.wallet_balance + }) +``` + +### 5. Wallet URLs + +Create `wallet/urls.py`: +```python +from django.urls import path +from . import views + +app_name = 'wallet' + +urlpatterns = [ + path('', views.topup, name='topup'), + path('create-payment-intent/', views.create_payment_intent, name='create_payment_intent'), + path('confirm-payment/', views.confirm_payment, name='confirm_payment'), + path('history/', views.transaction_history, name='history'), +] +``` + +### 6. Professional Topup Template + +Create `templates/wallet/topup.html`: +```html +{% extends 'base.html' %} + +{% block title %}Top Up Wallet - NetCop Hub{% endblock %} + +{% block content %} +
+
+
+

๐Ÿ’ฐ Top Up Wallet

+

Add funds to your wallet to use AI agents

+
+ +
+

Current Balance

+

+ {{ user_balance|floatformat:2 }} AED +

+
+ + +
+ +
+ + + +
+ +
+ + +
+
+ +
+ +
+ +
+ + +
+ + + +
+
+ + + + +{% endblock %} +``` + +### 7. Update Navigation + +Update `templates/base.html` to include wallet balance in navigation: +```html + +{% if user.is_authenticated %} +

Welcome, {{ user.username }}!

+ ๐Ÿ’ฐ {{ user.wallet_balance|floatformat:2 }} AED + +{% endif %} +``` + +### 8. Update Main URLs + +Add wallet URLs to `netcop_hub/urls.py`: +```python +urlpatterns = [ + path('admin/', admin.site.urls), + path('auth/', include('authentication.urls')), + path('wallet/', include('wallet.urls')), # Add this line + # ... other URLs +] +``` + +--- + +## ๐Ÿงช Testing Guide + +### 1. Database Migration +```bash +python manage.py makemigrations +python manage.py migrate +``` + +### 2. Test with Stripe Test Cards +- **Successful payment**: `4242 4242 4242 4242` +- **Requires authentication**: `4000 0025 0000 3155` +- **Declined card**: `4000 0000 0000 9995` + +### 3. Testing Checklist +- [ ] User can access wallet topup page +- [ ] Amount selection buttons work +- [ ] Card form validates properly +- [ ] Payment processing shows loading states +- [ ] Successful payments update balance immediately +- [ ] Failed payments show error messages +- [ ] Balance displays in navigation +- [ ] Transaction history is recorded + +--- + +## ๐Ÿš€ Advanced Features (Optional) + +### Agent Integration +Update agent views to check wallet balance: +```python +@login_required +def use_agent(request, agent_slug): + agent = get_object_or_404(BaseAgent, slug=agent_slug) + + if not request.user.has_sufficient_balance(agent.price): + return JsonResponse({ + 'error': f'Insufficient balance. Need {agent.price} AED.', + 'redirect_url': reverse('wallet:topup') + }, status=400) + + # Deduct balance before processing + request.user.deduct_balance( + amount=agent.price, + description=f"Used {agent.name} agent" + ) + + # Process agent request... +``` + +### Transaction History Page +Create `templates/wallet/history.html`: +```html +{% extends 'base.html' %} + +{% block content %} +
+

Transaction History

+

Current Balance: {{ current_balance }} AED

+ +
+ {% for transaction in transactions %} +
+ {{ transaction.amount }} AED + {{ transaction.get_type_display }} + {{ transaction.created_at|date:"M d, Y H:i" }} +
+ {% endfor %} +
+
+{% endblock %} +``` + +--- + +## ๐Ÿ”ง Troubleshooting + +### Common Issues: +1. **Stripe keys not working**: Verify test keys are correct in `.env` +2. **Payment not confirming**: Check browser console for JavaScript errors +3. **Balance not updating**: Ensure user model has wallet_balance field +4. **CSS not loading**: Run `python manage.py collectstatic` + +### Debug Mode: +Add to views.py for debugging: +```python +import logging +logger = logging.getLogger(__name__) + +# In payment views: +logger.info(f"Payment Intent created: {intent.id}") +logger.info(f"User {request.user.id} balance updated: {request.user.wallet_balance}") +``` + +--- + +## โœ… Production Checklist + +Before deploying to production: +- [ ] Switch to live Stripe keys +- [ ] Set up webhook endpoints (optional but recommended) +- [ ] Enable HTTPS for secure payments +- [ ] Set DEBUG=False in settings +- [ ] Configure proper error logging +- [ ] Test with real payment amounts +- [ ] Set up monitoring for failed payments + +--- + +This implementation provides a complete, professional wallet system with Stripe integration that works without webhooks for development and testing, while being easily extensible for production use. \ No newline at end of file diff --git a/netcop_hub/settings.py b/netcop_hub/settings.py index 476c2eb..af74794 100644 --- a/netcop_hub/settings.py +++ b/netcop_hub/settings.py @@ -42,6 +42,12 @@ DEBUG = config('DEBUG', default=True, cast=bool) ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost,127.0.0.1,testserver,netcop.up.railway.app').split(',') +# Site URL configuration for emails +if config('RAILWAY_ENVIRONMENT', default=''): + SITE_URL = 'https://netcop.up.railway.app' +else: + SITE_URL = config('SITE_URL', default='http://localhost:8000') + # Application definition @@ -256,6 +262,16 @@ N8N_WEBHOOK_SOCIAL_ADS = config('N8N_WEBHOOK_SOCIAL_ADS', default='') # OpenWeather API OPENWEATHER_API_KEY = config('OPENWEATHER_API_KEY', default='') +# Email Configuration +EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend') +EMAIL_HOST = config('EMAIL_HOST', default='smtp.gmail.com') +EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int) +EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool) +EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='') +EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='') +EMAIL_FILE_PATH = config('EMAIL_FILE_PATH', default='/tmp/app-messages') +DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='NetCop ') + # Security settings CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in config('CSRF_TRUSTED_ORIGINS', default='').split(',') if origin.strip()] diff --git a/templates/authentication/forgot_password.html b/templates/authentication/forgot_password.html new file mode 100644 index 0000000..a4d685b --- /dev/null +++ b/templates/authentication/forgot_password.html @@ -0,0 +1,289 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Forgot Password{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+ +
+

Forgot Password

+

Enter your email address and we'll send you a link to reset your password

+
+ + + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + + +
+ {% csrf_token %} + +
+ + +
+ + +
+ + + +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/templates/authentication/login.html b/templates/authentication/login.html index 8ba9bd8..5b0f886 100644 --- a/templates/authentication/login.html +++ b/templates/authentication/login.html @@ -311,6 +311,7 @@ diff --git a/templates/authentication/reset_password.html b/templates/authentication/reset_password.html new file mode 100644 index 0000000..932a1bc --- /dev/null +++ b/templates/authentication/reset_password.html @@ -0,0 +1,365 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Reset Password{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+ +
+

Reset Password

+

Enter your new password below

+
+ + + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + + +
+ {% csrf_token %} + +
+ + +
+

Password Requirements:

+
    +
  • At least 8 characters long
  • +
  • Mix of letters, numbers, and symbols recommended
  • +
  • Should not be commonly used
  • +
+
+
+ +
+ + +
+ + +
+ + + +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/test_email.py b/test_email.py new file mode 100644 index 0000000..8268965 --- /dev/null +++ b/test_email.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +import os +import django +from django.conf import settings + +# Setup Django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'netcop_hub.settings') +django.setup() + +from django.core.mail import send_mail +from authentication.models import User + +# Test email sending +def test_email(): + print("Testing email configuration...") + + # Check settings + print(f"EMAIL_BACKEND: {settings.EMAIL_BACKEND}") + print(f"EMAIL_FILE_PATH: {getattr(settings, 'EMAIL_FILE_PATH', 'Not set')}") + + # Send test email + try: + send_mail( + 'Test Email', + 'This is a test email from Django.', + settings.DEFAULT_FROM_EMAIL, + ['test@example.com'], + fail_silently=False, + ) + print("โœ… Email sent successfully!") + print("๐Ÿ“ Check /tmp/app-messages/ for the email file") + + # List files in email directory + import glob + files = glob.glob('/tmp/app-messages/*') + if files: + print(f"๐Ÿ“ง Email files: {files}") + else: + print("โš ๏ธ No email files found") + + except Exception as e: + print(f"โŒ Error sending email: {e}") + +if __name__ == "__main__": + test_email() \ No newline at end of file diff --git a/test_gmail_reset.py b/test_gmail_reset.py new file mode 100644 index 0000000..e291e5d --- /dev/null +++ b/test_gmail_reset.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +import os +import django +from django.conf import settings + +# Setup Django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'netcop_hub.settings') +django.setup() + +from django.core.mail import send_mail +from authentication.models import User, PasswordResetToken +import traceback + +def test_gmail_reset(): + print("๐Ÿ” Testing forgot password with Gmail address") + print("=" * 50) + + gmail_address = settings.EMAIL_HOST_USER # thecyberlearn@gmail.com + + # Create or update user with Gmail address + user, created = User.objects.update_or_create( + email=gmail_address, + defaults={ + 'username': 'gmail_user', + 'is_active': True, + } + ) + + if created: + print(f"โœ… Created new user: {user.email}") + user.set_password('temppassword123') + user.save() + else: + print(f"โœ… Using existing user: {user.email}") + + # Create password reset token + reset_token = PasswordResetToken.objects.create(user=user) + + # Build reset URL + reset_url = f"http://localhost:8000/auth/reset-password/{reset_token.token}/" + + # Send reset email + subject = 'NetCop Password Reset Request' + message = f''' +Hello {user.username}, + +You requested a password reset for your NetCop account. + +Click the link below to reset your password: +{reset_url} + +This link will expire in 1 hour. + +If you didn't request this reset, please ignore this email. + +Best regards, +NetCop Team + ''' + + try: + send_mail( + subject, + message, + settings.DEFAULT_FROM_EMAIL, + [user.email], + fail_silently=False, + ) + + print(f"โœ… Password reset email sent to: {user.email}") + print(f"๐Ÿ”— Reset URL: {reset_url}") + print("\n๐Ÿ“ง CHECK YOUR GMAIL INBOX!") + print("Note: Check spam folder if not in inbox") + + except Exception as e: + print(f"โŒ Failed to send email: {e}") + print(f"Error details: {traceback.format_exc()}") + +if __name__ == "__main__": + test_gmail_reset() \ No newline at end of file diff --git a/test_user_reset.py b/test_user_reset.py new file mode 100644 index 0000000..122ada5 --- /dev/null +++ b/test_user_reset.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +import os +import django +from django.conf import settings + +# Setup Django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'netcop_hub.settings') +django.setup() + +from django.core.mail import send_mail +from authentication.models import User, PasswordResetToken +import traceback + +def test_user_reset(): + print("๐Ÿ” Testing forgot password with user: amitrana01@gmail.com") + print("=" * 60) + + target_email = "amitrana01@gmail.com" + + # Check if user exists + try: + user = User.objects.get(email=target_email) + print(f"โœ… Found user: {user.username} ({user.email})") + except User.DoesNotExist: + print(f"โŒ User with email {target_email} not found") + return False + + # Create password reset token + reset_token = PasswordResetToken.objects.create(user=user) + print(f"โœ… Password reset token created: {reset_token.token}") + + # Build reset URL + reset_url = f"http://localhost:8000/auth/reset-password/{reset_token.token}/" + + # Send reset email (same as in the actual view) + subject = 'Password Reset Request' + message = f''' +Hello {user.username}, + +You requested a password reset for your NetCop account. + +Click the link below to reset your password: +{reset_url} + +This link will expire in 1 hour. + +If you didn't request this reset, please ignore this email. + +Best regards, +NetCop Team + ''' + + try: + send_mail( + subject, + message, + settings.DEFAULT_FROM_EMAIL, + [user.email], + fail_silently=False, + ) + + print(f"โœ… Password reset email sent successfully!") + print(f"๐Ÿ“ง Email sent to: {user.email}") + print(f"๐Ÿ”— Reset URL: {reset_url}") + print(f"โฐ Token expires in: 1 hour") + print("\n๐Ÿ“ง CHECK THE INBOX FOR: amitrana01@gmail.com") + print("Note: Check spam folder if not in inbox") + + return True + + except Exception as e: + print(f"โŒ Failed to send email: {e}") + print(f"Error details: {traceback.format_exc()}") + return False + +if __name__ == "__main__": + test_user_reset() \ No newline at end of file diff --git a/test_web_form.py b/test_web_form.py new file mode 100644 index 0000000..2a00467 --- /dev/null +++ b/test_web_form.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +import requests +import re +from bs4 import BeautifulSoup + +def test_web_form(): + print("๐Ÿ” Testing forgot password web form submission") + print("=" * 50) + + base_url = "http://localhost:8000" + session = requests.Session() + + # Step 1: Get the forgot password page + print("1. Getting forgot password page...") + response = session.get(f"{base_url}/auth/forgot-password/") + + if response.status_code != 200: + print(f"โŒ Failed to load forgot password page: {response.status_code}") + return False + + # Step 2: Extract CSRF token + soup = BeautifulSoup(response.text, 'html.parser') + csrf_token = soup.find('input', {'name': 'csrfmiddlewaretoken'}) + + if not csrf_token: + print("โŒ CSRF token not found") + return False + + csrf_value = csrf_token.get('value') + print(f"โœ… CSRF token extracted: {csrf_value[:20]}...") + + # Step 3: Submit the form + print("2. Submitting forgot password form...") + form_data = { + 'csrfmiddlewaretoken': csrf_value, + 'email': 'amitrana01@gmail.com' + } + + response = session.post(f"{base_url}/auth/forgot-password/", data=form_data) + + if response.status_code != 200: + print(f"โŒ Form submission failed: {response.status_code}") + return False + + # Step 4: Check for success message + soup = BeautifulSoup(response.text, 'html.parser') + messages = soup.find_all('div', class_='message') + + if messages: + for msg in messages: + print(f"๐Ÿ“ง Message: {msg.get_text().strip()}") + if 'sent' in msg.get_text().lower() or 'instructions' in msg.get_text().lower(): + print("โœ… Success message found!") + return True + + print("โš ๏ธ No success message found in response") + + # Check if there are any error messages + error_messages = soup.find_all('div', class_='message error') + if error_messages: + for msg in error_messages: + print(f"โŒ Error: {msg.get_text().strip()}") + + return False + +if __name__ == "__main__": + try: + test_web_form() + except Exception as e: + print(f"โŒ Test failed with error: {e}") + import traceback + traceback.print_exc() \ No newline at end of file