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:
173
README.md
173
README.md
@@ -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
|
- **Adventure Posts**: Rich content creation with titles, subtitles, and detailed stories
|
||||||
- **Comment System**: Community discussions on adventure posts
|
- **Comment System**: Community discussions on adventure posts
|
||||||
- **Like System**: Engagement tracking with real-time updates
|
- **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
|
- **User Profiles**: Personal dashboards with adventure statistics
|
||||||
- **Difficulty Ratings**: 5-star system for adventure difficulty assessment
|
- **Difficulty Ratings**: 5-star system for adventure difficulty assessment
|
||||||
- **Publication Workflow**: Admin approval system for content moderation
|
- **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
|
- **Registration System**: Email-based user registration
|
||||||
|
|
||||||
### 🛠️ Admin Panel & Analytics
|
### 🛠️ 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
|
- **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
|
- **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
|
- **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
|
- **Touch-Friendly Interface**: Optimized buttons and interactions for mobile devices
|
||||||
- **Responsive Grids**: Adaptive layouts that work perfectly on phones and tablets
|
- **Responsive Grids**: Adaptive layouts that work perfectly on phones and tablets
|
||||||
- **Progressive Enhancement**: Graceful degradation for older browsers
|
- **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
|
- **Frontend**: Tailwind CSS 3.x with custom components
|
||||||
- **Maps**: Leaflet.js with OpenStreetMap integration
|
- **Maps**: Leaflet.js with OpenStreetMap integration
|
||||||
- **File Handling**: Secure media uploads with thumbnail generation
|
- **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
|
- **Deployment**: Docker with Gunicorn WSGI server
|
||||||
|
|
||||||
## 📁 Project Structure
|
## 📁 Project Structure
|
||||||
@@ -112,7 +130,106 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent
|
|||||||
└── docker-compose.yml # Docker Compose setup
|
└── 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
|
### Local Development
|
||||||
|
|
||||||
@@ -172,8 +289,54 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Access the application**
|
2. **Access the application**
|
||||||
- Web application: http://localhost:5000
|
- Web application: http://localhost:8100
|
||||||
- PostgreSQL database: localhost:5432
|
- 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
|
### 📱 Testing Features
|
||||||
|
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ class ChatRoom(db.Model):
|
|||||||
name = db.Column(db.String(200), nullable=False)
|
name = db.Column(db.String(200), nullable=False)
|
||||||
description = db.Column(db.Text)
|
description = db.Column(db.Text)
|
||||||
room_type = db.Column(db.String(50), default='general') # general, post_discussion, admin_support, password_reset
|
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_private = db.Column(db.Boolean, default=False)
|
||||||
is_active = db.Column(db.Boolean, default=True)
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
@@ -436,3 +437,69 @@ class ChatMessage(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<ChatMessage {self.id} by {self.user.nickname}>'
|
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}>'
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ from functools import wraps
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from sqlalchemy import func, desc
|
from sqlalchemy import func, desc
|
||||||
import secrets
|
import secrets
|
||||||
|
import uuid
|
||||||
from app.routes.mail import mail
|
from app.routes.mail import mail
|
||||||
from app.extensions import db
|
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')
|
admin = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
|
|
||||||
@@ -151,6 +152,22 @@ def dashboard():
|
|||||||
.order_by(desc('view_count'))\
|
.order_by(desc('view_count'))\
|
||||||
.limit(10).all()
|
.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',
|
return render_template('admin/dashboard.html',
|
||||||
total_users=total_users,
|
total_users=total_users,
|
||||||
total_posts=total_posts,
|
total_posts=total_posts,
|
||||||
@@ -165,7 +182,13 @@ def dashboard():
|
|||||||
views_yesterday=views_yesterday,
|
views_yesterday=views_yesterday,
|
||||||
views_this_week=views_this_week,
|
views_this_week=views_this_week,
|
||||||
most_viewed_posts=most_viewed_posts,
|
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')
|
@admin.route('/posts')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -506,3 +529,367 @@ def api_quick_stats():
|
|||||||
'pending_posts': pending_count,
|
'pending_posts': pending_count,
|
||||||
'today_views': today_views
|
'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)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||||
from flask_login import login_user, logout_user, login_required, current_user
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
from werkzeug.security import check_password_hash
|
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 app.routes.reset_password import RequestResetForm, ResetPasswordForm
|
||||||
from flask_mail import Message
|
from flask_mail import Message
|
||||||
from app.routes.mail import mail
|
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')
|
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 redirect(url_for('auth.login'))
|
||||||
return render_template('auth/forgot_password.html', form=form)
|
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'])
|
@auth.route('/change-password', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -235,3 +215,59 @@ def is_valid_password(password):
|
|||||||
if not re.search(r'\d', password):
|
if not re.search(r'\d', password):
|
||||||
return False
|
return False
|
||||||
return True
|
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)
|
||||||
|
|||||||
@@ -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 import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from sqlalchemy import desc, and_
|
from sqlalchemy import desc, and_
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models import ChatRoom, ChatMessage, ChatParticipant, Post
|
from app.models import ChatRoom, ChatMessage, ChatParticipant, Post
|
||||||
@@ -90,7 +91,109 @@ def create_room_form():
|
|||||||
desc(Post.created_at)
|
desc(Post.created_at)
|
||||||
).limit(20).all()
|
).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')
|
@chat.route('/support')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -123,3 +226,51 @@ def embed_post_chat(post_id):
|
|||||||
return render_template('chat/embed.html',
|
return render_template('chat/embed.html',
|
||||||
post=post,
|
post=post,
|
||||||
discussion_room=discussion_room)
|
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)
|
||||||
|
|||||||
@@ -121,10 +121,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Content Overview -->
|
||||||
<div class="row">
|
<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 -->
|
<!-- Recent Posts -->
|
||||||
<div class="col-lg-6 mb-4">
|
<div class="col-lg-4 mb-4">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
<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>
|
<h6 class="m-0 fw-bold text-primary">Recent Posts</h6>
|
||||||
@@ -161,7 +239,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Most Viewed Posts -->
|
<!-- Most Viewed Posts -->
|
||||||
<div class="col-lg-6 mb-4">
|
<div class="col-lg-4 mb-4">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header py-3">
|
<div class="card-header py-3">
|
||||||
<h6 class="m-0 fw-bold text-primary">Most Viewed Posts</h6>
|
<h6 class="m-0 fw-bold text-primary">Most Viewed Posts</h6>
|
||||||
|
|||||||
603
app/templates/admin/manage_chats.html
Normal file
603
app/templates/admin/manage_chats.html
Normal 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"> </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 %}
|
||||||
269
app/templates/admin/password_reset_email_template.html
Normal file
269
app/templates/admin/password_reset_email_template.html
Normal 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 %}
|
||||||
242
app/templates/admin/password_reset_request_detail.html
Normal file
242
app/templates/admin/password_reset_request_detail.html
Normal 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 %}
|
||||||
230
app/templates/admin/password_reset_requests.html
Normal file
230
app/templates/admin/password_reset_requests.html
Normal 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 %}
|
||||||
324
app/templates/admin/password_reset_tokens.html
Normal file
324
app/templates/admin/password_reset_tokens.html
Normal 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 %}
|
||||||
182
app/templates/auth/reset_password_with_token.html
Normal file
182
app/templates/auth/reset_password_with_token.html
Normal 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 %}
|
||||||
243
app/templates/chat/create_room.html
Normal file
243
app/templates/chat/create_room.html
Normal 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 %}
|
||||||
@@ -48,60 +48,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<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-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="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
|
<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="bg-gradient-to-r from-green-600 to-emerald-600 p-6">
|
||||||
<div class="flex items-center text-white">
|
<div class="flex items-center text-white">
|
||||||
<i class="fas fa-plus-circle text-3xl mr-4"></i>
|
<i class="fas fa-plus-circle text-3xl mr-4"></i>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-lg font-bold">Create Room</h4>
|
<h4 class="text-xl font-bold">Create Chat Room</h4>
|
||||||
<p class="text-green-100 text-sm">Start a new discussion</p>
|
<p class="text-green-100">Start a new discussion</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6 text-center">
|
||||||
<p class="text-gray-600 mb-4">Start a new chat room on any motorcycle topic</p>
|
<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') }}"
|
<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">
|
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 Room
|
<i class="fas fa-plus mr-2"></i>Create New Room
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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-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">
|
<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>
|
<div>
|
||||||
<h4 class="text-lg font-bold">Password Reset</h4>
|
<h4 class="text-xl font-bold">Post Discussions</h4>
|
||||||
<p class="text-orange-100 text-sm">Forgot your password?</p>
|
<p class="text-blue-100">Chat about community posts</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6 text-center">
|
||||||
<p class="text-gray-600 mb-4">Request a password reset from administrators</p>
|
<p class="text-gray-600 mb-6">Join discussions about specific community posts and share your thoughts</p>
|
||||||
<a href="{{ url_for('auth.forgot_password') }}"
|
<a href="{{ url_for('chat.post_discussions') }}"
|
||||||
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">
|
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-unlock-alt mr-2"></i>Reset Password
|
<i class="fas fa-comments mr-2"></i>View Discussions
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
205
app/templates/chat/post_discussions.html
Normal file
205
app/templates/chat/post_discussions.html
Normal 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 %}
|
||||||
172
app/templates/chat/post_specific_discussions.html
Normal file
172
app/templates/chat/post_specific_discussions.html
Normal 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 %}
|
||||||
@@ -2,276 +2,131 @@
|
|||||||
|
|
||||||
{% block title %}{{ room.name }} - Chat{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="chat-room-container">
|
<!-- Chat Room Header -->
|
||||||
<div class="chat-header">
|
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 py-8">
|
||||||
<div class="d-flex align-items-center justify-content-between">
|
<div class="max-w-6xl mx-auto px-4">
|
||||||
<div>
|
<!-- Room Header Card -->
|
||||||
<h4 class="mb-0">{{ room.name }}</h4>
|
<div class="bg-white/10 backdrop-blur-md rounded-2xl p-6 mb-6 border border-white/20">
|
||||||
<small class="text-muted">{{ room.description or 'No description' }}</small>
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between">
|
||||||
{% if room.related_post %}
|
<div class="mb-4 lg:mb-0">
|
||||||
<br><small class="text-primary">Related to: <a href="#" class="text-primary">{{ room.related_post.title }}</a></small>
|
<div class="flex items-center mb-2">
|
||||||
{% endif %}
|
<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">
|
||||||
</div>
|
<i class="fas fa-comments text-white text-xl"></i>
|
||||||
<div>
|
</div>
|
||||||
<a href="{{ url_for('chat.index') }}" class="btn btn-outline-secondary">
|
<div>
|
||||||
<i class="fas fa-arrow-left"></i> Back to Chats
|
<h1 class="text-2xl font-bold text-white">{{ room.name }}</h1>
|
||||||
</a>
|
{% if room.description %}
|
||||||
</div>
|
<p class="text-blue-200 mb-2">{{ room.description }}</p>
|
||||||
</div>
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="chat-main">
|
|
||||||
<div class="messages-container">
|
<div class="flex flex-wrap gap-2">
|
||||||
<div class="messages-area" id="messagesArea">
|
{% if room.category %}
|
||||||
{% for message in messages %}
|
<span class="px-3 py-1 bg-blue-500/30 text-blue-200 rounded-full text-sm border border-blue-400/30">
|
||||||
<div class="message {% if message.user_id == current_user.id %}own-message{% endif %} {% if message.message_type == 'system' %}system-message{% endif %}">
|
<i class="fas fa-tag mr-1"></i>{{ room.category.title() }}
|
||||||
{% if message.message_type != 'system' %}
|
</span>
|
||||||
<div class="message-header">
|
|
||||||
<span class="message-author">{{ message.user.nickname }}</span>
|
|
||||||
{% if message.user.is_admin %}
|
|
||||||
<span class="admin-badge">ADMIN</span>
|
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="message-content">
|
</div>
|
||||||
{{ message.content }}
|
|
||||||
{% if message.is_edited %}
|
<div class="flex gap-2">
|
||||||
<small class="text-muted"> (edited)</small>
|
<button class="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg border border-white/20 transition-colors">
|
||||||
{% endif %}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="message-input-area">
|
<!-- Message Input -->
|
||||||
<form id="messageForm" class="d-flex gap-2">
|
<div class="border-t border-white/20 p-4">
|
||||||
<input type="text"
|
<form id="message-form" class="flex gap-3">
|
||||||
id="messageInput"
|
<div class="flex-1">
|
||||||
class="form-control message-input"
|
<input
|
||||||
placeholder="Type your message..."
|
type="text"
|
||||||
maxlength="2000"
|
id="message-input"
|
||||||
autocomplete="off">
|
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"
|
||||||
<button type="submit" class="send-button">
|
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>
|
<i class="fas fa-paper-plane"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<small class="text-muted">Press Enter to send • Max 2000 characters</small>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,13 +136,13 @@ const currentUserId = {{ current_user.id }};
|
|||||||
let lastMessageId = {{ messages[-1].id if messages else 0 }};
|
let lastMessageId = {{ messages[-1].id if messages else 0 }};
|
||||||
|
|
||||||
// Message form handling
|
// Message form handling
|
||||||
document.getElementById('messageForm').addEventListener('submit', function(e) {
|
document.getElementById('message-form').addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendMessage();
|
sendMessage();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enter key handling
|
// Enter key handling
|
||||||
document.getElementById('messageInput').addEventListener('keypress', function(e) {
|
document.getElementById('message-input').addEventListener('keypress', function(e) {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendMessage();
|
sendMessage();
|
||||||
@@ -295,7 +150,7 @@ document.getElementById('messageInput').addEventListener('keypress', function(e)
|
|||||||
});
|
});
|
||||||
|
|
||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
const input = document.getElementById('messageInput');
|
const input = document.getElementById('message-input');
|
||||||
const content = input.value.trim();
|
const content = input.value.trim();
|
||||||
|
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
@@ -306,8 +161,7 @@ function sendMessage() {
|
|||||||
fetch(`/api/v1/chat/rooms/${roomId}/messages`, {
|
fetch(`/api/v1/chat/rooms/${roomId}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json'
|
||||||
'X-CSRFToken': '{{ csrf_token() }}'
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content: content,
|
content: content,
|
||||||
@@ -335,35 +189,44 @@ function sendMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addMessageToUI(message) {
|
function addMessageToUI(message) {
|
||||||
const messagesArea = document.getElementById('messagesArea');
|
const messagesArea = document.getElementById('messages-container');
|
||||||
const messageDiv = document.createElement('div');
|
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 = '';
|
const isOwnMessage = message.user.id === currentUserId;
|
||||||
if (message.message_type !== 'system') {
|
const isSystemMessage = message.message_type === 'system';
|
||||||
messageHTML += `
|
|
||||||
<div class="message-header">
|
messageDiv.innerHTML = `
|
||||||
<span class="message-author">${message.user.nickname}</span>
|
<div class="flex ${isOwnMessage ? 'justify-end' : 'justify-start'}">
|
||||||
${message.user.is_admin ? '<span class="admin-badge">ADMIN</span>' : ''}
|
<div class="max-w-xs lg:max-w-md">
|
||||||
<span class="message-time">${new Date(message.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
|
${!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>
|
</div>
|
||||||
`;
|
|
||||||
}
|
|
||||||
messageHTML += `
|
|
||||||
<div class="message-content">
|
|
||||||
${message.content}
|
|
||||||
${message.is_edited ? '<small class="text-muted"> (edited)</small>' : ''}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
messageDiv.innerHTML = messageHTML;
|
|
||||||
messagesArea.appendChild(messageDiv);
|
messagesArea.appendChild(messageDiv);
|
||||||
|
|
||||||
lastMessageId = message.id;
|
lastMessageId = message.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
const messagesArea = document.getElementById('messagesArea');
|
const messagesArea = document.getElementById('messages-container');
|
||||||
messagesArea.scrollTop = messagesArea.scrollHeight;
|
messagesArea.scrollTop = messagesArea.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,11 +253,10 @@ scrollToBottom();
|
|||||||
setInterval(loadNewMessages, 3000);
|
setInterval(loadNewMessages, 3000);
|
||||||
|
|
||||||
// Auto-focus on message input
|
// Auto-focus on message input
|
||||||
document.getElementById('messageInput').focus();
|
document.getElementById('message-input').focus();
|
||||||
|
|
||||||
// Mobile app integration
|
// Mobile app integration
|
||||||
if (window.ReactNativeWebView) {
|
if (window.ReactNativeWebView) {
|
||||||
// React Native WebView integration
|
|
||||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||||
type: 'chat_room_opened',
|
type: 'chat_room_opened',
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
|
|||||||
39
migrations/add_category_to_chat_rooms.py
Normal file
39
migrations/add_category_to_chat_rooms.py
Normal 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()
|
||||||
66
migrations/add_password_reset_system.py
Normal file
66
migrations/add_password_reset_system.py
Normal 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()
|
||||||
0
migrations/add_password_reset_tables.py
Normal file
0
migrations/add_password_reset_tables.py
Normal file
Reference in New Issue
Block a user