diff --git a/moxie/admin/templates/comfyui.html b/moxie/admin/templates/comfyui.html index 6c8b9c7..6f35a0b 100755 --- a/moxie/admin/templates/comfyui.html +++ b/moxie/admin/templates/comfyui.html @@ -14,6 +14,7 @@ Endpoints Documents ComfyUI + Users diff --git a/moxie/admin/templates/dashboard.html b/moxie/admin/templates/dashboard.html index 8cb117e..6bee9e1 100755 --- a/moxie/admin/templates/dashboard.html +++ b/moxie/admin/templates/dashboard.html @@ -14,6 +14,7 @@ Endpoints Documents ComfyUI + Users @@ -48,15 +49,14 @@
  • Configure your API endpoints in Endpoints
  • Upload documents in Documents
  • Configure ComfyUI workflows in ComfyUI
  • -
  • Connect open-webui to http://localhost:8000/v1
  • +
  • Access the MOXIE UI at http://localhost:8000/
  • -

    API Configuration

    -

    Configure open-webui to use this endpoint:

    - Base URL: http://localhost:8000/v1 -

    No API key required (leave blank)

    +

    MOXIE UI

    +

    Access the MOXIE chat interface:

    + http://localhost:8000/
    diff --git a/moxie/admin/templates/documents.html b/moxie/admin/templates/documents.html index 5019ac2..6d0673d 100755 --- a/moxie/admin/templates/documents.html +++ b/moxie/admin/templates/documents.html @@ -14,6 +14,7 @@ Endpoints Documents ComfyUI + Users diff --git a/moxie/admin/templates/endpoints.html b/moxie/admin/templates/endpoints.html index f00562d..508e377 100755 --- a/moxie/admin/templates/endpoints.html +++ b/moxie/admin/templates/endpoints.html @@ -14,6 +14,7 @@ Endpoints Documents ComfyUI + Users diff --git a/moxie/admin/templates/users.html b/moxie/admin/templates/users.html new file mode 100644 index 0000000..3007c1f --- /dev/null +++ b/moxie/admin/templates/users.html @@ -0,0 +1,191 @@ + + + + + + User Management - MOXIE Admin + + + + + + +
    +

    User Management

    + +
    +

    Manage user accounts, permissions, and request limits.

    +
    + +
    + {% for user in users %} +
    +
    +
    +

    + {{ user.username }} + {% if user.is_admin %} + ADMIN + {% endif %} +

    +

    {{ user.email }}

    +
    +
    +
    Requests today
    +
    {{ user.request_count }}/{{ user.request_limit }}
    +
    +
    + +
    +
    + Created: {{ user.created_at[:10] if user.created_at else 'N/A' }} +
    + +
    + {% if not user.is_admin %} + + {% else %} + {% if user.username != 'admin' %} + + {% endif %} + {% endif %} + +
    + + +
    + + {% if user.username != 'admin' %} + + {% endif %} +
    +
    +
    + {% endfor %} +
    + + {% if not users %} +
    +

    No users found.

    +
    + {% endif %} +
    + + + + diff --git a/moxie/api/admin.py b/moxie/api/admin.py index f25e32b..123424a 100755 --- a/moxie/api/admin.py +++ b/moxie/api/admin.py @@ -13,6 +13,7 @@ from pydantic import BaseModel from loguru import logger from config import settings, get_data_dir, get_workflows_dir, save_config_to_db, load_config_from_db +from auth.models import get_auth_manager router = APIRouter() @@ -236,6 +237,78 @@ async def delete_comfyui_workflow(workflow_type: str): return {"status": "success"} +# ============================================================================ +# User Management Routes +# ============================================================================ + +@router.get("/users", response_class=HTMLResponse) +async def users_page(request: Request): + """User management page.""" + auth = get_auth_manager() + users = auth.get_all_users() + + return templates.TemplateResponse( + "users.html", + { + "request": request, + "users": users, + "settings": settings + } + ) + + +@router.post("/users/{user_id}/promote") +async def promote_user(user_id: str): + """Promote a user to admin.""" + auth = get_auth_manager() + success = auth.promote_to_admin(user_id) + + if success: + logger.info(f"User promoted to admin: {user_id}") + return {"status": "success"} + + raise HTTPException(status_code=400, detail="Cannot promote this user") + + +@router.post("/users/{user_id}/demote") +async def demote_user(user_id: str): + """Demote a user from admin.""" + auth = get_auth_manager() + success = auth.demote_from_admin(user_id) + + if success: + logger.info(f"User demoted from admin: {user_id}") + return {"status": "success"} + + raise HTTPException(status_code=400, detail="Cannot demote this user") + + +@router.post("/users/{user_id}/limit") +async def update_user_limit(user_id: str, limit: int = Form(...)): + """Update a user's daily request limit.""" + auth = get_auth_manager() + success = auth.update_user_limit(user_id, limit) + + if success: + logger.info(f"User limit updated: {user_id} -> {limit}") + return {"status": "success"} + + raise HTTPException(status_code=400, detail="Failed to update user limit") + + +@router.delete("/users/{user_id}") +async def delete_user(user_id: str): + """Delete a user.""" + auth = get_auth_manager() + success = auth.delete_user(user_id) + + if success: + logger.info(f"User deleted: {user_id}") + return {"status": "success"} + + raise HTTPException(status_code=400, detail="Cannot delete this user") + + @router.get("/status") async def get_status(request: Request): """Get system status.""" diff --git a/moxie/auth/__init__.py b/moxie/auth/__init__.py new file mode 100644 index 0000000..ac8c489 --- /dev/null +++ b/moxie/auth/__init__.py @@ -0,0 +1,7 @@ +""" +MOXIE Authentication Module +User management, session handling, and rate limiting. +""" +from .models import AuthManager, User, get_auth_manager + +__all__ = ["AuthManager", "User", "get_auth_manager"] diff --git a/moxie/auth/models.py b/moxie/auth/models.py new file mode 100644 index 0000000..f1745da --- /dev/null +++ b/moxie/auth/models.py @@ -0,0 +1,562 @@ +""" +Authentication Models and Database Management +SQLite-based user management with password hashing and rate limiting. +""" +import sqlite3 +import json +import secrets +import hashlib +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any +from pathlib import Path +from pydantic import BaseModel +from loguru import logger + +from config import get_db_path + + +class User(BaseModel): + """User model.""" + id: str + username: str + email: str + password_hash: str + is_admin: bool = False + created_at: str + request_count: int = 0 + request_limit: int = 5 + last_request_date: Optional[str] = None + + +class Session(BaseModel): + """Session model.""" + token: str + user_id: str + created_at: str + expires_at: str + + +class AuthManager: + """ + Handles user authentication, sessions, and rate limiting. + + Features: + - SQLite-based user storage + - Password hashing with salt + - Session token management + - Daily request rate limiting + """ + + def __init__(self): + self.db_path = get_db_path() + self._init_db() + self._ensure_admin_exists() + logger.info("Auth Manager initialized") + + def _init_db(self) -> None: + """Initialize the auth database schema.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + # Users table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER DEFAULT 0, + created_at TEXT, + request_count INTEGER DEFAULT 0, + request_limit INTEGER DEFAULT 5, + last_request_date TEXT + ) + """) + + # Sessions table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at TEXT, + expires_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + + # User documents table (for profile document uploads) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_documents ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + filename TEXT NOT NULL, + file_type TEXT, + content BLOB, + created_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + + conn.commit() + conn.close() + + def _ensure_admin_exists(self) -> None: + """Create default admin user if not exists.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute("SELECT id FROM users WHERE username = ?", ("admin",)) + if not cursor.fetchone(): + # Create default admin + admin_id = self._generate_id() + password_hash = self._hash_password("changeme") + now = datetime.utcnow().isoformat() + + cursor.execute(""" + INSERT INTO users (id, username, email, password_hash, is_admin, created_at, request_limit) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (admin_id, "admin", "admin@moxie.local", password_hash, 1, now, 999999)) + + conn.commit() + logger.info("Default admin user created (username: admin, password: changeme)") + + conn.close() + + def _generate_id(self) -> str: + """Generate a unique ID.""" + import uuid + return str(uuid.uuid4()) + + def _hash_password(self, password: str) -> str: + """Hash a password with salt.""" + salt = secrets.token_hex(16) + hash_val = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) + return f"{salt}:{hash_val.hex()}" + + def _verify_password(self, password: str, stored_hash: str) -> bool: + """Verify a password against stored hash.""" + try: + salt, hash_val = stored_hash.split(':') + new_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) + return new_hash.hex() == hash_val + except Exception: + return False + + def _generate_token(self) -> str: + """Generate a secure session token.""" + return secrets.token_urlsafe(32) + + def create_user(self, username: str, email: str, password: str) -> Optional[User]: + """Create a new user.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + try: + user_id = self._generate_id() + password_hash = self._hash_password(password) + now = datetime.utcnow().isoformat() + + cursor.execute(""" + INSERT INTO users (id, username, email, password_hash, created_at) + VALUES (?, ?, ?, ?, ?) + """, (user_id, username, email, password_hash, now)) + + conn.commit() + logger.info(f"User created: {username}") + + return User( + id=user_id, + username=username, + email=email, + password_hash=password_hash, + is_admin=False, + created_at=now, + request_count=0, + request_limit=5 + ) + except sqlite3.IntegrityError as e: + logger.warning(f"Failed to create user: {e}") + return None + finally: + conn.close() + + def authenticate(self, username: str, password: str) -> Optional[User]: + """Authenticate a user by username and password.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, username, email, password_hash, is_admin, created_at, + request_count, request_limit, last_request_date + FROM users WHERE username = ? + """, (username,)) + + row = cursor.fetchone() + conn.close() + + if not row: + return None + + if not self._verify_password(password, row[3]): + return None + + return User( + id=row[0], + username=row[1], + email=row[2], + password_hash=row[3], + is_admin=bool(row[4]), + created_at=row[5], + request_count=row[6] or 0, + request_limit=row[7] or 5, + last_request_date=row[8] + ) + + def create_session(self, user_id: str, expires_days: int = 7) -> str: + """Create a new session for a user.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + token = self._generate_token() + now = datetime.utcnow() + expires = now + timedelta(days=expires_days) + + cursor.execute(""" + INSERT INTO sessions (token, user_id, created_at, expires_at) + VALUES (?, ?, ?, ?) + """, (token, user_id, now.isoformat(), expires.isoformat())) + + conn.commit() + conn.close() + + return token + + def validate_session(self, token: str) -> Optional[User]: + """Validate a session token and return the user.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + now = datetime.utcnow().isoformat() + + cursor.execute(""" + SELECT s.user_id, u.username, u.email, u.password_hash, u.is_admin, + u.created_at, u.request_count, u.request_limit, u.last_request_date + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ? AND s.expires_at > ? + """, (token, now)) + + row = cursor.fetchone() + conn.close() + + if not row: + return None + + return User( + id=row[0], + username=row[1], + email=row[2], + password_hash=row[3], + is_admin=bool(row[4]), + created_at=row[5], + request_count=row[6] or 0, + request_limit=row[7] or 5, + last_request_date=row[8] + ) + + def delete_session(self, token: str) -> None: + """Delete a session (logout).""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute("DELETE FROM sessions WHERE token = ?", (token,)) + + conn.commit() + conn.close() + + def check_rate_limit(self, user_id: str) -> Dict[str, Any]: + """ + Check if user can make a request. + Returns dict with allowed status and remaining requests. + """ + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + today = datetime.utcnow().date().isoformat() + + cursor.execute(""" + SELECT request_count, request_limit, last_request_date + FROM users WHERE id = ? + """, (user_id,)) + + row = cursor.fetchone() + if not row: + conn.close() + return {"allowed": False, "remaining": 0, "limit": 0} + + request_count, request_limit, last_request_date = row + + # Reset count if it's a new day + if last_request_date != today: + request_count = 0 + + # Admins have unlimited requests + cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)) + is_admin = cursor.fetchone()[0] + + if is_admin: + conn.close() + return {"allowed": True, "remaining": 999999, "limit": 999999} + + allowed = request_count < request_limit + remaining = max(0, request_limit - request_count) + + conn.close() + + return { + "allowed": allowed, + "remaining": remaining, + "limit": request_limit + } + + def increment_request_count(self, user_id: str) -> None: + """Increment the request count for a user.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + today = datetime.utcnow().date().isoformat() + + # Check if we need to reset for new day + cursor.execute(""" + SELECT last_request_date FROM users WHERE id = ? + """, (user_id,)) + + row = cursor.fetchone() + if row and row[0] != today: + # New day, reset count + cursor.execute(""" + UPDATE users SET request_count = 1, last_request_date = ? + WHERE id = ? + """, (today, user_id)) + else: + # Same day, increment + cursor.execute(""" + UPDATE users SET request_count = request_count + 1, last_request_date = ? + WHERE id = ? + """, (today, user_id)) + + conn.commit() + conn.close() + + def get_user(self, user_id: str) -> Optional[User]: + """Get a user by ID.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, username, email, password_hash, is_admin, created_at, + request_count, request_limit, last_request_date + FROM users WHERE id = ? + """, (user_id,)) + + row = cursor.fetchone() + conn.close() + + if not row: + return None + + return User( + id=row[0], + username=row[1], + email=row[2], + password_hash=row[3], + is_admin=bool(row[4]), + created_at=row[5], + request_count=row[6] or 0, + request_limit=row[7] or 5, + last_request_date=row[8] + ) + + def get_all_users(self) -> List[Dict[str, Any]]: + """Get all users (for admin).""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, username, email, is_admin, created_at, request_count, request_limit + FROM users ORDER BY created_at DESC + """) + + users = [] + for row in cursor.fetchall(): + users.append({ + "id": row[0], + "username": row[1], + "email": row[2], + "is_admin": bool(row[3]), + "created_at": row[4], + "request_count": row[5] or 0, + "request_limit": row[6] or 5 + }) + + conn.close() + return users + + def promote_to_admin(self, user_id: str) -> bool: + """Promote a user to admin.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute("UPDATE users SET is_admin = 1 WHERE id = ?", (user_id,)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + def demote_from_admin(self, user_id: str) -> bool: + """Demote a user from admin.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + # Prevent demoting the last admin + cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1") + admin_count = cursor.fetchone()[0] + + cursor.execute("SELECT username FROM users WHERE id = ?", (user_id,)) + row = cursor.fetchone() + if row and row[0] == "admin": + conn.close() + return False # Cannot demote the default admin + + if admin_count <= 1: + conn.close() + return False # Cannot demote the last admin + + cursor.execute("UPDATE users SET is_admin = 0 WHERE id = ?", (user_id,)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + def update_user_limit(self, user_id: str, limit: int) -> bool: + """Update a user's daily request limit.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute("UPDATE users SET request_limit = ? WHERE id = ?", (limit, user_id)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + def change_password(self, user_id: str, new_password: str) -> bool: + """Change a user's password.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + password_hash = self._hash_password(new_password) + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + def delete_user(self, user_id: str) -> bool: + """Delete a user.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + # Prevent deleting the default admin + cursor.execute("SELECT username FROM users WHERE id = ?", (user_id,)) + row = cursor.fetchone() + if row and row[0] == "admin": + conn.close() + return False + + # Delete sessions first + cursor.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) + # Delete user documents + cursor.execute("DELETE FROM user_documents WHERE user_id = ?", (user_id,)) + # Delete user + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + # User document management + def add_user_document(self, user_id: str, filename: str, file_type: str, content: bytes) -> str: + """Add a document to a user's profile.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + doc_id = self._generate_id() + now = datetime.utcnow().isoformat() + + cursor.execute(""" + INSERT INTO user_documents (id, user_id, filename, file_type, content, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, (doc_id, user_id, filename, file_type, content, now)) + + conn.commit() + conn.close() + + return doc_id + + def get_user_documents(self, user_id: str) -> List[Dict[str, Any]]: + """Get all documents for a user.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, filename, file_type, created_at, LENGTH(content) as size + FROM user_documents WHERE user_id = ? ORDER BY created_at DESC + """, (user_id,)) + + docs = [] + for row in cursor.fetchall(): + docs.append({ + "id": row[0], + "filename": row[1], + "file_type": row[2], + "created_at": row[3], + "size": row[4] + }) + + conn.close() + return docs + + def delete_user_document(self, user_id: str, doc_id: str) -> bool: + """Delete a user document.""" + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + cursor.execute("DELETE FROM user_documents WHERE id = ? AND user_id = ?", (doc_id, user_id)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + + return success + + +# Global auth manager instance +_auth_manager: Optional[AuthManager] = None + + +def get_auth_manager() -> AuthManager: + """Get or create the global auth manager instance.""" + global _auth_manager + if _auth_manager is None: + _auth_manager = AuthManager() + return _auth_manager diff --git a/moxie/build.py b/moxie/build.py index 85b74e3..eb229bd 100755 --- a/moxie/build.py +++ b/moxie/build.py @@ -32,10 +32,19 @@ BUILD_CONFIG = { "loguru", "websockets", "numpy", + "starlette", + "anyio", + "httptools", + "python_multipart", ], "include_data_dirs": [ ("admin/templates", "admin/templates"), ("admin/static", "admin/static"), + ("web/templates", "web/templates"), + ("web/static", "web/static"), + ], + "include_data_files": [ + ("moxie_logo.jpg", "moxie_logo.jpg"), ], } @@ -72,6 +81,12 @@ def build(): if src_path.exists(): cmd.append(f"--include-data-dir={src}={dst}") + # Add data files + for src, dst in BUILD_CONFIG.get("include_data_files", []): + src_path = PROJECT_ROOT / src + if src_path.exists(): + cmd.append(f"--include-data-file={src}={dst}") + # Add main module cmd.append(BUILD_CONFIG["main_module"]) diff --git a/moxie/main.py b/moxie/main.py index 2e32bf8..bb41200 100755 --- a/moxie/main.py +++ b/moxie/main.py @@ -18,8 +18,10 @@ from loguru import logger from config import settings, get_data_dir, get_workflows_dir from api.routes import router as api_router from api.admin import router as admin_router +from web.routes import router as web_router from core.orchestrator import Orchestrator from rag.store import RAGStore +from auth.models import get_auth_manager # Configure logging @@ -40,6 +42,10 @@ async def lifespan(app: FastAPI): get_data_dir() get_workflows_dir() + # Initialize auth manager + auth_manager = get_auth_manager() + logger.info("Auth Manager initialized") + # Initialize RAG store app.state.rag_store = RAGStore() logger.info("RAG Store initialized") @@ -50,6 +56,7 @@ async def lifespan(app: FastAPI): logger.success(f"MOXIE ready on http://{settings.host}:{settings.port}") logger.info(f"Admin UI: http://{settings.host}:{settings.port}/{settings.admin_path}") + logger.info(f"Web UI: http://{settings.host}:{settings.port}/") yield @@ -60,14 +67,14 @@ async def lifespan(app: FastAPI): # Create FastAPI app app = FastAPI( title="MOXIE", - description="OpenAI-compatible API that orchestrates multiple AI services", + description="AI Assistant with multiple backend support", version="1.0.0", lifespan=lifespan, docs_url=None, # Hide docs redoc_url=None, # Hide redoc ) -# CORS middleware for open-webui +# CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -85,9 +92,19 @@ if admin_static_path.exists(): name="admin-static" ) +# Static files for web UI +web_static_path = Path(__file__).parent / "web" / "static" +if web_static_path.exists(): + app.mount( + "/static", + StaticFiles(directory=str(web_static_path)), + name="web-static" + ) + # Include routers -app.include_router(api_router, prefix="/v1") -app.include_router(admin_router, prefix=f"/{settings.admin_path}", tags=["admin"]) +app.include_router(web_router, tags=["web"]) # Web UI routes at root +app.include_router(api_router, prefix="/v1", tags=["api"]) # OpenAI-compatible API +app.include_router(admin_router, prefix=f"/{settings.admin_path}", tags=["admin"]) # Admin panel @app.get("/health") @@ -96,7 +113,7 @@ async def health_check(): return {"status": "healthy", "service": "moxie"} -# Serve favicon to avoid 404s +# Serve favicon @app.get("/favicon.ico") async def favicon(): return {"status": "not found"} diff --git a/moxie/requirements.txt b/moxie/requirements.txt index 56190e7..984d9cb 100755 --- a/moxie/requirements.txt +++ b/moxie/requirements.txt @@ -35,3 +35,6 @@ loguru>=0.7.0 # ComfyUI websockets>=12.0 + +# Auth +python-jose[cryptography]>=3.3.0 diff --git a/moxie/web/__init__.py b/moxie/web/__init__.py new file mode 100644 index 0000000..45ea593 --- /dev/null +++ b/moxie/web/__init__.py @@ -0,0 +1,7 @@ +""" +MOXIE Web UI Module +OpenWebUI-like interface for MOXIE. +""" +from .routes import router as web_router + +__all__ = ["web_router"] diff --git a/moxie/web/routes.py b/moxie/web/routes.py new file mode 100644 index 0000000..df006a8 --- /dev/null +++ b/moxie/web/routes.py @@ -0,0 +1,825 @@ +""" +Web UI Routes +Landing page, chat interface, and user profile. +""" +import json +import uuid +from datetime import datetime +from pathlib import Path +from typing import Optional, List +from fastapi import APIRouter, Request, Response, Cookie, Form, UploadFile, File, HTTPException, Depends +from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel +from loguru import logger + +from config import settings +from auth.models import get_auth_manager, User +from core.orchestrator import Orchestrator + + +router = APIRouter() + +# Templates +templates = Jinja2Templates(directory=Path(__file__).parent / "templates") + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +async def get_current_user( + request: Request, + session_token: Optional[str] = Cookie(None) +) -> Optional[User]: + """Get the current logged-in user from session cookie.""" + if not session_token: + # Try header as fallback + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + session_token = auth_header[7:] + + if not session_token: + return None + + auth = get_auth_manager() + return auth.validate_session(session_token) + + +async def require_user( + request: Request, + session_token: Optional[str] = Cookie(None) +) -> User: + """Require a logged-in user, redirect to login if not.""" + user = await get_current_user(request, session_token) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +# ============================================================================ +# Page Routes +# ============================================================================ + +@router.get("/", response_class=HTMLResponse) +async def landing_page(request: Request): + """Landing page - redirects to chat if logged in, otherwise shows login.""" + session_token = request.cookies.get("session_token") + if session_token: + auth = get_auth_manager() + user = auth.validate_session(session_token) + if user: + return HTMLResponse( + status_code=302, + headers={"Location": "/chat"} + ) + + return templates.TemplateResponse( + "landing.html", + {"request": request, "settings": settings} + ) + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request, error: Optional[str] = None): + """Login page.""" + return templates.TemplateResponse( + "login.html", + {"request": request, "settings": settings, "error": error} + ) + + +@router.get("/signup", response_class=HTMLResponse) +async def signup_page(request: Request, error: Optional[str] = None): + """Signup page.""" + return templates.TemplateResponse( + "signup.html", + {"request": request, "settings": settings, "error": error} + ) + + +@router.post("/login") +async def login_submit( + response: Response, + username: str = Form(...), + password: str = Form(...) +): + """Process login form.""" + auth = get_auth_manager() + user = auth.authenticate(username, password) + + if not user: + return templates.TemplateResponse( + "login.html", + {"request": {}, "settings": settings, "error": "Invalid username or password"}, + status_code=401 + ) + + # Create session + token = auth.create_session(user.id) + + # Set cookie and redirect + response = JSONResponse({"success": True, "redirect": "/chat"}) + response.set_cookie( + key="session_token", + value=token, + httponly=True, + max_age=7 * 24 * 60 * 60, # 7 days + samesite="lax" + ) + + return response + + +@router.post("/signup") +async def signup_submit( + request: Request, + username: str = Form(...), + email: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...) +): + """Process signup form.""" + if password != confirm_password: + return templates.TemplateResponse( + "signup.html", + {"request": request, "settings": settings, "error": "Passwords do not match"}, + status_code=400 + ) + + if len(password) < 6: + return templates.TemplateResponse( + "signup.html", + {"request": request, "settings": settings, "error": "Password must be at least 6 characters"}, + status_code=400 + ) + + auth = get_auth_manager() + user = auth.create_user(username, email, password) + + if not user: + return templates.TemplateResponse( + "signup.html", + {"request": request, "settings": settings, "error": "Username or email already exists"}, + status_code=400 + ) + + # Create session + token = auth.create_session(user.id) + + # Set cookie and redirect + response = JSONResponse({"success": True, "redirect": "/chat"}) + response.set_cookie( + key="session_token", + value=token, + httponly=True, + max_age=7 * 24 * 60 * 60, + samesite="lax" + ) + + return response + + +@router.get("/logout") +async def logout(response: Response, session_token: Optional[str] = Cookie(None)): + """Logout user.""" + if session_token: + auth = get_auth_manager() + auth.delete_session(session_token) + + response = JSONResponse({"success": True}) + response.delete_cookie("session_token") + return response + + +@router.get("/chat", response_class=HTMLResponse) +async def chat_page(request: Request, session_token: Optional[str] = Cookie(None)): + """Main chat interface.""" + auth = get_auth_manager() + user = None + + if session_token: + user = auth.validate_session(session_token) + + if not user: + return HTMLResponse( + status_code=302, + headers={"Location": "/login"} + ) + + rate_info = auth.check_rate_limit(user.id) + + return templates.TemplateResponse( + "chat.html", + { + "request": request, + "settings": settings, + "user": user, + "rate_info": rate_info + } + ) + + +@router.get("/profile", response_class=HTMLResponse) +async def profile_page(request: Request, session_token: Optional[str] = Cookie(None)): + """User profile page with document management.""" + auth = get_auth_manager() + user = None + + if session_token: + user = auth.validate_session(session_token) + + if not user: + return HTMLResponse( + status_code=302, + headers={"Location": "/login"} + ) + + documents = auth.get_user_documents(user.id) + rate_info = auth.check_rate_limit(user.id) + + return templates.TemplateResponse( + "profile.html", + { + "request": request, + "settings": settings, + "user": user, + "documents": documents, + "rate_info": rate_info + } + ) + + +# ============================================================================ +# API Routes (for chat functionality) +# ============================================================================ + +class ChatRequest(BaseModel): + """Chat request model.""" + message: str + conversation_id: Optional[str] = None + attachments: Optional[List[str]] = None + + +class ConversationUpdate(BaseModel): + """Conversation update model.""" + title: Optional[str] = None + + +@router.get("/api/user") +async def get_user_info(request: Request, session_token: Optional[str] = Cookie(None)): + """Get current user info.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + rate_info = auth.check_rate_limit(user.id) + + return { + "id": user.id, + "username": user.username, + "email": user.email, + "is_admin": user.is_admin, + "request_count": user.request_count, + "request_limit": user.request_limit, + "remaining_requests": rate_info["remaining"] + } + + +@router.get("/api/conversations") +async def get_conversations(request: Request, session_token: Optional[str] = Cookie(None)): + """Get all conversations for the current user.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + # Get conversations from database + from config import get_db_path + import sqlite3 + + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + cursor.execute(""" + SELECT id, title, created_at, updated_at + FROM conversations WHERE user_id = ? + ORDER BY updated_at DESC + """, (user.id,)) + + conversations = [] + for row in cursor.fetchall(): + conversations.append({ + "id": row[0], + "title": row[1], + "created_at": row[2], + "updated_at": row[3] + }) + + conn.close() + return conversations + + +@router.post("/api/conversations") +async def create_conversation( + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Create a new conversation.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + from config import get_db_path + import sqlite3 + + conv_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + # Ensure conversations table exists + cursor.execute(""" + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT, + created_at TEXT, + updated_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + + cursor.execute(""" + INSERT INTO conversations (id, user_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + """, (conv_id, user.id, "New Chat", now, now)) + + conn.commit() + conn.close() + + return {"id": conv_id, "title": "New Chat", "created_at": now, "updated_at": now} + + +@router.get("/api/conversations/{conv_id}/messages") +async def get_conversation_messages( + conv_id: str, + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Get all messages for a conversation.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + from config import get_db_path + import sqlite3 + + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + # Verify ownership + cursor.execute("SELECT user_id FROM conversations WHERE id = ?", (conv_id,)) + row = cursor.fetchone() + if not row or row[0] != user.id: + conn.close() + return JSONResponse({"error": "Not found"}, status_code=404) + + # Get messages + cursor.execute(""" + SELECT id, role, content, attachments, created_at + FROM messages WHERE conversation_id = ? + ORDER BY created_at ASC + """, (conv_id,)) + + messages = [] + for row in cursor.fetchall(): + attachments = json.loads(row[3]) if row[3] else [] + messages.append({ + "id": row[0], + "role": row[1], + "content": row[2], + "attachments": attachments, + "created_at": row[4] + }) + + conn.close() + return messages + + +@router.patch("/api/conversations/{conv_id}") +async def update_conversation( + conv_id: str, + update: ConversationUpdate, + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Update a conversation (e.g., rename).""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + from config import get_db_path + import sqlite3 + + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + # Verify ownership + cursor.execute("SELECT user_id FROM conversations WHERE id = ?", (conv_id,)) + row = cursor.fetchone() + if not row or row[0] != user.id: + conn.close() + return JSONResponse({"error": "Not found"}, status_code=404) + + now = datetime.utcnow().isoformat() + + if update.title: + cursor.execute( + "UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?", + (update.title, now, conv_id) + ) + + conn.commit() + conn.close() + + return {"success": True} + + +@router.delete("/api/conversations/{conv_id}") +async def delete_conversation( + conv_id: str, + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Delete a conversation and its messages.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + from config import get_db_path + import sqlite3 + + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + # Verify ownership + cursor.execute("SELECT user_id FROM conversations WHERE id = ?", (conv_id,)) + row = cursor.fetchone() + if not row or row[0] != user.id: + conn.close() + return JSONResponse({"error": "Not found"}, status_code=404) + + # Delete messages first + cursor.execute("DELETE FROM messages WHERE conversation_id = ?", (conv_id,)) + # Delete conversation + cursor.execute("DELETE FROM conversations WHERE id = ?", (conv_id,)) + + conn.commit() + conn.close() + + return {"success": True} + + +@router.post("/api/chat") +async def send_chat_message( + chat_request: ChatRequest, + request: Request, + session_token: Optional[str] = Cookie(None) +): + """Send a chat message and get a streaming response.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + # Check rate limit + rate_info = auth.check_rate_limit(user.id) + if not rate_info["allowed"]: + return JSONResponse( + {"error": f"Daily limit reached. You have used {user.request_count}/{user.request_limit} requests today."}, + status_code=429 + ) + + # Get orchestrator + orchestrator: Orchestrator = request.app.state.orchestrator + + from config import get_db_path + import sqlite3 + + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + # Ensure tables exist + cursor.execute(""" + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT, + created_at TEXT, + updated_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + attachments TEXT, + created_at TEXT, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) + ) + """) + + # Create conversation if needed + conv_id = chat_request.conversation_id + if not conv_id: + conv_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + + # Generate title from first message + title = chat_request.message[:50] + ("..." if len(chat_request.message) > 50 else "") + + cursor.execute(""" + INSERT INTO conversations (id, user_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + """, (conv_id, user.id, title, now, now)) + + # Save user message + user_msg_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + + cursor.execute(""" + INSERT INTO messages (id, conversation_id, role, content, attachments, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, (user_msg_id, conv_id, "user", chat_request.message, + json.dumps(chat_request.attachments or []), now)) + + # Update conversation timestamp + cursor.execute("UPDATE conversations SET updated_at = ? WHERE id = ?", (now, conv_id)) + + # Get previous messages for context + cursor.execute(""" + SELECT role, content FROM messages + WHERE conversation_id = ? + ORDER BY created_at ASC + """, (conv_id,)) + + messages = [{"role": row[0], "content": row[1]} for row in cursor.fetchall()] + + conn.commit() + conn.close() + + # Increment request count + auth.increment_request_count(user.id) + + # Return streaming response + return StreamingResponse( + stream_chat_response(request, orchestrator, messages, conv_id, user), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + "X-Conversation-Id": conv_id + } + ) + + +async def stream_chat_response( + request: Request, + orchestrator: Orchestrator, + messages: List[dict], + conv_id: str, + user: User +): + """Stream the chat response and save to database.""" + from config import get_db_path + import sqlite3 + + full_response = "" + + async for chunk in orchestrator.process_stream(messages=messages): + if "content" in chunk and chunk["content"]: + full_response += chunk["content"] + data = {"content": chunk["content"]} + yield f"data: {json.dumps(data)}\n\n" + + # Save assistant message to database + conn = sqlite3.connect(str(get_db_path())) + cursor = conn.cursor() + + asst_msg_id = str(uuid.uuid4()) + now = datetime.utcnow().isoformat() + + cursor.execute(""" + INSERT INTO messages (id, conversation_id, role, content, created_at) + VALUES (?, ?, ?, ?, ?) + """, (asst_msg_id, conv_id, "assistant", full_response, now)) + + conn.commit() + conn.close() + + # Send done signal + yield "data: [DONE]\n\n" + + +# ============================================================================ +# File Upload Routes +# ============================================================================ + +@router.post("/api/upload") +async def upload_file( + file: UploadFile = File(...), + session_token: Optional[str] = Cookie(None) +): + """Upload a file for attachment to messages.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + # Read file + content = await file.read() + + # Save to temp location or database + from config import get_data_dir + uploads_dir = get_data_dir() / "uploads" / user.id + uploads_dir.mkdir(parents=True, exist_ok=True) + + file_id = str(uuid.uuid4()) + file_ext = Path(file.filename or "file").suffix + file_path = uploads_dir / f"{file_id}{file_ext}" + + with open(file_path, "wb") as f: + f.write(content) + + return { + "id": file_id, + "filename": file.filename, + "size": len(content), + "url": f"/api/files/{file_id}" + } + + +@router.get("/api/files/{file_id}") +async def get_file( + file_id: str, + session_token: Optional[str] = Cookie(None) +): + """Get an uploaded file.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + from config import get_data_dir + uploads_dir = get_data_dir() / "uploads" / user.id + + # Find file by ID + for file_path in uploads_dir.glob(f"{file_id}.*"): + from fastapi.responses import FileResponse + return FileResponse(file_path) + + return JSONResponse({"error": "File not found"}, status_code=404) + + +# ============================================================================ +# User Profile Document Routes +# ============================================================================ + +@router.post("/api/profile/documents") +async def upload_profile_document( + file: UploadFile = File(...), + session_token: Optional[str] = Cookie(None) +): + """Upload a document to user's profile.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + content = await file.read() + file_type = Path(file.filename or "file").suffix + + doc_id = auth.add_user_document(user.id, file.filename or "document", file_type, content) + + return { + "id": doc_id, + "filename": file.filename, + "size": len(content), + "file_type": file_type + } + + +@router.get("/api/profile/documents") +async def get_profile_documents( + session_token: Optional[str] = Cookie(None) +): + """Get all documents in user's profile.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + documents = auth.get_user_documents(user.id) + return documents + + +@router.delete("/api/profile/documents/{doc_id}") +async def delete_profile_document( + doc_id: str, + session_token: Optional[str] = Cookie(None) +): + """Delete a document from user's profile.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + success = auth.delete_user_document(user.id, doc_id) + + if success: + return {"success": True} + return JSONResponse({"error": "Document not found"}, status_code=404) + + +# ============================================================================ +# Password Change +# ============================================================================ + +class PasswordChange(BaseModel): + current_password: str + new_password: str + + +@router.post("/api/profile/password") +async def change_password( + data: PasswordChange, + session_token: Optional[str] = Cookie(None) +): + """Change user's password.""" + auth = get_auth_manager() + if not session_token: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + + user = auth.validate_session(session_token) + if not user: + return JSONResponse({"error": "Invalid session"}, status_code=401) + + # Verify current password + verified = auth.authenticate(user.username, data.current_password) + if not verified: + return JSONResponse({"error": "Current password is incorrect"}, status_code=400) + + # Change password + success = auth.change_password(user.id, data.new_password) + + if success: + return {"success": True} + return JSONResponse({"error": "Failed to change password"}, status_code=500) diff --git a/moxie/web/static/moxie_logo.jpg b/moxie/web/static/moxie_logo.jpg new file mode 100644 index 0000000..a96812f Binary files /dev/null and b/moxie/web/static/moxie_logo.jpg differ diff --git a/moxie/web/templates/chat.html b/moxie/web/templates/chat.html new file mode 100644 index 0000000..f19d4a7 --- /dev/null +++ b/moxie/web/templates/chat.html @@ -0,0 +1,622 @@ + + + + + + MOXIE Chat + + + + + + + + + + + + +
    + +
    +
    New Chat
    +
    + + Ready +
    +
    + + +
    +
    + MOXIE +

    Welcome to MOXIE!

    +

    Start a conversation or ask me to create images, videos, or audio for you.

    +
    +
    +
    👋
    +
    Tell me about yourself
    +
    +
    +
    🎨
    +
    Create an image
    +
    +
    +
    🔍
    +
    Search the web
    +
    +
    +
    ✍️
    +
    Write something creative
    +
    +
    +
    +
    + +
    +
    + + +
    +
    + + + + +
    +
    + + + + + + + + +
    +
    + +
    + MOXIE can make mistakes. Free accounts limited to 5 requests/day. +
    +
    +
    +
    + + + + diff --git a/moxie/web/templates/landing.html b/moxie/web/templates/landing.html new file mode 100644 index 0000000..f13fae8 --- /dev/null +++ b/moxie/web/templates/landing.html @@ -0,0 +1,104 @@ + + + + + + MOXIE - AI Assistant + + + + + + +
    +
    +
    +
    + + +
    + +
    + MOXIE Logo +
    + + +

    MOXIE

    +

    Your Uplifting AI Assistant

    +

    + A fresh approach to AI. Powered by a neural network that lifts your ideas to new heights. +

    + + +
    + + Sign In + + + Create Account + +
    + + +
    +
    +
    🧠
    +

    Intelligent

    +

    Advanced reasoning powered by multiple AI systems

    +
    +
    +
    🎨
    +

    Creative

    +

    Generate images, videos, and audio seamlessly

    +
    +
    +
    📚
    +

    Knowledgeable

    +

    Access to web search and your personal documents

    +
    +
    +
    + + + + + diff --git a/moxie/web/templates/login.html b/moxie/web/templates/login.html new file mode 100644 index 0000000..0dfb57e --- /dev/null +++ b/moxie/web/templates/login.html @@ -0,0 +1,137 @@ + + + + + + Sign In - MOXIE + + + + + +
    +
    +
    +
    + +
    + +
    + + MOXIE + +

    Welcome Back

    +

    Sign in to continue to MOXIE

    +
    + + +
    +
    + {% if error %} +
    + {{ error }} +
    + {% endif %} + +
    + + +
    + +
    + + +
    + + +
    + +
    + Don't have an account? + Create one +
    +
    + + +
    + + ← Back to Home + +
    +
    + + + + diff --git a/moxie/web/templates/profile.html b/moxie/web/templates/profile.html new file mode 100644 index 0000000..14117fa --- /dev/null +++ b/moxie/web/templates/profile.html @@ -0,0 +1,257 @@ + + + + + + Profile - MOXIE + + + + + +
    +
    + + MOXIE + MOXIE + + ← Back to Chat +
    +
    + + +
    + +
    +
    + {{ user.username[0].upper() }} +
    +
    +

    {{ user.username }}

    +

    {{ user.email }}

    + {% if user.is_admin %} + Admin + {% endif %} +
    +
    + + +
    +
    +
    {{ rate_info.remaining }}
    +
    Requests remaining today
    +
    +
    +
    {{ user.request_limit }}
    +
    Daily request limit
    +
    +
    +
    {{ documents|length }}
    +
    Documents uploaded
    +
    +
    + +
    + +
    +

    + + + + Change Password +

    +
    +
    + + +
    +
    + + +
    + +
    +
    + + +
    +

    + + + + My Documents +

    +

    Upload documents to use in your conversations with MOXIE.

    + + +
    + + + + +

    Click to upload or drag and drop

    +

    PDF, DOC, DOCX, TXT, MD

    +
    + + +
    + {% for doc in documents %} +
    +
    + 📄 +
    +
    {{ doc.filename }}
    +
    {{ doc.size|filesizeformat }}
    +
    +
    + +
    + {% endfor %} + {% if not documents %} +

    No documents uploaded yet

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

    + + + + Account Information +

    +
    +
    + Member since: + {{ user.created_at[:10] if user.created_at else 'N/A' }} +
    +
    + Account type: + {{ 'Administrator' if user.is_admin else 'Free (5 req/day)' }} +
    +
    +
    +
    + + + + diff --git a/moxie/web/templates/signup.html b/moxie/web/templates/signup.html new file mode 100644 index 0000000..e266efe --- /dev/null +++ b/moxie/web/templates/signup.html @@ -0,0 +1,181 @@ + + + + + + Create Account - MOXIE + + + + + +
    +
    +
    +
    + +
    + +
    + + MOXIE + +

    Create Account

    +

    Join MOXIE and start exploring AI

    +
    + + +
    +
    + {% if error %} +
    + {{ error }} +
    + {% endif %} + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + Free accounts are limited to 5 requests per day. +
    + + +
    + +
    + Already have an account? + Sign in +
    +
    + + +
    + + ← Back to Home + +
    +
    + + + +