Major Feature Update: Modern Chat System & Admin Management

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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