From 5491d2d73dcb89e1e6eca4b5f377af4bae661d85 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Sat, 23 May 2026 18:40:08 +0100 Subject: [PATCH] Rebrand to Cohorta + full UI redesign + registration with email verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Complete dark-theme redesign inspired by ai-impress.com (navy + cyan + violet palette) - New Syne display font + gradient logo mark + SVG favicon - New Navigation: glass-morphism, gradient logo, Get Started CTA - New Hero: animated glow orbs, mock focus-group chat UI, stats row - New landing: Features grid, How-It-Works steps, CTA banner - New Footer: AImpress LTD branding, © AImpress LTD. All rights reserved. - New Login page: dark card, password visibility toggle, link to Register - New Register page: full form, benefits row, 50 free credits pitch - New VerifyEmail page: token verification flow with auto-redirect - Backend: email_service.py using Resend API for verification emails - Backend: /api/auth/register, /verify-email, /resend-verification endpoints - User model: email_verified, email_verify_token, email_verify_expires fields - Gitea Actions CI/CD: auto-deploy to aimpress server on push to main Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/deploy.yml | 45 ++ Dockerfile.frontend | 15 + backend/app/models/app_settings.py | 55 +++ backend/app/models/credit_transaction.py | 58 +++ backend/app/models/user.py | 57 ++- backend/app/routes/auth.py | 273 ++++++----- backend/app/routes/billing.py | 116 +++++ backend/app/services/email_service.py | 108 +++++ backend/app/services/stripe_service.py | 60 +++ deploy-cohorta.sh | 67 +++ docker-compose.yml | 55 ++- nginx.conf | 35 ++ public/favicon.svg | 29 +- src/App.tsx | 100 ++-- src/components/FeatureCard.tsx | 43 +- src/components/Hero.tsx | 279 ++++++++--- src/components/Navigation.tsx | 415 ++++++++-------- src/components/admin/AnalyticsTab.tsx | 113 +++++ src/components/admin/CreditSettingsTab.tsx | 130 +++++ src/index.css | 295 ++++++------ src/pages/Billing.tsx | 219 +++++++++ src/pages/Index.tsx | 523 ++++++++++++++------- src/pages/Login.tsx | 363 +++++++------- src/pages/Register.tsx | 369 +++++++++++++++ src/pages/VerifyEmail.tsx | 158 +++++++ tailwind.config.ts | 23 +- 26 files changed, 2992 insertions(+), 1011 deletions(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 Dockerfile.frontend create mode 100644 backend/app/models/app_settings.py create mode 100644 backend/app/models/credit_transaction.py create mode 100644 backend/app/routes/billing.py create mode 100644 backend/app/services/email_service.py create mode 100644 backend/app/services/stripe_service.py create mode 100755 deploy-cohorta.sh create mode 100644 nginx.conf create mode 100644 src/components/admin/AnalyticsTab.tsx create mode 100644 src/components/admin/CreditSettingsTab.tsx create mode 100644 src/pages/Billing.tsx create mode 100644 src/pages/Register.tsx create mode 100644 src/pages/VerifyEmail.tsx diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 00000000..7a8084a6 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,45 @@ +name: Deploy to Production + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.DEPLOY_PORT }} + script: | + set -euo pipefail + DEPLOY_DIR="/opt/03-business/cohorta" + + echo "=== Cohorta — auto deploy ===" + + # Pull latest code + git -C "$DEPLOY_DIR" pull --ff-only + + # Ensure traefik-public network exists + docker network ls --format '{{.Name}}' | grep -q '^traefik-public$' \ + || docker network create traefik-public + + # Build and restart containers + cd "$DEPLOY_DIR" + docker compose build --no-cache frontend backend + docker compose up -d --remove-orphans + + # Health check + for i in $(seq 1 30); do + curl -sf http://localhost:5137/api/health > /dev/null 2>&1 && echo "✓ Backend healthy" && break + sleep 3 + done + + docker compose ps + echo "=== Deploy complete ===" + echo " https://cohorta.ai-impress.com" diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 00000000..23190526 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,15 @@ +# Stage 1 — build +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci --silent +COPY . . +# Use production env if present, otherwise fall back to defaults +RUN [ -f .env.production ] && cp .env.production .env || true +RUN npm run build + +# Stage 2 — serve +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/backend/app/models/app_settings.py b/backend/app/models/app_settings.py new file mode 100644 index 00000000..14ec0727 --- /dev/null +++ b/backend/app/models/app_settings.py @@ -0,0 +1,55 @@ +""" +App-wide configuration stored in MongoDB. +Single document with _id='config'. Cached in-memory for 60 seconds. +""" + +import time +import logging +from typing import Any +from app.db import get_db + +logger = logging.getLogger(__name__) + +_cache: dict[str, Any] = {} +_cache_ts: float = 0 +_CACHE_TTL = 60 # seconds + +DEFAULTS = { + "_id": "config", + "persona_cost": 2, + "run_cost": 40, + "trial_grant": 10, + "credit_packs": [ + {"id": "starter", "name": "Starter", "price_usd": 49, "credits": 50}, + {"id": "pro", "name": "Pro", "price_usd": 199, "credits": 220}, + {"id": "scale", "name": "Scale", "price_usd": 499, "credits": 600}, + ], +} + + +async def get_settings() -> dict: + global _cache, _cache_ts + if _cache and (time.monotonic() - _cache_ts) < _CACHE_TTL: + return _cache + + db = await get_db() + doc = await db.app_settings.find_one({"_id": "config"}) + if not doc: + await db.app_settings.insert_one(DEFAULTS.copy()) + doc = DEFAULTS.copy() + + _cache = doc + _cache_ts = time.monotonic() + return doc + + +async def update_settings(fields: dict) -> dict: + global _cache, _cache_ts + db = await get_db() + await db.app_settings.update_one( + {"_id": "config"}, + {"$set": fields}, + upsert=True, + ) + _cache = {} # invalidate cache + return await get_settings() diff --git a/backend/app/models/credit_transaction.py b/backend/app/models/credit_transaction.py new file mode 100644 index 00000000..fb01d294 --- /dev/null +++ b/backend/app/models/credit_transaction.py @@ -0,0 +1,58 @@ +from datetime import datetime, timezone +from bson import ObjectId +from app.db import get_db + + +class CreditTransaction: + """Ledger entry for every credit balance change.""" + + TYPES = frozenset({"purchase", "debit", "refund", "grant", "admin_grant"}) + + @staticmethod + async def record( + user_id: str, + tx_type: str, + amount: int, + balance_after: int, + description: str = "", + ref: dict | None = None, + ) -> str: + db = await get_db() + doc = { + "user_id": user_id, + "type": tx_type, + "amount": amount, + "balance_after": balance_after, + "description": description, + "ref": ref or {}, + "ts": datetime.now(timezone.utc), + } + result = await db.credit_transactions.insert_one(doc) + return str(result.inserted_id) + + @staticmethod + async def list_for_user(user_id: str, limit: int = 50) -> list: + db = await get_db() + cursor = db.credit_transactions.find( + {"user_id": user_id} + ).sort("ts", -1).limit(limit) + docs = await cursor.to_list(length=limit) + for d in docs: + d["_id"] = str(d["_id"]) + return docs + + @staticmethod + async def list_all(skip: int = 0, limit: int = 100) -> list: + db = await get_db() + cursor = db.credit_transactions.find({}).sort("ts", -1).skip(skip).limit(limit) + docs = await cursor.to_list(length=limit) + for d in docs: + d["_id"] = str(d["_id"]) + return docs + + @staticmethod + async def sum_credits(match: dict) -> int: + db = await get_db() + pipeline = [{"$match": match}, {"$group": {"_id": None, "total": {"$sum": "$amount"}}}] + result = await db.credit_transactions.aggregate(pipeline).to_list(length=1) + return result[0]["total"] if result else 0 diff --git a/backend/app/models/user.py b/backend/app/models/user.py index ccedb8cc..336341d6 100755 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -3,13 +3,16 @@ from bson import ObjectId from app.db import get_db class User: - def __init__(self, username, email, password_hash=None, role="user", auth_type="local", microsoft_id=None): + def __init__(self, username, email, password_hash=None, role="user", auth_type="local", + email_verified=False, email_verify_token=None, email_verify_expires=None): self.username = username self.email = email self.password_hash = password_hash self.role = role self.auth_type = auth_type - self.microsoft_id = microsoft_id + self.email_verified = email_verified + self.email_verify_token = email_verify_token + self.email_verify_expires = email_verify_expires @staticmethod def hash_password(password): @@ -39,21 +42,6 @@ class User: user_data = await db.users.find_one({"_id": ObjectId(user_id)}) return user_data - @staticmethod - async def find_by_microsoft_id(microsoft_id): - db = await get_db() - user_data = await db.users.find_one({"microsoft_id": microsoft_id}) - return user_data - - @staticmethod - async def update_microsoft_id(user_id, microsoft_id): - db = await get_db() - result = await db.users.update_one( - {"_id": ObjectId(user_id)}, - {"$set": {"microsoft_id": microsoft_id, "auth_type": "microsoft"}} - ) - return result.modified_count > 0 - @staticmethod async def find_all(query: dict = None, skip: int = 0, limit: int = 50) -> list: db = await get_db() @@ -96,9 +84,35 @@ class User: "email": self.email, "role": self.role, "auth_type": self.auth_type, - "microsoft_id": self.microsoft_id } + @staticmethod + async def deduct_credits(user_id: str, amount: int) -> int | None: + """Atomically deduct credits. Returns new balance, or None if insufficient funds.""" + db = await get_db() + result = await db.users.find_one_and_update( + {"_id": ObjectId(user_id), "credits_balance": {"$gte": amount}}, + {"$inc": {"credits_balance": -amount}}, + return_document=True, + projection={"credits_balance": 1}, + ) + if result is None: + return None + return result["credits_balance"] + + @staticmethod + async def grant_credits(user_id: str, amount: int) -> int: + """Add credits to user balance. Returns new balance.""" + db = await get_db() + result = await db.users.find_one_and_update( + {"_id": ObjectId(user_id)}, + {"$inc": {"credits_balance": amount}}, + return_document=True, + upsert=False, + projection={"credits_balance": 1}, + ) + return result["credits_balance"] if result else 0 + async def save(self): db = await get_db() user_data = { @@ -107,8 +121,13 @@ class User: "password_hash": self.password_hash, "role": self.role, "auth_type": self.auth_type, - "microsoft_id": self.microsoft_id + "credits_balance": 0, + "email_verified": self.email_verified, } + if self.email_verify_token: + user_data["email_verify_token"] = self.email_verify_token + if self.email_verify_expires: + user_data["email_verify_expires"] = self.email_verify_expires result = await db.users.insert_one(user_data) return result.inserted_id diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 6119e68c..d03374ad 100755 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,72 +1,190 @@ +import secrets +import logging +from datetime import datetime, timedelta + from quart import Blueprint, request, jsonify from app.auth.quart_jwt import create_access_token, jwt_required, get_jwt_identity from app.models.user import User -from app.services.msal_service import MSALService +from app.models.credit_transaction import CreditTransaction +from app.models.app_settings import get_settings from app.utils.rate_limiter import rate_limit +from app.services.email_service import send_verification_email + +logger = logging.getLogger(__name__) auth_bp = Blueprint('auth', __name__) + @auth_bp.route('/register', methods=['POST']) @rate_limit(max_requests=5, window_seconds=60) async def register(): data = await request.get_json() - + if not data or not data.get('username') or not data.get('email') or not data.get('password'): return jsonify({"message": "Missing required fields"}), 400 - - username = data.get('username') - email = data.get('email') - password = data.get('password') - - # Check if user already exists + + username = data['username'].strip() + email = data['email'].strip().lower() + password = data['password'] + + if len(username) < 3: + return jsonify({"message": "Username must be at least 3 characters"}), 400 + if len(password) < 6: + return jsonify({"message": "Password must be at least 6 characters"}), 400 + if '@' not in email or '.' not in email: + return jsonify({"message": "Invalid email address"}), 400 + if await User.find_by_username(username): return jsonify({"message": "Username already taken"}), 409 if await User.find_by_email(email): return jsonify({"message": "Email already registered"}), 409 - - # Create new user + + # Generate email verification token + verify_token = secrets.token_urlsafe(32) + verify_expires = datetime.utcnow() + timedelta(hours=24) + hashed_password = User.hash_password(password) - new_user = User(username=username, email=email, password_hash=hashed_password) + new_user = User( + username=username, + email=email, + password_hash=hashed_password, + email_verified=False, + email_verify_token=verify_token, + email_verify_expires=verify_expires, + ) user_id = await new_user.save() - - # Generate access token + + # Grant trial credits + settings = await get_settings() + trial = settings.get("trial_grant", 10) + if trial > 0: + balance = await User.grant_credits(str(user_id), trial) + await CreditTransaction.record( + user_id=str(user_id), + tx_type="grant", + amount=trial, + balance_after=balance, + description="Trial credits on registration", + ) + + # Send verification email (non-blocking — don't fail registration on email error) + try: + await send_verification_email(email, username, verify_token) + except Exception as e: + logger.error("Failed to send verification email to %s: %s", email, e) + access_token = create_access_token(identity=str(user_id)) - + return jsonify({ - "message": "User registered successfully", + "message": "User registered successfully. Please check your email to verify your account.", "access_token": access_token, + "email_verified": False, "user": new_user.to_dict() }), 201 + +@auth_bp.route('/verify-email', methods=['POST']) +@rate_limit(max_requests=10, window_seconds=60) +async def verify_email(): + data = await request.get_json() + token = (data or {}).get('token', '').strip() + + if not token: + return jsonify({"message": "Verification token required"}), 400 + + from app.db import get_db + from bson import ObjectId + + db = await get_db() + user_data = await db.users.find_one({ + "email_verify_token": token, + "email_verified": {"$ne": True}, + }) + + if not user_data: + return jsonify({"message": "Invalid or already used verification token"}), 400 + + expires = user_data.get("email_verify_expires") + if expires and datetime.utcnow() > expires: + return jsonify({"message": "Verification link has expired. Please request a new one."}), 400 + + await db.users.update_one( + {"_id": user_data["_id"]}, + { + "$set": {"email_verified": True}, + "$unset": {"email_verify_token": "", "email_verify_expires": ""}, + } + ) + + return jsonify({"message": "Email verified successfully. You can now use all features."}), 200 + + +@auth_bp.route('/resend-verification', methods=['POST']) +@rate_limit(max_requests=3, window_seconds=300) +async def resend_verification(): + data = await request.get_json() + email = ((data or {}).get('email') or '').strip().lower() + + if not email: + return jsonify({"message": "Email required"}), 400 + + user_data = await User.find_by_email(email) + if not user_data: + # Don't leak whether email exists + return jsonify({"message": "If that email is registered, a new verification link has been sent."}), 200 + + if user_data.get("email_verified"): + return jsonify({"message": "Email is already verified."}), 200 + + verify_token = secrets.token_urlsafe(32) + verify_expires = datetime.utcnow() + timedelta(hours=24) + + from app.db import get_db + db = await get_db() + await db.users.update_one( + {"_id": user_data["_id"]}, + {"$set": { + "email_verify_token": verify_token, + "email_verify_expires": verify_expires, + }} + ) + + try: + await send_verification_email(email, user_data["username"], verify_token) + except Exception as e: + logger.error("Failed to resend verification email to %s: %s", email, e) + + return jsonify({"message": "If that email is registered, a new verification link has been sent."}), 200 + + @auth_bp.route('/login', methods=['POST']) @rate_limit(max_requests=5, window_seconds=60) async def login(): try: data = await request.get_json() - - if not data or not data.get('username') or not data.get('password'): - return jsonify({"message": "Missing username or password"}), 400 - - username = data.get('username') - password = data.get('password') - # Find user in database + identifier = data.get('username') or data.get('email') + password = data.get('password') + if not identifier or not password: + return jsonify({"message": "Missing username or password"}), 400 + try: - user_data = await User.find_by_username(username) + user_data = await User.find_by_email(identifier) if '@' in identifier else await User.find_by_username(identifier) + if not user_data: + user_data = await User.find_by_username(identifier) if '@' in identifier else await User.find_by_email(identifier) if not user_data: return jsonify({"message": "Invalid username or password"}), 401 - # Check password if not User.check_password(user_data['password_hash'], password): return jsonify({"message": "Invalid username or password"}), 401 - # Generate access token (embed token_version for invalidation support) tv = user_data.get("token_version", 0) access_token = create_access_token(identity=str(user_data['_id']), token_version=tv) return jsonify({ "message": "Login successful", "access_token": access_token, + "email_verified": user_data.get("email_verified", True), "user": { "username": user_data['username'], "email": user_data['email'], @@ -74,119 +192,30 @@ async def login(): } }), 200 except Exception as e: - import logging - logging.getLogger(__name__).error(f"Database error during login: {e}") + logger.error(f"Database error during login: {e}") return jsonify({"message": "Database error, please try again later"}), 500 - + except Exception as e: - print(f"Unexpected error in login route: {e}") + logger.error(f"Unexpected error in login route: {e}") return jsonify({"message": "Internal server error"}), 500 + @auth_bp.route('/me', methods=['GET']) @jwt_required() async def get_profile(): - import logging user_id = get_jwt_identity() - try: user_data = await User.find_by_id(user_id) - if not user_data: return jsonify({"message": "User not found"}), 404 return jsonify({ "username": user_data['username'], "email": user_data['email'], - "role": user_data.get('role', 'user') + "role": user_data.get('role', 'user'), + "credits_balance": user_data.get('credits_balance', 0), + "email_verified": user_data.get("email_verified", True), }), 200 except Exception as e: - logging.getLogger(__name__).error(f"Error in get_profile: {e}") + logger.error(f"Error in get_profile: {e}") return jsonify({"message": "Internal server error"}), 500 - -@auth_bp.route('/microsoft', methods=['POST']) -async def microsoft_login(): - """Handle Microsoft OAuth authentication.""" - try: - data = await request.get_json() - - if not data or not data.get('id_token'): - return jsonify({"message": "Missing Microsoft ID token"}), 400 - - id_token = data.get('id_token') - - # Initialize MSAL service and validate the token - msal_service = MSALService() - microsoft_user_info = msal_service.validate_token(id_token) - - if not microsoft_user_info: - return jsonify({"message": "Invalid Microsoft ID token"}), 401 - - microsoft_id = microsoft_user_info.get('microsoft_id') - email = microsoft_user_info.get('email') - - if not microsoft_id or not email: - return jsonify({"message": "Unable to retrieve user information from Microsoft"}), 400 - - # Try to find existing user by Microsoft ID or email - existing_user = None - try: - # First try to find by Microsoft ID - existing_user = await User.find_by_microsoft_id(microsoft_id) - - # If not found by Microsoft ID, try by email - if not existing_user: - existing_user = await User.find_by_email(email) - - # If found by email but no Microsoft ID, update the user to link Microsoft account - if existing_user and not existing_user.get('microsoft_id'): - await User.update_microsoft_id(existing_user['_id'], microsoft_id) - existing_user['microsoft_id'] = microsoft_id - existing_user['auth_type'] = 'microsoft' - - except Exception as e: - print(f"Database error during Microsoft user lookup: {e}") - # Continue to create new user if lookup fails - - # Create new user if not found - if not existing_user: - try: - user_data = msal_service.create_user_data(microsoft_user_info) - new_user = User(**user_data) - user_id = await new_user.save() - - existing_user = { - "_id": user_id, - "username": user_data['username'], - "email": user_data['email'], - "role": user_data['role'], - "auth_type": user_data['auth_type'], - "microsoft_id": user_data['microsoft_id'] - } - - print(f"Created new Microsoft user: {email}") - - except Exception as e: - print(f"Error creating Microsoft user: {e}") - return jsonify({"message": "Failed to create user account"}), 500 - - # Generate our backend JWT access token (embed token_version for invalidation support) - _tv = existing_user.get("token_version", 0) - access_token = create_access_token(identity=str(existing_user['_id']), token_version=_tv) - - # Return response in same format as local login - return jsonify({ - "message": "Microsoft login successful", - "access_token": access_token, - "user": { - "username": existing_user['username'], - "email": existing_user['email'], - "role": existing_user.get('role', 'user'), - "auth_type": "microsoft" - } - }), 200 - - except Exception as e: - print(f"Unexpected error in Microsoft login route: {e}") - return jsonify({"message": "Internal server error"}), 500 - - diff --git a/backend/app/routes/billing.py b/backend/app/routes/billing.py new file mode 100644 index 00000000..01a1d86c --- /dev/null +++ b/backend/app/routes/billing.py @@ -0,0 +1,116 @@ +""" +Billing endpoints: + GET /api/billing/balance — current credit balance + settings + GET /api/billing/transactions — transaction history for logged-in user + POST /api/billing/checkout — create Stripe Checkout Session + POST /api/billing/webhook — Stripe webhook (no auth) +""" + +import logging +from quart import Blueprint, request, jsonify +from app.auth.quart_jwt import jwt_required, get_jwt_identity +from app.models.user import User +from app.models.credit_transaction import CreditTransaction +from app.models.app_settings import get_settings +from app.services.stripe_service import create_checkout_session, verify_webhook + +logger = logging.getLogger(__name__) +billing_bp = Blueprint('billing', __name__) + + +@billing_bp.route('/balance', methods=['GET']) +@jwt_required() +async def get_balance(): + user_id = get_jwt_identity() + user = await User.find_by_id(user_id) + if not user: + return jsonify({"message": "User not found"}), 404 + + settings = await get_settings() + return jsonify({ + "credits_balance": user.get("credits_balance", 0), + "persona_cost": settings.get("persona_cost", 2), + "run_cost": settings.get("run_cost", 40), + "credit_packs": settings.get("credit_packs", []), + }), 200 + + +@billing_bp.route('/transactions', methods=['GET']) +@jwt_required() +async def get_transactions(): + user_id = get_jwt_identity() + limit = min(int(request.args.get("limit", 50)), 200) + txs = await CreditTransaction.list_for_user(user_id, limit=limit) + return jsonify({"transactions": txs}), 200 + + +@billing_bp.route('/checkout', methods=['POST']) +@jwt_required() +async def create_checkout(): + user_id = get_jwt_identity() + data = await request.get_json() + pack_id = data.get("pack_id") + + if not pack_id: + return jsonify({"message": "pack_id required"}), 400 + + settings = await get_settings() + pack = next((p for p in settings.get("credit_packs", []) if p["id"] == pack_id), None) + if not pack: + return jsonify({"message": "Unknown credit pack"}), 404 + + import os + origin = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:5173").split(",")[0].strip() + success_url = f"{origin}/billing?success=1" + cancel_url = f"{origin}/billing?cancelled=1" + + try: + url = await create_checkout_session( + pack_id=pack["id"], + pack_name=pack["name"], + price_usd=pack["price_usd"], + credits=pack["credits"], + user_id=user_id, + success_url=success_url, + cancel_url=cancel_url, + ) + return jsonify({"checkout_url": url}), 200 + except ValueError as e: + return jsonify({"message": str(e)}), 503 + except Exception as e: + logger.error(f"Stripe checkout error: {e}") + return jsonify({"message": "Payment service unavailable"}), 503 + + +@billing_bp.route('/webhook', methods=['POST']) +async def stripe_webhook(): + payload = await request.get_data() + sig_header = request.headers.get("Stripe-Signature", "") + + try: + event = verify_webhook(payload, sig_header) + except Exception as e: + logger.warning(f"Webhook signature invalid: {e}") + return jsonify({"message": "Invalid signature"}), 400 + + if event["type"] == "checkout.session.completed": + session = event["data"]["object"] + meta = session.get("metadata", {}) + user_id = meta.get("user_id") + credits = int(meta.get("credits", 0)) + pack_id = meta.get("pack_id", "") + payment_id = session.get("payment_intent", session.get("id", "")) + + if user_id and credits > 0: + balance = await User.grant_credits(user_id, credits) + await CreditTransaction.record( + user_id=user_id, + tx_type="purchase", + amount=credits, + balance_after=balance, + description=f"Purchased {pack_id} pack ({credits} credits)", + ref={"stripe_payment_id": payment_id}, + ) + logger.info(f"Granted {credits} credits to user {user_id} via Stripe") + + return jsonify({"status": "ok"}), 200 diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 00000000..d21f78d2 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,108 @@ +""" +Email service using Resend API for transactional emails. +Handles email verification and other notifications. +""" + +import os +import logging +import httpx + +logger = logging.getLogger(__name__) + +RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "") +FROM_EMAIL = os.environ.get("EMAIL_FROM", "Cohorta ") +APP_URL = os.environ.get("APP_URL", "http://localhost:5173") + + +async def send_email(to: str, subject: str, html: str) -> bool: + """Send an email via Resend API. Returns True on success.""" + if not RESEND_API_KEY: + logger.warning("RESEND_API_KEY not set — email not sent to %s", to) + return False + + payload = { + "from": FROM_EMAIL, + "to": [to], + "subject": subject, + "html": html, + } + + async with httpx.AsyncClient(timeout=15) as client: + try: + resp = await client.post( + "https://api.resend.com/emails", + json=payload, + headers={"Authorization": f"Bearer {RESEND_API_KEY}"}, + ) + if resp.status_code in (200, 201): + logger.info("Email sent to %s (subject: %s)", to, subject) + return True + else: + logger.error("Resend API error %s: %s", resp.status_code, resp.text) + return False + except Exception as e: + logger.error("Failed to send email to %s: %s", to, e) + return False + + +def _build_verification_html(username: str, verify_url: str) -> str: + return f""" + + + + + + + +
+
+ +
+
+

Verify your email address

+

Hi {username},
+ Welcome to Cohorta! Please confirm your email address to activate your account + and get access to your free credits.

+ Verify Email Address +

+ This link expires in 24 hours. If you didn't create a Cohorta account, + you can safely ignore this email. +

+
+ +
+ +""" + + +def _year() -> int: + from datetime import datetime + return datetime.utcnow().year + + +async def send_verification_email(to_email: str, username: str, token: str) -> bool: + verify_url = f"{APP_URL}/verify-email?token={token}" + html = _build_verification_html(username, verify_url) + return await send_email( + to=to_email, + subject="Verify your Cohorta account", + html=html, + ) diff --git a/backend/app/services/stripe_service.py b/backend/app/services/stripe_service.py new file mode 100644 index 00000000..9f063e85 --- /dev/null +++ b/backend/app/services/stripe_service.py @@ -0,0 +1,60 @@ +""" +Stripe checkout integration for credit pack purchases. +Requires STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in environment. +""" + +import os +import logging +import stripe + +logger = logging.getLogger(__name__) + +STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "") +STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "") + +if STRIPE_SECRET_KEY: + stripe.api_key = STRIPE_SECRET_KEY + + +async def create_checkout_session( + pack_id: str, + pack_name: str, + price_usd: int, + credits: int, + user_id: str, + success_url: str, + cancel_url: str, +) -> str: + """Create a Stripe Checkout Session and return its URL.""" + if not STRIPE_SECRET_KEY: + raise ValueError("STRIPE_SECRET_KEY not configured") + + session = stripe.checkout.Session.create( + payment_method_types=["card"], + line_items=[{ + "price_data": { + "currency": "usd", + "unit_amount": price_usd * 100, # cents + "product_data": { + "name": f"Cohorta {pack_name} — {credits} credits", + }, + }, + "quantity": 1, + }], + mode="payment", + success_url=success_url, + cancel_url=cancel_url, + metadata={ + "user_id": user_id, + "pack_id": pack_id, + "credits": str(credits), + }, + ) + return session.url + + +def verify_webhook(payload: bytes, sig_header: str) -> stripe.Event: + """Verify Stripe webhook signature and return the event.""" + if not STRIPE_WEBHOOK_SECRET: + raise ValueError("STRIPE_WEBHOOK_SECRET not configured") + return stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET) diff --git a/deploy-cohorta.sh b/deploy-cohorta.sh new file mode 100755 index 00000000..898ac9e7 --- /dev/null +++ b/deploy-cohorta.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Cohorta — deploy script for aimpress (OVH) server +# Run from the server: /opt/03-business/cohorta/deploy-cohorta.sh +# +# Prerequisites on server: +# - Docker + Docker Compose plugin installed +# - Traefik running with network "traefik-public" and letsencrypt certresolver +# - DNS A record: cohorta.ai-impress.com → 57.128.160.249 +# - backend/.env filled with production secrets (see backend/.env.example) + +set -euo pipefail + +BLUE='\033[0;34m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +DEPLOY_DIR="/opt/03-business/cohorta" + +echo -e "${BLUE}=== Cohorta — deploy ===${NC}" + +# ── 1. Pull latest code ─────────────────────────────────────────────────────── +echo "Pulling latest code..." +git -C "$DEPLOY_DIR" pull --ff-only + +# ── 2. Ensure traefik-public network exists ─────────────────────────────────── +if ! docker network ls --format '{{.Name}}' | grep -q '^traefik-public$'; then + echo "Creating traefik-public network..." + docker network create traefik-public +fi + +# ── 3. Add swap if not present (helps with AI-heavy workloads) ─────────────── +if ! swapon --show | grep -q swapfile 2>/dev/null; then + if [ ! -f /swapfile ]; then + echo "Creating 4 GB swap..." + fallocate -l 4G /swapfile + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + echo '/swapfile none swap sw 0 0' >> /etc/fstab + echo "vm.swappiness=10" >> /etc/sysctl.conf + sysctl -p + fi +fi + +# ── 4. Build and launch containers ──────────────────────────────────────────── +echo "Building and launching containers..." +cd "$DEPLOY_DIR" +docker compose pull mongo 2>/dev/null || true +docker compose build --no-cache frontend backend +docker compose up -d --remove-orphans + +# ── 5. Wait for backend health ──────────────────────────────────────────────── +echo "Waiting for backend to start..." +for i in $(seq 1 30); do + if curl -sf http://localhost:5137/api/health > /dev/null 2>&1; then + echo -e "${GREEN}✓ Backend is healthy${NC}" + break + fi + sleep 2 +done + +# ── 6. Show status ──────────────────────────────────────────────────────────── +docker compose ps +echo "" +echo -e "${GREEN}=== Deploy complete ===${NC}" +echo " https://cohorta.ai-impress.com" diff --git a/docker-compose.yml b/docker-compose.yml index 74aec9ad..408af11f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,5 @@ services: - frontend: - image: node:20-alpine - working_dir: /app - volumes: - - .:/app - - /var/www/html/semblance:/app/dist-out - command: > - sh -c "cp .env.production .env && - npm ci --silent && - npm run build && - cp -r dist/* /app/dist-out/" - profiles: - - build - + # ─── MongoDB ────────────────────────────────────────────────────── mongo: image: mongo:7 restart: unless-stopped @@ -26,21 +13,55 @@ services: timeout: 5s retries: 5 + # ─── Python / Quart backend ─────────────────────────────────────── backend: build: ./backend restart: unless-stopped - ports: - - "127.0.0.1:5137:5137" env_file: - ./backend/.env environment: - MONGO_URI: mongodb://mongo:27017/semblance_db + MONGO_URI: mongodb://mongo:27017/cohorta_db volumes: - ./backend/uploads:/app/uploads - ./backend/temp:/app/temp depends_on: mongo: condition: service_healthy + labels: + - "traefik.enable=true" + # REST API — higher priority than the SPA catch-all + - "traefik.http.routers.cohorta-api.rule=Host(`cohorta.ai-impress.com`) && PathPrefix(`/api`)" + - "traefik.http.routers.cohorta-api.entrypoints=websecure" + - "traefik.http.routers.cohorta-api.tls.certresolver=cloudflare" + - "traefik.http.routers.cohorta-api.priority=20" + - "traefik.http.services.cohorta-api.loadbalancer.server.port=5137" + # Socket.IO — also high priority + - "traefik.http.routers.cohorta-ws.rule=Host(`cohorta.ai-impress.com`) && PathPrefix(`/socket.io`)" + - "traefik.http.routers.cohorta-ws.entrypoints=websecure" + - "traefik.http.routers.cohorta-ws.tls.certresolver=cloudflare" + - "traefik.http.routers.cohorta-ws.priority=20" + - "traefik.http.routers.cohorta-ws.service=cohorta-api" + + # ─── React SPA (nginx-alpine) ───────────────────────────────────── + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + restart: unless-stopped + depends_on: + - backend + labels: + - "traefik.enable=true" + - "traefik.http.routers.cohorta.rule=Host(`cohorta.ai-impress.com`)" + - "traefik.http.routers.cohorta.entrypoints=websecure" + - "traefik.http.routers.cohorta.tls.certresolver=cloudflare" + - "traefik.http.routers.cohorta.priority=1" + - "traefik.http.services.cohorta.loadbalancer.server.port=80" volumes: mongo-data: + +networks: + default: + name: traefik-public + external: true diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 00000000..26e5df49 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # API and WebSocket requests must never reach nginx — Traefik routes them to backend. + # If they do arrive here, return 502 so the misconfiguration is visible. + location /api/ { + return 502 "nginx should not serve /api — check Traefik routing priorities"; + } + location /socket.io/ { + return 502 "nginx should not serve /socket.io — check Traefik routing priorities"; + } + + # SPA fallback — all other paths serve index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets aggressively + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Never cache index.html + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } +} diff --git a/public/favicon.svg b/public/favicon.svg index d8051573..5a9e8bbc 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,24 +1,13 @@ - + + - - - - - - - + + + - - - - - - - - - - - - + + + diff --git a/src/App.tsx b/src/App.tsx index 6c4c48c5..157464ec 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,15 +11,16 @@ import FocusGroupSession from "./pages/FocusGroupSession"; import Dashboard from "./pages/Dashboard"; import PersonaProfile from "./components/persona/PersonaProfile"; import Login from "./pages/Login"; +import Register from "./pages/Register"; +import VerifyEmail from "./pages/VerifyEmail"; import Admin from "./pages/Admin"; import MyUsage from "./pages/MyUsage"; +import Billing from "./pages/Billing"; import ProtectedRoute from "./components/ProtectedRoute"; import AdminRoute from "./components/admin/AdminRoute"; import { AuthProvider } from "./contexts/AuthContext"; import { NavigationProvider } from "./contexts/NavigationContext"; import { WebSocketProvider } from "./contexts/WebSocketContextNew"; -import { MsalProvider } from "./components/auth/MsalProvider"; - // CSS for consistent back button positioning import "./styles/backButton.css"; @@ -28,75 +29,32 @@ const queryClient = new QueryClient(); const App = () => ( - - - - - - - - } /> - } /> - - - - - } /> - - - - - } /> - - - - - } /> - - - - - } /> - - - - - } /> - - - - - } /> - - - - - } /> - - {/* Redirect legacy paths */} - } /> - - - - - } /> - - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - - - - - - + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); diff --git a/src/components/FeatureCard.tsx b/src/components/FeatureCard.tsx index 36064691..b056cd29 100755 --- a/src/components/FeatureCard.tsx +++ b/src/components/FeatureCard.tsx @@ -1,4 +1,3 @@ - import { LucideIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -7,24 +6,38 @@ interface FeatureCardProps { description: string; icon: LucideIcon; className?: string; + gradient?: string; } -export default function FeatureCard({ title, description, icon: Icon, className }: FeatureCardProps) { +export default function FeatureCard({ + title, + description, + icon: Icon, + className, + gradient = 'from-[#06B6D4] to-[#8B5CF6]', +}: FeatureCardProps) { return ( -
-
- -
-
- -
- -

{title}

-

{description}

+
+
+
+ +

+ {title} +

+

{description}

); } diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 8936a9d0..4b054814 100755 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -1,74 +1,233 @@ - -import { ChevronRight } from 'lucide-react'; +import { ArrowRight, Sparkles, Users, BarChart3, Shield } from 'lucide-react'; import { Link } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; + +const StatBadge = ({ value, label }: { value: string; label: string }) => ( +
+ + {value} + + {label} +
+); export default function Hero() { return ( -
- {/* Background gradient */} -