* 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

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(