opencompletion.com/templates/base.html
Russell e4cabf4be6
otp (#41)
* Add email OTP authentication system and private room management

## Authentication System
- Implement email OTP-based authentication with User and OTPToken models
- Add auth.py module with OTP generation, email sending, and session management
- Create authentication endpoints: /auth/send-otp, /auth/verify-otp, /auth/claim-name, /auth/status, /auth/logout
- Add authentication modal UI with email verification and display name claiming
- Support SMTP configuration via environment variables (optional)

## Room Privacy & Ownership
- Add is_private, is_archived, owner_id, and forked_from_id fields to Room model
- Implement private rooms (single-user, owner-only access)
- Add room forking: users can fork public rooms to public or private
- Add room archive/delete endpoints (owner-only operations)
- Implement access control for private room viewing

## UI Improvements
- Add public/private room tabs in sidebar
- Show authentication prompt in private rooms tab for non-authenticated users
- Add room action buttons (fork, archive, delete) with proper permissions
- Update homepage with statistics (public/private rooms, active users, active rooms)
- Remove model/voice from URL query strings, use localStorage exclusively

## Database Migration
- Create migration 2025011100 for User, OTPToken tables and Room model updates
- Add indexes for email, display_name, is_private, is_archived, owner_id

## Breaking Changes
- URL parameters now only include username (model/voice moved to localStorage)
- Private rooms require authentication to access
- Room creation can now require authentication (for private rooms)

* Update README with authentication and private room documentation

* Simplify README to be less verbose

* Add missing session import to fix linter errors

* Fix migration dependency to resolve multiple heads conflict

* asdf

* Fix SQLAlchemy auto-correlation error in homepage statistics query

* Change tagline from AI-Powered to Machine Learning Powered

* Add dedicated authentication page instead of modal

- Create new /auth route with full-page authentication flow
- Remove modal code from index.html
- Update Sign in link to point to /auth page
- Auth page has 4-step flow: email, OTP, display name, success
- Better UX with gradient background and cleaner design

* Fix migration: remove batch_alter_table to avoid circular dependency

- Use op.add_column() directly instead of batch_alter_table()
- Remove foreign key constraints (defined in models, not needed in migration)
- User and OTPToken tables created by db.create_all() in make init-db
- Fixes CircularDependencyError during migration

* Fix migration: check if columns exist before adding

- Use inspector to check existing columns and indexes
- Only add columns/indexes if they don't already exist
- Handles case where db.create_all() was run before migration
- Fixes 'duplicate column name' error

* Add profile page, room browsing, and updated_at timestamp

Features:
- Profile page with username change and dark/light mode settings
- Browse page for discovering public and private rooms
- Room updated_at timestamp (integer Unix epoch) that updates on new messages
- Dynamic room tabs based on current room type (public/private)
- Fork and delete room actions moved to right sidebar utility belt
- Remove archive feature and success alerts from room actions

Technical changes:
- Add updated_at column to Room model (integer timestamp)
- Add /profile route with authentication requirement
- Add /browse route for room discovery
- Add API endpoints for username availability check and update
- Update room.updated_at on message creation in app.py:1189
- Wider right sidebar (25% instead of 15%) for better button layout
- Profile link on homepage and browse page for authenticated users

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: russell@unturf. <russell@unturf.com>
2025-11-11 22:56:58 -05:00

622 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Chatroom{% endblock %}</title>
<!-- Include highlight.js library for syntax highlighting -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/highlight.min.js"></script>
<!-- Include highlight.js themes for light and dark modes -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/styles/github.min.css" id="highlight-theme-light">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/styles/github-dark.min.css" id="highlight-theme-dark" disabled>
<!-- Include socket.io for real-time bidirectional event-based communication -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.min.js"></script>
<!-- Include marked.js for markdown parsing -->
<script
src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.2/marked.min.js"
integrity="sha512-rfX4p3RNnxdwLT3wWP1K0NR3ztTobn+sISlT9WhxDDK00zNYbQ6MCHA5OHm0hqKAzEMXYCgFrp8iY/ER5MkXqA=="
crossorigin="anonymous"
referrerpolicy="no-referrer">
</script>
<!-- Include DOMPurify to sanitize HTML and prevent XSS attacks -->
<script src="https://cdn.jsdelivr.net/npm/dompurify@2/dist/purify.min.js"></script>
<!-- Include utility functions -->
<script src="{{ url_for('static', filename='js/utils.js') }}"></script>
<script>
// Connect to the server using socket.io
const socket = io.connect(window.location.protocol + "//" + document.domain + ":" + location.port);
</script>
<!-- Link to the favicon -->
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Link to external stylesheet -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<!-- Search form -->
<div id="search-form" style="width: 90%;">
<form action="/search" method="get">
<input type="text" id="search-keywords" name="keywords" placeholder="Search for keywords..." value="{{ keywords }}">
</form>
</div>
<!-- Hamburger button for mobile -->
<button id="hamburger-button"></button>
<!-- Modal for room list and utility belt -->
<div id="room-list-modal">
<div id="room-list-modal-content">
<button id="close-modal-button" onclick="closeModal()">×</button>
<div id="utility-belt-mobile" class="utility-belt">
<div id="model-chooser-mobile">
<label for="model-select-mobile">Choose Model:</label>
<select id="model-select-mobile">
<option value="None">None</option>
</select>
</div>
<div id="voice-chooser-mobile">
<label for="voice-select-mobile">Choose Voice:</label>
<select id="voice-select-mobile">
<option value="onyx">Onyx</option>
<option value="alloy">Alloy</option>
<option value="echo">Echo</option>
<option value="fable">Fable</option>
<option value="nova">Nova</option>
<option value="shimmer">Shimmer</option>
</select>
</div>
<div id="theme-toggle-mobile">
<button id="theme-toggle-btn-mobile" onclick="toggleTheme()">
🌙 Dark Mode
</button>
</div>
<div id="auto-play-tts-mobile">
<button id="auto-play-tts-btn-mobile" onclick="toggleAutoPlayTTS()" style="width: 100%; margin-top: 10px; background-color: #f44336; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer;">
Auto-Play TTS: OFF
</button>
</div>
<div id="activity-controls-mobile">
<h3>Activities</h3>
<div id="current-activity-info-mobile" style="display: none;">
<p>Current Activity: <span id="current-activity-name-mobile"></span></p>
<button id="cancel-activity-btn-mobile" onclick="cancelActivity()">Cancel Activity</button>
</div>
<div id="activity-list-section-mobile">
<div style="display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 5px; margin-bottom: 5px;">
<select id="activity-select-mobile" style="width: 100%; max-width: 100%; box-sizing: border-box;">
<option value="">-- Select an Activity --</option>
</select>
<button id="refresh-activities-btn-mobile" onclick="refreshActivityList()">🔄</button>
</div>
<button id="load-activity-btn-mobile" onclick="loadSelectedActivityMobile()" style="margin-top: 5px;">Load Activity</button>
</div>
</div>
<div id="user-lists-mobile">
<div id="active-users-list">
<h3>Active Users</h3>
<ul id="active-users-mobile">
<!-- Active users will be dynamically populated here -->
</ul>
</div>
<div id="inactive-users-list">
<h3>Inactive Users</h3>
<ul id="inactive-users-mobile">
<!-- Inactive users will be dynamically populated here -->
</ul>
</div>
</div>
</div>
<div id="rooms-list-modal-content">
<!-- Room list will be cloned here for mobile view -->
</div>
</div>
</div>
<!-- Chatroom list -->
<div class="main-container">
<div id="rooms-list">
<!-- Create Room button with grid layout -->
<div id="create-room-section" style="display: grid; gap: 10px; margin-bottom: 15px;">
<a href="/">
<button id="create-room-btn">Create Room</button>
</a>
</div>
<!-- Room tabs with dynamic active state based on current room -->
<div id="room-tabs">
<div class="room-tab {% if not current_room or not current_room.is_private %}active{% endif %}" id="public-rooms-tab" onclick="switchRoomTab('public')">
🌍 Public
</div>
<div class="room-tab {% if current_room and current_room.is_private %}active{% endif %}" id="private-rooms-tab" onclick="switchRoomTab('private')">
🔐 Private
</div>
</div>
<!-- Private rooms section (show if viewing a private room) -->
<div id="private-rooms-section" class="room-section" style="display: {% if current_room and current_room.is_private %}block{% else %}none{% endif %};">
<div id="private-rooms-content">
{% if user %}
{% if private_rooms %}
<ul class="rooms-list">
{% for room in private_rooms %}
<a href="{{ url_for('chat', room_name=room.name) }}">
<li data-room-id="{{ room.id }}" class="private-room">
<b>{{ room.name }}</b>
{% if room.title %}
<br />{{ room.title }}
{% endif %}
{% if room.get_active_users()|length %}
<br /> {{ room.get_active_users()|length }} users
{% endif %}
</li>
</a>
{% endfor %}
</ul>
{% else %}
<p class="empty-state">No private rooms yet. Create one to get started!</p>
{% endif %}
{% else %}
<div class="auth-prompt">
<p>🔒 Private rooms are only visible to you</p>
<p>Sign in to create and access private rooms</p>
<button class="auth-btn" onclick="showAuthModal()">Sign In / Sign Up</button>
</div>
{% endif %}
</div>
</div>
<!-- Public rooms section (show if viewing a public room or no room) -->
<div id="public-rooms-section" class="room-section" style="display: {% if not current_room or not current_room.is_private %}block{% else %}none{% endif %};">
<ul id="rooms-list-ul" class="rooms-list">
<!-- Loop through public rooms and create list items for each room -->
{% for room in public_rooms %}
<a href="{{ url_for('chat', room_name=room.name) }}">
<li data-room-id="{{ room.id }}" class="public-room">
<!-- Display the room title if available, otherwise the room name -->
<b>{{ room.name }}</b>
{% if room.title %}
<br />{{ room.title }}
{% endif %}
{% if room.get_active_users()|length %}
<br /> {{ room.get_active_users()|length }} users
{% endif %}
{% if user and room.owner_id == user.id %}
<span class="owner-badge">👑 Owner</span>
{% endif %}
</li>
</a>
{% endfor %}
</ul>
</div>
</div>
{% block content %}{% endblock %}
</div>
<!-- Authentication Modal -->
<div id="auth-modal" class="modal" style="display: none;">
<div class="modal-content-auth">
<button class="close-btn" onclick="closeAuthModal()">×</button>
<!-- Step 1: Enter email -->
<div id="auth-step-email" class="auth-step">
<h2>Sign In / Sign Up</h2>
<p>Enter your email to receive a verification code</p>
<input type="email" id="auth-email" placeholder="your@email.com" />
<button onclick="sendOTP()">Send Code</button>
<div id="auth-email-error" class="error-message"></div>
</div>
<!-- Step 2: Enter OTP -->
<div id="auth-step-otp" class="auth-step" style="display: none;">
<h2>Enter Verification Code</h2>
<p>We sent a 6-digit code to <span id="auth-email-display"></span></p>
<input type="text" id="auth-otp" placeholder="123456" maxlength="6" />
<button onclick="verifyOTP()">Verify</button>
<button class="secondary-btn" onclick="backToEmailStep()">Back</button>
<div id="auth-otp-error" class="error-message"></div>
</div>
<!-- Step 3: Claim display name (new users only) -->
<div id="auth-step-name" class="auth-step" style="display: none;">
<h2>Choose Display Name</h2>
<p>Pick a unique display name (3-50 characters)</p>
<input type="text" id="auth-display-name" placeholder="username" maxlength="50" />
<button onclick="claimName()">Complete Sign Up</button>
<div id="auth-name-error" class="error-message"></div>
</div>
<!-- Success -->
<div id="auth-step-success" class="auth-step" style="display: none;">
<h2>✅ Success!</h2>
<p>Welcome, <span id="auth-success-name"></span>!</p>
<button onclick="closeAuthModalAndReload()">Continue</button>
</div>
</div>
</div>
<script>
// Function to perform the search
function performSearch() {
const keywords = document.getElementById("search-keywords").value;
if (!keywords) {
alert("Please enter keywords to search.");
return;
}
// Navigate to the search results page with the keywords as a query parameter
window.location.href = `/search?keywords=${encodeURIComponent(keywords)}`;
}
// Add event listener for keyword search the "Enter" key
document.getElementById("search-keywords").addEventListener("keydown", function(event) {
if (event.key === "Enter") {
event.preventDefault();
performSearch();
}
});
// Function to open the modal
function openModal() {
const modal = document.getElementById("room-list-modal");
const modalContent = document.getElementById("room-list-modal-content");
const closeButton = document.getElementById("close-modal-button");
// Clone the create room section and rooms list
const createRoomSection = document.getElementById("create-room-section").cloneNode(true);
const roomsList = document.getElementById("rooms-list-ul").cloneNode(true);
// Clear previous content and add all sections
document.getElementById("rooms-list-modal-content").innerHTML = '';
document.getElementById("rooms-list-modal-content").appendChild(createRoomSection);
document.getElementById("rooms-list-modal-content").appendChild(roomsList);
modal.style.display = "flex";
modalContent.style.display = "block";
closeButton.style.display = "block";
}
// Function to close the modal
function closeModal() {
const modal = document.getElementById("room-list-modal");
const modalContent = document.getElementById("room-list-modal-content");
const closeButton = document.getElementById("close-modal-button");
modal.style.display = "none";
modalContent.style.display = "none";
closeButton.style.display = "none";
}
// Function to update all room links (no query parameters needed)
function updateRoomLinksWithCurrentParams() {
// No longer needed - links don't use username in query string
// Keeping function for compatibility
}
// Add event listener to the hamburger button
document.getElementById("hamburger-button").addEventListener("click", openModal);
// Theme switching functionality
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
// Update the data-theme attribute
html.setAttribute('data-theme', newTheme);
// Save to localStorage
localStorage.setItem('theme', newTheme);
// Update button text for both desktop and mobile
updateThemeButtonText(newTheme);
// Switch highlight.js theme
updateHighlightTheme(newTheme);
}
function updateHighlightTheme(theme) {
const lightTheme = document.getElementById('highlight-theme-light');
const darkTheme = document.getElementById('highlight-theme-dark');
if (theme === 'dark') {
lightTheme.disabled = true;
darkTheme.disabled = false;
} else {
lightTheme.disabled = false;
darkTheme.disabled = true;
}
}
function updateThemeButtonText(theme) {
const themeBtn = document.getElementById('theme-toggle-btn');
const themeBtnMobile = document.getElementById('theme-toggle-btn-mobile');
if (theme === 'dark') {
if (themeBtn) themeBtn.textContent = '☀️ Light Mode';
if (themeBtnMobile) themeBtnMobile.textContent = '☀️ Light Mode';
} else {
if (themeBtn) themeBtn.textContent = '🌙 Dark Mode';
if (themeBtnMobile) themeBtnMobile.textContent = '🌙 Dark Mode';
}
}
// Apply saved theme on page load
function applySavedTheme() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
updateThemeButtonText(savedTheme);
updateHighlightTheme(savedTheme);
}
// Apply theme immediately (before DOMContentLoaded to prevent flash)
applySavedTheme();
// Populate the mobile model dropdown dynamically
document.addEventListener('DOMContentLoaded', (event) => {
const modelSelectMobile = document.getElementById("model-select-mobile");
// Ensure theme button text is correct on page load
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
updateThemeButtonText(currentTheme);
// Function to populate the dropdown
function populateModelDropdown(models) {
while (modelSelectMobile.options.length > 1) {
modelSelectMobile.remove(1);
}
models.forEach(modelId => {
const option = document.createElement('option');
option.value = modelId;
option.textContent = modelId;
modelSelectMobile.appendChild(option);
});
}
// Memoization with localStorage (1-minute cache)
const cacheKey = 'modelList';
const cacheExpirationKey = 'modelListExpiration';
const cacheDuration = 60 * 1000; // 1 minute in milliseconds
const cachedData = localStorage.getItem(cacheKey);
const cachedExpiration = localStorage.getItem(cacheExpirationKey);
if (cachedData && cachedExpiration && Date.now() < parseInt(cachedExpiration)) {
const models = JSON.parse(cachedData);
populateModelDropdown(models);
} else {
fetch('/models')
.then(response => response.json())
.then(data => {
const models = data.models;
populateModelDropdown(models);
localStorage.setItem(cacheKey, JSON.stringify(models));
localStorage.setItem(cacheExpirationKey, Date.now() + cacheDuration);
})
.catch(error => console.error("Error fetching models:", error));
}
});
// Room tab switching
function switchRoomTab(tab) {
const publicTab = document.getElementById('public-rooms-tab');
const privateTab = document.getElementById('private-rooms-tab');
const publicSection = document.getElementById('public-rooms-section');
const privateSection = document.getElementById('private-rooms-section');
if (tab === 'public') {
publicTab.classList.add('active');
privateTab.classList.remove('active');
publicSection.style.display = 'block';
privateSection.style.display = 'none';
} else {
privateTab.classList.add('active');
publicTab.classList.remove('active');
privateSection.style.display = 'block';
publicSection.style.display = 'none';
}
}
// Authentication modal functions
let pendingEmail = '';
function showAuthModal() {
document.getElementById('auth-modal').style.display = 'flex';
showAuthStep('email');
}
function closeAuthModal() {
document.getElementById('auth-modal').style.display = 'none';
clearAuthErrors();
}
function closeAuthModalAndReload() {
closeAuthModal();
window.location.reload();
}
function showAuthStep(step) {
document.querySelectorAll('.auth-step').forEach(el => el.style.display = 'none');
document.getElementById('auth-step-' + step).style.display = 'block';
clearAuthErrors();
}
function clearAuthErrors() {
document.querySelectorAll('.error-message').forEach(el => el.textContent = '');
}
function backToEmailStep() {
showAuthStep('email');
}
async function sendOTP() {
const email = document.getElementById('auth-email').value.trim();
const errorEl = document.getElementById('auth-email-error');
if (!email) {
errorEl.textContent = 'Please enter your email';
return;
}
try {
const response = await fetch('/auth/send-otp', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email})
});
const data = await response.json();
if (response.ok) {
pendingEmail = email;
document.getElementById('auth-email-display').textContent = email;
showAuthStep('otp');
} else {
errorEl.textContent = data.error || 'Failed to send code';
}
} catch (error) {
errorEl.textContent = 'Network error. Please try again.';
}
}
async function verifyOTP() {
const otpCode = document.getElementById('auth-otp').value.trim();
const errorEl = document.getElementById('auth-otp-error');
if (!otpCode) {
errorEl.textContent = 'Please enter the code';
return;
}
try {
const response = await fetch('/auth/verify-otp', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email: pendingEmail, otp_code: otpCode})
});
const data = await response.json();
if (response.ok) {
if (data.needs_display_name) {
showAuthStep('name');
} else {
document.getElementById('auth-success-name').textContent = data.user.display_name;
showAuthStep('success');
}
} else {
errorEl.textContent = data.error || 'Invalid code';
}
} catch (error) {
errorEl.textContent = 'Network error. Please try again.';
}
}
async function claimName() {
const displayName = document.getElementById('auth-display-name').value.trim();
const errorEl = document.getElementById('auth-name-error');
if (!displayName) {
errorEl.textContent = 'Please enter a display name';
return;
}
try {
const response = await fetch('/auth/claim-name', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({display_name: displayName})
});
const data = await response.json();
if (response.ok) {
document.getElementById('auth-success-name').textContent = data.user.display_name;
showAuthStep('success');
} else {
errorEl.textContent = data.error || 'Failed to claim name';
}
} catch (error) {
errorEl.textContent = 'Network error. Please try again.';
}
}
// Room management functions
async function forkRoom(roomId) {
const makePrivate = confirm('Fork as private room? (Cancel for public)');
try {
const response = await fetch(`/api/rooms/${roomId}/fork`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({private: makePrivate})
});
const data = await response.json();
if (response.ok) {
window.location.href = `/chat/${data.room.name}`;
} else {
alert(data.error || 'Failed to fork room');
}
} catch (error) {
alert('Network error. Please try again.');
}
}
async function archiveRoom(roomId) {
if (!confirm('Archive this room? It will be hidden but not deleted.')) {
return;
}
try {
const response = await fetch(`/api/rooms/${roomId}/archive`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await response.json();
if (response.ok) {
alert('Room archived successfully!');
window.location.reload();
} else {
alert(data.error || 'Failed to archive room');
}
} catch (error) {
alert('Network error. Please try again.');
}
}
async function deleteRoom(roomId) {
if (!confirm('Delete this room permanently? This cannot be undone!')) {
return;
}
try {
const response = await fetch(`/api/rooms/${roomId}/delete`, {
method: 'DELETE',
headers: {'Content-Type': 'application/json'}
});
const data = await response.json();
if (response.ok) {
window.location.href = '/';
} else {
alert(data.error || 'Failed to delete room');
}
} catch (error) {
alert('Network error. Please try again.');
}
}
</script>
</body>
</html>