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>
This commit is contained in:
parent
d849ecbd7d
commit
e4cabf4be6
13 changed files with 2415 additions and 262 deletions
14
README.rst
14
README.rst
|
|
@ -20,6 +20,8 @@ Features
|
|||
- Commands to load and save code blocks to AWS S3.
|
||||
- Database storage for messages and chatrooms using SQLAlchemy.
|
||||
- Migration support with Flask-Migrate.
|
||||
- Email OTP authentication with private room support
|
||||
- Room forking, archiving, and owner management
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
|
@ -78,18 +80,26 @@ Here are some free endpoint for research only!::
|
|||
export MODEL_ENDPOINT_2=https://qwen.ai.unturf.com/v1
|
||||
export MODEL_ENDPOINT_3=https://gpt-oss.ai.unturf.com/v1
|
||||
|
||||
Optional SMTP for email OTP authentication::
|
||||
|
||||
export SMTP_HOST=smtp.gmail.com
|
||||
export SMTP_PORT=587
|
||||
export SMTP_USER=your@email.com
|
||||
export SMTP_PASSWORD=your_app_password
|
||||
|
||||
To start the application with socket.io run::
|
||||
|
||||
python app.py
|
||||
|
||||
Optionally flags ``python app.py --local-activities --profile <aws-profile-name>``::
|
||||
|
||||
usage: app.py [-h] [--profile PROFILE] [--local-activities]
|
||||
|
||||
usage: app.py [-h] [--profile PROFILE] [--local-activities] [--port PORT]
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
--profile PROFILE AWS profile name
|
||||
--local-activities Use local activity files instead of S3
|
||||
--port PORT Port number (default: 5001)
|
||||
|
||||
|
||||
The application will be available at ``http://127.0.0.1:5001`` by default.
|
||||
|
|
|
|||
474
app.py
474
app.py
|
|
@ -24,6 +24,7 @@ from flask import (
|
|||
Response,
|
||||
redirect,
|
||||
url_for,
|
||||
session,
|
||||
)
|
||||
|
||||
from flask_socketio import SocketIO, emit, join_room, leave_room
|
||||
|
|
@ -31,7 +32,7 @@ from flask_socketio import SocketIO, emit, join_room, leave_room
|
|||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy.exc import InvalidRequestError
|
||||
|
||||
from models import db, Room, UserSession, Message, ActivityState
|
||||
from models import db, Room, UserSession, Message, ActivityState, User, OTPToken
|
||||
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
|
||||
|
|
@ -57,6 +58,7 @@ cancellation_requests = {}
|
|||
|
||||
from openai import OpenAI
|
||||
import activity
|
||||
import auth
|
||||
|
||||
|
||||
# Build a list of endpoints dynamically.
|
||||
|
|
@ -299,7 +301,64 @@ def favicon():
|
|||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
# Get statistics for homepage
|
||||
total_public_rooms = Room.query.filter_by(is_private=False, is_archived=False).count()
|
||||
total_private_rooms = 0
|
||||
user = auth.get_current_user()
|
||||
if user:
|
||||
total_private_rooms = Room.query.filter_by(is_private=True, is_archived=False, owner_id=user.id).count()
|
||||
|
||||
# Active rooms - rooms with at least one message
|
||||
active_public_rooms = db.session.query(Room.id).join(Message).filter(
|
||||
Room.is_private == False,
|
||||
Room.is_archived == False
|
||||
).distinct().count()
|
||||
|
||||
# Total active users (from UserSession)
|
||||
active_users = UserSession.query.distinct(UserSession.username).count()
|
||||
|
||||
stats = {
|
||||
'total_public_rooms': total_public_rooms,
|
||||
'total_private_rooms': total_private_rooms,
|
||||
'active_public_rooms': active_public_rooms,
|
||||
'active_users': active_users,
|
||||
}
|
||||
|
||||
return render_template("index.html", stats=stats, user=user)
|
||||
|
||||
|
||||
@app.route("/auth")
|
||||
def auth_page():
|
||||
"""Authentication page"""
|
||||
return render_template("auth.html")
|
||||
|
||||
|
||||
@app.route("/browse")
|
||||
def browse_rooms():
|
||||
"""Browse all rooms (public and user's private rooms)"""
|
||||
user = auth.get_current_user()
|
||||
|
||||
# Get public rooms ordered by last updated
|
||||
public_rooms = Room.query.filter_by(
|
||||
is_private=False,
|
||||
is_archived=False
|
||||
).order_by(Room.updated_at.desc()).all()
|
||||
|
||||
# Get user's private rooms if authenticated
|
||||
private_rooms = []
|
||||
if user:
|
||||
private_rooms = Room.query.filter_by(
|
||||
is_private=True,
|
||||
is_archived=False,
|
||||
owner_id=user.id
|
||||
).order_by(Room.updated_at.desc()).all()
|
||||
|
||||
return render_template(
|
||||
"browse.html",
|
||||
public_rooms=public_rooms,
|
||||
private_rooms=private_rooms,
|
||||
user=user
|
||||
)
|
||||
|
||||
|
||||
@app.route("/models", methods=["GET"])
|
||||
|
|
@ -309,6 +368,200 @@ def get_models():
|
|||
return jsonify({"models": list(MODEL_CLIENT_MAP.keys())})
|
||||
|
||||
|
||||
# Authentication endpoints
|
||||
@app.route("/auth/send-otp", methods=["POST"])
|
||||
def send_otp():
|
||||
"""Send OTP to user's email"""
|
||||
data = request.get_json()
|
||||
email = data.get('email', '').strip().lower()
|
||||
|
||||
if not email:
|
||||
return jsonify({'error': 'Email is required'}), 400
|
||||
|
||||
# Basic email validation
|
||||
if '@' not in email or '.' not in email.split('@')[1]:
|
||||
return jsonify({'error': 'Invalid email address'}), 400
|
||||
|
||||
# Create OTP token
|
||||
otp_token = auth.create_otp_token(email)
|
||||
|
||||
# Send OTP via email
|
||||
if auth.send_otp_email(email, otp_token.otp_code):
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'OTP sent to your email',
|
||||
'email': email
|
||||
})
|
||||
else:
|
||||
return jsonify({'error': 'Failed to send OTP email'}), 500
|
||||
|
||||
|
||||
@app.route("/auth/verify-otp", methods=["POST"])
|
||||
def verify_otp():
|
||||
"""Verify OTP code and check if user exists"""
|
||||
data = request.get_json()
|
||||
email = data.get('email', '').strip().lower()
|
||||
otp_code = data.get('otp_code', '').strip()
|
||||
|
||||
if not email or not otp_code:
|
||||
return jsonify({'error': 'Email and OTP code are required'}), 400
|
||||
|
||||
# Verify OTP
|
||||
otp_token = auth.verify_otp(email, otp_code)
|
||||
if not otp_token:
|
||||
return jsonify({'error': 'Invalid or expired OTP code'}), 400
|
||||
|
||||
# Check if user exists
|
||||
user = auth.get_or_create_user(email)
|
||||
|
||||
if user:
|
||||
# Existing user - log them in
|
||||
auth.login_user(user)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'needs_display_name': False,
|
||||
'user': {
|
||||
'email': user.email,
|
||||
'display_name': user.display_name
|
||||
}
|
||||
})
|
||||
else:
|
||||
# New user - needs to claim display name
|
||||
# Store email in session temporarily
|
||||
session['pending_email'] = email
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'needs_display_name': True,
|
||||
'email': email
|
||||
})
|
||||
|
||||
|
||||
@app.route("/auth/claim-name", methods=["POST"])
|
||||
def claim_name():
|
||||
"""Claim display name for new user (after OTP verification)"""
|
||||
data = request.get_json()
|
||||
display_name = data.get('display_name', '').strip()
|
||||
email = session.get('pending_email')
|
||||
|
||||
if not email:
|
||||
return jsonify({'error': 'No pending email verification'}), 400
|
||||
|
||||
if not display_name:
|
||||
return jsonify({'error': 'Display name is required'}), 400
|
||||
|
||||
# Validate display name (alphanumeric, underscores, hyphens only, 3-50 chars)
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{3,50}$', display_name):
|
||||
return jsonify({
|
||||
'error': 'Display name must be 3-50 characters (letters, numbers, underscores, hyphens only)'
|
||||
}), 400
|
||||
|
||||
# Create user
|
||||
user, error = auth.create_user(email, display_name)
|
||||
if error:
|
||||
return jsonify({'error': error}), 400
|
||||
|
||||
# Log in user
|
||||
auth.login_user(user)
|
||||
|
||||
# Clear pending email
|
||||
session.pop('pending_email', None)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': {
|
||||
'email': user.email,
|
||||
'display_name': user.display_name
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route("/auth/status", methods=["GET"])
|
||||
def auth_status():
|
||||
"""Get current authentication status"""
|
||||
user = auth.get_current_user()
|
||||
if user:
|
||||
return jsonify({
|
||||
'authenticated': True,
|
||||
'user': {
|
||||
'email': user.email,
|
||||
'display_name': user.display_name
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({'authenticated': False})
|
||||
|
||||
|
||||
@app.route("/auth/logout", methods=["POST"])
|
||||
def logout():
|
||||
"""Log out current user"""
|
||||
auth.logout_user()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route("/profile")
|
||||
@auth.require_auth
|
||||
def profile_page():
|
||||
"""Profile settings page"""
|
||||
user = auth.get_current_user()
|
||||
return render_template("profile.html", user=user)
|
||||
|
||||
|
||||
@app.route("/api/check-username", methods=["GET"])
|
||||
def check_username():
|
||||
"""Check if username is available"""
|
||||
username = request.args.get('username', '').strip()
|
||||
|
||||
if not username:
|
||||
return jsonify({'available': False, 'error': 'Username is required'}), 400
|
||||
|
||||
# Validate format
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{3,50}$', username):
|
||||
return jsonify({'available': False, 'error': 'Invalid format'}), 400
|
||||
|
||||
# Check if username exists
|
||||
existing_user = User.query.filter_by(display_name=username).first()
|
||||
|
||||
return jsonify({'available': existing_user is None})
|
||||
|
||||
|
||||
@app.route("/api/update-username", methods=["POST"])
|
||||
@auth.require_auth
|
||||
def update_username():
|
||||
"""Update user's display name"""
|
||||
user = auth.get_current_user()
|
||||
data = request.get_json()
|
||||
new_username = data.get('new_username', '').strip()
|
||||
|
||||
if not new_username:
|
||||
return jsonify({'error': 'Username is required'}), 400
|
||||
|
||||
# Validate format
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{3,50}$', new_username):
|
||||
return jsonify({
|
||||
'error': 'Username must be 3-50 characters (letters, numbers, underscores, hyphens only)'
|
||||
}), 400
|
||||
|
||||
# Check if username is already taken
|
||||
existing_user = User.query.filter_by(display_name=new_username).first()
|
||||
if existing_user and existing_user.id != user.id:
|
||||
return jsonify({'error': 'Username is already taken'}), 400
|
||||
|
||||
# Update username
|
||||
user.display_name = new_username
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': {
|
||||
'email': user.email,
|
||||
'display_name': user.display_name
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/activities", methods=["GET"])
|
||||
def get_activities():
|
||||
"""Return the list of available activities."""
|
||||
|
|
@ -331,6 +584,185 @@ def get_activities():
|
|||
return jsonify({"activities": activities})
|
||||
|
||||
|
||||
@app.route("/api/rooms", methods=["GET"])
|
||||
def get_rooms_api():
|
||||
"""Get list of rooms (public or user's private rooms)"""
|
||||
user = auth.get_current_user()
|
||||
|
||||
# Get public rooms
|
||||
public_rooms = Room.query.filter_by(is_private=False, is_archived=False).order_by(Room.id.desc()).all()
|
||||
|
||||
# Get private rooms if authenticated
|
||||
private_rooms = []
|
||||
if user:
|
||||
private_rooms = Room.query.filter_by(is_private=True, is_archived=False, owner_id=user.id).order_by(Room.id.desc()).all()
|
||||
|
||||
return jsonify({
|
||||
'public_rooms': [{
|
||||
'id': r.id,
|
||||
'name': r.name,
|
||||
'title': r.title,
|
||||
'active_users_count': len(r.get_active_users())
|
||||
} for r in public_rooms],
|
||||
'private_rooms': [{
|
||||
'id': r.id,
|
||||
'name': r.name,
|
||||
'title': r.title,
|
||||
'active_users_count': len(r.get_active_users())
|
||||
} for r in private_rooms]
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/rooms/create", methods=["POST"])
|
||||
def create_room_api():
|
||||
"""Create a new room"""
|
||||
user = auth.get_current_user()
|
||||
data = request.get_json() or {}
|
||||
room_name = data.get('name', '').strip()
|
||||
is_private = data.get('is_private', False)
|
||||
|
||||
if not room_name:
|
||||
return jsonify({'error': 'Room name is required'}), 400
|
||||
|
||||
# Private rooms require authentication
|
||||
if is_private and not user:
|
||||
return jsonify({'error': 'Authentication required to create private rooms'}), 401
|
||||
|
||||
# Check if room already exists
|
||||
existing_room = Room.query.filter_by(name=room_name).first()
|
||||
if existing_room:
|
||||
return jsonify({'error': 'Room name already exists'}), 400
|
||||
|
||||
# Create room
|
||||
new_room = Room()
|
||||
new_room.name = room_name
|
||||
new_room.is_private = is_private
|
||||
new_room.owner_id = user.id if user else None
|
||||
|
||||
db.session.add(new_room)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'room': {
|
||||
'id': new_room.id,
|
||||
'name': new_room.name,
|
||||
'is_private': new_room.is_private
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/rooms/<int:room_id>/fork", methods=["POST"])
|
||||
def fork_room(room_id):
|
||||
"""Fork a room (authenticated users can fork to private or public)"""
|
||||
user = auth.get_current_user()
|
||||
data = request.get_json() or {}
|
||||
make_private = data.get('private', False)
|
||||
|
||||
# Get source room
|
||||
source_room = Room.query.get(room_id)
|
||||
if not source_room:
|
||||
return jsonify({'error': 'Room not found'}), 404
|
||||
|
||||
# Private rooms can only be forked by their owner
|
||||
if source_room.is_private:
|
||||
if not user or source_room.owner_id != user.id:
|
||||
return jsonify({'error': 'Cannot fork private rooms you do not own'}), 403
|
||||
|
||||
# Private rooms require authentication
|
||||
if make_private and not user:
|
||||
return jsonify({'error': 'Authentication required to create private rooms'}), 401
|
||||
|
||||
# Generate new room name
|
||||
base_name = f"{source_room.name}_fork"
|
||||
new_name = base_name
|
||||
counter = 1
|
||||
while Room.query.filter_by(name=new_name).first():
|
||||
new_name = f"{base_name}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# Create forked room
|
||||
new_room = Room()
|
||||
new_room.name = new_name
|
||||
new_room.title = f"Fork of {source_room.title or source_room.name}"
|
||||
new_room.is_private = make_private
|
||||
new_room.owner_id = user.id if user else None
|
||||
new_room.forked_from_id = source_room.id
|
||||
|
||||
db.session.add(new_room)
|
||||
db.session.commit()
|
||||
|
||||
# Copy messages from source room
|
||||
source_messages = Message.query.filter_by(room_id=source_room.id).all()
|
||||
for msg in source_messages:
|
||||
new_msg = Message(
|
||||
username=msg.username,
|
||||
content=msg.content,
|
||||
room_id=new_room.id
|
||||
)
|
||||
db.session.add(new_msg)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'room': {
|
||||
'id': new_room.id,
|
||||
'name': new_room.name,
|
||||
'title': new_room.title,
|
||||
'is_private': new_room.is_private
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/rooms/<int:room_id>/archive", methods=["POST"])
|
||||
@auth.require_auth
|
||||
def archive_room(room_id):
|
||||
"""Archive a room (owner only)"""
|
||||
user = auth.get_current_user()
|
||||
room = Room.query.get(room_id)
|
||||
|
||||
if not room:
|
||||
return jsonify({'error': 'Room not found'}), 404
|
||||
|
||||
if room.owner_id != user.id:
|
||||
return jsonify({'error': 'Only room owner can archive rooms'}), 403
|
||||
|
||||
room.is_archived = True
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route("/api/rooms/<int:room_id>/delete", methods=["DELETE"])
|
||||
@auth.require_auth
|
||||
def delete_room(room_id):
|
||||
"""Delete a room (owner only)"""
|
||||
user = auth.get_current_user()
|
||||
room = Room.query.get(room_id)
|
||||
|
||||
if not room:
|
||||
return jsonify({'error': 'Room not found'}), 404
|
||||
|
||||
if room.owner_id != user.id:
|
||||
return jsonify({'error': 'Only room owner can delete rooms'}), 403
|
||||
|
||||
# Delete all messages in the room
|
||||
Message.query.filter_by(room_id=room.id).delete()
|
||||
|
||||
# Delete activity state if any
|
||||
ActivityState.query.filter_by(room_id=room.id).delete()
|
||||
|
||||
# Delete user sessions
|
||||
UserSession.query.filter_by(room_id=room.id).delete()
|
||||
|
||||
# Delete the room
|
||||
db.session.delete(room)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route("/api/generate-artifact-name", methods=["POST"])
|
||||
def generate_artifact_name():
|
||||
"""Generate a meaningful filename for an artifact using AI.
|
||||
|
|
@ -410,15 +842,35 @@ Examples:
|
|||
|
||||
@app.route("/chat/<room_name>")
|
||||
def chat(room_name):
|
||||
# Query all rooms so that newest is first.
|
||||
rooms = Room.query.order_by(Room.id.desc()).all()
|
||||
user = auth.get_current_user()
|
||||
|
||||
# Get username from query parameters
|
||||
username = request.args.get("username", "guest")
|
||||
# Get or create the room
|
||||
room = Room.query.filter_by(name=room_name).first()
|
||||
|
||||
# Pass username and rooms into the template
|
||||
# If room doesn't exist yet, it will be created in get_room() when user joins
|
||||
# But check if they're trying to access a private room they don't own
|
||||
if room and room.is_private:
|
||||
if not user or room.owner_id != user.id:
|
||||
return "Access denied: This is a private room", 403
|
||||
|
||||
# Query public rooms and user's private rooms for sidebar
|
||||
public_rooms = Room.query.filter_by(is_private=False, is_archived=False).order_by(Room.id.desc()).all()
|
||||
private_rooms = []
|
||||
if user:
|
||||
private_rooms = Room.query.filter_by(is_private=True, is_archived=False, owner_id=user.id).order_by(Room.id.desc()).all()
|
||||
|
||||
# Use authenticated user's display name, or None (will prompt on client side)
|
||||
username = user.display_name if user else None
|
||||
|
||||
# Pass username, rooms, room (current room), and user into the template
|
||||
return render_template(
|
||||
"chat.html", room_name=room_name, rooms=rooms, username=username
|
||||
"chat.html",
|
||||
room_name=room_name,
|
||||
current_room=room,
|
||||
public_rooms=public_rooms,
|
||||
private_rooms=private_rooms,
|
||||
username=username,
|
||||
user=user
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -737,6 +1189,12 @@ def handle_message(data):
|
|||
room_id=room.id,
|
||||
)
|
||||
db.session.add(new_message)
|
||||
|
||||
# Update room's updated_at timestamp (Unix epoch)
|
||||
from datetime import datetime
|
||||
room.updated_at = int(datetime.utcnow().timestamp())
|
||||
db.session.add(room)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
emit(
|
||||
|
|
|
|||
195
auth.py
Normal file
195
auth.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""Authentication module for email OTP-based authentication"""
|
||||
|
||||
import os
|
||||
import random
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
|
||||
from flask import session, jsonify, request
|
||||
from models import db, User, OTPToken
|
||||
|
||||
|
||||
def generate_otp():
|
||||
"""Generate a 6-digit OTP code"""
|
||||
return ''.join([str(random.randint(0, 9)) for _ in range(6)])
|
||||
|
||||
|
||||
def send_otp_email(email, otp_code):
|
||||
"""Send OTP code to user's email via SMTP
|
||||
|
||||
Requires environment variables:
|
||||
- SMTP_HOST: SMTP server hostname (e.g., smtp.gmail.com)
|
||||
- SMTP_PORT: SMTP server port (e.g., 587)
|
||||
- SMTP_USER: SMTP username/email
|
||||
- SMTP_PASSWORD: SMTP password or app-specific password
|
||||
- SMTP_FROM_EMAIL: Email address to send from
|
||||
- SMTP_FROM_NAME: Display name for sender
|
||||
"""
|
||||
smtp_host = os.environ.get('SMTP_HOST', 'localhost')
|
||||
smtp_port = int(os.environ.get('SMTP_PORT', '587'))
|
||||
smtp_user = os.environ.get('SMTP_USER')
|
||||
smtp_password = os.environ.get('SMTP_PASSWORD')
|
||||
from_email = os.environ.get('SMTP_FROM_EMAIL', smtp_user)
|
||||
from_name = os.environ.get('SMTP_FROM_NAME', 'OpenCompletion')
|
||||
|
||||
if not smtp_user or not smtp_password:
|
||||
print("[WARNING] SMTP not configured. OTP code:", otp_code)
|
||||
print(f"[WARNING] To enable email, set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD")
|
||||
# In development, still return success and print OTP
|
||||
return True
|
||||
|
||||
# Create message
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = f'Your OpenCompletion verification code: {otp_code}'
|
||||
msg['From'] = f'{from_name} <{from_email}>'
|
||||
msg['To'] = email
|
||||
|
||||
# Plain text version
|
||||
text = f"""
|
||||
Your OpenCompletion verification code is: {otp_code}
|
||||
|
||||
This code will expire in 10 minutes.
|
||||
|
||||
If you didn't request this code, you can safely ignore this email.
|
||||
"""
|
||||
|
||||
# HTML version
|
||||
html = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
||||
<h2>Your OpenCompletion Verification Code</h2>
|
||||
<p>Enter this code to complete your authentication:</p>
|
||||
<h1 style="background-color: #f0f0f0; padding: 15px; text-align: center; letter-spacing: 5px;">
|
||||
{otp_code}
|
||||
</h1>
|
||||
<p style="color: #666;">This code will expire in 10 minutes.</p>
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
If you didn't request this code, you can safely ignore this email.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# Attach both versions
|
||||
msg.attach(MIMEText(text, 'plain'))
|
||||
msg.attach(MIMEText(html, 'html'))
|
||||
|
||||
try:
|
||||
# Send via SMTP
|
||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(smtp_user, smtp_password)
|
||||
server.send_message(msg)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to send OTP email: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_otp_token(email):
|
||||
"""Create and store an OTP token for the given email"""
|
||||
# Invalidate any existing unused OTP tokens for this email
|
||||
existing_tokens = OTPToken.query.filter_by(email=email, used=False).all()
|
||||
for token in existing_tokens:
|
||||
token.used = True
|
||||
|
||||
# Generate new OTP
|
||||
otp_code = generate_otp()
|
||||
otp_token = OTPToken(email=email, otp_code=otp_code)
|
||||
|
||||
db.session.add(otp_token)
|
||||
db.session.commit()
|
||||
|
||||
return otp_token
|
||||
|
||||
|
||||
def verify_otp(email, otp_code):
|
||||
"""Verify an OTP code for the given email
|
||||
|
||||
Returns:
|
||||
- OTPToken object if valid
|
||||
- None if invalid
|
||||
"""
|
||||
otp_token = OTPToken.query.filter_by(
|
||||
email=email,
|
||||
otp_code=otp_code,
|
||||
used=False
|
||||
).first()
|
||||
|
||||
if otp_token and otp_token.is_valid():
|
||||
# Mark as used
|
||||
otp_token.used = True
|
||||
db.session.commit()
|
||||
return otp_token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_or_create_user(email):
|
||||
"""Get existing user by email or return None if doesn't exist"""
|
||||
return User.query.filter_by(email=email).first()
|
||||
|
||||
|
||||
def create_user(email, display_name):
|
||||
"""Create a new user with email and display name"""
|
||||
# Check if display name is already taken
|
||||
existing_user = User.query.filter_by(display_name=display_name).first()
|
||||
if existing_user:
|
||||
return None, "Display name already taken"
|
||||
|
||||
# Check if email already exists
|
||||
existing_email = User.query.filter_by(email=email).first()
|
||||
if existing_email:
|
||||
return None, "Email already registered"
|
||||
|
||||
user = User(email=email, display_name=display_name)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
return user, None
|
||||
|
||||
|
||||
def login_user(user):
|
||||
"""Create session for authenticated user"""
|
||||
session['user_id'] = user.id
|
||||
session['user_email'] = user.email
|
||||
session['display_name'] = user.display_name
|
||||
session.permanent = True # Use permanent session
|
||||
|
||||
# Update last login
|
||||
user.last_login = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def logout_user():
|
||||
"""Clear user session"""
|
||||
session.pop('user_id', None)
|
||||
session.pop('user_email', None)
|
||||
session.pop('display_name', None)
|
||||
|
||||
|
||||
def get_current_user():
|
||||
"""Get currently authenticated user from session"""
|
||||
user_id = session.get('user_id')
|
||||
if user_id:
|
||||
return User.query.get(user_id)
|
||||
return None
|
||||
|
||||
|
||||
def require_auth(f):
|
||||
"""Decorator to require authentication for a route"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def is_authenticated():
|
||||
"""Check if current request is authenticated"""
|
||||
return 'user_id' in session
|
||||
58
migrations/versions/2025011100_add_auth_system.py
Normal file
58
migrations/versions/2025011100_add_auth_system.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Add authentication system with User, OTPToken models and Room ownership fields
|
||||
|
||||
Revision ID: 2025011100
|
||||
Revises: 5d93cdf18549
|
||||
Create Date: 2025-01-11 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import sqlite
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2025011100"
|
||||
down_revision = "5d93cdf18549"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add new columns to Room table
|
||||
# Note: User and OTPToken tables are created by db.create_all() in make init-db
|
||||
# Check if columns exist before adding (in case db.create_all() was run first)
|
||||
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('room')]
|
||||
|
||||
if 'is_private' not in columns:
|
||||
op.add_column('room', sa.Column('is_private', sa.Boolean(), nullable=False, server_default='0'))
|
||||
|
||||
if 'is_archived' not in columns:
|
||||
op.add_column('room', sa.Column('is_archived', sa.Boolean(), nullable=False, server_default='0'))
|
||||
|
||||
if 'owner_id' not in columns:
|
||||
op.add_column('room', sa.Column('owner_id', sa.Integer(), nullable=True))
|
||||
|
||||
if 'created_at' not in columns:
|
||||
op.add_column('room', sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')))
|
||||
|
||||
if 'forked_from_id' not in columns:
|
||||
op.add_column('room', sa.Column('forked_from_id', sa.Integer(), nullable=True))
|
||||
|
||||
# Create indexes (check if they exist first)
|
||||
indexes = [idx['name'] for idx in inspector.get_indexes('room')]
|
||||
|
||||
if 'ix_room_is_private' not in indexes:
|
||||
op.create_index(op.f('ix_room_is_private'), 'room', ['is_private'], unique=False)
|
||||
|
||||
if 'ix_room_is_archived' not in indexes:
|
||||
op.create_index(op.f('ix_room_is_archived'), 'room', ['is_archived'], unique=False)
|
||||
|
||||
if 'ix_room_owner_id' not in indexes:
|
||||
op.create_index(op.f('ix_room_owner_id'), 'room', ['owner_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
28
migrations/versions/5d0d533ff7c0_add_updated_at_to_room.py
Normal file
28
migrations/versions/5d0d533ff7c0_add_updated_at_to_room.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""add_updated_at_to_room
|
||||
|
||||
Revision ID: 5d0d533ff7c0
|
||||
Revises: 2025011100
|
||||
Create Date: 2025-11-11 21:53:13.141580
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '5d0d533ff7c0'
|
||||
down_revision = '2025011100'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add updated_at column to room table (Unix timestamp as integer)
|
||||
with op.batch_alter_table('room', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('updated_at', sa.Integer(), nullable=False, server_default=sa.text('(strftime(\'%s\', \'now\'))')))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Remove updated_at column from room table
|
||||
with op.batch_alter_table('room', schema=None) as batch_op:
|
||||
batch_op.drop_column('updated_at')
|
||||
46
models.py
46
models.py
|
|
@ -1,4 +1,5 @@
|
|||
from flask_sqlalchemy import SQLAlchemy
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import tiktoken
|
||||
|
||||
|
|
@ -7,12 +8,57 @@ import json
|
|||
db = SQLAlchemy()
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
"""User model for authentication and ownership"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
display_name = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
last_login = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
owned_rooms = db.relationship('Room', backref='owner', lazy='dynamic', foreign_keys='Room.owner_id')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.display_name} ({self.email})>'
|
||||
|
||||
|
||||
class OTPToken(db.Model):
|
||||
"""One-Time Password tokens for email authentication"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(255), nullable=False, index=True)
|
||||
otp_code = db.Column(db.String(6), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
expires_at = db.Column(db.DateTime, nullable=False)
|
||||
used = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
def __init__(self, email, otp_code, expiration_minutes=10):
|
||||
self.email = email
|
||||
self.otp_code = otp_code
|
||||
self.created_at = datetime.utcnow()
|
||||
self.expires_at = self.created_at + timedelta(minutes=expiration_minutes)
|
||||
self.used = False
|
||||
|
||||
def is_valid(self):
|
||||
"""Check if the OTP is still valid (not used and not expired)"""
|
||||
return not self.used and datetime.utcnow() < self.expires_at
|
||||
|
||||
def __repr__(self):
|
||||
return f'<OTPToken {self.email} expires_at={self.expires_at}>'
|
||||
|
||||
|
||||
class Room(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False, unique=True)
|
||||
title = db.Column(db.String(128), nullable=True)
|
||||
active_users = db.Column(db.Text, default="") # Store as a comma-separated string
|
||||
inactive_users = db.Column(db.Text, default="") # Store as a comma-separated string
|
||||
is_private = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
is_archived = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True, index=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.Integer, default=lambda: int(datetime.utcnow().timestamp()), nullable=False)
|
||||
forked_from_id = db.Column(db.Integer, db.ForeignKey('room.id'), nullable=True)
|
||||
|
||||
def add_user(self, username):
|
||||
active_users = set(self.active_users.split(",")) if self.active_users else set()
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ p {
|
|||
/* Styling for the main container that holds the rooms list and chat */
|
||||
.main-container {
|
||||
display: grid;
|
||||
grid-template-columns: 15% 70% 15%;
|
||||
grid-template-columns: 15% 60% 25%;
|
||||
width: 100%;
|
||||
height: 90vh;
|
||||
}
|
||||
|
|
@ -621,3 +621,37 @@ a:hover {
|
|||
[data-theme="dark"] * {
|
||||
scrollbar-color: #4a4a4a var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Room tabs styling */
|
||||
#room-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
margin-bottom: 15px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.room-tab {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.room-tab:hover {
|
||||
background-color: var(--highlight-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.room-tab.active {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--button-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
|
|||
290
templates/auth.html
Normal file
290
templates/auth.html
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign In - OpenCompletion</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.auth-container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 10px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.logo h1 {
|
||||
color: #667eea;
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
}
|
||||
.logo p {
|
||||
color: #666;
|
||||
margin: 5px 0 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.auth-step {
|
||||
display: none;
|
||||
}
|
||||
.auth-step.active {
|
||||
display: block;
|
||||
}
|
||||
.auth-step h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-step p {
|
||||
color: #666;
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
.auth-step input {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
margin-bottom: 15px;
|
||||
border: 2px solid #e1e1e1;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.auth-step input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
.auth-step button {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.auth-step button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.auth-step button.secondary-btn {
|
||||
background: #6c757d;
|
||||
}
|
||||
.error-message {
|
||||
color: #dc3545;
|
||||
font-size: 14px;
|
||||
margin-top: -10px;
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
.back-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.back-link a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
.back-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.success-icon {
|
||||
text-align: center;
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<div class="logo">
|
||||
<h1>🚀 OpenCompletion</h1>
|
||||
<p>Machine Learning Powered Collaboration</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Enter email -->
|
||||
<div id="auth-step-email" class="auth-step active">
|
||||
<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">
|
||||
<h2>Enter Verification Code</h2>
|
||||
<p>We sent a 6-digit code to <strong><span id="auth-email-display"></span></strong></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">
|
||||
<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">
|
||||
<div class="success-icon">✅</div>
|
||||
<h2>Success!</h2>
|
||||
<p>Welcome, <strong><span id="auth-success-name"></span></strong>!</p>
|
||||
<button onclick="redirectToChatRooms()">Go to Chat Rooms</button>
|
||||
</div>
|
||||
|
||||
<div class="back-link">
|
||||
<a href="/">← Back to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let pendingEmail = '';
|
||||
|
||||
function showAuthStep(step) {
|
||||
document.querySelectorAll('.auth-step').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('auth-step-' + step).classList.add('active');
|
||||
clearAuthErrors();
|
||||
}
|
||||
|
||||
function clearAuthErrors() {
|
||||
document.querySelectorAll('.error-message').forEach(el => el.textContent = '');
|
||||
}
|
||||
|
||||
function backToEmailStep() {
|
||||
showAuthStep('email');
|
||||
}
|
||||
|
||||
function redirectToChatRooms() {
|
||||
window.location.href = '/chat/general';
|
||||
}
|
||||
|
||||
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.';
|
||||
}
|
||||
}
|
||||
|
||||
// Allow Enter key to submit on each step
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('auth-email').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') sendOTP();
|
||||
});
|
||||
document.getElementById('auth-otp').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') verifyOTP();
|
||||
});
|
||||
document.getElementById('auth-display-name').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') claimName();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -43,7 +43,6 @@
|
|||
<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 }}">
|
||||
<input type="hidden" id="username" name="username" value="{{ username }}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
@ -55,10 +54,6 @@
|
|||
<div id="room-list-modal-content">
|
||||
<button id="close-modal-button" onclick="closeModal()">×</button>
|
||||
<div id="utility-belt-mobile" class="utility-belt">
|
||||
<div id="username-chooser-mobile">
|
||||
<label for="username-input-mobile">Username:</label>
|
||||
<input type="text" id="username-input-mobile" placeholder="guest" maxlength="50">
|
||||
</div>
|
||||
<div id="model-chooser-mobile">
|
||||
<label for="model-select-mobile">Choose Model:</label>
|
||||
<select id="model-select-mobile">
|
||||
|
|
@ -128,58 +123,137 @@
|
|||
<!-- Chatroom list -->
|
||||
<div class="main-container">
|
||||
<div id="rooms-list">
|
||||
<!-- opencompletion.com button -->
|
||||
<div id="site-header">
|
||||
<button id="opencompletion-btn" onclick="window.open('https://opencompletion.com', '_blank')">
|
||||
opencompletion.com
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New room creation section -->
|
||||
<div id="new-room-section">
|
||||
<textarea id="new-room-name" placeholder="Enter room name..." rows="2" maxlength="100"></textarea>
|
||||
<button id="create-room-btn" onclick="createNewRoom()">Create Room</button>
|
||||
</div>
|
||||
|
||||
<!-- Public rooms header -->
|
||||
<div id="public-rooms-header">
|
||||
<h4>Public Rooms</h4>
|
||||
</div>
|
||||
|
||||
<ul id="rooms-list-ul">
|
||||
<!-- Loop through rooms and create list items for each room -->
|
||||
{% for room in rooms %}
|
||||
<a href="{{ url_for('chat', room_name=room.name) }}?{{ request.query_string.decode('utf-8')|safe }}">
|
||||
<li data-room-id="{{ room.id }}">
|
||||
<!-- 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 %}
|
||||
</li>
|
||||
<!-- 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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</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;
|
||||
const username = document.getElementById("username").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)}&username=${username}`;
|
||||
window.location.href = `/search?keywords=${encodeURIComponent(keywords)}`;
|
||||
}
|
||||
|
||||
// Add event listener for keyword search the "Enter" key
|
||||
|
|
@ -195,20 +269,16 @@
|
|||
const modal = document.getElementById("room-list-modal");
|
||||
const modalContent = document.getElementById("room-list-modal-content");
|
||||
const closeButton = document.getElementById("close-modal-button");
|
||||
|
||||
// Clone the site header, new room section, public rooms header, and rooms list
|
||||
const siteHeader = document.getElementById("site-header").cloneNode(true);
|
||||
const newRoomSection = document.getElementById("new-room-section").cloneNode(true);
|
||||
const publicRoomsHeader = document.getElementById("public-rooms-header").cloneNode(true);
|
||||
|
||||
// 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(siteHeader);
|
||||
document.getElementById("rooms-list-modal-content").appendChild(newRoomSection);
|
||||
document.getElementById("rooms-list-modal-content").appendChild(publicRoomsHeader);
|
||||
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";
|
||||
|
|
@ -224,80 +294,11 @@
|
|||
closeButton.style.display = "none";
|
||||
}
|
||||
|
||||
// Function to update all room links with current URL parameters
|
||||
// Function to update all room links (no query parameters needed)
|
||||
function updateRoomLinksWithCurrentParams() {
|
||||
const currentParams = new URLSearchParams(window.location.search).toString();
|
||||
|
||||
// Update desktop room links
|
||||
const roomLinks = document.querySelectorAll('#rooms-list-ul a[href*="/chat/"]');
|
||||
roomLinks.forEach(link => {
|
||||
const url = new URL(link.href, window.location.origin);
|
||||
const roomPath = url.pathname; // e.g., "/chat/room-name"
|
||||
link.href = roomPath + (currentParams ? '?' + currentParams : '');
|
||||
});
|
||||
|
||||
// Update mobile room links (if they exist)
|
||||
const mobileRoomLinks = document.querySelectorAll('#rooms-list-modal-content a[href*="/chat/"]');
|
||||
mobileRoomLinks.forEach(link => {
|
||||
const url = new URL(link.href, window.location.origin);
|
||||
const roomPath = url.pathname; // e.g., "/chat/room-name"
|
||||
link.href = roomPath + (currentParams ? '?' + currentParams : '');
|
||||
});
|
||||
// No longer needed - links don't use username in query string
|
||||
// Keeping function for compatibility
|
||||
}
|
||||
|
||||
// Function to create a new room
|
||||
function createNewRoom() {
|
||||
const roomName = document.getElementById("new-room-name").value.trim();
|
||||
|
||||
if (!roomName) {
|
||||
alert("Please enter a room name.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Slugify the room name to make it URL-friendly
|
||||
const slugifiedRoomName = slugify(roomName);
|
||||
|
||||
if (!slugifiedRoomName) {
|
||||
alert("Please enter a valid room name with at least some letters or numbers.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current URL parameters to maintain username, model, voice, etc.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// Also check localStorage for model and voice if not in URL
|
||||
if (!urlParams.has('model')) {
|
||||
const storedModel = localStorage.getItem('selectedModel');
|
||||
if (storedModel && storedModel !== 'None') {
|
||||
urlParams.set('model', storedModel);
|
||||
}
|
||||
}
|
||||
|
||||
if (!urlParams.has('voice')) {
|
||||
const storedVoice = localStorage.getItem('selectedVoice');
|
||||
if (storedVoice) {
|
||||
urlParams.set('voice', storedVoice);
|
||||
}
|
||||
}
|
||||
|
||||
const currentParams = urlParams.toString();
|
||||
|
||||
// Navigate to the new room using the slugified name
|
||||
window.location.href = `/chat/${slugifiedRoomName}${currentParams ? '?' + currentParams : ''}`;
|
||||
}
|
||||
|
||||
// Add event listener for Enter key in the new room textarea
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const newRoomTextarea = document.getElementById("new-room-name");
|
||||
if (newRoomTextarea) {
|
||||
newRoomTextarea.addEventListener("keydown", function(event) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
createNewRoom();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listener to the hamburger button
|
||||
document.getElementById("hamburger-button").addEventListener("click", openModal);
|
||||
|
|
@ -402,6 +403,220 @@
|
|||
.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>
|
||||
|
|
|
|||
291
templates/browse.html
Normal file
291
templates/browse.html
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Browse Rooms - OpenCompletion</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f7f7f7;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
}
|
||||
.room-tabs {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
border-bottom: 2px solid #e1e1e1;
|
||||
}
|
||||
.room-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.room-tab:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
.room-tab.active {
|
||||
color: #667eea;
|
||||
border-bottom-color: #667eea;
|
||||
font-weight: bold;
|
||||
}
|
||||
.rooms-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.room-section {
|
||||
display: none;
|
||||
}
|
||||
.room-section.active {
|
||||
display: block;
|
||||
}
|
||||
.rooms-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.room-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
.room-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.room-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.room-name {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin: 0 0 5px 0;
|
||||
}
|
||||
.room-badge {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: #e7f3ff;
|
||||
color: #0066cc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.room-badge.private {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
.room-title {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 8px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.room-meta {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
.room-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
.empty-state h3 {
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.auth-prompt {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.auth-prompt button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.rooms-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🚀 Browse Rooms</h1>
|
||||
<div class="header-actions">
|
||||
<a href="/" class="btn">🏠 Home</a>
|
||||
{% if user %}
|
||||
<a href="/profile" class="btn btn-secondary">👤 {{ user.display_name }}</a>
|
||||
{% else %}
|
||||
<a href="/auth" class="btn btn-secondary">🔐 Sign In</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="room-tabs">
|
||||
<button class="room-tab active" onclick="switchTab('public')">
|
||||
🌍 Public Rooms
|
||||
</button>
|
||||
<button class="room-tab" onclick="switchTab('private')">
|
||||
🔐 Private Rooms
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rooms-container">
|
||||
<!-- Public Rooms -->
|
||||
<div id="public-section" class="room-section active">
|
||||
{% if public_rooms %}
|
||||
<div class="rooms-grid">
|
||||
{% for room in public_rooms %}
|
||||
<a href="{{ url_for('chat', room_name=room.name) }}" class="room-card">
|
||||
<div class="room-card-header">
|
||||
<h3 class="room-name">{{ room.name }}</h3>
|
||||
<span class="room-badge">Public</span>
|
||||
</div>
|
||||
{% if room.title %}
|
||||
<p class="room-title">{{ room.title }}</p>
|
||||
{% endif %}
|
||||
<div class="room-meta">
|
||||
<span class="room-meta-item">
|
||||
👥 {{ room.get_active_users()|length }} active
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>No public rooms yet</h3>
|
||||
<p>Be the first to create one!</p>
|
||||
<a href="/" class="btn">Create a Room</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Private Rooms -->
|
||||
<div id="private-section" class="room-section">
|
||||
{% if user %}
|
||||
{% if private_rooms %}
|
||||
<div class="rooms-grid">
|
||||
{% for room in private_rooms %}
|
||||
<a href="{{ url_for('chat', room_name=room.name) }}" class="room-card">
|
||||
<div class="room-card-header">
|
||||
<h3 class="room-name">{{ room.name }}</h3>
|
||||
<span class="room-badge private">Private</span>
|
||||
</div>
|
||||
{% if room.title %}
|
||||
<p class="room-title">{{ room.title }}</p>
|
||||
{% endif %}
|
||||
<div class="room-meta">
|
||||
<span class="room-meta-item">
|
||||
👥 {{ room.get_active_users()|length }} active
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<h3>No private rooms yet</h3>
|
||||
<p>Create your first private room!</p>
|
||||
<a href="/" class="btn">Create a Room</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="auth-prompt">
|
||||
<h3>🔒 Private rooms are only visible to you</h3>
|
||||
<p>Sign in to create and access your private rooms</p>
|
||||
<a href="/auth" class="btn">Sign In / Sign Up</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(tab) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.room-tab').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Update sections
|
||||
document.querySelectorAll('.room-section').forEach(section => {
|
||||
section.classList.remove('active');
|
||||
});
|
||||
document.getElementById(tab + '-section').classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -14,16 +14,25 @@
|
|||
</div>
|
||||
|
||||
<div class="utility-belt">
|
||||
<!-- Room Actions -->
|
||||
{% if current_room %}
|
||||
<div class="room-actions-section" style="margin-bottom: 15px;">
|
||||
<h3 style="margin-top: 0; margin-bottom: 10px;">Room Actions</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||
<button class="room-action-btn" onclick="forkRoom({{ current_room.id }})" style="width: 100%;">🍴 Fork</button>
|
||||
{% if user and current_room.owner_id == user.id %}
|
||||
<button class="room-action-btn delete-btn" onclick="deleteRoom({{ current_room.id }})" style="width: 100%;">🗑️ Delete</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="download-links">
|
||||
History
|
||||
<a href="/download_chat_history?room_name={{ room_name }}" download="{{ room_name }}.json">JSON</a> or
|
||||
<a href="/download_chat_history_md?room_name={{ room_name }}" download="{{ room_name }}.md">Markdown</a>
|
||||
</div>
|
||||
<br>
|
||||
<div>
|
||||
<label for="username-input">Username</label>
|
||||
<input type="text" id="username-input" placeholder="guest" maxlength="50">
|
||||
</div>
|
||||
<div>
|
||||
<label for="model-select">Model</label>
|
||||
<select id="model-select">
|
||||
|
|
@ -87,17 +96,16 @@ const API_KEY = "dummy-api-key";
|
|||
const TTS_API_URL = "https://speech.ai.unturf.com/v1/audio/speech";
|
||||
const VOICES_API_URL = "https://speech.ai.unturf.com/v1/voices";
|
||||
const CODE_EXEC_URL = "https://code.ai.unturf.com"; // Code execution service URL (served via Caddy)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let username = urlParams.get("username") || "guest"; // Default to "guest" if no username in URL
|
||||
|
||||
// If no username was in the URL, add it now
|
||||
if (!urlParams.get("username")) {
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.set("username", username);
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
const room_name = "{{ room_name }}";
|
||||
|
||||
// Get username from server (authenticated user's display name or None)
|
||||
let username = {% if username %}"{{ username }}"{% else %}null{% endif %};
|
||||
|
||||
// If not authenticated, prompt for username
|
||||
if (!username) {
|
||||
username = prompt("Enter your username:", "guest") || "guest";
|
||||
}
|
||||
|
||||
// Configuration for DOMPurify to specify which tags and attributes are allowed
|
||||
const dompurify_config = {
|
||||
ADD_TAGS: ["iframe", "img", "video"],
|
||||
|
|
@ -144,28 +152,17 @@ function copyMessageContent(content) {
|
|||
});
|
||||
}
|
||||
|
||||
// Function to sync all inputs and update the query string
|
||||
function syncInputsAndQueryString() {
|
||||
const usernameInputDesktop = document.getElementById("username-input");
|
||||
const usernameInputMobile = document.getElementById("username-input-mobile");
|
||||
// Function to sync dropdowns and save to localStorage
|
||||
function syncDropdownsAndQueryString() {
|
||||
const modelSelectDesktop = document.getElementById("model-select");
|
||||
const voiceSelectDesktop = document.getElementById("voice-select");
|
||||
const modelSelectMobile = document.getElementById("model-select-mobile");
|
||||
const voiceSelectMobile = document.getElementById("voice-select-mobile");
|
||||
|
||||
// Get current values
|
||||
const currentUsername = usernameInputDesktop?.value || username || "guest";
|
||||
const currentModel = modelSelectDesktop.value;
|
||||
const currentVoice = voiceSelectDesktop.value;
|
||||
|
||||
// Update global username variable
|
||||
username = currentUsername;
|
||||
const sanitizedUsername = sanitizeUsername(username);
|
||||
|
||||
// Sync username inputs
|
||||
if (usernameInputDesktop) usernameInputDesktop.value = sanitizedUsername;
|
||||
if (usernameInputMobile) usernameInputMobile.value = sanitizedUsername;
|
||||
|
||||
// Sync dropdowns
|
||||
modelSelectDesktop.value = currentModel;
|
||||
voiceSelectDesktop.value = currentVoice;
|
||||
|
|
@ -175,23 +172,6 @@ function syncInputsAndQueryString() {
|
|||
// Save to localStorage for persistence
|
||||
localStorage.setItem('selectedModel', currentModel);
|
||||
localStorage.setItem('selectedVoice', currentVoice);
|
||||
|
||||
// Update URL
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.set("username", sanitizedUsername);
|
||||
newUrl.searchParams.set("model", currentModel);
|
||||
newUrl.searchParams.set("voice", currentVoice);
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
|
||||
// Update room links with new parameters
|
||||
if (typeof updateRoomLinksWithCurrentParams === 'function') {
|
||||
updateRoomLinksWithCurrentParams();
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility
|
||||
function syncDropdownsAndQueryString() {
|
||||
syncInputsAndQueryString();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', (event) => {
|
||||
|
|
@ -217,10 +197,9 @@ document.addEventListener('DOMContentLoaded', (event) => {
|
|||
option.textContent = modelId;
|
||||
modelSelectDesktop.appendChild(option);
|
||||
});
|
||||
// Set initial value from URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const initialModel = urlParams.get("model") || "None";
|
||||
modelSelectDesktop.value = initialModel;
|
||||
// Set initial value from localStorage only (not URL)
|
||||
const storedModel = localStorage.getItem('selectedModel') || "None";
|
||||
modelSelectDesktop.value = storedModel;
|
||||
}
|
||||
|
||||
// Function to populate the voice dropdown
|
||||
|
|
@ -256,9 +235,8 @@ document.addEventListener('DOMContentLoaded', (event) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Set initial value from URL, localStorage, or first available option
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const initialVoice = urlParams.get("voice") || localStorage.getItem('selectedVoice') || voiceSelectDesktop.options[0]?.value;
|
||||
// Set initial value from localStorage or first available option (not URL)
|
||||
const initialVoice = localStorage.getItem('selectedVoice') || voiceSelectDesktop.options[0]?.value;
|
||||
if (initialVoice) {
|
||||
voiceSelectDesktop.value = initialVoice;
|
||||
if (voiceSelectMobile) voiceSelectMobile.value = initialVoice;
|
||||
|
|
@ -330,27 +308,20 @@ document.addEventListener('DOMContentLoaded', (event) => {
|
|||
userHasScrolledUp = distanceFromBottom > 5;
|
||||
});
|
||||
|
||||
// Load model and voice from localStorage if not in URL
|
||||
const storedModel = localStorage.getItem('selectedModel');
|
||||
// Load model and voice from localStorage only (not URL)
|
||||
const storedModel = localStorage.getItem('selectedModel') || "None";
|
||||
const storedVoice = localStorage.getItem('selectedVoice');
|
||||
|
||||
// Set initial model, voice, and username from URL, localStorage, or defaults
|
||||
const initialModel = urlParams.get("model") || storedModel || "None";
|
||||
const initialVoice = urlParams.get("voice") || storedVoice || null;
|
||||
const initialUsername = username; // Already set to URL param or "guest"
|
||||
// Set initial model and voice from localStorage or defaults
|
||||
const initialModel = storedModel;
|
||||
const initialVoice = storedVoice;
|
||||
|
||||
modelSelectDesktop.value = initialModel;
|
||||
// Voice is set by populateVoiceDropdown after voices are fetched
|
||||
modelSelectMobile.value = initialModel;
|
||||
|
||||
// Set initial username values
|
||||
const usernameInputDesktop = document.getElementById("username-input");
|
||||
const usernameInputMobile = document.getElementById("username-input-mobile");
|
||||
if (usernameInputDesktop) usernameInputDesktop.value = initialUsername;
|
||||
if (usernameInputMobile) usernameInputMobile.value = initialUsername;
|
||||
|
||||
// Initial sync to ensure localStorage and URL are updated with current values
|
||||
syncInputsAndQueryString();
|
||||
|
||||
// Initial sync to ensure localStorage is updated with current values
|
||||
syncDropdownsAndQueryString();
|
||||
|
||||
// Add event listeners for desktop dropdowns
|
||||
modelSelectDesktop.addEventListener("change", () => {
|
||||
|
|
@ -385,39 +356,6 @@ document.addEventListener('DOMContentLoaded', (event) => {
|
|||
syncDropdownsAndQueryString();
|
||||
isSyncingDropdowns = false;
|
||||
});
|
||||
|
||||
// Add event listeners for username inputs
|
||||
if (usernameInputDesktop) {
|
||||
usernameInputDesktop.addEventListener("input", () => {
|
||||
if (isSyncingDropdowns) return;
|
||||
isSyncingDropdowns = true;
|
||||
if (usernameInputMobile) {
|
||||
usernameInputMobile.value = usernameInputDesktop.value;
|
||||
}
|
||||
syncInputsAndQueryString();
|
||||
isSyncingDropdowns = false;
|
||||
});
|
||||
|
||||
usernameInputDesktop.addEventListener("blur", () => {
|
||||
syncInputsAndQueryString();
|
||||
});
|
||||
}
|
||||
|
||||
if (usernameInputMobile) {
|
||||
usernameInputMobile.addEventListener("input", () => {
|
||||
if (isSyncingDropdowns) return;
|
||||
isSyncingDropdowns = true;
|
||||
if (usernameInputDesktop) {
|
||||
usernameInputDesktop.value = usernameInputMobile.value;
|
||||
}
|
||||
syncInputsAndQueryString();
|
||||
isSyncingDropdowns = false;
|
||||
});
|
||||
|
||||
usernameInputMobile.addEventListener("blur", () => {
|
||||
syncInputsAndQueryString();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Socket event when the user connects
|
||||
|
|
@ -425,8 +363,6 @@ socket.on("connect", () => {
|
|||
// Sanitize the username before joining
|
||||
const sanitizedUsername = sanitizeUsername(username);
|
||||
socket.emit("join", {"username": sanitizedUsername, "room_name": room_name});
|
||||
// Sync inputs and update the query string
|
||||
syncInputsAndQueryString();
|
||||
});
|
||||
|
||||
// Function to update the active and inactive user lists in the DOM
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chatroom</title>
|
||||
<title>OpenCompletion - AI-Powered Chat Rooms</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/utils.js') }}"></script>
|
||||
|
|
@ -15,49 +15,247 @@
|
|||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
#chat-container {
|
||||
#main-container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
max-width: 800px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
#chat {
|
||||
height: 300px;
|
||||
overflow-y: scroll;
|
||||
border: 1px solid #e1e1e1;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
#message {
|
||||
width: 98%;
|
||||
border: 1px solid #e1e1e1;
|
||||
.tagline {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
#join-section {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
#join-section h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
#join-room-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
border: 2px solid #e1e1e1;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
.room-type-selector {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.room-type-selector label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
.room-type-selector input[type="radio"] {
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.auth-status {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: #e7f3ff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.auth-status.logged-in {
|
||||
background: #d4edda;
|
||||
}
|
||||
.browse-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.browse-link a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
.browse-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chat-container">
|
||||
<h2>Join a Chat Room</h2>
|
||||
<form id="join-room-form">
|
||||
<input id="username" type="text" placeholder="Enter your username">
|
||||
<input id="room" type="text" placeholder="Enter room name">
|
||||
<button type="submit">Join</button>
|
||||
</form>
|
||||
<div id="main-container">
|
||||
<h1>🚀 OpenCompletion</h1>
|
||||
<p class="tagline">Machine Learning Powered Collaboration</p>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ stats.total_public_rooms }}</div>
|
||||
<div class="stat-label">Public Rooms</div>
|
||||
</div>
|
||||
{% if user %}
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ stats.total_private_rooms }}</div>
|
||||
<div class="stat-label">Private Rooms</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ stats.active_public_rooms }}</div>
|
||||
<div class="stat-label">Active Rooms</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ stats.active_users }}</div>
|
||||
<div class="stat-label">Active Users</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth status -->
|
||||
{% if user %}
|
||||
<div class="auth-status logged-in">
|
||||
✅ Signed in as <strong>{{ user.display_name }}</strong> ({{ user.email }})
|
||||
<div style="margin-top: 10px;">
|
||||
<a href="/profile" style="color: #667eea; text-decoration: none; font-weight: bold;">⚙️ Profile Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="auth-status">
|
||||
👋 Welcome! <a href="/auth">Sign in</a> to create private rooms
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Join room form -->
|
||||
<div id="join-section">
|
||||
<h2>Join or Create a Room</h2>
|
||||
<form id="join-room-form">
|
||||
<input id="room" type="text" placeholder="Enter room name" required>
|
||||
<div class="room-type-selector">
|
||||
<label>
|
||||
<input type="radio" name="room-type" value="public" checked>
|
||||
🌍 Public Room
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="room-type" value="private">
|
||||
🔐 Private Room
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Create/Join Room</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="browse-link">
|
||||
<a href="/browse">Or browse existing rooms →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('join-room-form').addEventListener('submit', function(e) {
|
||||
document.getElementById('join-room-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('username').value;
|
||||
const room = slugify(document.getElementById('room').value);
|
||||
window.location.href = `/chat/${room}?username=${encodeURIComponent(username)}`;
|
||||
const roomInput = document.getElementById('room').value.trim();
|
||||
const roomType = document.querySelector('input[name="room-type"]:checked').value;
|
||||
|
||||
if (!roomInput) {
|
||||
alert('Please enter a room name');
|
||||
return;
|
||||
}
|
||||
|
||||
const room = slugify(roomInput);
|
||||
|
||||
if (!room) {
|
||||
alert('Please enter a valid room name with at least some letters or numbers');
|
||||
return;
|
||||
}
|
||||
|
||||
// If private room, check authentication
|
||||
if (roomType === 'private') {
|
||||
{% if not user %}
|
||||
alert('You must be signed in to create private rooms. Please sign in first.');
|
||||
window.location.href = '/auth';
|
||||
return;
|
||||
{% endif %}
|
||||
|
||||
// Create private room via API
|
||||
try {
|
||||
const response = await fetch('/api/rooms/create', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
name: room,
|
||||
is_private: true
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
window.location.href = `/chat/${data.room.name}`;
|
||||
} else {
|
||||
alert(data.error || 'Failed to create private room');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error. Please try again.');
|
||||
}
|
||||
} else {
|
||||
// Public room - just navigate
|
||||
window.location.href = `/chat/${room}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
394
templates/profile.html
Normal file
394
templates/profile.html
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Profile Settings - OpenCompletion</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f7f7f7;
|
||||
--bg-tertiary: #e1e1e1;
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #666666;
|
||||
--border-color: #e1e1e1;
|
||||
--button-primary: #667eea;
|
||||
--button-hover: #764ba2;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #e1e1e1;
|
||||
--success-color: #28a745;
|
||||
--error-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--bg-tertiary: #3d3d3d;
|
||||
--text-primary: #e1e1e1;
|
||||
--text-secondary: #a0a0a0;
|
||||
--border-color: #3d3d3d;
|
||||
--button-primary: #667eea;
|
||||
--button-hover: #764ba2;
|
||||
--input-bg: #2d2d2d;
|
||||
--input-border: #3d3d3d;
|
||||
--success-color: #28a745;
|
||||
--error-color: #dc3545;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin-top: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: 5px;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--button-primary);
|
||||
}
|
||||
|
||||
.availability-indicator {
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.available {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.unavailable {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.current-value {
|
||||
background-color: var(--bg-tertiary);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.current-value strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, var(--button-primary) 0%, var(--button-hover) 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-selector {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
padding: 15px;
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
background-color: var(--input-bg);
|
||||
}
|
||||
|
||||
.theme-option:hover {
|
||||
border-color: var(--button-primary);
|
||||
}
|
||||
|
||||
.theme-option.active {
|
||||
border-color: var(--button-primary);
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.theme-option .icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.back-link a {
|
||||
color: var(--button-primary);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.back-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 15px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background-color: var(--error-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message.show {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-container">
|
||||
<h1>Profile Settings</h1>
|
||||
<p class="subtitle">Manage your account preferences</p>
|
||||
|
||||
<!-- Username Section -->
|
||||
<div class="section">
|
||||
<h2>Change Username</h2>
|
||||
<div class="current-value">
|
||||
Current username: <strong>{{ user.display_name }}</strong>
|
||||
</div>
|
||||
<div id="username-message" class="message"></div>
|
||||
<form id="username-form">
|
||||
<div class="form-group">
|
||||
<label for="new-username">New Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-username"
|
||||
placeholder="Enter new username"
|
||||
pattern="[a-zA-Z0-9_-]{3,50}"
|
||||
title="3-50 characters (letters, numbers, underscores, hyphens only)"
|
||||
required
|
||||
>
|
||||
<div id="availability-indicator" class="availability-indicator"></div>
|
||||
</div>
|
||||
<button type="submit" id="update-username-btn" disabled>Update Username</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Theme Section -->
|
||||
<div class="section">
|
||||
<h2>Appearance</h2>
|
||||
<div class="theme-selector">
|
||||
<div class="theme-option" data-theme="light" onclick="setTheme('light')">
|
||||
<div class="icon">☀️</div>
|
||||
<div>Light Mode</div>
|
||||
</div>
|
||||
<div class="theme-option" data-theme="dark" onclick="setTheme('dark')">
|
||||
<div class="icon">🌙</div>
|
||||
<div>Dark Mode</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="back-link">
|
||||
<a href="/">← Back to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let checkTimeout;
|
||||
const currentUsername = "{{ user.display_name }}";
|
||||
|
||||
// Theme Management
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
// Update active state
|
||||
document.querySelectorAll('.theme-option').forEach(option => {
|
||||
option.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`.theme-option[data-theme="${theme}"]`).classList.add('active');
|
||||
}
|
||||
|
||||
// Load saved theme on page load
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
|
||||
// Username availability check
|
||||
document.getElementById('new-username').addEventListener('input', function(e) {
|
||||
const username = e.target.value.trim();
|
||||
const indicator = document.getElementById('availability-indicator');
|
||||
const submitBtn = document.getElementById('update-username-btn');
|
||||
|
||||
// Clear previous timeout
|
||||
clearTimeout(checkTimeout);
|
||||
|
||||
// Reset if empty or same as current
|
||||
if (!username || username === currentUsername) {
|
||||
indicator.textContent = '';
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate format
|
||||
const isValid = /^[a-zA-Z0-9_-]{3,50}$/.test(username);
|
||||
if (!isValid) {
|
||||
indicator.textContent = 'Invalid format (3-50 chars: letters, numbers, _, -)';
|
||||
indicator.className = 'availability-indicator unavailable';
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check availability after 500ms delay
|
||||
indicator.textContent = 'Checking availability...';
|
||||
indicator.className = 'availability-indicator';
|
||||
|
||||
checkTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/check-username?username=${encodeURIComponent(username)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.available) {
|
||||
indicator.textContent = '✓ Username is available';
|
||||
indicator.className = 'availability-indicator available';
|
||||
submitBtn.disabled = false;
|
||||
} else {
|
||||
indicator.textContent = '✗ Username is already taken';
|
||||
indicator.className = 'availability-indicator unavailable';
|
||||
submitBtn.disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
indicator.textContent = 'Error checking availability';
|
||||
indicator.className = 'availability-indicator unavailable';
|
||||
submitBtn.disabled = true;
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Username update form
|
||||
document.getElementById('username-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const newUsername = document.getElementById('new-username').value.trim();
|
||||
const messageDiv = document.getElementById('username-message');
|
||||
const submitBtn = document.getElementById('update-username-btn');
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Updating...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/update-username', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ new_username: newUsername })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
messageDiv.textContent = 'Username updated successfully! Redirecting...';
|
||||
messageDiv.className = 'message success show';
|
||||
|
||||
// Redirect after 2 seconds
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
messageDiv.textContent = data.error || 'Failed to update username';
|
||||
messageDiv.className = 'message error show';
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Update Username';
|
||||
}
|
||||
} catch (error) {
|
||||
messageDiv.textContent = 'Network error. Please try again.';
|
||||
messageDiv.className = 'message error show';
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Update Username';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue