password reset

This commit is contained in:
2025-06-19 16:11:42 +02:00
parent efdb6d50c3
commit 7092167001
20 changed files with 680 additions and 72 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

7
app.py
View File

@@ -112,6 +112,13 @@ def create_app():
cleanup_trash()
click.echo("Trash cleanup completed.")
@app.cli.command("cleanup-tokens")
def cleanup_tokens_command():
"""Clean up expired password reset and setup tokens."""
from tasks import cleanup_expired_tokens
cleanup_expired_tokens()
click.echo("Token cleanup completed.")
@app.cli.command("create-admin")
def create_admin():
"""Create the default administrator user."""

View File

@@ -0,0 +1,34 @@
"""Add password reset tokens table
Revision ID: add_password_reset_tokens
Revises: be1f7bdd10e1
Create Date: 2024-01-01 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_password_reset_tokens'
down_revision = 'be1f7bdd10e1'
branch_labels = None
depends_on = None
def upgrade():
# Create password_reset_tokens table
op.create_table('password_reset_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(length=100), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('used', sa.Boolean(), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token')
)
def downgrade():
# Drop password_reset_tokens table
op.drop_table('password_reset_tokens')

View File

@@ -447,6 +447,25 @@ class PasswordSetupToken(db.Model):
def __repr__(self):
return f'<PasswordSetupToken {self.token}>'
class PasswordResetToken(db.Model):
__tablename__ = 'password_reset_tokens'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
token = db.Column(db.String(100), unique=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
expires_at = db.Column(db.DateTime, nullable=False)
used = db.Column(db.Boolean, default=False)
ip_address = db.Column(db.String(45)) # Store IP address for security
# Relationships
user = db.relationship('User', backref=db.backref('password_reset_tokens', cascade='all, delete-orphan'))
def is_valid(self):
return not self.used and datetime.utcnow() < self.expires_at
def __repr__(self):
return f'<PasswordResetToken {self.token}>'
def user_has_permission(room, perm_name):
"""
Check if the current user has a specific permission in a room.

View File

@@ -1,10 +1,12 @@
from flask import render_template, request, flash, redirect, url_for, Blueprint, jsonify
from flask_login import login_user, logout_user, login_required, current_user
from models import db, User, Notif, PasswordSetupToken
from models import db, User, Notif, PasswordSetupToken, PasswordResetToken
from functools import wraps
from datetime import datetime
from datetime import datetime, timedelta
from utils import log_event, create_notification, get_unread_count
from utils.notification import generate_mail_from_notification
import string
import secrets
auth_bp = Blueprint('auth', __name__)
@@ -307,3 +309,154 @@ def init_routes(auth_bp):
return redirect(url_for('main.dashboard'))
return render_template('auth/setup_password.html')
@auth_bp.route('/forgot-password', methods=['GET', 'POST'])
def forgot_password():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
email = request.form.get('email')
if not email:
flash('Please enter your email address.', 'error')
return render_template('auth/forgot_password.html')
# Check if user exists
user = User.query.filter_by(email=email).first()
if user:
# Generate a secure token
token = secrets.token_urlsafe(32)
# Create password reset token
reset_token = PasswordResetToken(
user_id=user.id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=1), # 1 hour expiration
ip_address=request.remote_addr
)
db.session.add(reset_token)
# Create notification for password reset
notif = create_notification(
notif_type='password_reset',
user_id=user.id,
details={
'message': 'You requested a password reset. Click the link below to reset your password.',
'reset_link': url_for('auth.reset_password', token=token, _external=True),
'expiry_time': (datetime.utcnow() + timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S UTC'),
'ip_address': request.remote_addr,
'timestamp': datetime.utcnow().isoformat()
},
generate_mail=False # Don't auto-generate email, we'll do it manually
)
# Generate and send email manually
if notif:
generate_mail_from_notification(notif)
# Log the password reset request
log_event(
event_type='user_update',
details={
'user_id': user.id,
'user_name': f"{user.username} {user.last_name}",
'email': user.email,
'update_type': 'password_reset_request',
'ip_address': request.remote_addr,
'success': True
}
)
db.session.commit()
# Always show success message to prevent email enumeration
flash('If an account with that email exists, a password reset link has been sent to your email address.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/forgot_password.html')
@auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
# Find the token
reset_token = PasswordResetToken.query.filter_by(token=token).first()
if not reset_token or not reset_token.is_valid():
flash('Invalid or expired password reset link. Please request a new password reset.', 'error')
return redirect(url_for('auth.forgot_password'))
if request.method == 'POST':
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
if not password or not confirm_password:
flash('Please fill in all fields.', 'error')
return render_template('auth/reset_password.html', token=token)
if password != confirm_password:
flash('Passwords do not match.', 'error')
return render_template('auth/reset_password.html', token=token)
# Password requirements
if len(password) < 8:
flash('Password must be at least 8 characters long.', 'error')
return render_template('auth/reset_password.html', token=token)
if not any(c.isupper() for c in password):
flash('Password must contain at least one uppercase letter.', 'error')
return render_template('auth/reset_password.html', token=token)
if not any(c.islower() for c in password):
flash('Password must contain at least one lowercase letter.', 'error')
return render_template('auth/reset_password.html', token=token)
if not any(c.isdigit() for c in password):
flash('Password must contain at least one number.', 'error')
return render_template('auth/reset_password.html', token=token)
if not any(c in string.punctuation for c in password):
flash('Password must contain at least one special character.', 'error')
return render_template('auth/reset_password.html', token=token)
# Update user's password
user = reset_token.user
user.set_password(password)
# Mark token as used
reset_token.used = True
# Create password change notification
create_notification(
notif_type='password_changed',
user_id=user.id,
details={
'message': 'Your password has been reset successfully.',
'timestamp': datetime.utcnow().isoformat()
}
)
# Log password reset event
log_event(
event_type='user_update',
details={
'user_id': user.id,
'user_name': f"{user.username} {user.last_name}",
'email': user.email,
'update_type': 'password_reset',
'ip_address': request.remote_addr,
'success': True
}
)
db.session.commit()
# Log the user in and redirect to dashboard
login_user(user)
flash('Password reset successfully! Welcome back to DocuPulse.', 'success')
return redirect(url_for('main.dashboard'))
return render_template('auth/reset_password.html', token=token)

View File

@@ -1420,36 +1420,21 @@ def init_routes(main_bp):
return jsonify({'error': 'Unauthorized'}), 403
event = Event.query.get_or_404(event_id)
logger.info(f"Raw event object: {event}")
logger.info(f"Event details type: {type(event.details)}")
logger.info(f"Event details value: {event.details}")
# Convert details to dict if it's a string
details = event.details
if isinstance(details, str):
try:
import json
details = json.loads(details)
except json.JSONDecodeError:
details = {'raw_details': details}
# Return the raw event data
response_data = {
return jsonify({
'id': event.id,
'event_type': event.event_type,
'timestamp': event.timestamp.isoformat(),
'details': event.details,
'ip_address': event.ip_address,
'user_agent': event.user_agent,
'user': {
'id': event.user.id,
'username': event.user.username,
'last_name': event.user.last_name
} if event.user else None,
'ip_address': event.ip_address,
'user_agent': event.user_agent,
'details': details
}
logger.info(f"Sending response: {response_data}")
return jsonify(response_data)
'last_name': event.user.last_name,
'email': event.user.email
} if event.user else None
})
@main_bp.route('/settings/events/download')
@login_required
@@ -1723,3 +1708,25 @@ def init_routes(main_bp):
'success': True,
'results': results
})
@main_bp.route('/api/mails/<int:mail_id>')
@login_required
def get_mail_details(mail_id):
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
mail = Mail.query.get_or_404(mail_id)
return jsonify({
'id': mail.id,
'recipient': mail.recipient,
'subject': mail.subject,
'body': mail.body,
'status': mail.status,
'created_at': mail.created_at.isoformat(),
'sent_at': mail.sent_at.isoformat() if mail.sent_at else None,
'template': {
'id': mail.template.id,
'name': mail.template.name
} if mail.template else None
})

View File

@@ -78,27 +78,38 @@ function changePage(page) {
function viewMailDetails(mailId) {
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
fetch(`/settings?tab=mails&mail_id=${mailId}`, {
fetch(`/api/mails/${mailId}`, {
headers: {
'X-CSRF-Token': csrfToken
'X-CSRF-Token': csrfToken,
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(mail => {
document.getElementById('modalSubject').textContent = mail.subject;
document.getElementById('modalRecipient').textContent = mail.recipient;
document.getElementById('modalStatus').innerHTML = `
<span class="badge ${mail.status === 'pending' ? 'bg-warning' : mail.status === 'sent' ? 'bg-success' : 'bg-danger'}">
${mail.status}
</span>
`;
document.getElementById('modalTemplate').textContent = mail.template ? mail.template.name : '-';
document.getElementById('modalCreatedAt').textContent = new Date(mail.created_at).toLocaleString();
document.getElementById('modalSentAt').textContent = mail.sent_at ? new Date(mail.sent_at).toLocaleString() : '-';
document.getElementById('modalBody').innerHTML = mail.body;
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch mail details');
}
return response.json();
})
.then(mail => {
document.getElementById('modalSubject').textContent = mail.subject;
document.getElementById('modalRecipient').textContent = mail.recipient;
document.getElementById('modalStatus').innerHTML = `
<span class="badge ${mail.status === 'pending' ? 'bg-warning' : mail.status === 'sent' ? 'bg-success' : 'bg-danger'}">
${mail.status}
</span>
`;
document.getElementById('modalTemplate').textContent = mail.template ? mail.template.name : '-';
document.getElementById('modalCreatedAt').textContent = new Date(mail.created_at).toLocaleString();
document.getElementById('modalSentAt').textContent = mail.sent_at ? new Date(mail.sent_at).toLocaleString() : '-';
document.getElementById('modalBody').innerHTML = mail.body;
new bootstrap.Modal(document.getElementById('mailDetailsModal')).show();
});
new bootstrap.Modal(document.getElementById('mailDetailsModal')).show();
})
.catch(error => {
console.error('Error viewing mail details:', error);
// Show a user-friendly error message
alert('Error loading mail details. Please try again.');
});
}
function downloadMailLog() {

View File

@@ -1,5 +1,5 @@
from datetime import datetime, timedelta
from models import db, RoomFile
from models import db, RoomFile, PasswordResetToken, PasswordSetupToken
import os
def cleanup_trash():
@@ -37,3 +37,42 @@ def cleanup_trash():
except Exception as e:
print(f"Error committing changes: {str(e)}")
db.session.rollback()
def cleanup_expired_tokens():
"""
Removes expired password reset and setup tokens from the database.
This function should be called by a scheduler (e.g., cron job) daily.
"""
current_time = datetime.utcnow()
# Clean up expired password reset tokens
expired_reset_tokens = PasswordResetToken.query.filter(
PasswordResetToken.expires_at < current_time
).all()
for token in expired_reset_tokens:
try:
db.session.delete(token)
except Exception as e:
print(f"Error deleting expired password reset token {token.id}: {str(e)}")
continue
# Clean up expired password setup tokens
expired_setup_tokens = PasswordSetupToken.query.filter(
PasswordSetupToken.expires_at < current_time
).all()
for token in expired_setup_tokens:
try:
db.session.delete(token)
except Exception as e:
print(f"Error deleting expired password setup token {token.id}: {str(e)}")
continue
# Commit all changes
try:
db.session.commit()
print(f"Cleaned up {len(expired_reset_tokens)} expired password reset tokens and {len(expired_setup_tokens)} expired password setup tokens")
except Exception as e:
print(f"Error committing token cleanup changes: {str(e)}")
db.session.rollback()

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forgot Password - DocuPulse</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}?v={{ 'css/auth.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<script src="{{ url_for('static', filename='js/color-logger.js') }}?v={{ 'js/color-logger.js'|asset_version }}"></script>
</head>
<body>
<div class="container">
<div class="auth-container">
<div class="card auth-card">
<div class="auth-header text-center">
<h2><i class="fas fa-key me-2"></i>Forgot Password</h2>
<p class="mb-0">Enter your email to reset your password</p>
</div>
<div class="card-body p-4">
<form method="POST" action="{{ url_for('auth.forgot_password') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3">
<label for="email" class="form-label">
<i class="fas fa-envelope me-2" style="color: var(--primary-color);"></i>Email address
</label>
<input type="email" class="form-control" id="email" name="email" required>
<div class="form-text">We'll send you a link to reset your password.</div>
</div>
<button type="submit" class="btn btn-primary w-100"
style="background-color: var(--primary-color); border-color: var(--primary-color);"
onmouseover="this.style.backgroundColor='var(--primary-light)'"
onmouseout="this.style.backgroundColor='var(--primary-color)'">
<i class="fas fa-paper-plane me-2"></i>Send Reset Link
</button>
</form>
<div class="text-center mt-3">
<p class="mb-0">Remember your password? <a href="{{ url_for('auth.login') }}" class="text-decoration-none">Sign In</a></p>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -45,6 +45,11 @@
<i class="fas fa-sign-in-alt me-2"></i>Sign In
</button>
</form>
<div class="text-center mt-3">
<p class="mb-1"><a href="{{ url_for('auth.forgot_password') }}" class="text-decoration-none">Forgot your password?</a></p>
<p class="mb-0">Don't have an account? <a href="{{ url_for('auth.register') }}" class="text-decoration-none">Sign Up</a></p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Password - DocuPulse</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}?v={{ 'css/auth.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<script src="{{ url_for('static', filename='js/color-logger.js') }}?v={{ 'js/color-logger.js'|asset_version }}"></script>
</head>
<body>
<div class="container">
<div class="auth-container">
<div class="card auth-card">
<div class="auth-header text-center">
<h2><i class="fas fa-lock me-2"></i>Reset Password</h2>
<p class="mb-0">Enter your new password</p>
</div>
<div class="card-body p-4">
<form method="POST" action="{{ url_for('auth.reset_password', token=token) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3">
<label for="password" class="form-label">
<i class="fas fa-lock me-2" style="color: var(--primary-color);"></i>New Password
</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">
<i class="fas fa-check-circle me-2" style="color: var(--primary-color);"></i>Confirm New Password
</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
</div>
<div class="mt-4 space-y-2">
<h6 class="text-sm font-medium text-gray-700">Password Requirements:</h6>
<ul class="list-unstyled" id="password-requirements">
<li id="length-req" class="text-sm text-gray-500 mb-1">
<i class="fas fa-times-circle mr-2"></i>At least 8 characters long
</li>
<li id="uppercase-req" class="text-sm text-gray-500 mb-1">
<i class="fas fa-times-circle mr-2"></i>At least one uppercase letter
</li>
<li id="lowercase-req" class="text-sm text-gray-500 mb-1">
<i class="fas fa-times-circle mr-2"></i>At least one lowercase letter
</li>
<li id="number-req" class="text-sm text-gray-500 mb-1">
<i class="fas fa-times-circle mr-2"></i>At least one number
</li>
<li id="special-req" class="text-sm text-gray-500 mb-1">
<i class="fas fa-times-circle mr-2"></i>At least one special character
</li>
</ul>
</div>
<button type="submit" class="btn btn-primary w-100 mt-4"
style="background-color: var(--primary-color); border-color: var(--primary-color);"
onmouseover="this.style.backgroundColor='var(--primary-light)'"
onmouseout="this.style.backgroundColor='var(--primary-color)'">
<i class="fas fa-save me-2"></i>Reset Password
</button>
</form>
<div class="text-center mt-3">
<p class="mb-0">Remember your password? <a href="{{ url_for('auth.login') }}" class="text-decoration-none">Sign In</a></p>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const passwordInput = document.getElementById('password');
const confirmInput = document.getElementById('confirm_password');
function checkPasswordRequirements(password) {
// Length check
const lengthReq = document.getElementById('length-req');
if (password.length >= 8) {
lengthReq.classList.remove('text-gray-500');
lengthReq.classList.add('text-success');
lengthReq.querySelector('i').className = 'fas fa-check-circle mr-2';
} else {
lengthReq.classList.remove('text-success');
lengthReq.classList.add('text-gray-500');
lengthReq.querySelector('i').className = 'fas fa-times-circle mr-2';
}
// Uppercase check
const uppercaseReq = document.getElementById('uppercase-req');
if (/[A-Z]/.test(password)) {
uppercaseReq.classList.remove('text-gray-500');
uppercaseReq.classList.add('text-success');
uppercaseReq.querySelector('i').className = 'fas fa-check-circle mr-2';
} else {
uppercaseReq.classList.remove('text-success');
uppercaseReq.classList.add('text-gray-500');
uppercaseReq.querySelector('i').className = 'fas fa-times-circle mr-2';
}
// Lowercase check
const lowercaseReq = document.getElementById('lowercase-req');
if (/[a-z]/.test(password)) {
lowercaseReq.classList.remove('text-gray-500');
lowercaseReq.classList.add('text-success');
lowercaseReq.querySelector('i').className = 'fas fa-check-circle mr-2';
} else {
lowercaseReq.classList.remove('text-success');
lowercaseReq.classList.add('text-gray-500');
lowercaseReq.querySelector('i').className = 'fas fa-times-circle mr-2';
}
// Number check
const numberReq = document.getElementById('number-req');
if (/[0-9]/.test(password)) {
numberReq.classList.remove('text-gray-500');
numberReq.classList.add('text-success');
numberReq.querySelector('i').className = 'fas fa-check-circle mr-2';
} else {
numberReq.classList.remove('text-success');
numberReq.classList.add('text-gray-500');
numberReq.querySelector('i').className = 'fas fa-times-circle mr-2';
}
// Special character check
const specialReq = document.getElementById('special-req');
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
specialReq.classList.remove('text-gray-500');
specialReq.classList.add('text-success');
specialReq.querySelector('i').className = 'fas fa-check-circle mr-2';
} else {
specialReq.classList.remove('text-success');
specialReq.classList.add('text-gray-500');
specialReq.querySelector('i').className = 'fas fa-times-circle mr-2';
}
}
passwordInput.addEventListener('input', function() {
checkPasswordRequirements(this.value);
});
confirmInput.addEventListener('input', function() {
if (this.value === passwordInput.value) {
this.style.borderColor = 'var(--primary-color)';
} else {
this.style.borderColor = '#dc2626';
}
});
});
</script>
</body>
</html>

111
test_password_reset.py Normal file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Test script for password reset functionality
Run this script to test the password reset feature
"""
import os
import sys
from datetime import datetime, timedelta
# Add the current directory to Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import create_app
from models import db, User, PasswordResetToken
from utils import create_notification
from utils.notification import generate_mail_from_notification
def test_password_reset():
"""Test the password reset functionality"""
app = create_app()
with app.app_context():
print("Testing password reset functionality...")
# Check if we have a test user
test_user = User.query.filter_by(email='test@example.com').first()
if not test_user:
print("Creating test user...")
test_user = User(
username='testuser',
email='test@example.com',
last_name='Test User',
is_active=True
)
test_user.set_password('oldpassword123!')
db.session.add(test_user)
db.session.commit()
print(f"Created test user: {test_user.email}")
# Test 1: Create a password reset token
print("\n1. Testing password reset token creation...")
from routes.auth import forgot_password
import secrets
token = secrets.token_urlsafe(32)
reset_token = PasswordResetToken(
user_id=test_user.id,
token=token,
expires_at=datetime.utcnow() + timedelta(hours=1),
ip_address='127.0.0.1'
)
db.session.add(reset_token)
db.session.commit()
print(f"Created password reset token: {token[:20]}...")
# Test 2: Create notification
print("\n2. Testing notification creation...")
notif = create_notification(
notif_type='password_reset',
user_id=test_user.id,
details={
'message': 'You requested a password reset. Click the link below to reset your password.',
'reset_link': f'http://localhost:5000/reset-password/{token}',
'expiry_time': (datetime.utcnow() + timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S UTC'),
'ip_address': '127.0.0.1',
'timestamp': datetime.utcnow().isoformat()
}
)
print(f"Created notification: {notif.id}")
# Test 3: Generate email
print("\n3. Testing email generation...")
try:
mail = generate_mail_from_notification(notif)
if mail:
print(f"Generated email: {mail.subject}")
print(f"Email body preview: {mail.body[:100]}...")
else:
print("No email template found for password reset")
except Exception as e:
print(f"Error generating email: {e}")
# Test 4: Validate token
print("\n4. Testing token validation...")
stored_token = PasswordResetToken.query.filter_by(token=token).first()
if stored_token and stored_token.is_valid():
print("Token is valid")
else:
print("Token is invalid or expired")
# Test 5: Simulate password reset
print("\n5. Testing password reset...")
if stored_token and stored_token.is_valid():
test_user.set_password('newpassword123!')
stored_token.used = True
db.session.commit()
print("Password reset successful")
# Verify password change
if test_user.check_password('newpassword123!'):
print("Password verification successful")
else:
print("Password verification failed")
else:
print("Cannot reset password - token invalid")
print("\nPassword reset test completed!")
if __name__ == '__main__':
test_password_reset()

View File

@@ -30,9 +30,11 @@ def create_default_templates():
'body': '''
<h2>Password Reset Request</h2>
<p>Dear {{ user.username }},</p>
<p>We received a request to reset your password. Click the link below to set a new password:</p>
<p><a href="{{ reset_link }}">Reset Password</a></p>
<p>If you didn't request this, please ignore this email or contact support if you have concerns.</p>
<p>We received a request to reset your password for your DocuPulse account. Click the link below to set a new password:</p>
<p><a href="{{ reset_link }}" style="background-color: #16767b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block; margin: 10px 0;">Reset Password</a></p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request this password reset, please ignore this email. Your password will remain unchanged.</p>
<p>If you have any concerns about your account security, please contact your administrator immediately.</p>
<p>Best regards,<br>The DocuPulse Team</p>
'''
},

View File

@@ -119,22 +119,44 @@ def generate_mail_from_notification(notif: Notif) -> Optional[Mail]:
filled_body = filled_body.replace('{{ site.company_name }}', site_settings.company_name or '')
filled_body = filled_body.replace('{{ site.company_website }}', site_settings.company_website or '')
# Add notification details
# Add notification-specific variables
if notif.details:
for key, value in notif.details.items():
# Handle nested keys (e.g., room.name -> room_name)
if '.' in key:
parts = key.split('.')
if len(parts) == 2:
obj_name, attr = parts
if obj_name in notif.details and isinstance(notif.details[obj_name], dict):
if attr in notif.details[obj_name]:
filled_body = filled_body.replace(f'{{{{ {key} }}}}', str(notif.details[obj_name][attr]))
else:
# Special handling for setup_link to ensure it's a proper URL
if key == 'setup_link' and value.startswith('http://http//'):
value = value.replace('http://http//', 'http://')
filled_body = filled_body.replace(f'{{{{ {key} }}}}', str(value))
if 'setup_link' in filled_body and 'setup_link' in notif.details:
filled_body = filled_body.replace('{{ setup_link }}', notif.details['setup_link'])
if 'reset_link' in filled_body and 'reset_link' in notif.details:
filled_body = filled_body.replace('{{ reset_link }}', notif.details['reset_link'])
if 'expiry_time' in filled_body and 'expiry_time' in notif.details:
filled_body = filled_body.replace('{{ expiry_time }}', notif.details['expiry_time'])
if 'created_by' in filled_body and 'created_by' in notif.details:
filled_body = filled_body.replace('{{ created_by }}', notif.details['created_by'])
if 'deleted_by' in filled_body and 'deleted_by' in notif.details:
filled_body = filled_body.replace('{{ deleted_by }}', notif.details['deleted_by'])
if 'updated_by' in filled_body and 'updated_by' in notif.details:
filled_body = filled_body.replace('{{ updated_by }}', notif.details['updated_by'])
if 'remover.username' in filled_body and 'remover' in notif.details:
filled_body = filled_body.replace('{{ remover.username }}', notif.details['remover'])
if 'sender.username' in filled_body and 'sender' in notif.details:
filled_body = filled_body.replace('{{ sender.username }}', notif.details['sender'])
if 'conversation.name' in filled_body and 'conversation' in notif.details:
filled_body = filled_body.replace('{{ conversation.name }}', notif.details['conversation'])
if 'conversation.description' in filled_body and 'conversation_description' in notif.details:
filled_body = filled_body.replace('{{ conversation.description }}', notif.details['conversation_description'])
if 'message.content' in filled_body and 'message' in notif.details:
filled_body = filled_body.replace('{{ message.content }}', notif.details['message'])
if 'message.created_at' in filled_body and 'message_created_at' in notif.details:
filled_body = filled_body.replace('{{ message.created_at }}', notif.details['message_created_at'])
if 'message_link' in filled_body and 'message_link' in notif.details:
filled_body = filled_body.replace('{{ message_link }}', notif.details['message_link'])
if 'updated_fields' in filled_body and 'updated_fields' in notif.details:
filled_body = filled_body.replace('{{ updated_fields }}', notif.details['updated_fields'])
if 'created_at' in filled_body:
filled_body = filled_body.replace('{{ created_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
if 'updated_at' in filled_body:
filled_body = filled_body.replace('{{ updated_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
if 'deleted_at' in filled_body:
filled_body = filled_body.replace('{{ deleted_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
if 'removed_at' in filled_body:
filled_body = filled_body.replace('{{ removed_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
# Handle special URL variables
if 'room_link' in filled_body and 'room_id' in notif.details:
@@ -147,15 +169,6 @@ def generate_mail_from_notification(notif: Notif) -> Optional[Mail]:
conversation_link = url_for('conversations.conversation', conversation_id=notif.details['conversation_id'], _external=True)
filled_body = filled_body.replace('{{ conversation_link }}', conversation_link)
# Add timestamps
filled_body = filled_body.replace('{{ created_at }}', notif.timestamp.strftime('%Y-%m-%d %H:%M:%S'))
if 'updated_at' in filled_body:
filled_body = filled_body.replace('{{ updated_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
if 'deleted_at' in filled_body:
filled_body = filled_body.replace('{{ deleted_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
if 'removed_at' in filled_body:
filled_body = filled_body.replace('{{ removed_at }}', datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))
# Create a new Mail record
mail = Mail(
recipient=notif.user.email,