Internal
Public Access
Add email verification, admin approval, and user profile enhancements
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>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
87afc4e80c
commit
21d9d01885
@@ -68,6 +68,11 @@ WSGI_APPLICATION = 'config.wsgi.application'
|
|||||||
# Custom user model
|
# Custom user model
|
||||||
AUTH_USER_MODEL = 'users.User'
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|
||||||
|
# Authentication backends
|
||||||
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
'users.backends.EmailVerifiedApprovedBackend',
|
||||||
|
]
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
@@ -147,3 +152,9 @@ USAGE_LIMITS_ENABLED = SAAS_MODE
|
|||||||
LOGIN_URL = '/login/'
|
LOGIN_URL = '/login/'
|
||||||
LOGIN_REDIRECT_URL = '/'
|
LOGIN_REDIRECT_URL = '/'
|
||||||
LOGOUT_REDIRECT_URL = '/login/'
|
LOGOUT_REDIRECT_URL = '/login/'
|
||||||
|
|
||||||
|
# Email verification settings
|
||||||
|
EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS = int(
|
||||||
|
os.environ.get('EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', 24)
|
||||||
|
)
|
||||||
|
SITE_DOMAIN = os.environ.get('SITE_DOMAIN', 'localhost:8000')
|
||||||
|
|||||||
@@ -125,13 +125,14 @@ class TaskSyncSerializer(serializers.ModelSerializer):
|
|||||||
"""Serializer for tasks in sync responses - uses sync_id as id."""
|
"""Serializer for tasks in sync responses - uses sync_id as id."""
|
||||||
|
|
||||||
id = serializers.UUIDField(source='sync_id', read_only=True)
|
id = serializers.UUIDField(source='sync_id', read_only=True)
|
||||||
|
sync_id = serializers.UUIDField(read_only=True)
|
||||||
tag_sync_ids = serializers.SerializerMethodField()
|
tag_sync_ids = serializers.SerializerMethodField()
|
||||||
parent_sync_id = serializers.UUIDField(source='parent.sync_id', read_only=True, allow_null=True)
|
parent_sync_id = serializers.UUIDField(source='parent.sync_id', read_only=True, allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Task
|
model = Task
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'parent', 'parent_sync_id',
|
'id', 'sync_id', 'parent', 'parent_sync_id',
|
||||||
'title', 'description', 'status', 'priority', 'due_date', 'due_time',
|
'title', 'description', 'status', 'priority', 'due_date', 'due_time',
|
||||||
'reminder_at', 'completed_at', 'recurrence', 'recurrence_rule',
|
'reminder_at', 'completed_at', 'recurrence', 'recurrence_rule',
|
||||||
'recurrence_end_date', 'tag_sync_ids',
|
'recurrence_end_date', 'tag_sync_ids',
|
||||||
@@ -147,10 +148,11 @@ class TagSyncSerializer(serializers.ModelSerializer):
|
|||||||
"""Serializer for tags in sync responses - uses sync_id as id."""
|
"""Serializer for tags in sync responses - uses sync_id as id."""
|
||||||
|
|
||||||
id = serializers.UUIDField(source='sync_id', read_only=True)
|
id = serializers.UUIDField(source='sync_id', read_only=True)
|
||||||
|
sync_id = serializers.UUIDField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
fields = ['id', 'name', 'description', 'color', 'icon', 'sort_order',
|
fields = ['id', 'sync_id', 'name', 'description', 'color', 'icon', 'sort_order',
|
||||||
'is_archived', 'is_deleted', 'created_at', 'updated_at']
|
'is_archived', 'is_deleted', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
@@ -158,11 +160,12 @@ class TimeEntrySyncSerializer(serializers.ModelSerializer):
|
|||||||
"""Serializer for time entries in sync responses - uses sync_id as id."""
|
"""Serializer for time entries in sync responses - uses sync_id as id."""
|
||||||
|
|
||||||
id = serializers.UUIDField(source='sync_id', read_only=True)
|
id = serializers.UUIDField(source='sync_id', read_only=True)
|
||||||
|
sync_id = serializers.UUIDField(read_only=True)
|
||||||
task = serializers.UUIDField(source='task.sync_id', read_only=True)
|
task = serializers.UUIDField(source='task.sync_id', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TimeEntry
|
model = TimeEntry
|
||||||
fields = ['id', 'task', 'started_at', 'ended_at', 'notes',
|
fields = ['id', 'sync_id', 'task', 'started_at', 'ended_at', 'notes',
|
||||||
'is_deleted', 'created_at', 'updated_at']
|
'is_deleted', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="header-user">
|
<div class="header-user">
|
||||||
<span>{{ user.email }}</span>
|
<span>{{ user.email }}</span>
|
||||||
|
<a href="{% url 'profile' %}" class="btn btn-secondary btn-sm">Profile</a>
|
||||||
<a href="{% url 'logout' %}" class="btn btn-secondary btn-sm">Logout</a>
|
<a href="{% url 'logout' %}" class="btn btn-secondary btn-sm">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 5px;">
|
||||||
|
<h2 style="color: #10B981; margin-top: 0;">Your Account Has Been Approved!</h2>
|
||||||
|
|
||||||
|
<p>Hi {{ user.first_name|default:user.username }},</p>
|
||||||
|
|
||||||
|
<p>Great news! Your KeepItGoing account has been approved by our team.</p>
|
||||||
|
|
||||||
|
<p>You can now log in and start using KeepItGoing to manage your tasks and stay organized.</p>
|
||||||
|
|
||||||
|
<div style="margin: 30px 0; text-align: center;">
|
||||||
|
<a href="{{ login_url }}"
|
||||||
|
style="background-color: #10B981; color: white; padding: 12px 24px;
|
||||||
|
text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">
|
||||||
|
Log In Now
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>We're excited to have you on board!</p>
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
If you have any questions, please don't hesitate to reach out to our support team.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
Your Account Has Been Approved!
|
||||||
|
|
||||||
|
Hi {{ user.first_name|default:user.username }},
|
||||||
|
|
||||||
|
Great news! Your KeepItGoing account has been approved by our team.
|
||||||
|
|
||||||
|
You can now log in and start using KeepItGoing to manage your tasks and stay organized.
|
||||||
|
|
||||||
|
Log in here: {{ login_url }}
|
||||||
|
|
||||||
|
We're excited to have you on board!
|
||||||
|
|
||||||
|
---
|
||||||
|
KeepItGoing
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 5px;">
|
||||||
|
<h2 style="color: #2c3e50; margin-top: 0;">New User Registration</h2>
|
||||||
|
|
||||||
|
<p>A new user has registered and verified their email address:</p>
|
||||||
|
|
||||||
|
<div style="background-color: #e9ecef; padding: 15px; border-radius: 3px; margin: 20px 0;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 5px 0;"><strong>Email:</strong></td>
|
||||||
|
<td style="padding: 5px 0;">{{ user.email }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 5px 0;"><strong>Username:</strong></td>
|
||||||
|
<td style="padding: 5px 0;">{{ user.username }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if user.first_name or user.last_name %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 5px 0;"><strong>Name:</strong></td>
|
||||||
|
<td style="padding: 5px 0;">{{ user.first_name }} {{ user.last_name }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 5px 0;"><strong>Registered:</strong></td>
|
||||||
|
<td style="padding: 5px 0;">{{ user.created_at|date:"F j, Y, g:i a" }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Please review and approve this user in the admin panel:</p>
|
||||||
|
|
||||||
|
<div style="margin: 30px 0; text-align: center;">
|
||||||
|
<a href="{{ admin_url }}"
|
||||||
|
style="background-color: #3B82F6; color: white; padding: 12px 24px;
|
||||||
|
text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">
|
||||||
|
Review in Admin Panel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
This is an automated notification from KeepItGoing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
New User Registration
|
||||||
|
|
||||||
|
A new user has registered and verified their email address:
|
||||||
|
|
||||||
|
Email: {{ user.email }}
|
||||||
|
Username: {{ user.username }}
|
||||||
|
{% if user.first_name or user.last_name %}Name: {{ user.first_name }} {{ user.last_name }}
|
||||||
|
{% endif %}Registered: {{ user.created_at|date:"F j, Y, g:i a" }}
|
||||||
|
|
||||||
|
Please review and approve this user in the admin panel:
|
||||||
|
{{ admin_url }}
|
||||||
|
|
||||||
|
---
|
||||||
|
KeepItGoing (automated notification)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 5px;">
|
||||||
|
<h2 style="color: #2c3e50; margin-top: 0;">Verify Your Email Address</h2>
|
||||||
|
|
||||||
|
<p>Hi{% if user.first_name %} {{ user.first_name }}{% endif %},</p>
|
||||||
|
|
||||||
|
<p>Thank you for registering with KeepItGoing! Please verify your email address by clicking the button below:</p>
|
||||||
|
|
||||||
|
<div style="margin: 30px 0; text-align: center;">
|
||||||
|
<a href="{{ verification_url }}"
|
||||||
|
style="background-color: #3B82F6; color: white; padding: 12px 24px;
|
||||||
|
text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">
|
||||||
|
Verify Email Address
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Or copy and paste this link into your browser:</p>
|
||||||
|
<p style="word-break: break-all; color: #3B82F6; background-color: #e9ecef; padding: 10px; border-radius: 3px;">
|
||||||
|
{{ verification_url }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 30px;">
|
||||||
|
<small style="color: #666;">This link will expire in {{ expiry_hours }} hours.</small>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
If you didn't create an account with KeepItGoing, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
Verify Your Email Address
|
||||||
|
|
||||||
|
Hi{% if user.first_name %} {{ user.first_name }}{% endif %},
|
||||||
|
|
||||||
|
Thank you for registering with KeepItGoing! Please verify your email address by visiting this link:
|
||||||
|
|
||||||
|
{{ verification_url }}
|
||||||
|
|
||||||
|
This link will expire in {{ expiry_hours }} hours.
|
||||||
|
|
||||||
|
If you didn't create an account with KeepItGoing, you can safely ignore this email.
|
||||||
|
|
||||||
|
---
|
||||||
|
KeepItGoing
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Change Password - KeepItGoing{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="app-layout" style="display: flex; min-height: 100vh;">
|
||||||
|
<!-- Simple header for non-dashboard pages -->
|
||||||
|
<div style="position: fixed; top: 0; left: 0; right: 0; background: var(--color-bg-secondary, #fff); border-bottom: 1px solid var(--color-border, #e5e7eb); padding: 1rem 2rem; z-index: 100; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<a href="{% url 'dashboard' %}" style="font-size: 1.25rem; font-weight: 600; text-decoration: none; color: var(--color-text, #111);">KeepItGoing</a>
|
||||||
|
<div style="display: flex; gap: 1rem; align-items: center;">
|
||||||
|
<span style="color: var(--color-text-secondary, #666);">{{ user.email }}</span>
|
||||||
|
<a href="{% url 'profile' %}" style="color: var(--color-primary, #3B82F6); text-decoration: none;">Profile</a>
|
||||||
|
<a href="{% url 'logout' %}" style="color: var(--color-text-secondary, #666); text-decoration: none;">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div style="flex: 1; padding: 5rem 2rem 2rem; max-width: 600px; margin: 0 auto; width: 100%;">
|
||||||
|
<h1 style="font-size: 2rem; font-weight: 700; margin-bottom: 2rem; color: var(--color-text, #111);">Change Password</h1>
|
||||||
|
|
||||||
|
{% if success %}
|
||||||
|
<div style="background-color: #d1fae5; border: 1px solid #6ee7b7; color: #065f46; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
|
||||||
|
<strong>✓ Success!</strong><br>
|
||||||
|
{{ success }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" style="background: var(--color-bg-secondary, #fff); padding: 2rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<label for="old_password" style="display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--color-text, #111);">Current Password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="old_password"
|
||||||
|
name="old_password"
|
||||||
|
required
|
||||||
|
style="width: 100%; padding: 0.75rem; border: 1px solid var(--color-border, #d1d5db); border-radius: 0.375rem; font-size: 1rem;">
|
||||||
|
{% if errors.old_password %}
|
||||||
|
<p style="color: #dc2626; margin-top: 0.25rem; font-size: 0.875rem;">{{ errors.old_password }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<label for="new_password" style="display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--color-text, #111);">New Password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="new_password"
|
||||||
|
name="new_password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
style="width: 100%; padding: 0.75rem; border: 1px solid var(--color-border, #d1d5db); border-radius: 0.375rem; font-size: 1rem;">
|
||||||
|
<p style="color: var(--color-text-secondary, #666); margin-top: 0.25rem; font-size: 0.875rem;">Must be at least 8 characters</p>
|
||||||
|
{% if errors.new_password %}
|
||||||
|
<p style="color: #dc2626; margin-top: 0.25rem; font-size: 0.875rem;">{{ errors.new_password }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<label for="confirm_password" style="display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--color-text, #111);">Confirm New Password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
style="width: 100%; padding: 0.75rem; border: 1px solid var(--color-border, #d1d5db); border-radius: 0.375rem; font-size: 1rem;">
|
||||||
|
{% if errors.confirm_password %}
|
||||||
|
<p style="color: #dc2626; margin-top: 0.25rem; font-size: 0.875rem;">{{ errors.confirm_password }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 1rem;">
|
||||||
|
<button type="submit"
|
||||||
|
style="flex: 1; background-color: #3B82F6; color: white; padding: 0.75rem 1.5rem; border: none; border-radius: 0.375rem; font-size: 1rem; font-weight: 600; cursor: pointer;">
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'profile' %}"
|
||||||
|
style="flex: 1; background-color: var(--color-bg, #f3f4f6); color: var(--color-text, #111); padding: 0.75rem 1.5rem; border: none; border-radius: 0.375rem; font-size: 1rem; font-weight: 600; text-decoration: none; text-align: center; display: flex; align-items: center; justify-content: center;">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top: 2rem; text-align: center;">
|
||||||
|
<a href="{% url 'dashboard' %}" style="color: var(--color-text-secondary, #666); text-decoration: none; font-size: 0.875rem;">← Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -7,14 +7,24 @@
|
|||||||
<h1 class="auth-title">Login</h1>
|
<h1 class="auth-title">Login</h1>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-error">{{ error }}</div>
|
<div class="alert alert-error">
|
||||||
|
{{ error }}
|
||||||
|
{% if can_resend %}
|
||||||
|
<p style="margin-top: 10px;">
|
||||||
|
<a href="{% url 'resend-verification' %}" style="color: inherit; text-decoration: underline;">
|
||||||
|
Resend verification email
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="email">Email</label>
|
<label class="form-label" for="email">Email</label>
|
||||||
<input type="email" class="form-input" id="email" name="email" required autofocus>
|
<input type="email" class="form-input" id="email" name="email"
|
||||||
|
value="{{ email|default:'' }}" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="password">Password</label>
|
<label class="form-label" for="password">Password</label>
|
||||||
|
|||||||
@@ -3,75 +3,125 @@
|
|||||||
{% block title %}Profile - KeepItGoing{% endblock %}
|
{% block title %}Profile - KeepItGoing{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
<div class="page-header" style="margin-bottom: 2rem;">
|
||||||
<h1>Profile Settings</h1>
|
<h1 style="font-size: 1.875rem; font-weight: 700;">Profile Settings</h1>
|
||||||
|
<p style="color: var(--text-secondary); margin-top: 0.5rem;">Manage your account information and preferences</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if success %}
|
{% if success %}
|
||||||
<div class="alert alert-success">{{ success }}</div>
|
<div class="alert alert-success" style="margin-bottom: 1.5rem;">
|
||||||
|
<strong>✓ Success!</strong> {{ success }}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card">
|
<!-- Personal Information -->
|
||||||
|
<div class="card" style="background: var(--surface); padding: 1.5rem; border-radius: 0.75rem; margin-bottom: 1.5rem; border: 1px solid var(--border);">
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 1.5rem;">Personal Information</h2>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="form-row">
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="first_name">First Name</label>
|
<label for="first_name" style="display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem;">First Name</label>
|
||||||
<input type="text" id="first_name" name="first_name" value="{{ user.first_name }}">
|
<input type="text"
|
||||||
|
id="first_name"
|
||||||
|
name="first_name"
|
||||||
|
value="{{ user.first_name }}"
|
||||||
|
style="width: 100%; padding: 0.625rem; background: var(--bg); border: 1px solid var(--border); border-radius: 0.375rem; color: var(--text-primary); font-size: 0.875rem;">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="last_name">Last Name</label>
|
<label for="last_name" style="display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem;">Last Name</label>
|
||||||
<input type="text" id="last_name" name="last_name" value="{{ user.last_name }}">
|
<input type="text"
|
||||||
|
id="last_name"
|
||||||
|
name="last_name"
|
||||||
|
value="{{ user.last_name }}"
|
||||||
|
style="width: 100%; padding: 0.625rem; background: var(--bg); border: 1px solid var(--border); border-radius: 0.375rem; color: var(--text-primary); font-size: 0.875rem;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||||||
<label for="email">Email</label>
|
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem;">Email</label>
|
||||||
<input type="email" id="email" value="{{ user.email }}" disabled>
|
<input type="email"
|
||||||
<small>Email cannot be changed</small>
|
id="email"
|
||||||
|
value="{{ user.email }}"
|
||||||
|
disabled
|
||||||
|
style="width: 100%; padding: 0.625rem; background: var(--surface); border: 1px solid var(--border); border-radius: 0.375rem; color: var(--text-secondary); font-size: 0.875rem; cursor: not-allowed; opacity: 0.6;">
|
||||||
|
<small style="display: block; margin-top: 0.375rem; color: var(--text-secondary); font-size: 0.75rem;">Email cannot be changed</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||||||
<label for="timezone">Timezone</label>
|
<label for="timezone" style="display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem;">Timezone</label>
|
||||||
<select id="timezone" name="timezone">
|
<select id="timezone"
|
||||||
|
name="timezone"
|
||||||
|
style="width: 100%; padding: 0.625rem; background: var(--bg); border: 1px solid var(--border); border-radius: 0.375rem; color: var(--text-primary); font-size: 0.875rem;">
|
||||||
<option value="UTC" {% if user.timezone == 'UTC' %}selected{% endif %}>UTC</option>
|
<option value="UTC" {% if user.timezone == 'UTC' %}selected{% endif %}>UTC</option>
|
||||||
<option value="America/New_York" {% if user.timezone == 'America/New_York' %}selected{% endif %}>Eastern Time</option>
|
<option value="America/New_York" {% if user.timezone == 'America/New_York' %}selected{% endif %}>Eastern Time (ET)</option>
|
||||||
<option value="America/Chicago" {% if user.timezone == 'America/Chicago' %}selected{% endif %}>Central Time</option>
|
<option value="America/Chicago" {% if user.timezone == 'America/Chicago' %}selected{% endif %}>Central Time (CT)</option>
|
||||||
<option value="America/Denver" {% if user.timezone == 'America/Denver' %}selected{% endif %}>Mountain Time</option>
|
<option value="America/Denver" {% if user.timezone == 'America/Denver' %}selected{% endif %}>Mountain Time (MT)</option>
|
||||||
<option value="America/Los_Angeles" {% if user.timezone == 'America/Los_Angeles' %}selected{% endif %}>Pacific Time</option>
|
<option value="America/Los_Angeles" {% if user.timezone == 'America/Los_Angeles' %}selected{% endif %}>Pacific Time (PT)</option>
|
||||||
<option value="Europe/London" {% if user.timezone == 'Europe/London' %}selected{% endif %}>London</option>
|
<option value="Europe/London" {% if user.timezone == 'Europe/London' %}selected{% endif %}>London (GMT)</option>
|
||||||
<option value="Europe/Paris" {% if user.timezone == 'Europe/Paris' %}selected{% endif %}>Paris</option>
|
<option value="Europe/Paris" {% if user.timezone == 'Europe/Paris' %}selected{% endif %}>Paris (CET)</option>
|
||||||
<option value="Asia/Tokyo" {% if user.timezone == 'Asia/Tokyo' %}selected{% endif %}>Tokyo</option>
|
<option value="Asia/Tokyo" {% if user.timezone == 'Asia/Tokyo' %}selected{% endif %}>Tokyo (JST)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" style="margin-bottom: 1.5rem;">
|
||||||
<label for="default_reminder_minutes">Default Reminder (minutes before due)</label>
|
<label for="default_reminder_minutes" style="display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem;">Default Reminder</label>
|
||||||
<select id="default_reminder_minutes" name="default_reminder_minutes">
|
<select id="default_reminder_minutes"
|
||||||
|
name="default_reminder_minutes"
|
||||||
|
style="width: 100%; padding: 0.625rem; background: var(--bg); border: 1px solid var(--border); border-radius: 0.375rem; color: var(--text-primary); font-size: 0.875rem;">
|
||||||
<option value="0" {% if user.default_reminder_minutes == 0 %}selected{% endif %}>No reminder</option>
|
<option value="0" {% if user.default_reminder_minutes == 0 %}selected{% endif %}>No reminder</option>
|
||||||
<option value="5" {% if user.default_reminder_minutes == 5 %}selected{% endif %}>5 minutes</option>
|
<option value="5" {% if user.default_reminder_minutes == 5 %}selected{% endif %}>5 minutes before</option>
|
||||||
<option value="15" {% if user.default_reminder_minutes == 15 %}selected{% endif %}>15 minutes</option>
|
<option value="15" {% if user.default_reminder_minutes == 15 %}selected{% endif %}>15 minutes before</option>
|
||||||
<option value="30" {% if user.default_reminder_minutes == 30 %}selected{% endif %}>30 minutes</option>
|
<option value="30" {% if user.default_reminder_minutes == 30 %}selected{% endif %}>30 minutes before</option>
|
||||||
<option value="60" {% if user.default_reminder_minutes == 60 %}selected{% endif %}>1 hour</option>
|
<option value="60" {% if user.default_reminder_minutes == 60 %}selected{% endif %}>1 hour before</option>
|
||||||
<option value="1440" {% if user.default_reminder_minutes == 1440 %}selected{% endif %}>1 day</option>
|
<option value="1440" {% if user.default_reminder_minutes == 1440 %}selected{% endif %}>1 day before</option>
|
||||||
</select>
|
</select>
|
||||||
|
<small style="display: block; margin-top: 0.375rem; color: var(--text-secondary); font-size: 0.75rem;">How long before a task is due should you be reminded?</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div style="margin-bottom: 1.5rem;">
|
||||||
<label class="checkbox-label">
|
<label style="display: flex; align-items: center; cursor: pointer; padding: 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: 0.375rem; margin-bottom: 0.75rem;">
|
||||||
<input type="checkbox" name="email_notifications" {% if user.email_notifications %}checked{% endif %}>
|
<input type="checkbox"
|
||||||
Email Notifications
|
name="email_notifications"
|
||||||
|
{% if user.email_notifications %}checked{% endif %}
|
||||||
|
style="margin-right: 0.75rem; width: 1rem; height: 1rem;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 500; font-size: 0.875rem;">Email Notifications</div>
|
||||||
|
<div style="color: var(--text-secondary); font-size: 0.75rem;">Receive task reminders and updates via email</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style="display: flex; align-items: center; cursor: pointer; padding: 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: 0.375rem;">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="push_notifications"
|
||||||
|
{% if user.push_notifications %}checked{% endif %}
|
||||||
|
style="margin-right: 0.75rem; width: 1rem; height: 1rem;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 500; font-size: 0.875rem;">Push Notifications</div>
|
||||||
|
<div style="color: var(--text-secondary); font-size: 0.75rem;">Receive push notifications on your devices</div>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<button type="submit"
|
||||||
<label class="checkbox-label">
|
class="btn btn-primary"
|
||||||
<input type="checkbox" name="push_notifications" {% if user.push_notifications %}checked{% endif %}>
|
style="padding: 0.625rem 1.25rem; font-size: 0.875rem;">
|
||||||
Push Notifications
|
Save Changes
|
||||||
</label>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Section -->
|
||||||
|
<div class="card" style="background: var(--surface); padding: 1.5rem; border-radius: 0.75rem; border: 1px solid var(--border);">
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem;">Security</h2>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 1.25rem; font-size: 0.875rem;">Manage your account security settings</p>
|
||||||
|
|
||||||
|
<a href="{% url 'change-password' %}"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
style="display: inline-block; padding: 0.625rem 1.25rem; text-decoration: none; font-size: 0.875rem;">
|
||||||
|
Change Password
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -18,11 +18,38 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="email">Email</label>
|
<label class="form-label" for="email">Email</label>
|
||||||
<input type="email" class="form-input" id="email" name="email" required autofocus>
|
<input type="email" class="form-input" id="email" name="email"
|
||||||
|
value="{{ email|default:'' }}" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="username">Username</label>
|
<label class="form-label" for="username">Username</label>
|
||||||
<input type="text" class="form-input" id="username" name="username" required>
|
<input type="text" class="form-input" id="username" name="username"
|
||||||
|
value="{{ username|default:'' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="first_name">First Name</label>
|
||||||
|
<input type="text" class="form-input" id="first_name" name="first_name"
|
||||||
|
value="{{ first_name|default:'' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="last_name">Last Name</label>
|
||||||
|
<input type="text" class="form-input" id="last_name" name="last_name"
|
||||||
|
value="{{ last_name|default:'' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="timezone">Timezone</label>
|
||||||
|
<select class="form-input" id="timezone" name="timezone">
|
||||||
|
<option value="UTC" {% if timezone == 'UTC' or not timezone %}selected{% endif %}>UTC</option>
|
||||||
|
<option value="America/New_York" {% if timezone == 'America/New_York' %}selected{% endif %}>Eastern Time (ET)</option>
|
||||||
|
<option value="America/Chicago" {% if timezone == 'America/Chicago' %}selected{% endif %}>Central Time (CT)</option>
|
||||||
|
<option value="America/Denver" {% if timezone == 'America/Denver' %}selected{% endif %}>Mountain Time (MT)</option>
|
||||||
|
<option value="America/Los_Angeles" {% if timezone == 'America/Los_Angeles' %}selected{% endif %}>Pacific Time (PT)</option>
|
||||||
|
<option value="Europe/London" {% if timezone == 'Europe/London' %}selected{% endif %}>London (GMT)</option>
|
||||||
|
<option value="Europe/Paris" {% if timezone == 'Europe/Paris' %}selected{% endif %}>Paris (CET)</option>
|
||||||
|
<option value="Asia/Tokyo" {% if timezone == 'Asia/Tokyo' %}selected{% endif %}>Tokyo (JST)</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="password">Password</label>
|
<label class="form-label" for="password">Password</label>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Registration Successful - KeepItGoing{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; background-color: var(--color-bg, #f5f5f5); padding: 20px;">
|
||||||
|
<div style="max-width: 500px; width: 100%; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
|
||||||
|
<div style="text-align: center; margin-bottom: 30px;">
|
||||||
|
<svg style="width: 80px; height: 80px; color: #10B981;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 19v-8.93a2 2 0 01.89-1.664l7-4.666a2 2 0 012.22 0l7 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5m0 0l-1.14.76a2 2 0 01-2.22 0l-1.14-.76"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="text-align: center; color: #2c3e50; margin-bottom: 20px;">Check Your Email!</h2>
|
||||||
|
|
||||||
|
<p style="text-align: center; color: #666; margin-bottom: 15px;">
|
||||||
|
We've sent a verification email to:
|
||||||
|
</p>
|
||||||
|
<p style="text-align: center; font-weight: bold; color: #2c3e50; margin-bottom: 20px;">
|
||||||
|
{{ email }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="color: #666; margin-bottom: 15px;">
|
||||||
|
Please click the link in the email to verify your account. After verification,
|
||||||
|
your account will be reviewed by our team for approval.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background-color: #e3f2fd; padding: 15px; border-radius: 5px; margin-top: 30px;">
|
||||||
|
<p style="margin: 0; color: #0d47a1; font-size: 14px;">
|
||||||
|
<strong>Didn't receive the email?</strong><br>
|
||||||
|
Check your spam folder or <a href="{% url 'resend-verification' %}" style="color: #1976d2; text-decoration: none; font-weight: bold;">request a new verification email</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<a href="{% url 'login' %}" style="color: #666; text-decoration: none; font-size: 14px;">Return to Login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Resend Verification Email - KeepItGoing{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; background-color: var(--color-bg, #f5f5f5); padding: 20px;">
|
||||||
|
<div style="max-width: 500px; width: 100%; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
|
||||||
|
<h2 style="text-align: center; color: #2c3e50; margin-bottom: 30px;">Resend Verification Email</h2>
|
||||||
|
|
||||||
|
{% if success %}
|
||||||
|
<div style="background-color: #d1fae5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||||
|
<p style="margin: 0; color: #065f46;">
|
||||||
|
<strong>Success!</strong><br>
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<a href="{% url 'login' %}" style="color: #666; text-decoration: none; font-size: 14px;">Return to Login</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if error %}
|
||||||
|
<div style="background-color: #fee2e2; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||||
|
<p style="margin: 0; color: #991b1b;">
|
||||||
|
<strong>Error:</strong><br>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" style="margin-bottom: 20px;">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label for="email" style="display: block; margin-bottom: 5px; color: #374151; font-weight: 500;">Email Address</label>
|
||||||
|
<input type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #d1d5db; border-radius: 5px; font-size: 16px;"
|
||||||
|
placeholder="Enter your email address">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
style="width: 100%; background-color: #3B82F6; color: white; padding: 12px; border: none; border-radius: 5px; font-size: 16px; font-weight: bold; cursor: pointer;">
|
||||||
|
Send Verification Email
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<a href="{% url 'login' %}" style="color: #666; text-decoration: none; font-size: 14px;">Return to Login</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Email Verification - KeepItGoing{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; background-color: var(--color-bg, #f5f5f5); padding: 20px;">
|
||||||
|
<div style="max-width: 500px; width: 100%; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
|
||||||
|
{% if success %}
|
||||||
|
<div style="text-align: center; margin-bottom: 30px;">
|
||||||
|
<svg style="width: 80px; height: 80px; color: #10B981;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="text-align: center; color: #10B981; margin-bottom: 20px;">Email Verified!</h2>
|
||||||
|
|
||||||
|
<p style="text-align: center; color: #666; margin-bottom: 20px;">
|
||||||
|
Your email has been successfully verified.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if is_approved %}
|
||||||
|
<p style="text-align: center; color: #666; margin-bottom: 30px;">
|
||||||
|
Your account has been approved! You can now log in.
|
||||||
|
</p>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{% url 'login' %}"
|
||||||
|
style="display: inline-block; background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">
|
||||||
|
Go to Login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="background-color: #fff3cd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||||
|
<p style="margin: 0; color: #856404;">
|
||||||
|
<strong>Pending Approval</strong><br>
|
||||||
|
Your account is now pending approval from our team.
|
||||||
|
You'll receive an email once your account is approved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; margin-bottom: 30px;">
|
||||||
|
<svg style="width: 80px; height: 80px; color: #EF4444;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="text-align: center; color: #EF4444; margin-bottom: 20px;">Verification Failed</h2>
|
||||||
|
|
||||||
|
<p style="text-align: center; color: #666; margin-bottom: 30px;">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if can_resend %}
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{% url 'resend-verification' %}"
|
||||||
|
style="display: inline-block; background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">
|
||||||
|
Request New Verification Email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<a href="{% url 'login' %}" style="color: #666; text-decoration: none; font-size: 14px;">Return to Login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+53
-3
@@ -11,16 +11,30 @@ from .models import User, DeviceToken
|
|||||||
class UserAdmin(BaseUserAdmin):
|
class UserAdmin(BaseUserAdmin):
|
||||||
"""Admin configuration for User model."""
|
"""Admin configuration for User model."""
|
||||||
|
|
||||||
list_display = ['email', 'username', 'first_name', 'last_name', 'is_staff', 'created_at']
|
list_display = [
|
||||||
list_filter = ['is_staff', 'is_superuser', 'is_active', 'created_at']
|
'email', 'username', 'first_name', 'last_name',
|
||||||
|
'email_verified', 'is_approved', 'is_staff', 'created_at'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'email_verified', 'is_approved', 'is_staff',
|
||||||
|
'is_superuser', 'is_active', 'created_at'
|
||||||
|
]
|
||||||
search_fields = ['email', 'username', 'first_name', 'last_name']
|
search_fields = ['email', 'username', 'first_name', 'last_name']
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
actions = ['approve_users', 'unapprove_users']
|
||||||
|
|
||||||
fieldsets = BaseUserAdmin.fieldsets + (
|
fieldsets = BaseUserAdmin.fieldsets + (
|
||||||
('Profile', {'fields': ('timezone', 'avatar')}),
|
('Profile', {'fields': ('timezone', 'avatar')}),
|
||||||
('Preferences', {'fields': ('default_reminder_minutes', 'email_notifications', 'push_notifications')}),
|
('Preferences', {
|
||||||
|
'fields': ('default_reminder_minutes', 'email_notifications', 'push_notifications')
|
||||||
|
}),
|
||||||
|
('Verification & Approval', {
|
||||||
|
'fields': ('email_verified', 'email_verified_at', 'is_approved', 'approved_by', 'approved_at')
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
readonly_fields = ['email_verified_at', 'approved_by', 'approved_at']
|
||||||
|
|
||||||
add_fieldsets = (
|
add_fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
'classes': ('wide',),
|
'classes': ('wide',),
|
||||||
@@ -28,6 +42,42 @@ class UserAdmin(BaseUserAdmin):
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def approve_users(self, request, queryset):
|
||||||
|
"""Approve selected users."""
|
||||||
|
from django.utils import timezone
|
||||||
|
from .utils import send_approval_notification
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for user in queryset.filter(is_approved=False):
|
||||||
|
user.is_approved = True
|
||||||
|
user.approved_by = request.user
|
||||||
|
user.approved_at = timezone.now()
|
||||||
|
user.save(update_fields=['is_approved', 'approved_by', 'approved_at'])
|
||||||
|
|
||||||
|
# Send notification email
|
||||||
|
if user.email_notifications:
|
||||||
|
try:
|
||||||
|
send_approval_notification(user)
|
||||||
|
except Exception:
|
||||||
|
pass # Don't fail approval if email fails
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
self.message_user(request, f'{count} user(s) approved successfully.')
|
||||||
|
|
||||||
|
approve_users.short_description = "Approve selected users"
|
||||||
|
|
||||||
|
def unapprove_users(self, request, queryset):
|
||||||
|
"""Remove approval from selected users."""
|
||||||
|
count = queryset.filter(is_approved=True).update(
|
||||||
|
is_approved=False,
|
||||||
|
approved_by=None,
|
||||||
|
approved_at=None
|
||||||
|
)
|
||||||
|
self.message_user(request, f'{count} user(s) unapproved.')
|
||||||
|
|
||||||
|
unapprove_users.short_description = "Remove approval from selected users"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(DeviceToken)
|
@admin.register(DeviceToken)
|
||||||
class DeviceTokenAdmin(admin.ModelAdmin):
|
class DeviceTokenAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
Custom authentication backends for KeepItGoing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib.auth.backends import ModelBackend
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class EmailVerifiedApprovedBackend(ModelBackend):
|
||||||
|
"""
|
||||||
|
Authentication backend that requires email verification and admin approval.
|
||||||
|
|
||||||
|
This backend extends Django's ModelBackend to add additional checks:
|
||||||
|
- User must have verified their email address
|
||||||
|
- User must be approved by an admin
|
||||||
|
|
||||||
|
If authentication fails due to these checks, helpful error messages
|
||||||
|
are stored in the session for the web UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||||
|
# Call parent to do the actual authentication
|
||||||
|
user = super().authenticate(request, username=username, password=password, **kwargs)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check email verification
|
||||||
|
if not user.email_verified:
|
||||||
|
# Store reason for failed login (for web UI)
|
||||||
|
if request:
|
||||||
|
request.session['login_error'] = 'email_not_verified'
|
||||||
|
request.session['login_email'] = user.email
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check admin approval
|
||||||
|
if not user.is_approved:
|
||||||
|
if request:
|
||||||
|
request.session['login_error'] = 'not_approved'
|
||||||
|
request.session['login_email'] = user.email
|
||||||
|
return None
|
||||||
|
|
||||||
|
return user
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2025-12-23 00:46
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
def approve_existing_users(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Auto-approve and mark as verified all existing users.
|
||||||
|
This ensures existing users can continue logging in.
|
||||||
|
"""
|
||||||
|
User = apps.get_model('users', 'User')
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
User.objects.all().update(
|
||||||
|
email_verified=True,
|
||||||
|
email_verified_at=now,
|
||||||
|
is_approved=True,
|
||||||
|
approved_at=now
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_approval(apps, schema_editor):
|
||||||
|
"""Reverse the auto-approval."""
|
||||||
|
User = apps.get_model('users', 'User')
|
||||||
|
User.objects.all().update(
|
||||||
|
email_verified=False,
|
||||||
|
email_verified_at=None,
|
||||||
|
is_approved=False,
|
||||||
|
approved_by=None,
|
||||||
|
approved_at=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='approved_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='approved_by',
|
||||||
|
field=models.ForeignKey(blank=True, limit_choices_to={'is_staff': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_users', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='email_verified',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='email_verified_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='is_approved',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EmailVerificationToken',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('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)),
|
||||||
|
('used_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='verification_tokens', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'email_verification_tokens',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RunPython(approve_existing_users, reverse_approval),
|
||||||
|
]
|
||||||
@@ -25,6 +25,22 @@ class User(AbstractUser):
|
|||||||
email_notifications = models.BooleanField(default=True)
|
email_notifications = models.BooleanField(default=True)
|
||||||
push_notifications = models.BooleanField(default=True)
|
push_notifications = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
# Email verification
|
||||||
|
email_verified = models.BooleanField(default=False)
|
||||||
|
email_verified_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Admin approval
|
||||||
|
is_approved = models.BooleanField(default=False)
|
||||||
|
approved_by = models.ForeignKey(
|
||||||
|
'self',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='approved_users',
|
||||||
|
limit_choices_to={'is_staff': True}
|
||||||
|
)
|
||||||
|
approved_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
@@ -67,3 +83,28 @@ class DeviceToken(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user.email} - {self.platform} - {self.device_name}"
|
return f"{self.user.email} - {self.platform} - {self.device_name}"
|
||||||
|
|
||||||
|
|
||||||
|
class EmailVerificationToken(models.Model):
|
||||||
|
"""
|
||||||
|
Token for email verification.
|
||||||
|
Uses UUID tokens with expiration for security.
|
||||||
|
"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='verification_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)
|
||||||
|
used_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'email_verification_tokens'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def is_expired(self):
|
||||||
|
from django.utils import timezone
|
||||||
|
return timezone.now() > self.expires_at
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Verification token for {self.user.email}"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class UserRegistrationSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ['email', 'username', 'password', 'password_confirm', 'first_name', 'last_name']
|
fields = ['email', 'username', 'password', 'password_confirm', 'first_name', 'last_name', 'timezone']
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
if attrs['password'] != attrs['password_confirm']:
|
if attrs['password'] != attrs['password_confirm']:
|
||||||
|
|||||||
+7
-5
@@ -3,21 +3,20 @@ User URLs for KeepItGoing.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework_simplejwt.views import (
|
from rest_framework_simplejwt.views import TokenRefreshView
|
||||||
TokenObtainPairView,
|
|
||||||
TokenRefreshView,
|
|
||||||
)
|
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
# API URLs (to be included under /api/users/)
|
# API URLs (to be included under /api/users/)
|
||||||
api_urlpatterns = [
|
api_urlpatterns = [
|
||||||
path('register/', views.RegisterAPIView.as_view(), name='api-register'),
|
path('register/', views.RegisterAPIView.as_view(), name='api-register'),
|
||||||
|
path('verify-email/', views.VerifyEmailAPIView.as_view(), name='api-verify-email'),
|
||||||
|
path('resend-verification/', views.ResendVerificationEmailAPIView.as_view(), name='api-resend-verification'),
|
||||||
path('profile/', views.UserProfileAPIView.as_view(), name='api-profile'),
|
path('profile/', views.UserProfileAPIView.as_view(), name='api-profile'),
|
||||||
path('change-password/', views.ChangePasswordAPIView.as_view(), name='api-change-password'),
|
path('change-password/', views.ChangePasswordAPIView.as_view(), name='api-change-password'),
|
||||||
path('devices/', views.DeviceTokenAPIView.as_view(), name='api-devices'),
|
path('devices/', views.DeviceTokenAPIView.as_view(), name='api-devices'),
|
||||||
path('devices/<uuid:pk>/', views.DeviceTokenDeleteAPIView.as_view(), name='api-device-delete'),
|
path('devices/<uuid:pk>/', views.DeviceTokenDeleteAPIView.as_view(), name='api-device-delete'),
|
||||||
path('token/', TokenObtainPairView.as_view(), name='token-obtain-pair'),
|
path('token/', views.TokenObtainPairView.as_view(), name='token-obtain-pair'),
|
||||||
path('token/refresh/', TokenRefreshView.as_view(), name='token-refresh'),
|
path('token/refresh/', TokenRefreshView.as_view(), name='token-refresh'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -26,5 +25,8 @@ web_urlpatterns = [
|
|||||||
path('login/', views.LoginView.as_view(), name='login'),
|
path('login/', views.LoginView.as_view(), name='login'),
|
||||||
path('logout/', views.LogoutView.as_view(), name='logout'),
|
path('logout/', views.LogoutView.as_view(), name='logout'),
|
||||||
path('register/', views.RegisterView.as_view(), name='register'),
|
path('register/', views.RegisterView.as_view(), name='register'),
|
||||||
|
path('verify-email/<uuid:token>/', views.VerifyEmailWebView.as_view(), name='verify-email'),
|
||||||
|
path('resend-verification/', views.ResendVerificationWebView.as_view(), name='resend-verification'),
|
||||||
path('profile/', views.ProfileView.as_view(), name='profile'),
|
path('profile/', views.ProfileView.as_view(), name='profile'),
|
||||||
|
path('change-password/', views.ChangePasswordWebView.as_view(), name='change-password'),
|
||||||
]
|
]
|
||||||
|
|||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
Utility functions for user management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils import timezone
|
||||||
|
from .models import EmailVerificationToken
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN_EXPIRY_HOURS = getattr(settings, 'EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', 24)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_verification_token(user):
|
||||||
|
"""
|
||||||
|
Generate a new verification token for user.
|
||||||
|
Invalidates any existing unused tokens before creating a new one.
|
||||||
|
"""
|
||||||
|
# Invalidate any existing unused tokens
|
||||||
|
EmailVerificationToken.objects.filter(
|
||||||
|
user=user,
|
||||||
|
is_used=False
|
||||||
|
).update(is_used=True)
|
||||||
|
|
||||||
|
# Create new token
|
||||||
|
token = EmailVerificationToken.objects.create(
|
||||||
|
user=user,
|
||||||
|
expires_at=timezone.now() + timedelta(hours=TOKEN_EXPIRY_HOURS)
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def send_verification_email(user, request=None):
|
||||||
|
"""
|
||||||
|
Send email verification link to user.
|
||||||
|
"""
|
||||||
|
token = generate_verification_token(user)
|
||||||
|
|
||||||
|
# Build verification URL
|
||||||
|
if request:
|
||||||
|
domain = request.get_host()
|
||||||
|
protocol = 'https' if request.is_secure() else 'http'
|
||||||
|
else:
|
||||||
|
domain = settings.SITE_DOMAIN
|
||||||
|
protocol = 'https' if not settings.DEBUG else 'http'
|
||||||
|
|
||||||
|
verification_url = f"{protocol}://{domain}/verify-email/{token.token}/"
|
||||||
|
|
||||||
|
# Render email from template
|
||||||
|
context = {
|
||||||
|
'user': user,
|
||||||
|
'verification_url': verification_url,
|
||||||
|
'expiry_hours': TOKEN_EXPIRY_HOURS,
|
||||||
|
}
|
||||||
|
|
||||||
|
html_message = render_to_string('emails/verify_email.html', context)
|
||||||
|
text_message = render_to_string('emails/verify_email.txt', context)
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject='Verify your KeepItGoing email',
|
||||||
|
message=text_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[user.email],
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_approval_notification(user):
|
||||||
|
"""
|
||||||
|
Notify user their account has been approved.
|
||||||
|
"""
|
||||||
|
protocol = 'https' if not settings.DEBUG else 'http'
|
||||||
|
login_url = f"{protocol}://{settings.SITE_DOMAIN}/login/"
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'user': user,
|
||||||
|
'login_url': login_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
html_message = render_to_string('emails/account_approved.html', context)
|
||||||
|
text_message = render_to_string('emails/account_approved.txt', context)
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject='Your KeepItGoing account has been approved',
|
||||||
|
message=text_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[user.email],
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_admins_new_user(user):
|
||||||
|
"""
|
||||||
|
Notify all staff users about new registration awaiting approval.
|
||||||
|
"""
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
# Get all staff users
|
||||||
|
staff_users = User.objects.filter(is_staff=True, is_active=True)
|
||||||
|
staff_emails = [u.email for u in staff_users if u.email]
|
||||||
|
|
||||||
|
if not staff_emails:
|
||||||
|
return
|
||||||
|
|
||||||
|
protocol = 'https' if not settings.DEBUG else 'http'
|
||||||
|
admin_url = f"{protocol}://{settings.SITE_DOMAIN}/admin/users/user/{user.id}/change/"
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'user': user,
|
||||||
|
'admin_url': admin_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
html_message = render_to_string('emails/admin_new_user.html', context)
|
||||||
|
text_message = render_to_string('emails/admin_new_user.txt', context)
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject=f'New user registration: {user.email}',
|
||||||
|
message=text_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=staff_emails,
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=True, # Don't fail registration if admin email fails
|
||||||
|
)
|
||||||
+336
-14
@@ -4,11 +4,15 @@ User views for KeepItGoing.
|
|||||||
|
|
||||||
from django.contrib.auth import get_user_model, login, logout
|
from django.contrib.auth import get_user_model, login, logout
|
||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
|
from django.utils import timezone
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from rest_framework import generics, permissions, status
|
from rest_framework import generics, permissions, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
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 (
|
from .serializers import (
|
||||||
UserSerializer,
|
UserSerializer,
|
||||||
@@ -16,7 +20,7 @@ from .serializers import (
|
|||||||
ChangePasswordSerializer,
|
ChangePasswordSerializer,
|
||||||
DeviceTokenSerializer,
|
DeviceTokenSerializer,
|
||||||
)
|
)
|
||||||
from .models import DeviceToken
|
from .models import DeviceToken, EmailVerificationToken
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -37,15 +41,16 @@ class RegisterAPIView(generics.CreateAPIView):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
|
|
||||||
# Generate tokens
|
# Send verification email
|
||||||
refresh = RefreshToken.for_user(user)
|
from .utils import send_verification_email
|
||||||
|
send_verification_email(user, request)
|
||||||
|
|
||||||
|
# DO NOT generate tokens - user must verify email first
|
||||||
return Response({
|
return Response({
|
||||||
'user': UserSerializer(user).data,
|
'message': 'Registration successful! Please check your email to verify your account.',
|
||||||
'tokens': {
|
'email': user.email,
|
||||||
'refresh': str(refresh),
|
'email_verified': False,
|
||||||
'access': str(refresh.access_token),
|
'is_approved': False,
|
||||||
}
|
|
||||||
}, status=status.HTTP_201_CREATED)
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +102,131 @@ class DeviceTokenDeleteAPIView(generics.DestroyAPIView):
|
|||||||
return DeviceToken.objects.filter(user=self.request.user)
|
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)
|
# Web Views (Django Templates)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -107,7 +237,27 @@ class LoginView(View):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
return redirect('dashboard')
|
return redirect('dashboard')
|
||||||
return render(request, 'users/login.html')
|
|
||||||
|
# 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):
|
def post(self, request):
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
@@ -122,8 +272,26 @@ class LoginView(View):
|
|||||||
next_url = request.GET.get('next', 'dashboard')
|
next_url = request.GET.get('next', 'dashboard')
|
||||||
return redirect(next_url)
|
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', {
|
return render(request, 'users/login.html', {
|
||||||
'error': 'Invalid email or password.'
|
'error': error,
|
||||||
|
'email': email,
|
||||||
|
'can_resend': can_resend,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -152,6 +320,9 @@ class RegisterView(View):
|
|||||||
username = request.POST.get('username')
|
username = request.POST.get('username')
|
||||||
password = request.POST.get('password')
|
password = request.POST.get('password')
|
||||||
password_confirm = request.POST.get('password_confirm')
|
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 = {}
|
errors = {}
|
||||||
|
|
||||||
@@ -165,15 +336,32 @@ class RegisterView(View):
|
|||||||
errors['username'] = 'Username already taken.'
|
errors['username'] = 'Username already taken.'
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return render(request, 'users/register.html', {'errors': 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(
|
user = User.objects.create_user(
|
||||||
email=email,
|
email=email,
|
||||||
username=username,
|
username=username,
|
||||||
password=password
|
password=password,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
timezone=timezone
|
||||||
)
|
)
|
||||||
login(request, user)
|
|
||||||
return redirect('dashboard')
|
# 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):
|
class ProfileView(View):
|
||||||
@@ -200,3 +388,137 @@ class ProfileView(View):
|
|||||||
return render(request, 'users/profile.html', {
|
return render(request, 'users/profile.html', {
|
||||||
'success': 'Profile updated successfully.'
|
'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!'
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user