* 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:
Russell 2025-11-11 22:56:58 -05:00 committed by GitHub
parent d849ecbd7d
commit e4cabf4be6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2415 additions and 262 deletions

View file

@ -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
View file

@ -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
View 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

View 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

View 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')

View file

@ -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()

View file

@ -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
View 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>

View file

@ -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
View 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>

View file

@ -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

View file

@ -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
View 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>