password reset
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
7
app.py
7
app.py
@@ -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."""
|
||||
|
||||
34
migrations/versions/add_password_reset_tokens_table.py
Normal file
34
migrations/versions/add_password_reset_tokens_table.py
Normal 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')
|
||||
19
models.py
19
models.py
@@ -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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
159
routes/auth.py
159
routes/auth.py
@@ -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__)
|
||||
|
||||
@@ -306,4 +308,155 @@ def init_routes(auth_bp):
|
||||
flash('Password set up successfully! Welcome to DocuPulse.', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('auth/setup_password.html')
|
||||
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)
|
||||
@@ -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
|
||||
@@ -1722,4 +1707,26 @@ def init_routes(main_bp):
|
||||
return jsonify({
|
||||
'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
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
new bootstrap.Modal(document.getElementById('mailDetailsModal')).show();
|
||||
});
|
||||
.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();
|
||||
})
|
||||
.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() {
|
||||
|
||||
41
tasks.py
41
tasks.py
@@ -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():
|
||||
@@ -36,4 +36,43 @@ def cleanup_trash():
|
||||
db.session.commit()
|
||||
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()
|
||||
50
templates/auth/forgot_password.html
Normal file
50
templates/auth/forgot_password.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
157
templates/auth/reset_password.html
Normal file
157
templates/auth/reset_password.html
Normal 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
111
test_password_reset.py
Normal 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()
|
||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
'''
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user