Add authentication improvements and search functionality
- Implement anonymous access control with ALLOW_ANONYMOUS_ACCESS env var - Add complete password reset workflow with token-based validation - Add username recovery functionality for better UX - Implement full-text search API with relevance scoring and highlighting - Add Docker compatibility improvements with permission handling and fallback storage - Add quick stats API for real-time dashboard updates - Improve security with proper token expiration and input validation - Add search result pagination and navigation - Enhance error handling and logging throughout the application 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -728,8 +728,28 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
setupFilterSwitching();
|
||||
setupInfiniteScroll();
|
||||
setupAutoRefresh();
|
||||
loadQuickStats();
|
||||
});
|
||||
|
||||
// Load quick stats data
|
||||
async function loadQuickStats() {
|
||||
try {
|
||||
const response = await fetch('/api/stats');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.posts_today !== undefined) {
|
||||
// Update posts today stat
|
||||
const postsTodayElement = document.querySelector('.stat-card .stat-number');
|
||||
if (postsTodayElement) {
|
||||
postsTodayElement.textContent = data.posts_today;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading quick stats:', error);
|
||||
// Keep default value if API fails
|
||||
}
|
||||
}
|
||||
|
||||
// Load platform configuration and communities
|
||||
async function loadPlatformConfig() {
|
||||
try {
|
||||
@@ -1032,11 +1052,170 @@ document.querySelector('.search-input').addEventListener('keypress', function(e)
|
||||
if (e.key === 'Enter') {
|
||||
const query = this.value.trim();
|
||||
if (query) {
|
||||
alert(`Search functionality coming soon! You searched for: "${query}"`);
|
||||
performSearch(query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Search button functionality
|
||||
document.querySelector('.search-btn').addEventListener('click', function() {
|
||||
const query = document.querySelector('.search-input').value.trim();
|
||||
if (query) {
|
||||
performSearch(query);
|
||||
}
|
||||
});
|
||||
|
||||
// Search posts function
|
||||
async function performSearch(query) {
|
||||
try {
|
||||
// Show loading state in search bar
|
||||
const searchInput = document.querySelector('.search-input');
|
||||
const searchBtn = document.querySelector('.search-btn');
|
||||
const originalPlaceholder = searchInput.placeholder;
|
||||
|
||||
searchInput.placeholder = 'Searching...';
|
||||
searchBtn.disabled = true;
|
||||
|
||||
// Build search parameters
|
||||
const params = new URLSearchParams();
|
||||
params.append('q', query);
|
||||
params.append('page', 1);
|
||||
params.append('per_page', 20);
|
||||
|
||||
const response = await fetch(`/api/search?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.posts) {
|
||||
// Hide loading state
|
||||
searchInput.placeholder = originalPlaceholder;
|
||||
searchBtn.disabled = false;
|
||||
|
||||
// Update UI for search results
|
||||
displaySearchResults(query, data);
|
||||
} else {
|
||||
throw new Error(data.error || 'Search failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
|
||||
// Hide loading state
|
||||
const searchInput = document.querySelector('.search-input');
|
||||
const searchBtn = document.querySelector('.search-btn');
|
||||
searchInput.placeholder = 'Search failed...';
|
||||
searchBtn.disabled = false;
|
||||
|
||||
setTimeout(() => {
|
||||
searchInput.placeholder = 'Search content...';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Display search results in the main content area
|
||||
function displaySearchResults(query, searchData) {
|
||||
// Update page title and header
|
||||
document.title = `Search Results: "${query}" - BalanceBoard`;
|
||||
|
||||
const contentHeader = document.querySelector('.content-header h1');
|
||||
contentHeader.textContent = `Search Results for "${query}"`;
|
||||
|
||||
// Update page info to show search results
|
||||
const pageInfo = document.querySelector('.page-info');
|
||||
pageInfo.textContent = `Found ${searchData.pagination.total_posts} results`;
|
||||
|
||||
// Render search results using the same post card template
|
||||
const postsContainer = document.getElementById('posts-container');
|
||||
postsContainer.innerHTML = '';
|
||||
|
||||
if (searchData.posts.length === 0) {
|
||||
postsContainer.innerHTML = `
|
||||
<div class="no-posts">
|
||||
<h3>No results found</h3>
|
||||
<p>Try different keywords or check your spelling.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create post cards for search results
|
||||
const postsHTML = searchData.posts.map(post => createSearchResultPostCard(post, query)).join('');
|
||||
postsContainer.innerHTML = postsHTML;
|
||||
}
|
||||
|
||||
// Create search result post card with highlighted matches
|
||||
function createSearchResultPostCard(post, query) {
|
||||
const timeAgo = formatTimeAgo(post.timestamp);
|
||||
const platformClass = `platform-${post.platform}`;
|
||||
const platformInitial = post.platform.charAt(0).toUpperCase();
|
||||
const hasExternalLink = post.external_url && !post.external_url.includes(window.location.hostname);
|
||||
|
||||
// Highlight matched fields in title and content
|
||||
const highlightedTitle = highlightText(post.title, query);
|
||||
const highlightedContent = highlightText(post.content_preview || '', query);
|
||||
|
||||
return `
|
||||
<article class="post-card" onclick="openPost('${post.id}')">
|
||||
<div class="post-header">
|
||||
<div class="platform-badge ${platformClass}" onclick="event.stopPropagation(); filterByPlatform('${post.platform}')" title="Filter by ${post.platform}">
|
||||
${platformInitial}
|
||||
</div>
|
||||
<div class="post-meta">
|
||||
<span class="post-author">${escapeHtml(post.author)}</span>
|
||||
<span class="post-separator">•</span>
|
||||
${post.source_display ? `<span class="post-source" onclick="event.stopPropagation(); filterByCommunity('${post.source}', '${post.platform}')" title="Filter by ${post.source_display}">${escapeHtml(post.source_display)}</span><span class="post-separator">•</span>` : ''}
|
||||
<span class="post-time">${timeAgo}</span>
|
||||
${hasExternalLink ? '<span class="external-link-indicator">🔗</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="post-title">${highlightedTitle}</h3>
|
||||
|
||||
${highlightedContent ? `<div class="post-preview">${highlightedContent}</div>` : ''}
|
||||
|
||||
${post.tags && post.tags.length > 0 ? `
|
||||
<div class="post-tags">
|
||||
${post.tags.map(tag => `<span class="tag">${escapeHtml(tag)}</span>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="post-footer">
|
||||
<div class="post-stats">
|
||||
<div class="post-score">
|
||||
<span>▲</span>
|
||||
<span>${post.score}</span>
|
||||
</div>
|
||||
<div class="post-comments">
|
||||
<span>💬</span>
|
||||
<span>${post.comment_count || 0} comments</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
${hasExternalLink ? `<button class="post-action" onclick="event.stopPropagation(); window.open('${escapeHtml(post.external_url)}', '_blank')">🔗 Source</button>` : ''}
|
||||
<button class="post-action" onclick="event.stopPropagation(); sharePost('${post.id}')">Share</button>
|
||||
<button class="post-action" onclick="event.stopPropagation(); savePost('${post.id}')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show search match info -->
|
||||
<div class="search-match-info" style="background: #e0f7fa; padding: 8px 12px; margin-top: 8px; border-radius: 6px; font-size: 0.85rem; color: #0277bd;">
|
||||
<strong>Matched in:</strong> ${post.matched_fields.join(', ') || 'title, content'}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
// Highlight matching text
|
||||
function highlightText(text, query) {
|
||||
if (!text || !query) return text;
|
||||
|
||||
const regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
|
||||
return text.replace(regex, '<mark style="background: #fff3cd; padding: 2px 4px; border-radius: 2px;">$1</mark>');
|
||||
}
|
||||
|
||||
// Escape regex special characters
|
||||
function escapeRegex(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Setup infinite scroll functionality
|
||||
function setupInfiniteScroll() {
|
||||
if (!userSettings?.experience?.infinite_scroll) {
|
||||
@@ -1193,6 +1372,55 @@ function loadNextPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Search result pagination functions
|
||||
function loadNextSearchPage(currentQuery, currentPage) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('q', currentQuery);
|
||||
params.append('page', currentPage + 1);
|
||||
params.append('per_page', 20);
|
||||
|
||||
fetch(`/api/search?${params}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.posts) {
|
||||
// Append new results to existing ones
|
||||
const postsContainer = document.getElementById('posts-container');
|
||||
const newPostsHTML = data.posts.map(post => createSearchResultPostCard(post, currentQuery)).join('');
|
||||
postsContainer.insertAdjacentHTML('beforeend', newPostsHTML);
|
||||
|
||||
// Update pagination info
|
||||
updateSearchPagination(data.pagination);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading next search page:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function loadPreviousSearchPage(currentQuery, currentPage) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('q', currentQuery);
|
||||
params.append('page', currentPage - 1);
|
||||
params.append('per_page', 20);
|
||||
|
||||
fetch(`/api/search?${params}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.posts) {
|
||||
// Replace current results with previous page
|
||||
displaySearchResults(currentQuery, data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading previous search page:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSearchPagination(pagination) {
|
||||
const pageInfo = document.querySelector('.page-info');
|
||||
pageInfo.textContent = `Page ${pagination.current_page} of ${pagination.total_pages} (${pagination.total_posts} results)`;
|
||||
}
|
||||
|
||||
function loadPreviousPage() {
|
||||
if (paginationData.has_prev) {
|
||||
loadPosts(currentPage - 1, currentCommunity, currentPlatform);
|
||||
|
||||
79
templates/forgot_password.html
Normal file
79
templates/forgot_password.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Reset Password - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||
<h1><span class="balance">balance</span>Board</h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">Reset your password</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" required
|
||||
placeholder="Enter your registered email address"
|
||||
value="{{ request.form.email or '' }}">
|
||||
<small class="form-help">
|
||||
We'll send you instructions to reset your password.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Send Reset Instructions
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Remember your password? <a href="{{ url_for('login') }}">Back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color, #4db6ac);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark, #26a69a);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
79
templates/forgot_username.html
Normal file
79
templates/forgot_username.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Find Username - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||
<h1><span class="balance">balance</span>Board</h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">Find your username</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" required
|
||||
placeholder="Enter your registered email address"
|
||||
value="{{ request.form.email or '' }}">
|
||||
<small class="form-help">
|
||||
We'll send your username to this email address.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Find My Username
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Remember your username? <a href="{{ url_for('login') }}">Back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color, #4db6ac);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark, #26a69a);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -55,6 +55,11 @@
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Don't have an account? <a href="{{ url_for('signup') }}">Sign up</a></p>
|
||||
<div class="auth-links">
|
||||
<a href="{{ url_for('forgot_username') }}">Forgot username?</a>
|
||||
<span>·</span>
|
||||
<a href="{{ url_for('forgot_password') }}">Forgot password?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
179
templates/reset_password.html
Normal file
179
templates/reset_password.html
Normal file
@@ -0,0 +1,179 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Reset Password - BalanceBoard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo">
|
||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
||||
<h1><span class="balance">balance</span>Board</h1>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">Create your new password</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" class="auth-form" id="resetPasswordForm">
|
||||
<div class="form-group">
|
||||
<label for="password">New Password</label>
|
||||
<input type="password" id="password" name="password" required
|
||||
minlength="8"
|
||||
placeholder="Enter new password"
|
||||
oninput="checkPasswordStrength()">
|
||||
<small class="form-help">
|
||||
Password must be at least 8 characters long.
|
||||
</small>
|
||||
<div id="passwordStrength" class="password-strength" style="display: none;">
|
||||
<div class="strength-bar"></div>
|
||||
<small class="strength-text"></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Confirm New Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required
|
||||
placeholder="Confirm your new password"
|
||||
oninput="checkPasswordMatch()">
|
||||
<small class="form-help" id="passwordMatch" style="display: none;"></small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block" id="resetBtn">
|
||||
Reset Password
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p><a href="{{ url_for('login') }}">Back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function checkPasswordStrength() {
|
||||
const password = document.getElementById('password').value;
|
||||
const strengthDiv = document.getElementById('passwordStrength');
|
||||
const strengthBar = strengthDiv.querySelector('.strength-bar');
|
||||
const strengthText = strengthDiv.querySelector('.strength-text');
|
||||
|
||||
if (password.length === 0) {
|
||||
strengthDiv.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
strengthDiv.style.display = 'block';
|
||||
|
||||
let strength = 0;
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.length >= 12) strength++;
|
||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
|
||||
if (/[0-9]/.test(password)) strength++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength++;
|
||||
|
||||
const strengthLevels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'];
|
||||
const strengthColors = ['#ff4444', '#ff8844', '#ffaa44', '#44ff88', '#44aa44'];
|
||||
|
||||
strengthBar.style.width = `${(strength + 1) * 20}%`;
|
||||
strengthBar.style.backgroundColor = strengthColors[strength];
|
||||
strengthText.textContent = strengthLevels[strength];
|
||||
strengthText.style.color = strengthColors[strength];
|
||||
}
|
||||
|
||||
function checkPasswordMatch() {
|
||||
const password = document.getElementById('password').value;
|
||||
const confirm = document.getElementById('confirm_password').value;
|
||||
const matchDiv = document.getElementById('passwordMatch');
|
||||
|
||||
if (confirm.length === 0) {
|
||||
matchDiv.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
matchDiv.style.display = 'block';
|
||||
|
||||
if (password === confirm) {
|
||||
matchDiv.textContent = '✓ Passwords match';
|
||||
matchDiv.style.color = '#44aa44';
|
||||
document.getElementById('resetBtn').disabled = false;
|
||||
} else {
|
||||
matchDiv.textContent = '✗ Passwords do not match';
|
||||
matchDiv.style.color = '#ff4444';
|
||||
document.getElementById('resetBtn').disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('resetPasswordForm').addEventListener('submit', function(e) {
|
||||
const password = document.getElementById('password').value;
|
||||
const confirm = document.getElementById('confirm_password').value;
|
||||
|
||||
if (password !== confirm) {
|
||||
e.preventDefault();
|
||||
document.getElementById('passwordMatch').click();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
height: 4px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.strength-text {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color, #4db6ac);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-dark, #26a69a);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #cccccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user