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.
325 lines
14 KiB
HTML
325 lines
14 KiB
HTML
{% 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 %}
|