otp (#41)
* Add email OTP authentication system and private room management ## Authentication System - Implement email OTP-based authentication with User and OTPToken models - Add auth.py module with OTP generation, email sending, and session management - Create authentication endpoints: /auth/send-otp, /auth/verify-otp, /auth/claim-name, /auth/status, /auth/logout - Add authentication modal UI with email verification and display name claiming - Support SMTP configuration via environment variables (optional) ## Room Privacy & Ownership - Add is_private, is_archived, owner_id, and forked_from_id fields to Room model - Implement private rooms (single-user, owner-only access) - Add room forking: users can fork public rooms to public or private - Add room archive/delete endpoints (owner-only operations) - Implement access control for private room viewing ## UI Improvements - Add public/private room tabs in sidebar - Show authentication prompt in private rooms tab for non-authenticated users - Add room action buttons (fork, archive, delete) with proper permissions - Update homepage with statistics (public/private rooms, active users, active rooms) - Remove model/voice from URL query strings, use localStorage exclusively ## Database Migration - Create migration 2025011100 for User, OTPToken tables and Room model updates - Add indexes for email, display_name, is_private, is_archived, owner_id ## Breaking Changes - URL parameters now only include username (model/voice moved to localStorage) - Private rooms require authentication to access - Room creation can now require authentication (for private rooms) * Update README with authentication and private room documentation * Simplify README to be less verbose * Add missing session import to fix linter errors * Fix migration dependency to resolve multiple heads conflict * asdf * Fix SQLAlchemy auto-correlation error in homepage statistics query * Change tagline from AI-Powered to Machine Learning Powered * Add dedicated authentication page instead of modal - Create new /auth route with full-page authentication flow - Remove modal code from index.html - Update Sign in link to point to /auth page - Auth page has 4-step flow: email, OTP, display name, success - Better UX with gradient background and cleaner design * Fix migration: remove batch_alter_table to avoid circular dependency - Use op.add_column() directly instead of batch_alter_table() - Remove foreign key constraints (defined in models, not needed in migration) - User and OTPToken tables created by db.create_all() in make init-db - Fixes CircularDependencyError during migration * Fix migration: check if columns exist before adding - Use inspector to check existing columns and indexes - Only add columns/indexes if they don't already exist - Handles case where db.create_all() was run before migration - Fixes 'duplicate column name' error * Add profile page, room browsing, and updated_at timestamp Features: - Profile page with username change and dark/light mode settings - Browse page for discovering public and private rooms - Room updated_at timestamp (integer Unix epoch) that updates on new messages - Dynamic room tabs based on current room type (public/private) - Fork and delete room actions moved to right sidebar utility belt - Remove archive feature and success alerts from room actions Technical changes: - Add updated_at column to Room model (integer timestamp) - Add /profile route with authentication requirement - Add /browse route for room discovery - Add API endpoints for username availability check and update - Update room.updated_at on message creation in app.py:1189 - Wider right sidebar (25% instead of 15%) for better button layout - Profile link on homepage and browse page for authenticated users --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: russell@unturf. <russell@unturf.com>
This commit is contained in:
parent
d849ecbd7d
commit
e4cabf4be6
13 changed files with 2415 additions and 262 deletions
474
app.py
474
app.py
|
|
@ -24,6 +24,7 @@ from flask import (
|
|||
Response,
|
||||
redirect,
|
||||
url_for,
|
||||
session,
|
||||
)
|
||||
|
||||
from flask_socketio import SocketIO, emit, join_room, leave_room
|
||||
|
|
@ -31,7 +32,7 @@ from flask_socketio import SocketIO, emit, join_room, leave_room
|
|||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy.exc import InvalidRequestError
|
||||
|
||||
from models import db, Room, UserSession, Message, ActivityState
|
||||
from models import db, Room, UserSession, Message, ActivityState, User, OTPToken
|
||||
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
|
||||
|
|
@ -57,6 +58,7 @@ cancellation_requests = {}
|
|||
|
||||
from openai import OpenAI
|
||||
import activity
|
||||
import auth
|
||||
|
||||
|
||||
# Build a list of endpoints dynamically.
|
||||
|
|
@ -299,7 +301,64 @@ def favicon():
|
|||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
# Get statistics for homepage
|
||||
total_public_rooms = Room.query.filter_by(is_private=False, is_archived=False).count()
|
||||
total_private_rooms = 0
|
||||
user = auth.get_current_user()
|
||||
if user:
|
||||
total_private_rooms = Room.query.filter_by(is_private=True, is_archived=False, owner_id=user.id).count()
|
||||
|
||||
# Active rooms - rooms with at least one message
|
||||
active_public_rooms = db.session.query(Room.id).join(Message).filter(
|
||||
Room.is_private == False,
|
||||
Room.is_archived == False
|
||||
).distinct().count()
|
||||
|
||||
# Total active users (from UserSession)
|
||||
active_users = UserSession.query.distinct(UserSession.username).count()
|
||||
|
||||
stats = {
|
||||
'total_public_rooms': total_public_rooms,
|
||||
'total_private_rooms': total_private_rooms,
|
||||
'active_public_rooms': active_public_rooms,
|
||||
'active_users': active_users,
|
||||
}
|
||||
|
||||
return render_template("index.html", stats=stats, user=user)
|
||||
|
||||
|
||||
@app.route("/auth")
|
||||
def auth_page():
|
||||
"""Authentication page"""
|
||||
return render_template("auth.html")
|
||||
|
||||
|
||||
@app.route("/browse")
|
||||
def browse_rooms():
|
||||
"""Browse all rooms (public and user's private rooms)"""
|
||||
user = auth.get_current_user()
|
||||
|
||||
# Get public rooms ordered by last updated
|
||||
public_rooms = Room.query.filter_by(
|
||||
is_private=False,
|
||||
is_archived=False
|
||||
).order_by(Room.updated_at.desc()).all()
|
||||
|
||||
# Get user's private rooms if authenticated
|
||||
private_rooms = []
|
||||
if user:
|
||||
private_rooms = Room.query.filter_by(
|
||||
is_private=True,
|
||||
is_archived=False,
|
||||
owner_id=user.id
|
||||
).order_by(Room.updated_at.desc()).all()
|
||||
|
||||
return render_template(
|
||||
"browse.html",
|
||||
public_rooms=public_rooms,
|
||||
private_rooms=private_rooms,
|
||||
user=user
|
||||
)
|
||||
|
||||
|
||||
@app.route("/models", methods=["GET"])
|
||||
|
|
@ -309,6 +368,200 @@ def get_models():
|
|||
return jsonify({"models": list(MODEL_CLIENT_MAP.keys())})
|
||||
|
||||
|
||||
# Authentication endpoints
|
||||
@app.route("/auth/send-otp", methods=["POST"])
|
||||
def send_otp():
|
||||
"""Send OTP to user's email"""
|
||||
data = request.get_json()
|
||||
email = data.get('email', '').strip().lower()
|
||||
|
||||
if not email:
|
||||
return jsonify({'error': 'Email is required'}), 400
|
||||
|
||||
# Basic email validation
|
||||
if '@' not in email or '.' not in email.split('@')[1]:
|
||||
return jsonify({'error': 'Invalid email address'}), 400
|
||||
|
||||
# Create OTP token
|
||||
otp_token = auth.create_otp_token(email)
|
||||
|
||||
# Send OTP via email
|
||||
if auth.send_otp_email(email, otp_token.otp_code):
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'OTP sent to your email',
|
||||
'email': email
|
||||
})
|
||||
else:
|
||||
return jsonify({'error': 'Failed to send OTP email'}), 500
|
||||
|
||||
|
||||
@app.route("/auth/verify-otp", methods=["POST"])
|
||||
def verify_otp():
|
||||
"""Verify OTP code and check if user exists"""
|
||||
data = request.get_json()
|
||||
email = data.get('email', '').strip().lower()
|
||||
otp_code = data.get('otp_code', '').strip()
|
||||
|
||||
if not email or not otp_code:
|
||||
return jsonify({'error': 'Email and OTP code are required'}), 400
|
||||
|
||||
# Verify OTP
|
||||
otp_token = auth.verify_otp(email, otp_code)
|
||||
if not otp_token:
|
||||
return jsonify({'error': 'Invalid or expired OTP code'}), 400
|
||||
|
||||
# Check if user exists
|
||||
user = auth.get_or_create_user(email)
|
||||
|
||||
if user:
|
||||
# Existing user - log them in
|
||||
auth.login_user(user)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'needs_display_name': False,
|
||||
'user': {
|
||||
'email': user.email,
|
||||
'display_name': user.display_name
|
||||
}
|
||||
})
|
||||
else:
|
||||
# New user - needs to claim display name
|
||||
# Store email in session temporarily
|
||||
session['pending_email'] = email
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'needs_display_name': True,
|
||||
'email': email
|
||||
})
|
||||
|
||||
|
||||
@app.route("/auth/claim-name", methods=["POST"])
|
||||
def claim_name():
|
||||
"""Claim display name for new user (after OTP verification)"""
|
||||
data = request.get_json()
|
||||
display_name = data.get('display_name', '').strip()
|
||||
email = session.get('pending_email')
|
||||
|
||||
if not email:
|
||||
return jsonify({'error': 'No pending email verification'}), 400
|
||||
|
||||
if not display_name:
|
||||
return jsonify({'error': 'Display name is required'}), 400
|
||||
|
||||
# Validate display name (alphanumeric, underscores, hyphens only, 3-50 chars)
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{3,50}$', display_name):
|
||||
return jsonify({
|
||||
'error': 'Display name must be 3-50 characters (letters, numbers, underscores, hyphens only)'
|
||||
}), 400
|
||||
|
||||
# Create user
|
||||
user, error = auth.create_user(email, display_name)
|
||||
if error:
|
||||
return jsonify({'error': error}), 400
|
||||
|
||||
# Log in user
|
||||
auth.login_user(user)
|
||||
|
||||
# Clear pending email
|
||||
session.pop('pending_email', None)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': {
|
||||
'email': user.email,
|
||||
'display_name': user.display_name
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route("/auth/status", methods=["GET"])
|
||||
def auth_status():
|
||||
"""Get current authentication status"""
|
||||
user = auth.get_current_user()
|
||||
if user:
|
||||
return jsonify({
|
||||
'authenticated': True,
|
||||
'user': {
|
||||
'email': user.email,
|
||||
'display_name': user.display_name
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({'authenticated': False})
|
||||
|
||||
|
||||
@app.route("/auth/logout", methods=["POST"])
|
||||
def logout():
|
||||
"""Log out current user"""
|
||||
auth.logout_user()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route("/profile")
|
||||
@auth.require_auth
|
||||
def profile_page():
|
||||
"""Profile settings page"""
|
||||
user = auth.get_current_user()
|
||||
return render_template("profile.html", user=user)
|
||||
|
||||
|
||||
@app.route("/api/check-username", methods=["GET"])
|
||||
def check_username():
|
||||
"""Check if username is available"""
|
||||
username = request.args.get('username', '').strip()
|
||||
|
||||
if not username:
|
||||
return jsonify({'available': False, 'error': 'Username is required'}), 400
|
||||
|
||||
# Validate format
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{3,50}$', username):
|
||||
return jsonify({'available': False, 'error': 'Invalid format'}), 400
|
||||
|
||||
# Check if username exists
|
||||
existing_user = User.query.filter_by(display_name=username).first()
|
||||
|
||||
return jsonify({'available': existing_user is None})
|
||||
|
||||
|
||||
@app.route("/api/update-username", methods=["POST"])
|
||||
@auth.require_auth
|
||||
def update_username():
|
||||
"""Update user's display name"""
|
||||
user = auth.get_current_user()
|
||||
data = request.get_json()
|
||||
new_username = data.get('new_username', '').strip()
|
||||
|
||||
if not new_username:
|
||||
return jsonify({'error': 'Username is required'}), 400
|
||||
|
||||
# Validate format
|
||||
import re
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{3,50}$', new_username):
|
||||
return jsonify({
|
||||
'error': 'Username must be 3-50 characters (letters, numbers, underscores, hyphens only)'
|
||||
}), 400
|
||||
|
||||
# Check if username is already taken
|
||||
existing_user = User.query.filter_by(display_name=new_username).first()
|
||||
if existing_user and existing_user.id != user.id:
|
||||
return jsonify({'error': 'Username is already taken'}), 400
|
||||
|
||||
# Update username
|
||||
user.display_name = new_username
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': {
|
||||
'email': user.email,
|
||||
'display_name': user.display_name
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/activities", methods=["GET"])
|
||||
def get_activities():
|
||||
"""Return the list of available activities."""
|
||||
|
|
@ -331,6 +584,185 @@ def get_activities():
|
|||
return jsonify({"activities": activities})
|
||||
|
||||
|
||||
@app.route("/api/rooms", methods=["GET"])
|
||||
def get_rooms_api():
|
||||
"""Get list of rooms (public or user's private rooms)"""
|
||||
user = auth.get_current_user()
|
||||
|
||||
# Get public rooms
|
||||
public_rooms = Room.query.filter_by(is_private=False, is_archived=False).order_by(Room.id.desc()).all()
|
||||
|
||||
# Get private rooms if authenticated
|
||||
private_rooms = []
|
||||
if user:
|
||||
private_rooms = Room.query.filter_by(is_private=True, is_archived=False, owner_id=user.id).order_by(Room.id.desc()).all()
|
||||
|
||||
return jsonify({
|
||||
'public_rooms': [{
|
||||
'id': r.id,
|
||||
'name': r.name,
|
||||
'title': r.title,
|
||||
'active_users_count': len(r.get_active_users())
|
||||
} for r in public_rooms],
|
||||
'private_rooms': [{
|
||||
'id': r.id,
|
||||
'name': r.name,
|
||||
'title': r.title,
|
||||
'active_users_count': len(r.get_active_users())
|
||||
} for r in private_rooms]
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/rooms/create", methods=["POST"])
|
||||
def create_room_api():
|
||||
"""Create a new room"""
|
||||
user = auth.get_current_user()
|
||||
data = request.get_json() or {}
|
||||
room_name = data.get('name', '').strip()
|
||||
is_private = data.get('is_private', False)
|
||||
|
||||
if not room_name:
|
||||
return jsonify({'error': 'Room name is required'}), 400
|
||||
|
||||
# Private rooms require authentication
|
||||
if is_private and not user:
|
||||
return jsonify({'error': 'Authentication required to create private rooms'}), 401
|
||||
|
||||
# Check if room already exists
|
||||
existing_room = Room.query.filter_by(name=room_name).first()
|
||||
if existing_room:
|
||||
return jsonify({'error': 'Room name already exists'}), 400
|
||||
|
||||
# Create room
|
||||
new_room = Room()
|
||||
new_room.name = room_name
|
||||
new_room.is_private = is_private
|
||||
new_room.owner_id = user.id if user else None
|
||||
|
||||
db.session.add(new_room)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'room': {
|
||||
'id': new_room.id,
|
||||
'name': new_room.name,
|
||||
'is_private': new_room.is_private
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/rooms/<int:room_id>/fork", methods=["POST"])
|
||||
def fork_room(room_id):
|
||||
"""Fork a room (authenticated users can fork to private or public)"""
|
||||
user = auth.get_current_user()
|
||||
data = request.get_json() or {}
|
||||
make_private = data.get('private', False)
|
||||
|
||||
# Get source room
|
||||
source_room = Room.query.get(room_id)
|
||||
if not source_room:
|
||||
return jsonify({'error': 'Room not found'}), 404
|
||||
|
||||
# Private rooms can only be forked by their owner
|
||||
if source_room.is_private:
|
||||
if not user or source_room.owner_id != user.id:
|
||||
return jsonify({'error': 'Cannot fork private rooms you do not own'}), 403
|
||||
|
||||
# Private rooms require authentication
|
||||
if make_private and not user:
|
||||
return jsonify({'error': 'Authentication required to create private rooms'}), 401
|
||||
|
||||
# Generate new room name
|
||||
base_name = f"{source_room.name}_fork"
|
||||
new_name = base_name
|
||||
counter = 1
|
||||
while Room.query.filter_by(name=new_name).first():
|
||||
new_name = f"{base_name}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# Create forked room
|
||||
new_room = Room()
|
||||
new_room.name = new_name
|
||||
new_room.title = f"Fork of {source_room.title or source_room.name}"
|
||||
new_room.is_private = make_private
|
||||
new_room.owner_id = user.id if user else None
|
||||
new_room.forked_from_id = source_room.id
|
||||
|
||||
db.session.add(new_room)
|
||||
db.session.commit()
|
||||
|
||||
# Copy messages from source room
|
||||
source_messages = Message.query.filter_by(room_id=source_room.id).all()
|
||||
for msg in source_messages:
|
||||
new_msg = Message(
|
||||
username=msg.username,
|
||||
content=msg.content,
|
||||
room_id=new_room.id
|
||||
)
|
||||
db.session.add(new_msg)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'room': {
|
||||
'id': new_room.id,
|
||||
'name': new_room.name,
|
||||
'title': new_room.title,
|
||||
'is_private': new_room.is_private
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/rooms/<int:room_id>/archive", methods=["POST"])
|
||||
@auth.require_auth
|
||||
def archive_room(room_id):
|
||||
"""Archive a room (owner only)"""
|
||||
user = auth.get_current_user()
|
||||
room = Room.query.get(room_id)
|
||||
|
||||
if not room:
|
||||
return jsonify({'error': 'Room not found'}), 404
|
||||
|
||||
if room.owner_id != user.id:
|
||||
return jsonify({'error': 'Only room owner can archive rooms'}), 403
|
||||
|
||||
room.is_archived = True
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route("/api/rooms/<int:room_id>/delete", methods=["DELETE"])
|
||||
@auth.require_auth
|
||||
def delete_room(room_id):
|
||||
"""Delete a room (owner only)"""
|
||||
user = auth.get_current_user()
|
||||
room = Room.query.get(room_id)
|
||||
|
||||
if not room:
|
||||
return jsonify({'error': 'Room not found'}), 404
|
||||
|
||||
if room.owner_id != user.id:
|
||||
return jsonify({'error': 'Only room owner can delete rooms'}), 403
|
||||
|
||||
# Delete all messages in the room
|
||||
Message.query.filter_by(room_id=room.id).delete()
|
||||
|
||||
# Delete activity state if any
|
||||
ActivityState.query.filter_by(room_id=room.id).delete()
|
||||
|
||||
# Delete user sessions
|
||||
UserSession.query.filter_by(room_id=room.id).delete()
|
||||
|
||||
# Delete the room
|
||||
db.session.delete(room)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route("/api/generate-artifact-name", methods=["POST"])
|
||||
def generate_artifact_name():
|
||||
"""Generate a meaningful filename for an artifact using AI.
|
||||
|
|
@ -410,15 +842,35 @@ Examples:
|
|||
|
||||
@app.route("/chat/<room_name>")
|
||||
def chat(room_name):
|
||||
# Query all rooms so that newest is first.
|
||||
rooms = Room.query.order_by(Room.id.desc()).all()
|
||||
user = auth.get_current_user()
|
||||
|
||||
# Get username from query parameters
|
||||
username = request.args.get("username", "guest")
|
||||
# Get or create the room
|
||||
room = Room.query.filter_by(name=room_name).first()
|
||||
|
||||
# Pass username and rooms into the template
|
||||
# If room doesn't exist yet, it will be created in get_room() when user joins
|
||||
# But check if they're trying to access a private room they don't own
|
||||
if room and room.is_private:
|
||||
if not user or room.owner_id != user.id:
|
||||
return "Access denied: This is a private room", 403
|
||||
|
||||
# Query public rooms and user's private rooms for sidebar
|
||||
public_rooms = Room.query.filter_by(is_private=False, is_archived=False).order_by(Room.id.desc()).all()
|
||||
private_rooms = []
|
||||
if user:
|
||||
private_rooms = Room.query.filter_by(is_private=True, is_archived=False, owner_id=user.id).order_by(Room.id.desc()).all()
|
||||
|
||||
# Use authenticated user's display name, or None (will prompt on client side)
|
||||
username = user.display_name if user else None
|
||||
|
||||
# Pass username, rooms, room (current room), and user into the template
|
||||
return render_template(
|
||||
"chat.html", room_name=room_name, rooms=rooms, username=username
|
||||
"chat.html",
|
||||
room_name=room_name,
|
||||
current_room=room,
|
||||
public_rooms=public_rooms,
|
||||
private_rooms=private_rooms,
|
||||
username=username,
|
||||
user=user
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -737,6 +1189,12 @@ def handle_message(data):
|
|||
room_id=room.id,
|
||||
)
|
||||
db.session.add(new_message)
|
||||
|
||||
# Update room's updated_at timestamp (Unix epoch)
|
||||
from datetime import datetime
|
||||
room.updated_at = int(datetime.utcnow().timestamp())
|
||||
db.session.add(room)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
emit(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue