From e4cabf4be6f8f5e87e1d805e11cb70cb88451035 Mon Sep 17 00:00:00 2001 From: Russell Date: Tue, 11 Nov 2025 22:56:58 -0500 Subject: [PATCH] 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 Co-authored-by: russell@unturf. --- README.rst | 14 +- app.py | 474 +++++++++++++++++- auth.py | 195 +++++++ .../versions/2025011100_add_auth_system.py | 58 +++ .../5d0d533ff7c0_add_updated_at_to_room.py | 28 ++ models.py | 46 ++ static/css/style.css | 36 +- templates/auth.html | 290 +++++++++++ templates/base.html | 461 ++++++++++++----- templates/browse.html | 291 +++++++++++ templates/chat.html | 136 ++--- templates/index.html | 254 ++++++++-- templates/profile.html | 394 +++++++++++++++ 13 files changed, 2415 insertions(+), 262 deletions(-) create mode 100644 auth.py create mode 100644 migrations/versions/2025011100_add_auth_system.py create mode 100644 migrations/versions/5d0d533ff7c0_add_updated_at_to_room.py create mode 100644 templates/auth.html create mode 100644 templates/browse.html create mode 100644 templates/profile.html diff --git a/README.rst b/README.rst index 682c2a6..128e87a 100644 --- a/README.rst +++ b/README.rst @@ -20,6 +20,8 @@ Features - Commands to load and save code blocks to AWS S3. - Database storage for messages and chatrooms using SQLAlchemy. - Migration support with Flask-Migrate. +- Email OTP authentication with private room support +- Room forking, archiving, and owner management Requirements ------------ @@ -78,18 +80,26 @@ Here are some free endpoint for research only!:: export MODEL_ENDPOINT_2=https://qwen.ai.unturf.com/v1 export MODEL_ENDPOINT_3=https://gpt-oss.ai.unturf.com/v1 +Optional SMTP for email OTP authentication:: + + export SMTP_HOST=smtp.gmail.com + export SMTP_PORT=587 + export SMTP_USER=your@email.com + export SMTP_PASSWORD=your_app_password + To start the application with socket.io run:: python app.py Optionally flags ``python app.py --local-activities --profile ``:: - 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. diff --git a/app.py b/app.py index f9243de..51dc84a 100644 --- a/app.py +++ b/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//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//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//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/") 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( diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..81a6207 --- /dev/null +++ b/auth.py @@ -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""" + + +

Your OpenCompletion Verification Code

+

Enter this code to complete your authentication:

+

+ {otp_code} +

+

This code will expire in 10 minutes.

+

+ If you didn't request this code, you can safely ignore this email. +

+ + +""" + + # 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 diff --git a/migrations/versions/2025011100_add_auth_system.py b/migrations/versions/2025011100_add_auth_system.py new file mode 100644 index 0000000..a9dde0f --- /dev/null +++ b/migrations/versions/2025011100_add_auth_system.py @@ -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 diff --git a/migrations/versions/5d0d533ff7c0_add_updated_at_to_room.py b/migrations/versions/5d0d533ff7c0_add_updated_at_to_room.py new file mode 100644 index 0000000..610e83a --- /dev/null +++ b/migrations/versions/5d0d533ff7c0_add_updated_at_to_room.py @@ -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') diff --git a/models.py b/models.py index b56d574..91cf37c 100644 --- a/models.py +++ b/models.py @@ -1,4 +1,5 @@ from flask_sqlalchemy import SQLAlchemy +from datetime import datetime, timedelta import tiktoken @@ -7,12 +8,57 @@ import json db = SQLAlchemy() +class User(db.Model): + """User model for authentication and ownership""" + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + display_name = db.Column(db.String(50), unique=True, nullable=False, index=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + last_login = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + owned_rooms = db.relationship('Room', backref='owner', lazy='dynamic', foreign_keys='Room.owner_id') + + def __repr__(self): + return f'' + + +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'' + + 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() diff --git a/static/css/style.css b/static/css/style.css index a781c1c..2a278d9 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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; +} diff --git a/templates/auth.html b/templates/auth.html new file mode 100644 index 0000000..c3104cc --- /dev/null +++ b/templates/auth.html @@ -0,0 +1,290 @@ + + + + + + Sign In - OpenCompletion + + + + +
+ + + +
+

Sign In / Sign Up

+

Enter your email to receive a verification code

+ + +
+
+ + +
+

Enter Verification Code

+

We sent a 6-digit code to

+ + + +
+
+ + +
+

Choose Display Name

+

Pick a unique display name (3-50 characters)

+ + +
+
+ + +
+
+

Success!

+

Welcome, !

+ +
+ + +
+ + + + diff --git a/templates/base.html b/templates/base.html index 6df9585..ce0d6be 100644 --- a/templates/base.html +++ b/templates/base.html @@ -43,7 +43,6 @@
-
@@ -55,10 +54,6 @@
-
- - -
- -
- - -
-

Public Rooms

-
- - +
+ + +
+
+ 🌍 Public +
+
+ 🔐 Private +
+
+ + +
+
+ {% if user %} + {% if private_rooms %} + + {% else %} +

No private rooms yet. Create one to get started!

+ {% endif %} + {% else %} +
+

🔒 Private rooms are only visible to you

+

Sign in to create and access private rooms

+ +
+ {% endif %} +
+
+ + +
{% block content %}{% endblock %} + + + diff --git a/templates/browse.html b/templates/browse.html new file mode 100644 index 0000000..8bc6c94 --- /dev/null +++ b/templates/browse.html @@ -0,0 +1,291 @@ + + + + + + Browse Rooms - OpenCompletion + + + + +
+

🚀 Browse Rooms

+
+ 🏠 Home + {% if user %} + 👤 {{ user.display_name }} + {% else %} + 🔐 Sign In + {% endif %} +
+
+ +
+ + +
+ +
+ +
+ {% if public_rooms %} + + {% else %} +
+

No public rooms yet

+

Be the first to create one!

+ Create a Room +
+ {% endif %} +
+ + +
+ {% if user %} + {% if private_rooms %} + + {% else %} +
+

No private rooms yet

+

Create your first private room!

+ Create a Room +
+ {% endif %} + {% else %} +
+

🔒 Private rooms are only visible to you

+

Sign in to create and access your private rooms

+ Sign In / Sign Up +
+ {% endif %} +
+
+ + + + diff --git a/templates/chat.html b/templates/chat.html index 53edce0..f39fb3d 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -14,16 +14,25 @@
+ + {% if current_room %} +
+

Room Actions

+
+ + {% if user and current_room.owner_id == user.id %} + + {% endif %} +
+
+ {% endif %} +
-
- - -
- - - +
+

🚀 OpenCompletion

+

Machine Learning Powered Collaboration

+ + +
+
+
{{ stats.total_public_rooms }}
+
Public Rooms
+
+ {% if user %} +
+
{{ stats.total_private_rooms }}
+
Private Rooms
+
+ {% endif %} +
+
{{ stats.active_public_rooms }}
+
Active Rooms
+
+
+
{{ stats.active_users }}
+
Active Users
+
+
+ + + {% if user %} +
+ ✅ Signed in as {{ user.display_name }} ({{ user.email }}) + +
+ {% else %} +
+ 👋 Welcome! Sign in to create private rooms +
+ {% endif %} + + +
+

Join or Create a Room

+
+ +
+ + +
+ +
+
+ +
diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..f33c8ce --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,394 @@ + + + + + + Profile Settings - OpenCompletion + + + + +
+

Profile Settings

+

Manage your account preferences

+ + +
+

Change Username

+
+ Current username: {{ user.display_name }} +
+
+
+
+ + +
+
+ +
+
+ + +
+

Appearance

+
+
+
☀️
+
Light Mode
+
+
+
🌙
+
Dark Mode
+
+
+
+ + +
+ + + +