Major Feature Update: Modern Chat System & Admin Management

Features Added:
🔥 Modern Chat System:
- Real-time messaging with modern Tailwind CSS design
- Post-linked discussions for adventure sharing
- Chat categories (general, technical-support, adventure-planning)
- Mobile-responsive interface with gradient backgrounds
- JavaScript polling for live message updates

🎯 Comprehensive Admin Panel:
- Chat room management with merge capabilities
- Password reset system with email templates
- User management with admin controls
- Chat statistics and analytics dashboard
- Room binding to posts and categorization

�� Mobile API Integration:
- RESTful API endpoints at /api/v1/chat
- Session-based authentication for mobile apps
- Comprehensive endpoints for rooms, messages, users
- Mobile app compatibility (React Native, Flutter)

🛠️ Technical Improvements:
- Enhanced database models with ChatRoom categories
- Password reset token system with email verification
- Template synchronization fixes for Docker deployment
- Migration scripts for database schema updates
- Improved error handling and validation

🎨 UI/UX Enhancements:
- Modern card-based layouts matching app design
- Consistent styling across chat and admin interfaces
- Mobile-optimized touch interactions
- Professional gradient designs and glass morphism effects

📚 Documentation:
- Updated README with comprehensive API documentation
- Added deployment instructions for Docker (port 8100)
- Configuration guide for production environments
- Mobile integration examples and endpoints

This update transforms the platform into a comprehensive motorcycle adventure community with modern chat capabilities and professional admin management tools.
This commit is contained in:
ske087
2025-08-10 00:22:33 +03:00
parent 1661f5f588
commit 30bd4c62ad
20 changed files with 3649 additions and 349 deletions

173
README.md
View File

@@ -28,6 +28,10 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent
- **Adventure Posts**: Rich content creation with titles, subtitles, and detailed stories
- **Comment System**: Community discussions on adventure posts
- **Like System**: Engagement tracking with real-time updates
- **Real-time Chat System**: Modern chat interface with room management
- **Post-linked Discussions**: Chat rooms connected to specific adventure posts
- **Chat Categories**: Organized rooms for different topics (general, technical, routes, etc.)
- **Mobile API Integration**: RESTful API for mobile app connectivity
- **User Profiles**: Personal dashboards with adventure statistics
- **Difficulty Ratings**: 5-star system for adventure difficulty assessment
- **Publication Workflow**: Admin approval system for content moderation
@@ -40,13 +44,25 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent
- **Registration System**: Email-based user registration
### 🛠️ Admin Panel & Analytics
- **Comprehensive Dashboard**: User and post management interface
- **Comprehensive Dashboard**: User and post management interface with statistics
- **Content Moderation**: Review and approve community posts
- **User Analytics**: User engagement and activity metrics
- **User Analytics**: User engagement and activity metrics with page view tracking
- **Post Management**: Bulk operations and detailed post information
- **Chat Management**: Full chat room administration with merge capabilities
- **Password Reset System**: Admin-controlled password reset with secure tokens
- **Mail System Configuration**: SMTP settings and email template management
- **System Configuration**: Admin-only settings and controls
### 📱 Mobile-Optimized Experience
### <EFBFBD> Real-time Chat System
- **Modern Chat Interface**: App-style design with gradient backgrounds and card layouts
- **Room Management**: Create, join, and manage chat rooms with categories
- **Post Integration**: Link chat rooms to specific adventure posts for focused discussions
- **Admin Controls**: Comprehensive chat administration with room merging and moderation
- **Mobile API**: RESTful API endpoints for mobile app integration
- **Real-time Updates**: JavaScript polling for live message updates
- **Message Features**: Text messages with editing, deletion, and system notifications
### <20>📱 Mobile-Optimized Experience
- **Touch-Friendly Interface**: Optimized buttons and interactions for mobile devices
- **Responsive Grids**: Adaptive layouts that work perfectly on phones and tablets
- **Progressive Enhancement**: Graceful degradation for older browsers
@@ -62,6 +78,8 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent
- **Frontend**: Tailwind CSS 3.x with custom components
- **Maps**: Leaflet.js with OpenStreetMap integration
- **File Handling**: Secure media uploads with thumbnail generation
- **Chat System**: Real-time messaging with WebSocket-ready architecture
- **API**: RESTful endpoints for mobile app integration
- **Deployment**: Docker with Gunicorn WSGI server
## 📁 Project Structure
@@ -112,7 +130,106 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent
└── docker-compose.yml # Docker Compose setup
```
## 🚀 Quick Start
## <EFBFBD> API Documentation
The platform provides a comprehensive RESTful API for mobile app integration and third-party services.
### Base URL
```
https://your-domain.com/api/v1
```
### Authentication
All API endpoints use session-based authentication. Mobile apps can authenticate using:
```http
POST /auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "password"
}
```
### Chat API Endpoints
#### Get Chat Rooms
```http
GET /api/v1/chat/rooms
```
Response:
```json
{
"rooms": [
{
"id": 1,
"name": "General Discussion",
"category": "general",
"post_id": null,
"created_at": "2024-01-01T10:00:00Z"
}
]
}
```
#### Join Chat Room
```http
POST /api/v1/chat/rooms/{room_id}/join
```
#### Send Message
```http
POST /api/v1/chat/rooms/{room_id}/messages
Content-Type: application/json
{
"content": "Hello, world!"
}
```
#### Get Messages
```http
GET /api/v1/chat/rooms/{room_id}/messages?page=1&per_page=50
```
### Posts API Endpoints
#### Get Posts
```http
GET /api/v1/posts?page=1&per_page=20
```
#### Create Post
```http
POST /api/v1/posts
Content-Type: multipart/form-data
title: "Adventure Title"
content: "Post content"
images: [file uploads]
gpx_file: [GPX file upload]
```
### User API Endpoints
#### Get User Profile
```http
GET /api/v1/users/{user_id}
```
#### Update Profile
```http
PUT /api/v1/users/profile
Content-Type: application/json
{
"bio": "Updated bio",
"location": "New location"
}
```
## <20>🚀 Quick Start
### Local Development
@@ -172,8 +289,54 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent
```
2. **Access the application**
- Web application: http://localhost:5000
- Web application: http://localhost:8100
- PostgreSQL database: localhost:5432
- API endpoints: http://localhost:8100/api/v1
3. **Production deployment**
```bash
# Set production environment variables
export FLASK_ENV=production
export SECRET_KEY="your-secure-production-key"
export DATABASE_URL="postgresql://user:password@localhost:5432/moto_adventure"
export MAIL_SERVER="your-smtp-server.com"
export MAIL_USERNAME="your-email@domain.com"
export MAIL_PASSWORD="your-email-password"
# Run in production mode
docker-compose -f docker-compose.prod.yml up -d
```
### 🔧 Configuration
#### Environment Variables
- `SECRET_KEY`: Flask secret key for session management
- `DATABASE_URL`: Database connection string
- `MAIL_SERVER`: SMTP server for email notifications
- `MAIL_PORT`: SMTP port (default: 587)
- `MAIL_USE_TLS`: Enable TLS for email (default: True)
- `MAIL_USERNAME`: Email account username
- `MAIL_PASSWORD`: Email account password
- `UPLOAD_PATH`: Custom upload directory path
- `MAX_CONTENT_LENGTH`: Maximum file upload size
#### Admin Configuration
To create an admin user:
```bash
# Access the container
docker exec -it moto-adventure-app bash
# Run Python shell
python
>>> from app import create_app, db
>>> from app.models import User
>>> app = create_app()
>>> with app.app_context():
... admin = User(email='admin@example.com', is_admin=True)
... admin.set_password('secure_password')
... db.session.add(admin)
... db.session.commit()
```
### 📱 Testing Features

View File

@@ -313,6 +313,7 @@ class ChatRoom(db.Model):
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
room_type = db.Column(db.String(50), default='general') # general, post_discussion, admin_support, password_reset
category = db.Column(db.String(50), default='general') # general, technical, maintenance, routes, events, safety, gear, social
is_private = db.Column(db.Boolean, default=False)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@@ -436,3 +437,69 @@ class ChatMessage(db.Model):
def __repr__(self):
return f'<ChatMessage {self.id} by {self.user.nickname}>'
class PasswordResetRequest(db.Model):
"""Model for tracking password reset requests from chat system"""
__tablename__ = 'password_reset_requests'
id = db.Column(db.Integer, primary_key=True)
user_email = db.Column(db.String(120), nullable=False)
requester_message = db.Column(db.Text) # Original request message
status = db.Column(db.String(20), default='pending') # pending, token_generated, completed, expired
admin_notes = db.Column(db.Text) # Admin can add notes
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign Keys
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # Nullable in case user not found
chat_message_id = db.Column(db.Integer, db.ForeignKey('chat_messages.id'), nullable=True)
# Relationships
user = db.relationship('User', backref='password_reset_requests')
chat_message = db.relationship('ChatMessage', backref='password_reset_request')
tokens = db.relationship('PasswordResetToken', backref='request', cascade='all, delete-orphan')
def __repr__(self):
return f'<PasswordResetRequest {self.id} for {self.user_email}>'
class PasswordResetToken(db.Model):
"""Model for one-time password reset tokens generated by admin"""
__tablename__ = 'password_reset_tokens'
id = db.Column(db.Integer, primary_key=True)
token = db.Column(db.String(255), unique=True, nullable=False)
is_used = db.Column(db.Boolean, default=False)
used_at = db.Column(db.DateTime, nullable=True)
expires_at = db.Column(db.DateTime, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
user_ip = db.Column(db.String(45), nullable=True) # IP when token was used
user_agent = db.Column(db.Text, nullable=True) # User agent when token was used
# Foreign Keys
request_id = db.Column(db.Integer, db.ForeignKey('password_reset_requests.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_by_admin_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Relationships
user = db.relationship('User', foreign_keys=[user_id], backref='reset_tokens')
created_by_admin = db.relationship('User', foreign_keys=[created_by_admin_id])
@property
def is_expired(self):
return datetime.utcnow() > self.expires_at
@property
def is_valid(self):
return not self.is_used and not self.is_expired
def mark_as_used(self, ip_address=None, user_agent=None):
self.is_used = True
self.used_at = datetime.utcnow()
self.user_ip = ip_address
self.user_agent = user_agent
db.session.commit()
def __repr__(self):
return f'<PasswordResetToken {self.token[:8]}... for {self.user.nickname}>'

View File

@@ -7,9 +7,10 @@ from functools import wraps
from datetime import datetime, timedelta
from sqlalchemy import func, desc
import secrets
import uuid
from app.routes.mail import mail
from app.extensions import db
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView, MailSettings, SentEmail
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView, MailSettings, SentEmail, PasswordResetRequest, PasswordResetToken, ChatRoom, ChatMessage
admin = Blueprint('admin', __name__, url_prefix='/admin')
@@ -151,6 +152,22 @@ def dashboard():
.order_by(desc('view_count'))\
.limit(10).all()
# Password reset statistics
pending_password_requests = PasswordResetRequest.query.filter_by(status='pending').count()
active_reset_tokens = PasswordResetToken.query.filter_by(is_used=False).filter(
PasswordResetToken.expires_at > datetime.utcnow()
).count()
# Chat statistics
total_chat_rooms = ChatRoom.query.count()
linked_chat_rooms = ChatRoom.query.filter(ChatRoom.related_post_id.isnot(None)).count()
active_chat_rooms = db.session.query(ChatRoom.id).filter(
ChatRoom.messages.any(ChatMessage.created_at >= thirty_days_ago)
).distinct().count()
recent_chat_messages = ChatMessage.query.filter(
ChatMessage.created_at >= thirty_days_ago
).count()
return render_template('admin/dashboard.html',
total_users=total_users,
total_posts=total_posts,
@@ -165,7 +182,13 @@ def dashboard():
views_yesterday=views_yesterday,
views_this_week=views_this_week,
most_viewed_posts=most_viewed_posts,
most_viewed_pages=most_viewed_pages)
most_viewed_pages=most_viewed_pages,
pending_password_requests=pending_password_requests,
active_reset_tokens=active_reset_tokens,
total_chat_rooms=total_chat_rooms,
linked_chat_rooms=linked_chat_rooms,
active_chat_rooms=active_chat_rooms,
recent_chat_messages=recent_chat_messages)
@admin.route('/posts')
@login_required
@@ -506,3 +529,367 @@ def api_quick_stats():
'pending_posts': pending_count,
'today_views': today_views
})
# Password Reset Management Routes
@admin.route('/password-reset-requests')
@login_required
@admin_required
def password_reset_requests():
"""View all password reset requests"""
page = request.args.get('page', 1, type=int)
status = request.args.get('status', 'all')
query = PasswordResetRequest.query
if status != 'all':
query = query.filter_by(status=status)
requests = query.order_by(PasswordResetRequest.created_at.desc()).paginate(
page=page, per_page=20, error_out=False
)
return render_template('admin/password_reset_requests.html',
requests=requests, status=status)
@admin.route('/password-reset-requests/<int:request_id>')
@login_required
@admin_required
def password_reset_request_detail(request_id):
"""View individual password reset request details"""
reset_request = PasswordResetRequest.query.get_or_404(request_id)
# Get associated tokens
tokens = PasswordResetToken.query.filter_by(request_id=request_id).order_by(
PasswordResetToken.created_at.desc()
).all()
return render_template('admin/password_reset_request_detail.html',
request=reset_request, tokens=tokens)
@admin.route('/password-reset-requests/<int:request_id>/generate-token', methods=['POST'])
@login_required
@admin_required
def generate_password_reset_token(request_id):
"""Generate a new password reset token for a request"""
reset_request = PasswordResetRequest.query.get_or_404(request_id)
# Create token
token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(hours=24) # 24 hour expiry
reset_token = PasswordResetToken(
token=token,
request_id=request_id,
user_id=reset_request.user.id,
created_by_admin_id=current_user.id,
expires_at=expires_at
)
db.session.add(reset_token)
reset_request.status = 'token_generated'
reset_request.updated_at = datetime.utcnow()
db.session.commit()
flash('Password reset token generated successfully!', 'success')
return redirect(url_for('admin.password_reset_token_template', token_id=reset_token.id))
@admin.route('/password-reset-tokens')
@login_required
@admin_required
def password_reset_tokens():
"""View all password reset tokens"""
page = request.args.get('page', 1, type=int)
status = request.args.get('status', 'all')
query = PasswordResetToken.query.join(User).order_by(PasswordResetToken.created_at.desc())
if status == 'active':
query = query.filter_by(is_used=False).filter(PasswordResetToken.expires_at > datetime.utcnow())
elif status == 'used':
query = query.filter_by(is_used=True)
elif status == 'expired':
query = query.filter(PasswordResetToken.expires_at <= datetime.utcnow(), PasswordResetToken.is_used == False)
tokens = query.paginate(page=page, per_page=20, error_out=False)
# Get counts for statistics
active_count = PasswordResetToken.query.filter_by(is_used=False).filter(
PasswordResetToken.expires_at > datetime.utcnow()
).count()
used_count = PasswordResetToken.query.filter_by(is_used=True).count()
expired_count = PasswordResetToken.query.filter(
PasswordResetToken.expires_at <= datetime.utcnow(),
PasswordResetToken.is_used == False
).count()
return render_template('admin/password_reset_tokens.html',
tokens=tokens, status=status,
active_count=active_count, used_count=used_count, expired_count=expired_count)
@admin.route('/manage-chats')
@login_required
@admin_required
def manage_chats():
"""Admin chat room management"""
page = request.args.get('page', 1, type=int)
category = request.args.get('category', '')
status = request.args.get('status', '')
search = request.args.get('search', '')
# Base query with message count
query = db.session.query(
ChatRoom,
func.count(ChatMessage.id).label('message_count'),
func.max(ChatMessage.created_at).label('last_activity')
).outerjoin(ChatMessage).group_by(ChatRoom.id)
# Apply filters
if category:
query = query.filter(ChatRoom.category == category)
if status == 'linked':
query = query.filter(ChatRoom.related_post_id.isnot(None))
elif status == 'unlinked':
query = query.filter(ChatRoom.related_post_id.is_(None))
elif status == 'active':
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
query = query.having(func.max(ChatMessage.created_at) >= thirty_days_ago)
elif status == 'inactive':
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
query = query.having(
db.or_(
func.max(ChatMessage.created_at) < thirty_days_ago,
func.max(ChatMessage.created_at).is_(None)
)
)
if search:
query = query.filter(
db.or_(
ChatRoom.name.contains(search),
ChatRoom.description.contains(search)
)
)
# Order by last activity
query = query.order_by(func.max(ChatMessage.created_at).desc().nullslast())
# Paginate
results = query.paginate(page=page, per_page=20, error_out=False)
# Process results to add message count and last activity to room objects
chat_rooms = []
for room, message_count, last_activity in results.items:
room.message_count = message_count
room.last_activity = last_activity
chat_rooms.append(room)
# Get statistics
total_rooms = ChatRoom.query.count()
linked_rooms = ChatRoom.query.filter(ChatRoom.related_post_id.isnot(None)).count()
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
active_today = db.session.query(ChatRoom.id).filter(
ChatRoom.messages.any(
func.date(ChatMessage.created_at) == datetime.utcnow().date()
)
).distinct().count()
total_messages = ChatMessage.query.count()
# Get available posts for linking
available_posts = Post.query.filter_by(published=True).order_by(Post.title).all()
# Create pagination object with processed rooms
class PaginationWrapper:
def __init__(self, original_pagination, items):
self.page = original_pagination.page
self.per_page = original_pagination.per_page
self.total = original_pagination.total
self.pages = original_pagination.pages
self.has_prev = original_pagination.has_prev
self.prev_num = original_pagination.prev_num
self.has_next = original_pagination.has_next
self.next_num = original_pagination.next_num
self.items = items
def iter_pages(self):
return range(1, self.pages + 1)
pagination = PaginationWrapper(results, chat_rooms)
return render_template('admin/manage_chats.html',
chat_rooms=chat_rooms,
pagination=pagination,
total_rooms=total_rooms,
linked_rooms=linked_rooms,
active_today=active_today,
total_messages=total_messages,
available_posts=available_posts)
@admin.route('/api/chat-rooms')
@login_required
@admin_required
def api_chat_rooms():
"""API endpoint for chat rooms (for AJAX calls)"""
exclude_id = request.args.get('exclude', type=int)
query = ChatRoom.query
if exclude_id:
query = query.filter(ChatRoom.id != exclude_id)
rooms = query.all()
# Get message counts
room_data = []
for room in rooms:
message_count = ChatMessage.query.filter_by(room_id=room.id).count()
room_data.append({
'id': room.id,
'name': room.name,
'description': room.description,
'category': room.category,
'message_count': message_count,
'created_at': room.created_at.isoformat() if room.created_at else None
})
return jsonify({'success': True, 'rooms': room_data})
@admin.route('/api/chat-rooms/<int:room_id>', methods=['PUT'])
@login_required
@admin_required
def api_update_chat_room(room_id):
"""Update chat room details"""
room = ChatRoom.query.get_or_404(room_id)
try:
data = request.get_json()
room.name = data.get('name', room.name)
room.description = data.get('description', room.description)
room.category = data.get('category', room.category)
# Handle post linking
related_post_id = data.get('related_post_id')
if related_post_id:
post = Post.query.get(related_post_id)
if post:
room.related_post_id = related_post_id
else:
return jsonify({'success': False, 'error': 'Post not found'})
else:
room.related_post_id = None
db.session.commit()
return jsonify({'success': True, 'message': 'Room updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
@admin.route('/api/chat-rooms/<int:room_id>/link-post', methods=['POST'])
@login_required
@admin_required
def api_link_chat_room_to_post(room_id):
"""Link chat room to a post"""
room = ChatRoom.query.get_or_404(room_id)
try:
data = request.get_json()
post_id = data.get('post_id')
if post_id:
post = Post.query.get_or_404(post_id)
room.related_post_id = post_id
else:
room.related_post_id = None
db.session.commit()
return jsonify({'success': True, 'message': 'Room linked to post successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
@admin.route('/api/chat-rooms/<int:source_room_id>/merge', methods=['POST'])
@login_required
@admin_required
def api_merge_chat_rooms(source_room_id):
"""Merge source room into target room"""
source_room = ChatRoom.query.get_or_404(source_room_id)
try:
data = request.get_json()
target_room_id = data.get('target_room_id')
target_room = ChatRoom.query.get_or_404(target_room_id)
# Move all messages from source to target room
messages = ChatMessage.query.filter_by(room_id=source_room_id).all()
for message in messages:
message.room_id = target_room_id
# Add system message about the merge
merge_message = ChatMessage(
room_id=target_room_id,
sender_id=current_user.id,
content=f"Room '{source_room.name}' has been merged into this room by admin {current_user.nickname}",
message_type='system',
is_system_message=True
)
db.session.add(merge_message)
# Delete the source room
db.session.delete(source_room)
db.session.commit()
return jsonify({'success': True, 'message': 'Rooms merged successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
@admin.route('/api/chat-rooms/<int:room_id>', methods=['DELETE'])
@login_required
@admin_required
def api_delete_chat_room(room_id):
"""Delete chat room and all its messages"""
room = ChatRoom.query.get_or_404(room_id)
try:
# Delete all messages first
ChatMessage.query.filter_by(room_id=room_id).delete()
# Delete the room
db.session.delete(room)
db.session.commit()
return jsonify({'success': True, 'message': 'Room deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
@admin.route('/password-reset-tokens/<int:token_id>/template')
@login_required
@admin_required
def password_reset_token_template(token_id):
"""Display email template for password reset token"""
token = PasswordResetToken.query.get_or_404(token_id)
# Generate the reset URL
reset_url = url_for('auth.reset_password_with_token', token=token.token, _external=True)
return render_template('admin/password_reset_email_template.html',
token=token, reset_url=reset_url)

View File

@@ -1,7 +1,7 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
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.models import User, db, PasswordResetToken
from app.routes.reset_password import RequestResetForm, ResetPasswordForm
from flask_mail import Message
from app.routes.mail import mail
@@ -161,26 +161,6 @@ def forgot_password():
flash('Your password reset request has been sent to administrators. They will contact you soon to help reset your password.', '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)
@auth.route('/change-password', methods=['POST'])
@login_required
@@ -235,3 +215,59 @@ def is_valid_password(password):
if not re.search(r'\d', password):
return False
return True
class ResetPasswordWithTokenForm(FlaskForm):
password = PasswordField('New Password', validators=[DataRequired(), Length(min=8)])
password2 = PasswordField('Confirm New Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Reset Password')
@auth.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password_with_token(token):
"""Reset password using admin-generated token"""
# Find the token in database
reset_token = PasswordResetToken.query.filter_by(token=token).first()
if not reset_token:
flash('Invalid or expired reset link.', 'error')
return redirect(url_for('auth.login'))
# Check if token is expired
if reset_token.is_expired:
flash('This reset link has expired. Please request a new one.', 'error')
return redirect(url_for('auth.login'))
# Check if token is already used
if reset_token.is_used:
flash('This reset link has already been used.', 'error')
return redirect(url_for('auth.login'))
form = ResetPasswordWithTokenForm()
if form.validate_on_submit():
user = reset_token.user
# Validate password strength
if not is_valid_password(form.password.data):
flash('Password must be at least 8 characters long and contain both letters and numbers.', 'error')
return render_template('auth/reset_password_with_token.html', form=form, token=token)
# Update password
user.set_password(form.password.data)
# Mark token as used
reset_token.used_at = datetime.utcnow()
reset_token.user_ip = request.environ.get('REMOTE_ADDR')
# Update request status
if reset_token.request:
reset_token.request.status = 'completed'
reset_token.request.updated_at = datetime.utcnow()
db.session.commit()
flash('Your password has been reset successfully! You can now log in with your new password.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/reset_password_with_token.html', form=form, token=token, user=reset_token.user)

View File

@@ -5,6 +5,7 @@ Provides HTML templates and endpoints for web-based chat
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from sqlalchemy import desc, and_
from datetime import datetime, timedelta
from app.extensions import db
from app.models import ChatRoom, ChatMessage, ChatParticipant, Post
@@ -90,7 +91,109 @@ def create_room_form():
desc(Post.created_at)
).limit(20).all()
return render_template('chat/create_room.html', posts=recent_posts)
# Check if a specific post was requested
pre_selected_post = request.args.get('post_id')
if pre_selected_post:
try:
pre_selected_post = int(pre_selected_post)
except ValueError:
pre_selected_post = None
return render_template('chat/create_room.html', posts=recent_posts, pre_selected_post=pre_selected_post)
@chat.route('/create', methods=['POST'])
@login_required
def create_room():
"""Create a new chat room"""
room_name = request.form.get('room_name')
description = request.form.get('description', '')
room_type = request.form.get('room_type', 'general')
is_private = bool(request.form.get('is_private'))
related_post_id = request.form.get('related_post_id')
if not room_name:
flash('Room name is required.', 'error')
return redirect(url_for('chat.create_room_form'))
# Convert to integer if post ID is provided
if related_post_id:
try:
related_post_id = int(related_post_id)
# Verify the post exists
related_post = Post.query.get(related_post_id)
if not related_post:
flash('Selected post does not exist.', 'error')
return redirect(url_for('chat.create_room_form'))
# If post is selected, set room type to post_discussion
room_type = 'post_discussion'
except ValueError:
related_post_id = None
else:
related_post_id = None
# If no post selected, ensure it's general discussion
if room_type == 'post_discussion':
room_type = 'general'
# Check if room name already exists
existing_room = ChatRoom.query.filter_by(name=room_name).first()
if existing_room:
flash('A room with that name already exists.', 'error')
return redirect(url_for('chat.create_room_form'))
try:
# Create the room
room = ChatRoom(
name=room_name,
description=description,
room_type=room_type,
is_private=is_private,
is_active=True,
created_by_id=current_user.id,
related_post_id=related_post_id
)
db.session.add(room)
db.session.flush() # Get the room ID
# Add creator as participant with admin role
participant = ChatParticipant(
room_id=room.id,
user_id=current_user.id,
role='admin',
joined_at=datetime.utcnow()
)
db.session.add(participant)
# Add welcome message
if related_post_id:
welcome_content = f"Welcome to the discussion for '{related_post.title}'! This room was created by {current_user.nickname} to discuss this post."
else:
welcome_content = f"Welcome to {room_name}! This room was created by {current_user.nickname}."
welcome_message = ChatMessage(
content=welcome_content,
room_id=room.id,
sender_id=current_user.id,
is_system_message=True
)
db.session.add(welcome_message)
# Update room activity
room.last_activity = datetime.utcnow()
room.message_count = 1
db.session.commit()
if related_post_id:
flash(f'Chat room "{room_name}" created successfully and linked to the post!', 'success')
else:
flash(f'Chat room "{room_name}" created successfully!', 'success')
return redirect(url_for('chat.room', room_id=room.id))
except Exception as e:
db.session.rollback()
flash(f'Error creating room: {str(e)}', 'error')
return redirect(url_for('chat.create_room_form'))
@chat.route('/support')
@login_required
@@ -123,3 +226,51 @@ def embed_post_chat(post_id):
return render_template('chat/embed.html',
post=post,
discussion_room=discussion_room)
@chat.route('/post-discussions')
@login_required
def post_discussions():
"""View all chat rooms related to posts"""
page = request.args.get('page', 1, type=int)
# Get all rooms that are linked to posts
post_rooms = ChatRoom.query.filter(
ChatRoom.related_post_id.isnot(None),
ChatRoom.is_active == True
).join(Post).order_by(ChatRoom.last_activity.desc()).paginate(
page=page, per_page=20, error_out=False
)
# Get statistics
total_post_discussions = ChatRoom.query.filter(
ChatRoom.related_post_id.isnot(None),
ChatRoom.is_active == True
).count()
active_discussions = ChatRoom.query.filter(
ChatRoom.related_post_id.isnot(None),
ChatRoom.is_active == True,
ChatRoom.last_activity >= datetime.utcnow() - timedelta(days=7)
).count()
return render_template('chat/post_discussions.html',
rooms=post_rooms,
total_discussions=total_post_discussions,
active_discussions=active_discussions)
@chat.route('/post/<int:post_id>/discussions')
@login_required
def post_specific_discussions(post_id):
"""View all chat rooms for a specific post"""
post = Post.query.get_or_404(post_id)
# Get all rooms for this specific post
rooms = ChatRoom.query.filter(
ChatRoom.related_post_id == post_id,
ChatRoom.is_active == True
).order_by(ChatRoom.last_activity.desc()).all()
return render_template('chat/post_specific_discussions.html',
post=post, rooms=rooms)

View File

@@ -121,10 +121,88 @@
</div>
</div>
<!-- Password Reset Management -->
<div class="row mb-4">
<div class="col-xl-6 col-md-6 mb-4">
<div class="card border-left-danger h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs fw-bold text-danger text-uppercase mb-1">Password Reset Requests</div>
<div class="h5 mb-0 fw-bold text-gray-800">
<a href="{{ url_for('admin.password_reset_requests') }}" class="text-decoration-none text-dark">
{{ pending_password_requests or 0 }}
</a>
</div>
<div class="small text-muted">Pending requests need attention</div>
</div>
<div class="col-auto">
<i class="fas fa-key fa-2x text-gray-300"></i>
</div>
</div>
<div class="mt-3">
<a href="{{ url_for('admin.password_reset_requests') }}" class="btn btn-danger btn-sm">
<i class="fas fa-cogs me-1"></i>Manage Requests
</a>
</div>
</div>
</div>
</div>
<div class="col-xl-6 col-md-6 mb-4">
<div class="card border-left-secondary h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs fw-bold text-secondary text-uppercase mb-1">Active Reset Tokens</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ active_reset_tokens or 0 }}</div>
<div class="small text-muted">Unused tokens (24h expiry)</div>
</div>
<div class="col-auto">
<i class="fas fa-link fa-2x text-gray-300"></i>
</div>
</div>
<div class="mt-3">
<a href="{{ url_for('admin.password_reset_tokens') }}" class="btn btn-secondary btn-sm">
<i class="fas fa-list me-1"></i>View Tokens
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Content Overview -->
<div class="row">
<!-- Chat Management -->
<div class="col-lg-4 mb-4">
<div class="card border-left-info h-100">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 fw-bold text-info">
<i class="fas fa-comments me-2"></i>Chat Management
</h6>
</div>
<div class="card-body">
<div class="text-center mb-3">
<div class="h4 mb-0 text-gray-800">{{ total_chat_rooms or 0 }}</div>
<small class="text-muted">Total Chat Rooms</small>
</div>
<div class="mb-3">
<div class="small text-muted mb-1">Active Rooms: {{ active_chat_rooms or 0 }}</div>
<div class="small text-muted mb-1">Linked to Posts: {{ linked_chat_rooms or 0 }}</div>
<div class="small text-muted">Recent Messages: {{ recent_chat_messages or 0 }}</div>
</div>
<div class="text-center">
<a href="{{ url_for('admin.manage_chats') }}" class="btn btn-info btn-block">
<i class="fas fa-cogs me-1"></i>Manage Chats
</a>
</div>
</div>
</div>
</div>
<!-- Recent Posts -->
<div class="col-lg-6 mb-4">
<div class="col-lg-4 mb-4">
<div class="card">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 fw-bold text-primary">Recent Posts</h6>
@@ -161,7 +239,7 @@
</div>
<!-- Most Viewed Posts -->
<div class="col-lg-6 mb-4">
<div class="col-lg-4 mb-4">
<div class="card">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Most Viewed Posts</h6>

View File

@@ -0,0 +1,603 @@
{% extends "admin/base.html" %}
{% block title %}Manage Chats - Admin{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
<h1 class="h2">
<i class="fas fa-comments me-2"></i>Manage Chat Rooms
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="location.reload()">
<i class="fas fa-sync-alt"></i> Refresh
</button>
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createRoomModal">
<i class="fas fa-plus"></i> Create Room
</button>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs fw-bold text-primary text-uppercase mb-1">Total Rooms</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ total_rooms }}</div>
</div>
<div class="col-auto">
<i class="fas fa-comments fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-success h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs fw-bold text-success text-uppercase mb-1">Linked to Posts</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ linked_rooms }}</div>
</div>
<div class="col-auto">
<i class="fas fa-link fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-warning h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs fw-bold text-warning text-uppercase mb-1">Active Today</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ active_today }}</div>
</div>
<div class="col-auto">
<i class="fas fa-clock fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs fw-bold text-info text-uppercase mb-1">Total Messages</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ total_messages }}</div>
</div>
<div class="col-auto">
<i class="fas fa-comment fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters and Search -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-3">
<label for="category" class="form-label">Category</label>
<select class="form-select" id="category" name="category">
<option value="">All Categories</option>
<option value="general" {{ 'selected' if request.args.get('category') == 'general' }}>General</option>
<option value="technical" {{ 'selected' if request.args.get('category') == 'technical' }}>Technical</option>
<option value="maintenance" {{ 'selected' if request.args.get('category') == 'maintenance' }}>Maintenance</option>
<option value="routes" {{ 'selected' if request.args.get('category') == 'routes' }}>Routes</option>
<option value="events" {{ 'selected' if request.args.get('category') == 'events' }}>Events</option>
<option value="safety" {{ 'selected' if request.args.get('category') == 'safety' }}>Safety</option>
<option value="gear" {{ 'selected' if request.args.get('category') == 'gear' }}>Gear & Equipment</option>
<option value="social" {{ 'selected' if request.args.get('category') == 'social' }}>Social</option>
</select>
</div>
<div class="col-md-3">
<label for="status" class="form-label">Status</label>
<select class="form-select" id="status" name="status">
<option value="">All Status</option>
<option value="active" {{ 'selected' if request.args.get('status') == 'active' }}>Active</option>
<option value="inactive" {{ 'selected' if request.args.get('status') == 'inactive' }}>Inactive</option>
<option value="linked" {{ 'selected' if request.args.get('status') == 'linked' }}>Linked to Post</option>
<option value="unlinked" {{ 'selected' if request.args.get('status') == 'unlinked' }}>Not Linked</option>
</select>
</div>
<div class="col-md-4">
<label for="search" class="form-label">Search</label>
<input type="text" class="form-control" id="search" name="search"
placeholder="Search room name or description..."
value="{{ request.args.get('search', '') }}">
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i> Filter
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Chat Rooms Table -->
<div class="card">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Chat Rooms</h6>
</div>
<div class="card-body">
{% if chat_rooms %}
<div class="table-responsive">
<table class="table table-bordered table-hover">
<thead class="table-light">
<tr>
<th>Room Name</th>
<th>Category</th>
<th>Created By</th>
<th>Linked Post</th>
<th>Messages</th>
<th>Last Activity</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for room in chat_rooms %}
<tr>
<td>
<div class="d-flex align-items-center">
<div>
<a href="{{ url_for('chat.room', room_id=room.id) }}"
class="fw-bold text-decoration-none" target="_blank">
{{ room.name }}
</a>
{% if room.description %}
<div class="small text-muted">{{ room.description[:100] }}{% if room.description|length > 100 %}...{% endif %}</div>
{% endif %}
</div>
</div>
</td>
<td>
<span class="badge bg-{{ 'success' if room.category == 'general' else 'info' if room.category == 'technical' else 'warning' if room.category == 'maintenance' else 'primary' }}">
{{ room.category.title() if room.category else 'Uncategorized' }}
</span>
</td>
<td>
<a href="{{ url_for('admin.user_detail', user_id=room.created_by.id) }}" class="text-decoration-none">
{{ room.created_by.nickname }}
</a>
</td>
<td>
{% if room.related_post %}
<a href="{{ url_for('admin.post_detail', post_id=room.related_post.id) }}"
class="text-decoration-none">
{{ room.related_post.title[:30] }}{% if room.related_post.title|length > 30 %}...{% endif %}
</a>
{% else %}
<span class="text-muted">Not linked</span>
{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ room.message_count or 0 }}</span>
</td>
<td>
{% if room.last_activity %}
<small>{{ room.last_activity.strftime('%Y-%m-%d %H:%M') }}</small>
{% else %}
<small class="text-muted">Never</small>
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle"
data-bs-toggle="dropdown">
Actions
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="#"
onclick="editRoom({{ room.id }}, '{{ room.name }}', '{{ room.description or '' }}', '{{ room.category or '' }}', {{ room.related_post.id if room.related_post else 'null' }})">
<i class="fas fa-edit"></i> Edit Room
</a>
</li>
<li>
<a class="dropdown-item" href="#"
onclick="linkToPost({{ room.id }}, '{{ room.name }}')">
<i class="fas fa-link"></i> Link to Post
</a>
</li>
<li>
<a class="dropdown-item" href="#"
onclick="mergeRoom({{ room.id }}, '{{ room.name }}')">
<i class="fas fa-compress-arrows-alt"></i> Merge Room
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-danger" href="#"
onclick="deleteRoom({{ room.id }}, '{{ room.name }}')">
<i class="fas fa-trash"></i> Delete Room
</a>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination %}
<nav>
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.manage_chats', page=pagination.prev_num, **request.args) }}">Previous</a>
</li>
{% endif %}
{% for page_num in pagination.iter_pages() %}
{% if page_num %}
{% if page_num != pagination.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.manage_chats', page=page_num, **request.args) }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.manage_chats', page=pagination.next_num, **request.args) }}">Next</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-4">
<i class="fas fa-comments fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No chat rooms found</h5>
<p class="text-muted">Create a new room or adjust your filters.</p>
</div>
{% endif %}
</div>
</div>
<!-- Edit Room Modal -->
<div class="modal fade" id="editRoomModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Chat Room</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="editRoomForm">
<div class="modal-body">
<input type="hidden" id="editRoomId">
<div class="mb-3">
<label for="editRoomName" class="form-label">Room Name</label>
<input type="text" class="form-control" id="editRoomName" required>
</div>
<div class="mb-3">
<label for="editRoomDescription" class="form-label">Description</label>
<textarea class="form-control" id="editRoomDescription" rows="3"></textarea>
</div>
<div class="mb-3">
<label for="editRoomCategory" class="form-label">Category</label>
<select class="form-select" id="editRoomCategory">
<option value="">Select Category</option>
<option value="general">General</option>
<option value="technical">Technical</option>
<option value="maintenance">Maintenance</option>
<option value="routes">Routes</option>
<option value="events">Events</option>
<option value="safety">Safety</option>
<option value="gear">Gear & Equipment</option>
<option value="social">Social</option>
</select>
</div>
<div class="mb-3">
<label for="editLinkedPost" class="form-label">Linked Post</label>
<select class="form-select" id="editLinkedPost">
<option value="">No linked post</option>
{% for post in available_posts %}
<option value="{{ post.id }}">{{ post.title }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<!-- Link to Post Modal -->
<div class="modal fade" id="linkPostModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Link Chat Room to Post</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="linkPostForm">
<div class="modal-body">
<input type="hidden" id="linkRoomId">
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
Linking a chat room to a post will make it appear in the post's discussion section.
</div>
<div class="mb-3">
<label for="linkRoomName" class="form-label">Room Name</label>
<input type="text" class="form-control" id="linkRoomName" readonly>
</div>
<div class="mb-3">
<label for="linkPostSelect" class="form-label">Select Post</label>
<select class="form-select" id="linkPostSelect" required>
<option value="">Choose a post...</option>
{% for post in available_posts %}
<option value="{{ post.id }}">{{ post.title }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Link to Post</button>
</div>
</form>
</div>
</div>
</div>
<!-- Merge Room Modal -->
<div class="modal fade" id="mergeRoomModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Merge Chat Room</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="mergeRoomForm">
<div class="modal-body">
<input type="hidden" id="mergeSourceRoomId">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>Warning:</strong> This action will merge all messages from the source room into the target room.
The source room will be deleted. This cannot be undone.
</div>
<div class="row">
<div class="col-md-6">
<h6>Source Room (will be deleted)</h6>
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title" id="mergeSourceRoomName"></h6>
<p class="card-text small" id="mergeSourceRoomInfo"></p>
</div>
</div>
</div>
<div class="col-md-6">
<h6>Target Room (messages will be merged here)</h6>
<select class="form-select" id="mergeTargetRoom" required>
<option value="">Select target room...</option>
</select>
<div id="mergeTargetRoomPreview" class="mt-2" style="display: none;">
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title" id="mergeTargetRoomName"></h6>
<p class="card-text small" id="mergeTargetRoomInfo"></p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Merge Rooms</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Edit room functionality
function editRoom(roomId, name, description, category, linkedPostId) {
document.getElementById('editRoomId').value = roomId;
document.getElementById('editRoomName').value = name;
document.getElementById('editRoomDescription').value = description;
document.getElementById('editRoomCategory').value = category;
document.getElementById('editLinkedPost').value = linkedPostId || '';
const modal = new bootstrap.Modal(document.getElementById('editRoomModal'));
modal.show();
}
// Link to post functionality
function linkToPost(roomId, roomName) {
document.getElementById('linkRoomId').value = roomId;
document.getElementById('linkRoomName').value = roomName;
const modal = new bootstrap.Modal(document.getElementById('linkPostModal'));
modal.show();
}
// Merge room functionality
function mergeRoom(roomId, roomName) {
document.getElementById('mergeSourceRoomId').value = roomId;
document.getElementById('mergeSourceRoomName').textContent = roomName;
// Load available rooms for merging
fetch(`/admin/api/chat-rooms?exclude=${roomId}`)
.then(response => response.json())
.then(data => {
const select = document.getElementById('mergeTargetRoom');
select.innerHTML = '<option value="">Select target room...</option>';
data.rooms.forEach(room => {
const option = document.createElement('option');
option.value = room.id;
option.textContent = `${room.name} (${room.category}) - ${room.message_count} messages`;
option.dataset.roomData = JSON.stringify(room);
select.appendChild(option);
});
});
const modal = new bootstrap.Modal(document.getElementById('mergeRoomModal'));
modal.show();
}
// Delete room functionality
function deleteRoom(roomId, roomName) {
if (confirm(`Are you sure you want to delete the room "${roomName}"? This action cannot be undone.`)) {
fetch(`/admin/api/chat-rooms/${roomId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error deleting room: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to delete room');
});
}
}
// Form submissions
document.getElementById('editRoomForm').addEventListener('submit', function(e) {
e.preventDefault();
const roomId = document.getElementById('editRoomId').value;
const formData = {
name: document.getElementById('editRoomName').value,
description: document.getElementById('editRoomDescription').value,
category: document.getElementById('editRoomCategory').value,
related_post_id: document.getElementById('editLinkedPost').value || null
};
fetch(`/admin/api/chat-rooms/${roomId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error updating room: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to update room');
});
});
document.getElementById('linkPostForm').addEventListener('submit', function(e) {
e.preventDefault();
const roomId = document.getElementById('linkRoomId').value;
const postId = document.getElementById('linkPostSelect').value;
fetch(`/admin/api/chat-rooms/${roomId}/link-post`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ post_id: postId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error linking room to post: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to link room to post');
});
});
document.getElementById('mergeRoomForm').addEventListener('submit', function(e) {
e.preventDefault();
const sourceRoomId = document.getElementById('mergeSourceRoomId').value;
const targetRoomId = document.getElementById('mergeTargetRoom').value;
fetch(`/admin/api/chat-rooms/${sourceRoomId}/merge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ target_room_id: targetRoomId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Rooms merged successfully!');
location.reload();
} else {
alert('Error merging rooms: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to merge rooms');
});
});
// Target room preview
document.getElementById('mergeTargetRoom').addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const preview = document.getElementById('mergeTargetRoomPreview');
if (selectedOption.value && selectedOption.dataset.roomData) {
const roomData = JSON.parse(selectedOption.dataset.roomData);
document.getElementById('mergeTargetRoomName').textContent = roomData.name;
document.getElementById('mergeTargetRoomInfo').textContent =
`Category: ${roomData.category} | Messages: ${roomData.message_count}`;
preview.style.display = 'block';
} else {
preview.style.display = 'none';
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,269 @@
{% extends "admin/base.html" %}
{% block title %}Password Reset Email Template - Admin{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
<h1 class="h2">Password Reset Email Template</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{{ url_for('admin.password_reset_request_detail', request_id=token.request_id) }}"
class="btn btn-outline-secondary btn-sm me-2">
<i class="fas fa-arrow-left"></i> Back to Request
</a>
<a href="{{ url_for('admin.password_reset_tokens') }}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-list"></i> All Tokens
</a>
</div>
</div>
<!-- Token Status Alert -->
<div class="alert alert-info">
<h5 class="alert-heading">
<i class="fas fa-info-circle"></i> Token Information
</h5>
<p class="mb-2">
<strong>Token Status:</strong>
{% if token.is_used %}
<span class="badge bg-success">Used</span> - This token has already been used
{% elif token.is_expired %}
<span class="badge bg-secondary">Expired</span> - This token has expired
{% else %}
<span class="badge bg-warning">Active</span> - This token is ready to use
{% endif %}
</p>
<p class="mb-2">
<strong>Expires:</strong> {{ token.expires_at.strftime('%B %d, %Y at %I:%M %p') }}
</p>
<p class="mb-0">
<strong>For User:</strong> {{ token.user.nickname }} ({{ token.user.email }})
</p>
</div>
<!-- Email Template Card -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="m-0 fw-bold text-primary">Email Template - Copy and Send to User</h6>
<button type="button" class="btn btn-success btn-sm" onclick="copyEmailTemplate()">
<i class="fas fa-copy"></i> Copy All
</button>
</div>
<div class="card-body">
<!-- Email Subject -->
<div class="mb-4">
<label class="form-label fw-bold">Subject:</label>
<div class="input-group">
<input type="text" class="form-control" id="email-subject" readonly
value="Password Reset Request - Moto Adventure Website">
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('email-subject')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<!-- Email Body -->
<div class="mb-4">
<label class="form-label fw-bold">Email Body:</label>
<div class="position-relative">
<textarea class="form-control" id="email-body" rows="12" readonly>Hello {{ token.user.nickname }},
We received your request for a password reset for your Moto Adventure website account.
To reset your password, please click the link below:
{{ reset_url }}
This link is valid for 24 hours and can only be used once. If you did not request this password reset, please ignore this email.
Important Security Information:
- This link expires on {{ token.expires_at.strftime('%B %d, %Y at %I:%M %p') }}
- Do not share this link with anyone
- If the link doesn't work, you may need to request a new password reset
If you have any questions or need assistance, please contact our support team.
Best regards,
Moto Adventure Team
---
This is an automated message. Please do not reply to this email.</textarea>
<button class="btn btn-outline-secondary position-absolute top-0 end-0 m-2"
type="button" onclick="copyToClipboard('email-body')">
<i class="fas fa-copy"></i> Copy
</button>
</div>
</div>
<!-- Reset Link Only -->
<div class="mb-4">
<label class="form-label fw-bold">Reset Link Only:</label>
<div class="input-group">
<input type="text" class="form-control" id="reset-link" readonly value="{{ reset_url }}">
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('reset-link')">
<i class="fas fa-copy"></i>
</button>
</div>
<small class="text-muted">Use this if you prefer to compose your own email message.</small>
</div>
</div>
</div>
<!-- Instructions Card -->
<div class="card mt-4">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Instructions for Admin</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="fw-bold">How to Send:</h6>
<ol class="small">
<li>Copy the subject and email body above</li>
<li>Open your email client (Gmail, Outlook, etc.)</li>
<li>Create a new email to: <strong>{{ token.user.email }}</strong></li>
<li>Paste the subject and body</li>
<li>Send the email</li>
<li>Return here to monitor if the link was used</li>
</ol>
</div>
<div class="col-md-6">
<h6 class="fw-bold">Security Notes:</h6>
<ul class="small">
<li>Token expires in 24 hours automatically</li>
<li>Token can only be used once</li>
<li>Monitor token usage below</li>
<li>Do not share the reset link publicly</li>
<li>User must enter a new password to complete reset</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Token Usage Tracking -->
<div class="card mt-4">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Token Usage Tracking</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="text-center">
<div class="h4 mb-0 {{ 'text-success' if token.is_used else 'text-muted' }}">
{{ 'Yes' if token.is_used else 'No' }}
</div>
<small class="text-muted">Used</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h4 mb-0 {{ 'text-danger' if token.is_expired else 'text-success' }}">
{{ 'Yes' if token.is_expired else 'No' }}
</div>
<small class="text-muted">Expired</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h4 mb-0">
{% if token.used_at %}
{{ token.used_at.strftime('%m/%d %H:%M') }}
{% else %}
-
{% endif %}
</div>
<small class="text-muted">Used At</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h4 mb-0">
{% if token.user_ip %}
{{ token.user_ip }}
{% else %}
-
{% endif %}
</div>
<small class="text-muted">User IP</small>
</div>
</div>
</div>
<div class="mt-3">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="location.reload()">
<i class="fas fa-sync-alt"></i> Refresh Status
</button>
</div>
</div>
</div>
<script>
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
element.select();
element.setSelectionRange(0, 99999); // For mobile devices
try {
document.execCommand('copy');
showCopySuccess();
} catch (err) {
console.error('Failed to copy: ', err);
showCopyError();
}
}
function copyEmailTemplate() {
const subject = document.getElementById('email-subject').value;
const body = document.getElementById('email-body').value;
const combined = `Subject: ${subject}\n\n${body}`;
navigator.clipboard.writeText(combined).then(function() {
showCopySuccess();
}, function(err) {
console.error('Failed to copy: ', err);
showCopyError();
});
}
function showCopySuccess() {
// Create temporary success alert
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
alert.style.top = '20px';
alert.style.right = '20px';
alert.style.zIndex = '9999';
alert.innerHTML = `
<i class="fas fa-check"></i> Copied to clipboard!
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
// Auto-remove after 3 seconds
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 3000);
}
function showCopyError() {
// Create temporary error alert
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
alert.style.top = '20px';
alert.style.right = '20px';
alert.style.zIndex = '9999';
alert.innerHTML = `
<i class="fas fa-times"></i> Failed to copy. Please select and copy manually.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
// Auto-remove after 5 seconds
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 5000);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,242 @@
{% extends "admin/base.html" %}
{% block title %}Password Reset Request #{{ reset_request.id }} - Admin{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
<h1 class="h2">Password Reset Request #{{ reset_request.id }}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{{ url_for('admin.password_reset_requests') }}" class="btn btn-outline-secondary btn-sm me-2">
<i class="fas fa-arrow-left"></i> Back to List
</a>
{% if reset_request.user and reset_request.status == 'pending' %}
<form method="POST" action="{{ url_for('admin.generate_password_reset_token', request_id=reset_request.id) }}"
class="d-inline" onsubmit="return confirm('Generate reset token for {{ reset_request.user_email }}?')">
<button type="submit" class="btn btn-success btn-sm">
<i class="fas fa-key"></i> Generate Reset Token
</button>
</form>
{% endif %}
</div>
</div>
<div class="row">
<!-- Request Information -->
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Request Details</h6>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-sm-3 fw-bold">Status:</div>
<div class="col-sm-9">
{% if reset_request.status == 'pending' %}
<span class="badge bg-warning fs-6">
<i class="fas fa-clock"></i> Pending
</span>
{% elif reset_request.status == 'token_generated' %}
<span class="badge bg-info fs-6">
<i class="fas fa-link"></i> Token Generated
</span>
{% elif reset_request.status == 'completed' %}
<span class="badge bg-success fs-6">
<i class="fas fa-check-circle"></i> Completed
</span>
{% elif reset_request.status == 'expired' %}
<span class="badge bg-secondary fs-6">
<i class="fas fa-calendar-times"></i> Expired
</span>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 fw-bold">User Email:</div>
<div class="col-sm-9">
<span class="fw-bold">{{ reset_request.user_email }}</span>
{% if reset_request.user %}
<br><small class="text-success">
<i class="fas fa-user-check"></i> User found: {{ reset_request.user.nickname }}
</small>
{% else %}
<br><small class="text-danger">
<i class="fas fa-user-times"></i> User not found in system
</small>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 fw-bold">Requested:</div>
<div class="col-sm-9">
{{ reset_request.created_at.strftime('%B %d, %Y at %I:%M %p') }}
<br><small class="text-muted">{{ reset_request.created_at.strftime('%A') }}</small>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 fw-bold">Last Updated:</div>
<div class="col-sm-9">
{{ reset_request.updated_at.strftime('%B %d, %Y at %I:%M %p') }}
</div>
</div>
{% if reset_request.requester_message %}
<div class="row mb-3">
<div class="col-sm-3 fw-bold">Original Message:</div>
<div class="col-sm-9">
<div class="bg-light p-3 rounded">
{{ reset_request.requester_message }}
</div>
</div>
</div>
{% endif %}
{% if reset_request.chat_message %}
<div class="row mb-3">
<div class="col-sm-3 fw-bold">Chat Reference:</div>
<div class="col-sm-9">
<a href="{{ url_for('chat.room', room_id=reset_request.chat_message.room_id) }}"
class="btn btn-outline-info btn-sm">
<i class="fas fa-comments"></i> View in Chat
</a>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Admin Notes -->
<div class="card mb-4">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Admin Notes</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.update_password_reset_notes', request_id=reset_request.id) }}">
<div class="mb-3">
<label for="admin_notes" class="form-label">Notes (visible only to admins):</label>
<textarea class="form-control" id="admin_notes" name="admin_notes" rows="4"
placeholder="Add notes about this password reset request...">{{ reset_request.admin_notes or '' }}</textarea>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Notes
</button>
</form>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Generated Tokens -->
<div class="card mb-4">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Generated Tokens ({{ reset_request.tokens|length }})</h6>
</div>
<div class="card-body">
{% if reset_request.tokens %}
{% for token in reset_request.tokens %}
<div class="border rounded p-3 mb-3 {{ 'bg-light' if not token.is_valid else '' }}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
{% if token.is_used %}
<span class="badge bg-success">Used</span>
{% elif token.is_expired %}
<span class="badge bg-secondary">Expired</span>
{% else %}
<span class="badge bg-warning">Active</span>
{% endif %}
</div>
<small class="text-muted">
{{ token.created_at.strftime('%m/%d %H:%M') }}
</small>
</div>
<div class="small mb-2">
<strong>Token:</strong> {{ token.token[:12] }}...
</div>
<div class="small mb-2">
<strong>Expires:</strong> {{ token.expires_at.strftime('%m/%d/%Y %H:%M') }}
</div>
{% if token.is_used %}
<div class="small mb-2">
<strong>Used:</strong> {{ token.used_at.strftime('%m/%d/%Y %H:%M') }}
</div>
{% endif %}
<div class="small mb-2">
<strong>Created by:</strong> {{ token.created_by_admin.nickname }}
</div>
{% if token.is_valid %}
<div class="mt-2">
<a href="{{ url_for('admin.password_reset_token_template', token_id=token.id) }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-envelope"></i> Email Template
</a>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted py-4">
<i class="fas fa-key fa-2x mb-2"></i>
<p>No tokens generated yet.</p>
{% if reset_request.user and reset_request.status == 'pending' %}
<form method="POST" action="{{ url_for('admin.generate_password_reset_token', request_id=reset_request.id) }}"
onsubmit="return confirm('Generate reset token for {{ reset_request.user_email }}?')">
<button type="submit" class="btn btn-success btn-sm">
<i class="fas fa-key"></i> Generate Token
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
<!-- User Information -->
{% if reset_request.user %}
<div class="card">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">User Information</h6>
</div>
<div class="card-body">
<div class="mb-2">
<strong>Username:</strong> {{ reset_request.user.nickname }}
</div>
<div class="mb-2">
<strong>Email:</strong> {{ reset_request.user.email }}
</div>
<div class="mb-2">
<strong>Account Created:</strong> {{ reset_request.user.created_at.strftime('%m/%d/%Y') }}
</div>
<div class="mb-2">
<strong>Admin:</strong>
{% if reset_request.user.is_admin %}
<span class="badge bg-danger">Yes</span>
{% else %}
<span class="badge bg-secondary">No</span>
{% endif %}
</div>
<div class="mb-3">
<strong>Active:</strong>
{% if reset_request.user.is_active %}
<span class="badge bg-success">Yes</span>
{% else %}
<span class="badge bg-danger">No</span>
{% endif %}
</div>
<a href="{{ url_for('admin.user_detail', user_id=reset_request.user.id) }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-user"></i> View User Profile
</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,230 @@
{% extends "admin/base.html" %}
{% block title %}Password Reset Requests - Admin{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
<h1 class="h2">Password Reset Requests</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<a href="{{ url_for('admin.password_reset_requests', status='all') }}"
class="btn btn-sm {{ 'btn-primary' if status == 'all' else 'btn-outline-secondary' }}">
All Requests
</a>
<a href="{{ url_for('admin.password_reset_requests', status='pending') }}"
class="btn btn-sm {{ 'btn-warning' if status == 'pending' else 'btn-outline-secondary' }}">
Pending
</a>
<a href="{{ url_for('admin.password_reset_requests', status='token_generated') }}"
class="btn btn-sm {{ 'btn-info' if status == 'token_generated' else 'btn-outline-secondary' }}">
Token Generated
</a>
<a href="{{ url_for('admin.password_reset_requests', status='completed') }}"
class="btn btn-sm {{ 'btn-success' if status == 'completed' else 'btn-outline-secondary' }}">
Completed
</a>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="location.reload()">
<i class="fas fa-sync-alt"></i> Refresh
</button>
</div>
</div>
{% if requests.items %}
<div class="card">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">
{{ requests.total }} Password Reset {{ 'Request' if requests.total == 1 else 'Requests' }}
{% if status != 'all' %}({{ status.replace('_', ' ').title() }}){% endif %}
</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead class="table-dark">
<tr>
<th>Request Date</th>
<th>User Email</th>
<th>User Found</th>
<th>Status</th>
<th>Generated Tokens</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for request in requests.items %}
<tr>
<td>
<div class="fw-bold">{{ request.created_at.strftime('%Y-%m-%d') }}</div>
<small class="text-muted">{{ request.created_at.strftime('%H:%M:%S') }}</small>
</td>
<td>
<div class="fw-bold">{{ request.user_email }}</div>
{% if request.user %}
<small class="text-success">
<i class="fas fa-user-check"></i> {{ request.user.nickname }}
</small>
{% endif %}
</td>
<td>
{% if request.user %}
<span class="badge bg-success">
<i class="fas fa-check"></i> Found
</span>
{% else %}
<span class="badge bg-danger">
<i class="fas fa-times"></i> Not Found
</span>
{% endif %}
</td>
<td>
{% if request.status == 'pending' %}
<span class="badge bg-warning">
<i class="fas fa-clock"></i> Pending
</span>
{% elif request.status == 'token_generated' %}
<span class="badge bg-info">
<i class="fas fa-link"></i> Token Generated
</span>
{% elif request.status == 'completed' %}
<span class="badge bg-success">
<i class="fas fa-check-circle"></i> Completed
</span>
{% elif request.status == 'expired' %}
<span class="badge bg-secondary">
<i class="fas fa-calendar-times"></i> Expired
</span>
{% endif %}
</td>
<td>
<div class="fw-bold">{{ request.tokens|length }}</div>
{% set active_tokens = request.tokens|selectattr('is_valid')|list %}
{% if active_tokens %}
<small class="text-success">{{ active_tokens|length }} active</small>
{% else %}
<small class="text-muted">None active</small>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ url_for('admin.password_reset_request_detail', request_id=request.id) }}"
class="btn btn-outline-primary" title="View Details">
<i class="fas fa-eye"></i>
</a>
{% if request.user and request.status == 'pending' %}
<form method="POST" action="{{ url_for('admin.generate_password_reset_token', request_id=request.id) }}"
class="d-inline" onsubmit="return confirm('Generate reset token for {{ request.user_email }}?')">
<button type="submit" class="btn btn-outline-success" title="Generate Token">
<i class="fas fa-key"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pagination -->
{% if requests.pages > 1 %}
<nav aria-label="Page navigation" class="mt-3">
<ul class="pagination justify-content-center">
{% if requests.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.password_reset_requests', page=requests.prev_num, status=status) }}">
Previous
</a>
</li>
{% endif %}
{% for page_num in requests.iter_pages() %}
{% if page_num %}
{% if page_num != requests.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.password_reset_requests', page=page_num, status=status) }}">
{{ page_num }}
</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link"></span>
</li>
{% endif %}
{% endfor %}
{% if requests.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.password_reset_requests', page=requests.next_num, status=status) }}">
Next
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="card">
<div class="card-body text-center py-5">
<i class="fas fa-key fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Password Reset Requests</h5>
<p class="text-muted">
{% if status == 'all' %}
No password reset requests have been made yet.
{% else %}
No {{ status.replace('_', ' ') }} password reset requests found.
{% endif %}
</p>
{% if status != 'all' %}
<a href="{{ url_for('admin.password_reset_requests', status='all') }}" class="btn btn-primary">
View All Requests
</a>
{% endif %}
</div>
</div>
{% endif %}
<!-- Help Information -->
<div class="row mt-4">
<div class="col-12">
<div class="card border-info">
<div class="card-header bg-info text-white">
<h6 class="m-0"><i class="fas fa-info-circle"></i> How Password Reset Works</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="fw-bold">Process Flow:</h6>
<ol class="small">
<li>User requests password reset through chat system</li>
<li>Request appears here with "Pending" status</li>
<li>Admin generates one-time reset token (24h expiry)</li>
<li>Admin copies email template and sends to user</li>
<li>User clicks link and resets password</li>
<li>Token becomes "Used" and request "Completed"</li>
</ol>
</div>
<div class="col-md-6">
<h6 class="fw-bold">Status Meanings:</h6>
<ul class="small">
<li><span class="badge bg-warning">Pending</span> - Awaiting admin action</li>
<li><span class="badge bg-info">Token Generated</span> - Reset link created</li>
<li><span class="badge bg-success">Completed</span> - Password successfully reset</li>
<li><span class="badge bg-secondary">Expired</span> - Token expired unused</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,324 @@
{% extends "admin/base.html" %}
{% block title %}Password Reset Tokens - Admin{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
<h1 class="h2">Password Reset Tokens</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{{ url_for('admin.password_reset_requests') }}" class="btn btn-outline-secondary btn-sm me-2">
<i class="fas fa-list"></i> View Requests
</a>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Total Tokens
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ tokens.total }}</div>
</div>
<div class="col-auto">
<i class="fas fa-key fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
Active Tokens
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ active_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-clock fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
Used Tokens
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ used_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-check fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-left-secondary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-secondary text-uppercase mb-1">
Expired Tokens
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ expired_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-times fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Filter Tokens</h6>
</div>
<div class="card-body">
<form method="GET" action="{{ url_for('admin.password_reset_tokens') }}">
<div class="row">
<div class="col-md-3">
<label for="status" class="form-label">Status</label>
<select class="form-select" id="status" name="status">
<option value="">All Statuses</option>
<option value="active" {{ 'selected' if request.args.get('status') == 'active' }}>Active</option>
<option value="used" {{ 'selected' if request.args.get('status') == 'used' }}>Used</option>
<option value="expired" {{ 'selected' if request.args.get('status') == 'expired' }}>Expired</option>
</select>
</div>
<div class="col-md-3">
<label for="user_email" class="form-label">User Email</label>
<input type="email" class="form-control" id="user_email" name="user_email"
value="{{ request.args.get('user_email', '') }}" placeholder="user@example.com">
</div>
<div class="col-md-3">
<label for="date_from" class="form-label">Created From</label>
<input type="date" class="form-control" id="date_from" name="date_from"
value="{{ request.args.get('date_from', '') }}">
</div>
<div class="col-md-3">
<label for="date_to" class="form-label">Created To</label>
<input type="date" class="form-control" id="date_to" name="date_to"
value="{{ request.args.get('date_to', '') }}">
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i> Filter
</button>
<a href="{{ url_for('admin.password_reset_tokens') }}" class="btn btn-secondary">
<i class="fas fa-undo"></i> Clear
</a>
</div>
</div>
</form>
</div>
</div>
<!-- Tokens Table -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="m-0 fw-bold text-primary">Password Reset Tokens</h6>
<span class="text-muted">{{ tokens.total }} total tokens</span>
</div>
<div class="card-body">
{% if tokens.items %}
<div class="table-responsive">
<table class="table table-bordered table-hover">
<thead class="table-light">
<tr>
<th>User</th>
<th>Status</th>
<th>Created</th>
<th>Expires</th>
<th>Used</th>
<th>Admin</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for token in tokens.items %}
<tr>
<td>
<div class="d-flex align-items-center">
<div>
<div class="fw-bold">{{ token.user.nickname }}</div>
<div class="text-muted small">{{ token.user.email }}</div>
</div>
</div>
</td>
<td>
{% if token.is_used %}
<span class="badge bg-success">Used</span>
{% elif token.is_expired %}
<span class="badge bg-secondary">Expired</span>
{% else %}
<span class="badge bg-warning">Active</span>
{% endif %}
</td>
<td>
<div>{{ token.created_at.strftime('%m/%d/%Y') }}</div>
<small class="text-muted">{{ token.created_at.strftime('%I:%M %p') }}</small>
</td>
<td>
<div>{{ token.expires_at.strftime('%m/%d/%Y') }}</div>
<small class="text-muted">{{ token.expires_at.strftime('%I:%M %p') }}</small>
</td>
<td>
{% if token.used_at %}
<div>{{ token.used_at.strftime('%m/%d/%Y') }}</div>
<small class="text-muted">{{ token.used_at.strftime('%I:%M %p') }}</small>
{% if token.user_ip %}
<br><small class="text-muted">IP: {{ token.user_ip }}</small>
{% endif %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<div class="fw-bold">{{ token.created_by.nickname }}</div>
<small class="text-muted">{{ token.created_by.email }}</small>
</td>
<td>
<div class="btn-group" role="group">
{% if not token.is_used and not token.is_expired %}
<a href="{{ url_for('admin.password_reset_token_template', token_id=token.id) }}"
class="btn btn-sm btn-outline-primary" title="Copy Email Template">
<i class="fas fa-envelope"></i>
</a>
{% endif %}
<a href="{{ url_for('admin.password_reset_request_detail', request_id=token.request_id) }}"
class="btn btn-sm btn-outline-secondary" title="View Request">
<i class="fas fa-eye"></i>
</a>
{% if not token.is_used and not token.is_expired %}
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="confirmExpireToken('{{ token.id }}')" title="Expire Token">
<i class="fas fa-ban"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if tokens.pages > 1 %}
<nav aria-label="Tokens pagination">
<ul class="pagination justify-content-center">
<li class="page-item {{ 'disabled' if not tokens.has_prev }}">
<a class="page-link" href="{{ url_for('admin.password_reset_tokens', page=tokens.prev_num, **request.args) }}">Previous</a>
</li>
{% for page_num in tokens.iter_pages() %}
{% if page_num %}
{% if page_num != tokens.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.password_reset_tokens', page=page_num, **request.args) }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
<li class="page-item {{ 'disabled' if not tokens.has_next }}">
<a class="page-link" href="{{ url_for('admin.password_reset_tokens', page=tokens.next_num, **request.args) }}">Next</a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-4">
<i class="fas fa-key fa-3x text-muted mb-3"></i>
<h5>No Tokens Found</h5>
<p class="text-muted">No password reset tokens match your current filters.</p>
<a href="{{ url_for('admin.password_reset_requests') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Generate New Token
</a>
</div>
{% endif %}
</div>
</div>
<!-- Expire Token Modal -->
<div class="modal fade" id="expireTokenModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Expire Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to expire this password reset token?</p>
<p class="text-muted small">This action cannot be undone. The user will not be able to use this token to reset their password.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="expireToken()">Expire Token</button>
</div>
</div>
</div>
</div>
<script>
let tokenToExpire = null;
function confirmExpireToken(tokenId) {
tokenToExpire = tokenId;
const modal = new bootstrap.Modal(document.getElementById('expireTokenModal'));
modal.show();
}
function expireToken() {
if (tokenToExpire) {
// Create form and submit
const form = document.createElement('form');
form.method = 'POST';
form.action = `/admin/password-reset-tokens/${tokenToExpire}/expire`;
// Add CSRF token if available
const csrfToken = document.querySelector('meta[name=csrf-token]');
if (csrfToken) {
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = csrfToken.getAttribute('content');
form.appendChild(csrfInput);
}
document.body.appendChild(form);
form.submit();
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}Reset Your Password - Moto Adventure{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Moto Adventure" class="auth-logo">
<h2>Reset Your Password</h2>
<p class="text-muted">Enter your new password below</p>
</div>
<div class="auth-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- User Info -->
<div class="alert alert-info">
<i class="fas fa-user"></i>
Resetting password for: <strong>{{ user.nickname }}</strong> ({{ user.email }})
</div>
<form method="POST">
{{ form.hidden_tag() }}
<div class="form-group mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control") }}
{% if form.password.errors %}
<div class="text-danger small mt-1">
{% for error in form.password.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
<div class="form-text">
Password must be at least 8 characters long and contain both letters and numbers.
</div>
</div>
<div class="form-group mb-4">
{{ form.password2.label(class="form-label") }}
{{ form.password2(class="form-control") }}
{% if form.password2.errors %}
<div class="text-danger small mt-1">
{% for error in form.password2.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="d-grid">
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
</form>
</div>
<div class="auth-footer">
<div class="text-center">
<a href="{{ url_for('auth.login') }}" class="text-decoration-none">
<i class="fas fa-arrow-left"></i> Back to Login
</a>
</div>
</div>
</div>
</div>
<!-- Security Features -->
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card border-warning">
<div class="card-body text-center">
<h6 class="card-title text-warning">
<i class="fas fa-shield-alt"></i> Security Notice
</h6>
<p class="card-text small text-muted mb-0">
This reset link can only be used once and will expire soon.
After resetting your password, you'll be able to log in immediately.
</p>
</div>
</div>
</div>
</div>
</div>
<style>
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.auth-card {
background: white;
border-radius: 15px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
overflow: hidden;
}
.auth-header {
text-align: center;
padding: 40px 30px 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.auth-logo {
width: 60px;
height: 60px;
margin-bottom: 20px;
border-radius: 50%;
border: 3px solid white;
}
.auth-header h2 {
margin-bottom: 10px;
font-weight: 600;
}
.auth-body {
padding: 30px;
}
.auth-footer {
padding: 20px 30px;
background-color: #f8f9fa;
text-align: center;
}
.form-control {
border-radius: 8px;
border: 2px solid #e9ecef;
padding: 12px 15px;
transition: all 0.3s ease;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
padding: 12px;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
@media (max-width: 576px) {
.auth-container {
padding: 10px;
}
.auth-header, .auth-body {
padding: 20px;
}
}
</style>
{% endblock %}

View File

@@ -0,0 +1,243 @@
{% extends "base.html" %}
{% block title %}Create Chat Room{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 pt-16">
<!-- Header Section -->
<div class="relative overflow-hidden py-12">
<div class="absolute inset-0 bg-black/20"></div>
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<h1 class="text-3xl font-bold text-white mb-2">
<i class="fas fa-plus-circle mr-3"></i>Create New Chat Room
</h1>
<p class="text-blue-200">Start a discussion with the motorcycle community</p>
</div>
</div>
</div>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 -mt-6">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-green-600 to-emerald-600 p-6">
<div class="flex items-center justify-between text-white">
<div class="flex items-center">
<i class="fas fa-comments text-2xl mr-3"></i>
<div>
<h2 class="text-xl font-bold">Room Configuration</h2>
<p class="text-green-100 text-sm">Set up your chat room details</p>
</div>
</div>
<a href="{{ url_for('chat.index') }}" class="bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg transition-all duration-200">
<i class="fas fa-arrow-left mr-2"></i>Back to Chat
</a>
</div>
</div>
<div class="p-8">
<form method="POST" action="{{ url_for('chat.create_room') }}" class="space-y-6">
<!-- Room Name -->
<div class="space-y-2">
<label for="room_name" class="block text-sm font-semibold text-gray-700">
<i class="fas fa-tag mr-2 text-green-600"></i>Room Name *
</label>
<input type="text" class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200"
id="room_name" name="room_name"
placeholder="Enter a descriptive room name" required maxlength="100">
<p class="text-xs text-gray-500">Choose a clear, descriptive name for your chat room</p>
</div>
<!-- Description -->
<div class="space-y-2">
<label for="description" class="block text-sm font-semibold text-gray-700">
<i class="fas fa-align-left mr-2 text-green-600"></i>Description
</label>
<textarea class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200"
id="description" name="description" rows="3"
placeholder="Describe what this room is about..." maxlength="500"></textarea>
<p class="text-xs text-gray-500">Optional: Help others understand the room's purpose</p>
</div>
<!-- Post Binding Section -->
<div class="space-y-4 p-6 bg-blue-50 rounded-xl border border-blue-200">
<div class="flex items-center">
<i class="fas fa-link mr-3 text-blue-600 text-lg"></i>
<h3 class="text-lg font-semibold text-gray-800">Link to Post (Optional)</h3>
</div>
<div class="space-y-2">
<label for="related_post_id" class="block text-sm font-semibold text-gray-700">
Select a post to discuss
</label>
<select class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
id="related_post_id" name="related_post_id">
<option value="">No specific post - General discussion</option>
{% for post in posts %}
<option value="{{ post.id }}" {% if pre_selected_post and post.id == pre_selected_post %}selected{% endif %}>
{{ post.title }} - by {{ post.author.nickname }}
</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500">
<i class="fas fa-info-circle mr-1"></i>
Link this room to a specific post for focused discussions
</p>
</div>
</div>
<!-- Room Type -->
<div class="space-y-2">
<label for="room_type" class="block text-sm font-semibold text-gray-700">
<i class="fas fa-folder mr-2 text-green-600"></i>Room Category
</label>
<select class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200"
id="room_type" name="room_type">
<option value="general">General Discussion</option>
<option value="technical">Technical Support</option>
<option value="social">Social Chat</option>
<option value="post_discussion">Post Discussion</option>
</select>
<p class="text-xs text-gray-500">Category will auto-update based on post selection</p>
</div>
<!-- Privacy Setting -->
<div class="space-y-3 p-6 bg-amber-50 rounded-xl border border-amber-200">
<div class="flex items-center">
<i class="fas fa-shield-alt mr-3 text-amber-600 text-lg"></i>
<h3 class="text-lg font-semibold text-gray-800">Privacy Settings</h3>
</div>
<div class="flex items-start space-x-3">
<input class="mt-1 w-4 h-4 text-amber-600 border-gray-300 rounded focus:ring-amber-500"
type="checkbox" id="is_private" name="is_private">
<div>
<label class="block text-sm font-medium text-gray-700" for="is_private">
Make this room private
</label>
<p class="text-xs text-gray-500 mt-1">
Private rooms are only visible to invited members. Public rooms can be joined by anyone.
</p>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 pt-6">
<a href="{{ url_for('chat.index') }}"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 font-semibold rounded-xl hover:bg-gray-200 transition-all duration-200 text-center">
<i class="fas fa-times mr-2"></i>Cancel
</a>
<button type="submit"
class="flex-1 px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-xl hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Create Chat Room
</button>
</div>
</form>
</div>
</div>
<!-- Recent Posts Preview -->
{% if posts %}
<div class="mt-8 bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-newspaper text-2xl mr-3"></i>
<div>
<h3 class="text-xl font-bold">Recent Community Posts</h3>
<p class="text-blue-100 text-sm">Available for discussion rooms</p>
</div>
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for post in posts[:6] %}
<div class="bg-gray-50 rounded-xl p-4 hover:bg-gray-100 transition-all duration-200 cursor-pointer post-preview"
data-post-id="{{ post.id }}" data-post-title="{{ post.title }}">
<h4 class="font-semibold text-gray-800 mb-2 line-clamp-2">
{{ post.title }}
</h4>
<p class="text-gray-600 text-sm mb-3 line-clamp-3">
{{ post.content[:120] }}{% if post.content|length > 120 %}...{% endif %}
</p>
<div class="flex items-center justify-between text-xs text-gray-500">
<span>by {{ post.author.nickname }}</span>
<span>{{ post.created_at.strftime('%m/%d') }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
<script>
// Auto-update room type when post is selected
document.getElementById('related_post_id').addEventListener('change', function() {
const roomTypeSelect = document.getElementById('room_type');
if (this.value) {
roomTypeSelect.value = 'post_discussion';
document.getElementById('room_name').placeholder = 'Discussion: ' + this.options[this.selectedIndex].text.split(' - ')[0];
} else {
roomTypeSelect.value = 'general';
document.getElementById('room_name').placeholder = 'Enter a descriptive room name';
}
});
// Post preview selection
document.querySelectorAll('.post-preview').forEach(preview => {
preview.addEventListener('click', function() {
const postId = this.dataset.postId;
const postTitle = this.dataset.postTitle;
// Update the select dropdown
document.getElementById('related_post_id').value = postId;
// Update room name suggestion
document.getElementById('room_name').value = `Discussion: ${postTitle}`;
// Update room type
document.getElementById('room_type').value = 'post_discussion';
// Visual feedback
document.querySelectorAll('.post-preview').forEach(p => p.classList.remove('ring-2', 'ring-blue-500', 'bg-blue-100'));
this.classList.add('ring-2', 'ring-blue-500', 'bg-blue-100');
// Scroll to form
document.querySelector('form').scrollIntoView({ behavior: 'smooth' });
});
});
// Form validation
document.querySelector('form').addEventListener('submit', function(e) {
const roomName = document.getElementById('room_name').value.trim();
if (!roomName) {
e.preventDefault();
alert('Please enter a room name');
document.getElementById('room_name').focus();
}
});
</script>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-preview:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>
{% endblock %}

View File

@@ -48,60 +48,41 @@
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-life-ring text-3xl mr-4"></i>
<div>
<h4 class="text-lg font-bold">Get Support</h4>
<p class="text-blue-100 text-sm">Need help or password reset?</p>
</div>
</div>
</div>
<div class="p-6">
<p class="text-gray-600 mb-4">Contact administrators for support or password reset</p>
<a href="{{ url_for('chat.support') }}"
class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-headset mr-2"></i>Get Support
</a>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8 max-w-4xl mx-auto">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<div class="bg-gradient-to-r from-green-600 to-emerald-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-plus-circle text-3xl mr-4"></i>
<div>
<h4 class="text-lg font-bold">Create Room</h4>
<p class="text-green-100 text-sm">Start a new discussion</p>
<h4 class="text-xl font-bold">Create Chat Room</h4>
<p class="text-green-100">Start a new discussion</p>
</div>
</div>
</div>
<div class="p-6">
<p class="text-gray-600 mb-4">Start a new chat room on any motorcycle topic</p>
<div class="p-6 text-center">
<p class="text-gray-600 mb-6">Start a new chat room on any motorcycle topic or connect it to a specific post</p>
<a href="{{ url_for('chat.create_room_form') }}"
class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Create Room
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Create New Room
</a>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<div class="bg-gradient-to-r from-orange-600 to-red-600 p-6">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-key text-3xl mr-4"></i>
<i class="fas fa-newspaper text-3xl mr-4"></i>
<div>
<h4 class="text-lg font-bold">Password Reset</h4>
<p class="text-orange-100 text-sm">Forgot your password?</p>
<h4 class="text-xl font-bold">Post Discussions</h4>
<p class="text-blue-100">Chat about community posts</p>
</div>
</div>
</div>
<div class="p-6">
<p class="text-gray-600 mb-4">Request a password reset from administrators</p>
<a href="{{ url_for('auth.forgot_password') }}"
class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-orange-600 to-red-600 text-white font-semibold rounded-lg hover:from-orange-700 hover:to-red-700 transition-all duration-200">
<i class="fas fa-unlock-alt mr-2"></i>Reset Password
<div class="p-6 text-center">
<p class="text-gray-600 mb-6">Join discussions about specific community posts and share your thoughts</p>
<a href="{{ url_for('chat.post_discussions') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-comments mr-2"></i>View Discussions
</a>
</div>
</div>

View File

@@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block title %}Post Discussions - Chat Rooms{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 pt-16">
<!-- Header Section -->
<div class="relative overflow-hidden py-12">
<div class="absolute inset-0 bg-black/20"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<h1 class="text-3xl font-bold text-white mb-2">
<i class="fas fa-newspaper mr-3"></i>Post Discussions
</h1>
<p class="text-blue-200">Chat rooms linked to community posts</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 -mt-6">
<!-- Navigation -->
<div class="flex justify-center mb-8">
<div class="bg-white rounded-2xl shadow-xl p-2 flex space-x-2">
<a href="{{ url_for('chat.index') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-all duration-200">
<i class="fas fa-comments mr-2"></i>All Chats
</a>
<span class="px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg font-semibold">
<i class="fas fa-newspaper mr-2"></i>Post Discussions
</span>
<a href="{{ url_for('chat.create_room_form') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Create Room
</a>
</div>
</div>
<!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-blue-600 to-cyan-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-chart-bar text-3xl mr-4"></i>
<div>
<h3 class="text-xl font-bold">{{ total_discussions }}</h3>
<p class="text-blue-100">Total Post Discussions</p>
</div>
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-green-600 to-emerald-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-fire text-3xl mr-4"></i>
<div>
<h3 class="text-xl font-bold">{{ active_discussions }}</h3>
<p class="text-green-100">Active This Week</p>
</div>
</div>
</div>
</div>
</div>
{% if rooms.items %}
<!-- Discussion Rooms -->
<div class="space-y-6">
{% for room in rooms.items %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl">
<div class="p-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between">
<div class="flex-1">
<!-- Room Header -->
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-xl font-bold text-gray-800 mb-2">{{ room.name }}</h3>
{% if room.description %}
<p class="text-gray-600 mb-3">{{ room.description }}</p>
{% endif %}
</div>
<span class="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-semibold ml-4">
<i class="fas fa-comments mr-1"></i>{{ room.message_count or 0 }} messages
</span>
</div>
<!-- Related Post -->
{% if room.related_post %}
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4">
<div class="flex items-start">
<i class="fas fa-link text-blue-600 mr-3 mt-1"></i>
<div class="flex-1">
<h4 class="font-semibold text-blue-800 mb-1">Discussing Post:</h4>
<a href="{{ url_for('community.post_detail', id=room.related_post.id) }}"
class="text-blue-700 hover:text-blue-900 font-medium">
{{ room.related_post.title }}
</a>
<p class="text-blue-600 text-sm mt-1">
by {{ room.related_post.author.nickname }} •
{{ room.related_post.created_at.strftime('%B %d, %Y') }}
</p>
</div>
</div>
</div>
{% endif %}
<!-- Room Info -->
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500">
<span>
<i class="fas fa-user mr-1"></i>
Created by {{ room.created_by.nickname }}
</span>
<span>
<i class="fas fa-users mr-1"></i>
{{ room.participants.count() }} members
</span>
<span>
<i class="fas fa-clock mr-1"></i>
{% if room.last_activity %}
Last activity {{ room.last_activity.strftime('%m/%d/%Y at %I:%M %p') }}
{% else %}
No recent activity
{% endif %}
</span>
{% if room.is_private %}
<span class="bg-amber-100 text-amber-800 px-2 py-1 rounded-full">
<i class="fas fa-lock mr-1"></i>Private
</span>
{% endif %}
</div>
</div>
<!-- Action Buttons -->
<div class="mt-4 lg:mt-0 lg:ml-6 flex flex-col space-y-2">
<a href="{{ url_for('chat.room', room_id=room.id) }}"
class="inline-flex items-center justify-center px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-sign-in-alt mr-2"></i>Join Discussion
</a>
{% if room.related_post %}
<a href="{{ url_for('community.post_detail', id=room.related_post.id) }}"
class="inline-flex items-center justify-center px-6 py-2 bg-gray-100 text-gray-700 font-medium rounded-lg hover:bg-gray-200 transition-all duration-200">
<i class="fas fa-eye mr-2"></i>View Post
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if rooms.pages > 1 %}
<div class="flex justify-center mt-8">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="flex">
{% if rooms.has_prev %}
<a href="{{ url_for('chat.post_discussions', page=rooms.prev_num) }}"
class="px-4 py-2 text-gray-600 hover:bg-gray-100 transition-all duration-200">
<i class="fas fa-chevron-left"></i>
</a>
{% endif %}
{% for page_num in rooms.iter_pages() %}
{% if page_num %}
{% if page_num != rooms.page %}
<a href="{{ url_for('chat.post_discussions', page=page_num) }}"
class="px-4 py-2 text-gray-600 hover:bg-gray-100 transition-all duration-200">
{{ page_num }}
</a>
{% else %}
<span class="px-4 py-2 bg-blue-600 text-white">{{ page_num }}</span>
{% endif %}
{% else %}
<span class="px-4 py-2 text-gray-400">...</span>
{% endif %}
{% endfor %}
{% if rooms.has_next %}
<a href="{{ url_for('chat.post_discussions', page=rooms.next_num) }}"
class="px-4 py-2 text-gray-600 hover:bg-gray-100 transition-all duration-200">
<i class="fas fa-chevron-right"></i>
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% else %}
<!-- Empty State -->
<div class="text-center py-16">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-12 border border-white/20">
<i class="fas fa-newspaper text-6xl text-white/50 mb-6"></i>
<h3 class="text-2xl font-bold text-white mb-4">No post discussions yet</h3>
<p class="text-blue-200 mb-8">Create the first chat room linked to a community post!</p>
<a href="{{ url_for('chat.create_room_form') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Create Post Discussion
</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,172 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - Discussions{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 pt-16">
<!-- Header Section -->
<div class="relative overflow-hidden py-12">
<div class="absolute inset-0 bg-black/20"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<div class="flex items-center justify-between">
<div class="flex-1">
<h1 class="text-2xl md:text-3xl font-bold text-white mb-2">
<i class="fas fa-comments mr-3"></i>Discussions for:
</h1>
<h2 class="text-xl md:text-2xl text-blue-200">{{ post.title }}</h2>
<p class="text-blue-300 text-sm mt-2">
by {{ post.author.nickname }} • {{ post.created_at.strftime('%B %d, %Y') }}
</p>
</div>
<a href="{{ url_for('community.post_detail', id=post.id) }}"
class="bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg transition-all duration-200 text-white">
<i class="fas fa-eye mr-2"></i>View Post
</a>
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 -mt-6">
<!-- Navigation -->
<div class="flex justify-center mb-8">
<div class="bg-white rounded-2xl shadow-xl p-2 flex flex-wrap gap-2">
<a href="{{ url_for('chat.index') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-all duration-200">
<i class="fas fa-comments mr-2"></i>All Chats
</a>
<a href="{{ url_for('chat.post_discussions') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-all duration-200">
<i class="fas fa-newspaper mr-2"></i>Post Discussions
</a>
<a href="{{ url_for('chat.create_room_form') }}?post_id={{ post.id }}"
class="px-4 py-2 bg-gradient-to-r from-green-600 to-emerald-600 text-white rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>New Discussion
</a>
</div>
</div>
<!-- Post Summary -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden mb-8">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-file-alt text-2xl mr-3"></i>
<div>
<h3 class="text-xl font-bold">Original Post</h3>
<p class="text-blue-100 text-sm">{{ post.created_at.strftime('%B %d, %Y at %I:%M %p') }}</p>
</div>
</div>
</div>
<div class="p-6">
<div class="prose max-w-none">
{{ post.content[:300] }}{% if post.content|length > 300 %}...{% endif %}
</div>
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200">
<div class="flex items-center space-x-4 text-sm text-gray-500">
<span>
<i class="fas fa-user mr-1"></i>{{ post.author.nickname }}
</span>
{% if post.likes %}
<span>
<i class="fas fa-heart mr-1 text-red-500"></i>{{ post.likes.count() }} likes
</span>
{% endif %}
{% if post.comments %}
<span>
<i class="fas fa-comment mr-1 text-blue-500"></i>{{ post.comments.count() }} comments
</span>
{% endif %}
</div>
<a href="{{ url_for('community.post_detail', id=post.id) }}"
class="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-all duration-200">
<i class="fas fa-external-link-alt mr-2"></i>Read Full Post
</a>
</div>
</div>
</div>
{% if rooms %}
<!-- Discussion Rooms -->
<div class="space-y-6">
<h2 class="text-2xl font-bold text-white mb-6 flex items-center">
<i class="fas fa-comments mr-3"></i>Discussion Rooms ({{ rooms|length }})
</h2>
{% for room in rooms %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl">
<div class="p-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between">
<div class="flex-1">
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-xl font-bold text-gray-800 mb-2">{{ room.name }}</h3>
{% if room.description %}
<p class="text-gray-600 mb-3">{{ room.description }}</p>
{% endif %}
</div>
<div class="flex space-x-2 ml-4">
<span class="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-semibold">
<i class="fas fa-comments mr-1"></i>{{ room.message_count or 0 }}
</span>
{% if room.is_private %}
<span class="bg-amber-100 text-amber-800 px-3 py-1 rounded-full text-sm font-semibold">
<i class="fas fa-lock mr-1"></i>Private
</span>
{% endif %}
</div>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500">
<span>
<i class="fas fa-user mr-1"></i>
Created by {{ room.created_by.nickname }}
</span>
<span>
<i class="fas fa-users mr-1"></i>
{{ room.participants.count() }} members
</span>
<span>
<i class="fas fa-clock mr-1"></i>
{% if room.last_activity %}
{{ room.last_activity.strftime('%m/%d/%Y at %I:%M %p') }}
{% else %}
No recent activity
{% endif %}
</span>
<span>
<i class="fas fa-calendar mr-1"></i>
Created {{ room.created_at.strftime('%m/%d/%Y') }}
</span>
</div>
</div>
<div class="mt-4 lg:mt-0 lg:ml-6">
<a href="{{ url_for('chat.room', room_id=room.id) }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-sign-in-alt mr-2"></i>Join Discussion
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<!-- Empty State -->
<div class="text-center py-16">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-12 border border-white/20">
<i class="fas fa-comments text-6xl text-white/50 mb-6"></i>
<h3 class="text-2xl font-bold text-white mb-4">No discussions yet</h3>
<p class="text-blue-200 mb-8">Be the first to start a discussion about this post!</p>
<a href="{{ url_for('chat.create_room_form') }}?post_id={{ post.id }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Start Discussion
</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -2,276 +2,131 @@
{% block title %}{{ room.name }} - Chat{% endblock %}
{% block head %}
<style>
.chat-room-container {
height: calc(100vh - 80px);
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.chat-header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 1rem;
border-bottom: 1px solid #e0e0e0;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.chat-main {
flex: 1;
display: flex;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
.messages-container {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
}
.messages-area {
flex: 1;
overflow-y: auto;
padding: 1rem;
background: #f8f9fa;
}
.message {
margin-bottom: 1rem;
animation: fadeIn 0.3s ease-in;
}
.message-header {
display: flex;
align-items: center;
margin-bottom: 0.25rem;
}
.message-author {
font-weight: 600;
color: #495057;
margin-right: 0.5rem;
}
.message-time {
font-size: 0.75rem;
color: #6c757d;
}
.message-content {
background: white;
padding: 0.75rem 1rem;
border-radius: 18px;
border: 1px solid #e9ecef;
max-width: 70%;
word-wrap: break-word;
}
.message.own-message {
text-align: right;
}
.message.own-message .message-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
margin-left: auto;
}
.message.system-message .message-content {
background: #e9ecef;
color: #6c757d;
font-style: italic;
text-align: center;
max-width: 100%;
}
.message-input-area {
padding: 1rem;
background: white;
border-top: 1px solid #e0e0e0;
}
.message-input {
border: 2px solid #e9ecef;
border-radius: 25px;
padding: 0.75rem 1rem;
font-size: 1rem;
transition: all 0.3s ease;
}
.message-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.send-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
color: white;
font-size: 1.2rem;
transition: all 0.3s ease;
}
.send-button:hover {
transform: scale(1.1);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
.participants-sidebar {
width: 250px;
background: white;
border-left: 1px solid #e0e0e0;
padding: 1rem;
overflow-y: auto;
}
.participant-item {
display: flex;
align-items: center;
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: 10px;
transition: background 0.2s ease;
}
.participant-item:hover {
background: #f8f9fa;
}
.participant-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
margin-right: 0.75rem;
}
.participant-info {
flex: 1;
}
.participant-name {
font-weight: 600;
color: #495057;
}
.participant-role {
font-size: 0.75rem;
color: #6c757d;
}
.admin-badge {
background: #dc3545;
color: white;
padding: 0.1rem 0.5rem;
border-radius: 10px;
font-size: 0.6rem;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.participants-sidebar {
display: none;
}
.message-content {
max-width: 85%;
}
}
</style>
{% endblock %}
{% block content %}
<div class="chat-room-container">
<div class="chat-header">
<div class="d-flex align-items-center justify-content-between">
<div>
<h4 class="mb-0">{{ room.name }}</h4>
<small class="text-muted">{{ room.description or 'No description' }}</small>
{% if room.related_post %}
<br><small class="text-primary">Related to: <a href="#" class="text-primary">{{ room.related_post.title }}</a></small>
{% endif %}
</div>
<div>
<a href="{{ url_for('chat.index') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Chats
</a>
</div>
</div>
</div>
<div class="chat-main">
<div class="messages-container">
<div class="messages-area" id="messagesArea">
{% for message in messages %}
<div class="message {% if message.user_id == current_user.id %}own-message{% endif %} {% if message.message_type == 'system' %}system-message{% endif %}">
{% if message.message_type != 'system' %}
<div class="message-header">
<span class="message-author">{{ message.user.nickname }}</span>
{% if message.user.is_admin %}
<span class="admin-badge">ADMIN</span>
<!-- Chat Room Header -->
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 py-8">
<div class="max-w-6xl mx-auto px-4">
<!-- Room Header Card -->
<div class="bg-white/10 backdrop-blur-md rounded-2xl p-6 mb-6 border border-white/20">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between">
<div class="mb-4 lg:mb-0">
<div class="flex items-center mb-2">
<div class="w-12 h-12 bg-gradient-to-r from-blue-500 to-purple-500 rounded-xl flex items-center justify-center mr-4">
<i class="fas fa-comments text-white text-xl"></i>
</div>
<div>
<h1 class="text-2xl font-bold text-white">{{ room.name }}</h1>
{% if room.description %}
<p class="text-blue-200 mb-2">{{ room.description }}</p>
{% endif %}
</div>
</div>
<div class="flex flex-wrap gap-2">
{% if room.category %}
<span class="px-3 py-1 bg-blue-500/30 text-blue-200 rounded-full text-sm border border-blue-400/30">
<i class="fas fa-tag mr-1"></i>{{ room.category.title() }}
</span>
{% endif %}
<span class="message-time">{{ message.created_at.strftime('%H:%M') }}</span>
{% if room.related_post %}
<span class="px-3 py-1 bg-green-500/30 text-green-200 rounded-full text-sm border border-green-400/30">
<i class="fas fa-link mr-1"></i>Linked to Post
</span>
{% endif %}
<span class="px-3 py-1 bg-purple-500/30 text-purple-200 rounded-full text-sm border border-purple-400/30">
<i class="fas fa-users mr-1"></i>{{ room.participants.count() if room.participants else 0 }} Members
</span>
</div>
{% if room.related_post %}
<div class="mt-3 p-3 bg-white/5 rounded-lg border border-white/10">
<div class="flex items-center text-sm text-gray-300">
<i class="fas fa-newspaper mr-2 text-green-400"></i>
<span class="mr-2">Discussing:</span>
<a href="{{ url_for('community.post_detail', post_id=room.related_post.id) }}"
class="text-green-300 hover:text-green-200 underline transition-colors"
target="_blank">
{{ room.related_post.title }}
</a>
</div>
</div>
{% endif %}
<div class="message-content">
{{ message.content }}
{% if message.is_edited %}
<small class="text-muted"> (edited)</small>
{% endif %}
</div>
<div class="flex gap-2">
<button class="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg border border-white/20 transition-colors">
<i class="fas fa-users mr-1"></i> Members
</button>
<button class="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg border border-white/20 transition-colors">
<i class="fas fa-cog mr-1"></i> Settings
</button>
</div>
</div>
</div>
<!-- Chat Interface -->
<div class="bg-white/5 backdrop-blur-md rounded-2xl border border-white/20 overflow-hidden">
<!-- Messages Area -->
<div id="messages-container" class="h-96 overflow-y-auto p-6 space-y-4">
{% for message in messages %}
<div class="message mb-4 {{ 'ml-12' if message.sender_id == current_user.id else 'mr-12' }}">
<div class="flex {{ 'justify-end' if message.sender_id == current_user.id else 'justify-start' }}">
<div class="max-w-xs lg:max-w-md">
{% if not message.is_system_message %}
<div class="flex items-center mb-1 {{ 'justify-end' if message.sender_id == current_user.id else 'justify-start' }}">
<span class="text-xs text-gray-400">
{{ message.sender.nickname }}
{% if message.sender.is_admin %}
<span class="px-1 py-0.5 bg-yellow-100 text-yellow-800 text-xs rounded-full ml-1">ADMIN</span>
{% endif %}
• {{ message.created_at.strftime('%H:%M') }}
</span>
</div>
{% endif %}
<div class="rounded-2xl px-4 py-3 {{
'bg-gradient-to-r from-blue-600 to-purple-600 text-white' if message.sender_id == current_user.id else
'bg-yellow-100 border-yellow-300 text-yellow-800 text-center' if message.is_system_message else
'bg-white border border-gray-200 text-gray-800'
}}">
{% if message.is_system_message %}
<i class="fas fa-info-circle mr-2"></i>
{% endif %}
{{ message.content }}
{% if message.is_edited %}
<small class="opacity-75 text-xs block mt-1">(edited)</small>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="message-input-area">
<form id="messageForm" class="d-flex gap-2">
<input type="text"
id="messageInput"
class="form-control message-input"
placeholder="Type your message..."
maxlength="2000"
autocomplete="off">
<button type="submit" class="send-button">
<!-- Message Input -->
<div class="border-t border-white/20 p-4">
<form id="message-form" class="flex gap-3">
<div class="flex-1">
<input
type="text"
id="message-input"
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Type your message..."
maxlength="1000"
autocomplete="off"
>
</div>
<button
type="submit"
class="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-xl font-medium transition-all duration-200 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<i class="fas fa-paper-plane"></i>
</button>
</form>
<small class="text-muted">Press Enter to send • Max 2000 characters</small>
</div>
</div>
<div class="participants-sidebar">
<h6 class="mb-3">Participants ({{ participants|length }})</h6>
{% for participant in participants %}
<div class="participant-item">
<div class="participant-avatar">
{{ participant.user.nickname[0].upper() }}
</div>
<div class="participant-info">
<div class="participant-name">{{ participant.user.nickname }}</div>
<div class="participant-role">
{{ participant.role.title() }}
{% if participant.user.is_admin %}• Admin{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
@@ -281,13 +136,13 @@ const currentUserId = {{ current_user.id }};
let lastMessageId = {{ messages[-1].id if messages else 0 }};
// Message form handling
document.getElementById('messageForm').addEventListener('submit', function(e) {
document.getElementById('message-form').addEventListener('submit', function(e) {
e.preventDefault();
sendMessage();
});
// Enter key handling
document.getElementById('messageInput').addEventListener('keypress', function(e) {
document.getElementById('message-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
@@ -295,7 +150,7 @@ document.getElementById('messageInput').addEventListener('keypress', function(e)
});
function sendMessage() {
const input = document.getElementById('messageInput');
const input = document.getElementById('message-input');
const content = input.value.trim();
if (!content) return;
@@ -306,8 +161,7 @@ function sendMessage() {
fetch(`/api/v1/chat/rooms/${roomId}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
'Content-Type': 'application/json'
},
body: JSON.stringify({
content: content,
@@ -335,35 +189,44 @@ function sendMessage() {
}
function addMessageToUI(message) {
const messagesArea = document.getElementById('messagesArea');
const messagesArea = document.getElementById('messages-container');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${message.user.id === currentUserId ? 'own-message' : ''} ${message.message_type === 'system' ? 'system-message' : ''}`;
messageDiv.className = `message mb-4 ${message.user.id === currentUserId ? 'ml-12' : 'mr-12'}`;
messageDiv.setAttribute('data-message-id', message.id);
let messageHTML = '';
if (message.message_type !== 'system') {
messageHTML += `
<div class="message-header">
<span class="message-author">${message.user.nickname}</span>
${message.user.is_admin ? '<span class="admin-badge">ADMIN</span>' : ''}
<span class="message-time">${new Date(message.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
const isOwnMessage = message.user.id === currentUserId;
const isSystemMessage = message.message_type === 'system';
messageDiv.innerHTML = `
<div class="flex ${isOwnMessage ? 'justify-end' : 'justify-start'}">
<div class="max-w-xs lg:max-w-md">
${!isSystemMessage ? `
<div class="flex items-center mb-1 ${isOwnMessage ? 'justify-end' : 'justify-start'}">
<span class="text-xs text-gray-400">
${message.user.nickname} ${message.user.is_admin ? '<span class="px-1 py-0.5 bg-yellow-100 text-yellow-800 text-xs rounded-full ml-1">ADMIN</span>' : ''}${new Date(message.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</div>
` : ''}
<div class="rounded-2xl px-4 py-3 ${
isOwnMessage ? 'bg-gradient-to-r from-blue-600 to-purple-600 text-white' :
isSystemMessage ? 'bg-yellow-100 border-yellow-300 text-yellow-800 text-center' :
'bg-white border border-gray-200 text-gray-800'
}">
${isSystemMessage ? '<i class="fas fa-info-circle mr-2"></i>' : ''}
${message.content}
${message.is_edited ? '<small class="opacity-75 text-xs block mt-1"> (edited)</small>' : ''}
</div>
</div>
`;
}
messageHTML += `
<div class="message-content">
${message.content}
${message.is_edited ? '<small class="text-muted"> (edited)</small>' : ''}
</div>
`;
messageDiv.innerHTML = messageHTML;
messagesArea.appendChild(messageDiv);
lastMessageId = message.id;
}
function scrollToBottom() {
const messagesArea = document.getElementById('messagesArea');
const messagesArea = document.getElementById('messages-container');
messagesArea.scrollTop = messagesArea.scrollHeight;
}
@@ -390,11 +253,10 @@ scrollToBottom();
setInterval(loadNewMessages, 3000);
// Auto-focus on message input
document.getElementById('messageInput').focus();
document.getElementById('message-input').focus();
// Mobile app integration
if (window.ReactNativeWebView) {
// React Native WebView integration
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'chat_room_opened',
roomId: roomId,

View File

@@ -0,0 +1,39 @@
"""
Database migration to add category field to chat_rooms table
"""
from app.extensions import db
def upgrade():
"""Add category field to chat_rooms table"""
try:
# Add the category column
db.engine.execute("""
ALTER TABLE chat_rooms
ADD COLUMN category VARCHAR(50) DEFAULT 'general'
""")
# Update existing rooms to have category based on room_type
db.engine.execute("""
UPDATE chat_rooms
SET category = CASE
WHEN room_type = 'general' THEN 'general'
WHEN room_type = 'post_discussion' THEN 'general'
WHEN room_type = 'admin_support' THEN 'technical'
WHEN room_type = 'password_reset' THEN 'technical'
ELSE 'general'
END
""")
print("✅ Successfully added category field to chat_rooms table")
except Exception as e:
print(f"❌ Error adding category field: {e}")
# If column already exists, that's fine
if "duplicate column name" in str(e).lower() or "already exists" in str(e).lower():
print(" Category column already exists")
else:
raise
if __name__ == "__main__":
upgrade()

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""
Database migration script for Password Reset System
Adds PasswordResetRequest and PasswordResetToken tables
"""
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.extensions import db
from app.models import PasswordResetRequest, PasswordResetToken
from config import Config
def create_password_reset_tables():
"""Create password reset tables"""
try:
# Create tables
db.create_all()
print("✅ Password reset tables created successfully!")
# Verify tables exist
from sqlalchemy import inspect
inspector = inspect(db.engine)
tables = inspector.get_table_names()
if 'password_reset_request' in tables:
print("✅ PasswordResetRequest table exists")
else:
print("❌ PasswordResetRequest table missing")
if 'password_reset_token' in tables:
print("✅ PasswordResetToken table exists")
else:
print("❌ PasswordResetToken table missing")
return True
except Exception as e:
print(f"❌ Error creating tables: {e}")
return False
def main():
"""Main migration function"""
print("🔄 Starting password reset system migration...")
# Import app to initialize database
from run import app
with app.app_context():
success = create_password_reset_tables()
if success:
print("✅ Migration completed successfully!")
print("\nNew features available:")
print("- Admin can view password reset requests")
print("- Admin can generate secure reset tokens")
print("- Email templates for manual sending")
print("- Token usage tracking")
print("- Request status management")
else:
print("❌ Migration failed!")
sys.exit(1)
if __name__ == '__main__':
main()

View File