Finalize mail settings admin UI and Mailrise compatibility

This commit is contained in:
ske087
2025-07-26 18:50:42 +03:00
parent 2a5b5ee468
commit 377e379883
23 changed files with 629 additions and 170 deletions

View File

@@ -92,5 +92,23 @@ def create_app(config_name=None):
os.makedirs(upload_dir, exist_ok=True)
os.makedirs(os.path.join(upload_dir, 'images'), exist_ok=True)
os.makedirs(os.path.join(upload_dir, 'gpx'), exist_ok=True)
# --- Initial Admin Creation from .env ---
from app.models import User
with app.app_context():
admin_email = os.environ.get('ADMIN_EMAIL')
admin_nickname = os.environ.get('ADMIN_NICKNAME')
admin_password = os.environ.get('ADMIN_PASSWORD')
if admin_email and admin_nickname and admin_password:
if not User.query.filter_by(email=admin_email).first():
user = User(nickname=admin_nickname, email=admin_email, is_admin=True, is_active=True)
user.set_password(admin_password)
db.session.add(user)
db.session.commit()
print(f"[INFO] Admin user {admin_nickname} <{admin_email}> created from .env.")
else:
print(f"[INFO] Admin with email {admin_email} already exists.")
else:
print("[INFO] ADMIN_EMAIL, ADMIN_NICKNAME, or ADMIN_PASSWORD not set in .env. Skipping admin creation.")
return app

View File

@@ -273,3 +273,35 @@ class PageView(db.Model):
def __repr__(self):
return f'<PageView {self.path} at {self.created_at}>'
# --- Mail Server Management Models ---
class MailSettings(db.Model):
__tablename__ = 'mail_settings'
id = db.Column(db.Integer, primary_key=True)
enabled = db.Column(db.Boolean, default=False, nullable=False)
server = db.Column(db.String(255), nullable=False)
port = db.Column(db.Integer, nullable=False)
use_tls = db.Column(db.Boolean, default=True, nullable=False)
username = db.Column(db.String(255))
password = db.Column(db.String(255))
default_sender = db.Column(db.String(255))
provider = db.Column(db.String(50), default='smtp') # 'smtp' or 'mailrise'
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<MailSettings {self.server}:{self.port} enabled={self.enabled}>'
class SentEmail(db.Model):
__tablename__ = 'sent_emails'
id = db.Column(db.Integer, primary_key=True)
recipient = db.Column(db.String(255), nullable=False)
subject = db.Column(db.String(255), nullable=False)
body = db.Column(db.Text, nullable=False)
status = db.Column(db.String(50), default='sent') # sent, failed, etc.
error = db.Column(db.Text)
sent_at = db.Column(db.DateTime, default=datetime.utcnow)
provider = db.Column(db.String(50), default='smtp')
def __repr__(self):
return f'<SentEmail to={self.recipient} subject={self.subject} status={self.status}>'

View File

@@ -1,13 +1,88 @@
from flask import Blueprint, render_template, request, flash, redirect, url_for, jsonify, current_app
from flask_mail import Message
from flask_login import login_required, current_user
from functools import wraps
from datetime import datetime, timedelta
from sqlalchemy import func, desc
import secrets
from app.routes.mail import mail
from app.extensions import db
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView, MailSettings, SentEmail
admin = Blueprint('admin', __name__, url_prefix='/admin')
def admin_required(f):
"""Decorator to require admin access"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
flash('Admin access required.', 'error')
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
@admin.route('/mail-settings', methods=['GET', 'POST'])
@login_required
@admin_required
def mail_settings():
settings = MailSettings.query.first()
if request.method == 'POST':
enabled = bool(request.form.get('enabled'))
provider = request.form.get('provider')
server = request.form.get('server')
port = int(request.form.get('port') or 0)
use_tls = bool(request.form.get('use_tls'))
username = request.form.get('username')
password = request.form.get('password')
default_sender = request.form.get('default_sender')
if not settings:
settings = MailSettings()
db.session.add(settings)
settings.enabled = enabled
settings.provider = provider
settings.server = server
settings.port = port
settings.use_tls = use_tls
settings.username = username
settings.password = password
settings.default_sender = default_sender
db.session.commit()
flash('Mail settings updated.', 'success')
sent_emails = SentEmail.query.order_by(SentEmail.sent_at.desc()).limit(50).all()
return render_template('admin/mail_settings.html', settings=settings, sent_emails=sent_emails)
## Duplicate imports and Blueprint definitions removed
# Password reset token generator (simple, for demonstration)
def generate_reset_token(user):
# In production, use itsdangerous or Flask-Security for secure tokens
return secrets.token_urlsafe(32)
# Admin: Send password reset email to user
@admin.route('/users/<int:user_id>/reset-password', methods=['POST'])
@login_required
@admin_required
def reset_user_password(user_id):
user = User.query.get_or_404(user_id)
token = generate_reset_token(user)
# In production, save token to DB or cache, and validate on reset
reset_url = url_for('auth.reset_password', token=token, _external=True)
msg = Message(
subject="Password Reset Request",
recipients=[user.email],
body=f"Hello {user.nickname},\n\nAn admin has requested a password reset for your account. Click the link below to reset your password:\n{reset_url}\n\nIf you did not request this, please ignore this email."
)
try:
mail.send(msg)
flash(f"Password reset email sent to {user.email}.", "success")
except Exception as e:
current_app.logger.error(f"Error sending reset email: {e}")
flash(f"Failed to send password reset email: {e}", "danger")
return redirect(url_for('admin.user_detail', user_id=user.id))
def admin_required(f):
"""Decorator to require admin access"""
@wraps(f)
@@ -98,7 +173,7 @@ def dashboard():
def posts():
"""Admin post management - review posts"""
page = request.args.get('page', 1, type=int)
status = request.args.get('status', 'pending') # pending, published, all
status = request.args.get('status', 'all') # pending, published, all
query = Post.query

View File

@@ -3,8 +3,12 @@ from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import check_password_hash
from app.models import User, db
from app.forms import LoginForm, RegisterForm, ForgotPasswordForm
from app.routes.reset_password import RequestResetForm, ResetPasswordForm
from flask_mail import Message
from app.routes.mail import mail
from app.utils.token import generate_reset_token, verify_reset_token
import re
from app.forms import LoginForm, RegisterForm, ForgotPasswordForm
auth = Blueprint('auth', __name__)
@auth.route('/login', methods=['GET', 'POST'])
@@ -83,18 +87,44 @@ def forgot_password():
"""Forgot password page"""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = ForgotPasswordForm()
form = RequestResetForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
# TODO: Implement email sending for password reset
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
else:
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
token = generate_reset_token(user.email)
reset_url = url_for('auth.reset_password', token=token, _external=True)
msg = Message(
subject="Password Reset Request",
recipients=[user.email],
body=f"Hello {user.nickname},\n\nTo reset your password, click the link below:\n{reset_url}\n\nIf you did not request this, please ignore this email."
)
try:
mail.send(msg)
except Exception as e:
flash(f"Failed to send reset email: {e}", "danger")
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
return redirect(url_for('auth.login'))
return render_template('auth/forgot_password.html', form=form)
# Password reset route
@auth.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('main.index'))
email = verify_reset_token(token)
if not email:
flash('Invalid or expired reset link.', 'danger')
return redirect(url_for('auth.forgot_password'))
user = User.query.filter_by(email=email).first()
if not user:
flash('Invalid or expired reset link.', 'danger')
return redirect(url_for('auth.forgot_password'))
form = ResetPasswordForm()
if form.validate_on_submit():
user.set_password(form.password.data)
db.session.commit()
flash('Your password has been reset. You can now log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/reset_password.html', form=form)
def is_valid_password(password):
"""Validate password strength"""

2
app/routes/mail.py Normal file
View File

@@ -0,0 +1,2 @@
from flask_mail import Mail
mail = Mail()

View File

@@ -0,0 +1,12 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length
class RequestResetForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
class ResetPasswordForm(FlaskForm):
password = PasswordField('New Password', validators=[DataRequired(), Length(min=6)])
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Reset Password')

View File

@@ -38,17 +38,24 @@ window.addEventListener('resize', resizeMap);
resizeMap();
// Add OSM tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
// Use CartoDB Positron for English map labels (clean, readable, English by default)
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors & CartoDB',
subdomains: 'abcd',
maxZoom: 19
}).addTo(map);
// Ensure map is always north-up (default in Leaflet)
// Prevent any future rotation plugins or gestures
map.dragRotate && map.dragRotate.disable && map.dragRotate.disable();
map.touchZoomRotate && map.touchZoomRotate.disableRotation && map.touchZoomRotate.disableRotation();
// Fetch route data
if (routeId) {
fetch(`/community/api/route/${routeId}`)
.then(response => response.json())
.then(data => {
if (data && data.coordinates && data.coordinates.length > 0) {
const latlngs = data.coordinates.map(pt => [pt[1], pt[0]]);
const latlngs = data.coordinates.map(pt => [pt[0], pt[1]]);
const polyline = L.polyline(latlngs, { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(map);
map.fitBounds(polyline.getBounds(), { padding: [20, 20], maxZoom: 15 });
// Start marker
@@ -70,30 +77,4 @@ if (routeId) {
}
</script>
</body>
</html>
color: '#f97316', weight: 5, opacity: 0.9, smoothFactor: 1
}).addTo(routeLayer);
polyline.bindPopup(`<div><h3>🏍️ ${route.title}</h3><div>Distance: ${route.distance.toFixed(2)} km</div><div>Elevation Gain: ${route.elevation_gain.toFixed(0)} m</div></div>`);
// Always fit bounds to the route and bind to frame size
function fitRouteBounds() {
map.invalidateSize();
map.fitBounds(polyline.getBounds(), { padding: [10, 10], maxZoom: 18 });
}
fitRouteBounds();
window.addEventListener('resize', fitRouteBounds);
})
.catch(error => showError(error.message));
}
function showError(msg) {
const loading = document.getElementById('map-loading');
loading.innerHTML = `<div style='color:#dc2626;'><div style='font-size:1.2em;margin-bottom:8px;'>⚠️</div><div>${msg}</div></div>`;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeMap);
} else {
initializeMap();
}
window.addEventListener('resize', () => { if (map) setTimeout(() => map.invalidateSize(), 100); });
</script>
</body>
</html>

View File

@@ -173,6 +173,11 @@
<i class="fas fa-chart-bar"></i> Analytics
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'admin.mail_settings' }}" href="{{ url_for('admin.mail_settings') }}">
<i class="fas fa-envelope"></i> Mail Server Settings
</a>
</li>
</ul>
<div class="sidebar-heading">

View File

@@ -0,0 +1,66 @@
{% extends 'admin/base.html' %}
{% block admin_content %}
<h2>Mail Server Settings</h2>
<form method="post">
<div class="form-group">
<label for="enabled">Enable Email Sending</label>
<input type="checkbox" id="enabled" name="enabled" value="1" {% if settings and settings.enabled %}checked{% endif %}>
</div>
<div class="form-group">
<label for="provider">Provider</label>
<select id="provider" name="provider" class="form-control">
<option value="smtp" {% if settings and settings.provider == 'smtp' %}selected{% endif %}>SMTP</option>
<option value="mailrise" {% if settings and settings.provider == 'mailrise' %}selected{% endif %}>Mailrise</option>
</select>
</div>
<div class="form-group">
<label for="server">Server</label>
<input type="text" id="server" name="server" class="form-control" value="{{ settings.server if settings else '' }}">
</div>
<div class="form-group">
<label for="port">Port</label>
<input type="number" id="port" name="port" class="form-control" value="{{ settings.port if settings else '' }}">
</div>
<div class="form-group">
<label for="use_tls">Use TLS</label>
<input type="checkbox" id="use_tls" name="use_tls" value="1" {% if settings and settings.use_tls %}checked{% endif %}>
</div>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-control" value="{{ settings.username if settings else '' }}">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-control" value="{{ settings.password if settings else '' }}">
</div>
<div class="form-group">
<label for="default_sender">Default Sender</label>
<input type="text" id="default_sender" name="default_sender" class="form-control" value="{{ settings.default_sender if settings else '' }}">
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
<hr>
<h3>Sent Emails Log</h3>
<table class="table table-striped">
<thead>
<tr>
<th>Date</th>
<th>Recipient</th>
<th>Subject</th>
<th>Status</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{% for email in sent_emails %}
<tr>
<td>{{ email.sent_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ email.recipient }}</td>
<td>{{ email.subject }}</td>
<td>{{ email.status }}</td>
<td>{{ email.error or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -17,6 +17,19 @@
<div class="row">
<!-- User Information -->
<div class="col-lg-4">
<!-- Admin: Reset Password Card -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-danger">Admin: Reset Password</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.reset_user_password', user_id=user.id) }}">
<button type="submit" class="btn btn-warning w-100" onclick="return confirm('Send password reset email to this user?')">
<i class="fas fa-envelope"></i> Send Password Reset Email
</button>
</form>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">User Information</h6>

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Forgot Password{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-50 py-20">
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg overflow-hidden">
<div class="bg-gradient-to-r from-blue-500 via-purple-500 to-teal-500 p-6 text-white text-center">
<h2 class="text-2xl font-bold">Forgot your password?</h2>
<p class="text-blue-100 mt-1">Enter your email to receive a reset link.</p>
</div>
<form method="POST" class="p-6 space-y-6">
{{ form.hidden_tag() }}
<div>
{{ form.email.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.email(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") }}
{% if form.email.errors %}
<div class="text-red-500 text-sm mt-1">
{% for error in form.email.errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
{{ form.submit(class="w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white py-2 px-4 rounded-lg hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition font-medium") }}
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Reset Password{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-50 py-20">
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg overflow-hidden">
<div class="bg-gradient-to-r from-blue-500 via-purple-500 to-teal-500 p-6 text-white text-center">
<h2 class="text-2xl font-bold">Reset your password</h2>
<p class="text-blue-100 mt-1">Enter your new password below.</p>
</div>
<form method="POST" class="p-6 space-y-6">
{{ form.hidden_tag() }}
<div>
{{ form.password.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.password(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") }}
{% if form.password.errors %}
<div class="text-red-500 text-sm mt-1">
{% for error in form.password.errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
<div>
{{ form.confirm_password.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.confirm_password(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent") }}
{% if form.confirm_password.errors %}
<div class="text-red-500 text-sm mt-1">
{% for error in form.confirm_password.errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
{{ form.submit(class="w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white py-2 px-4 rounded-lg hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition font-medium") }}
</form>
</div>
</div>
{% endblock %}

19
app/utils/token.py Normal file
View File

@@ -0,0 +1,19 @@
import os
from itsdangerous import URLSafeTimedSerializer
from flask import current_app
def get_serializer():
secret = os.environ.get('RESET_TOKEN_SECRET') or current_app.config.get('SECRET_KEY')
return URLSafeTimedSerializer(secret)
def generate_reset_token(email):
s = get_serializer()
return s.dumps(email, salt='password-reset')
def verify_reset_token(token, max_age=3600):
s = get_serializer()
try:
email = s.loads(token, salt='password-reset', max_age=max_age)
return email
except Exception:
return None