From 1ddab3ef50ede48a4f6626bb947001070adda825 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Sun, 3 Aug 2025 09:57:44 +0300 Subject: [PATCH 1/2] initial support for organizations --- Pipfile | 1 + Pipfile.lock | 33 +- api/app_factory.py | 8 +- api/auth/organization_management.py | 514 ++++++++++++++++ api/auth/user_management.py | 33 +- api/routes/__init__.py | 4 +- api/routes/auth.py | 90 --- api/routes/main.py | 50 ++ api/routes/organization.py | 302 ++++++++++ api/static/css/modals.css | 14 +- api/templates/chat.j2 | 3 + .../components/organization_modal.j2 | 551 ++++++++++++++++++ api/templates/components/user_profile.j2 | 1 + 13 files changed, 1492 insertions(+), 112 deletions(-) create mode 100644 api/auth/organization_management.py delete mode 100644 api/routes/auth.py create mode 100644 api/routes/main.py create mode 100644 api/routes/organization.py create mode 100644 api/templates/components/organization_modal.j2 diff --git a/Pipfile b/Pipfile index 129f473c..1404864a 100644 --- a/Pipfile +++ b/Pipfile @@ -12,6 +12,7 @@ tqdm = "~=4.67.1" boto3 = "~=1.37.29" psycopg2-binary = "~=2.9.9" flask-dance = "~=7.1.0" +disposable-email-domains = "~=0.0.129" [dev-packages] pytest = "~=8.2.0" diff --git a/Pipfile.lock b/Pipfile.lock index 903a6202..6fcfab1f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d59ed116ece4cc6a274aaf55585dd77a5879507b54b9807e6357a888a612ba0d" + "sha256": "14ccb13224fac79c410592fae212fab216b621c8b4bbdbb9aba6d406e77559bb" }, "pipfile-spec": 6, "requires": { @@ -175,11 +175,11 @@ }, "certifi": { "hashes": [ - "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", - "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995" + "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", + "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5" ], "markers": "python_version >= '3.7'", - "version": "==2025.7.14" + "version": "==2025.8.3" }, "charset-normalizer": { "hashes": [ @@ -281,11 +281,19 @@ }, "click": { "hashes": [ - "sha256:068616e6ef9705a07b6db727cb9c248f4eb9dae437a30239f56fa94b18b852ef", - "sha256:52e1e9f5d3db8c85aa76968c7c67ed41ddbacb167f43201511c8fd61eb5ba2ca" + "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", + "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b" ], "markers": "python_version >= '3.10'", - "version": "==8.2.2" + "version": "==8.2.1" + }, + "disposable-email-domains": { + "hashes": [ + "sha256:aefdc0ce57c1b32a3795393cba3056ba2a88984bcbb0afa14bffdc150af3db30", + "sha256:cc7c93b89b44efcc538fd347bcd983f1bf119a674cc513ac64ef6be541b50fa9" + ], + "index": "pypi", + "version": "==0.0.129" }, "distro": { "hashes": [ @@ -638,11 +646,10 @@ "bedrock" ], "hashes": [ - "sha256:0e6029314a235dbce5d03376a48f7504a221b2e1e8b24f0b090ff3580a6e78be", - "sha256:39aad802d7aa3eabb1678552017d705fe76ba09fdeab8122206a24781ddc4248" + "sha256:8c0f73f89f15603afebbc912cb30ba6f18439eb12249f16a37646752fb76825e" ], "markers": "python_version not in '2.7, 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7' and python_version >= '3.8'", - "version": "==1.74.14" + "version": "==1.74.15.post1" }, "markupsafe": { "hashes": [ @@ -1761,11 +1768,11 @@ }, "certifi": { "hashes": [ - "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", - "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995" + "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", + "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5" ], "markers": "python_version >= '3.7'", - "version": "==2025.7.14" + "version": "==2025.8.3" }, "charset-normalizer": { "hashes": [ diff --git a/api/app_factory.py b/api/app_factory.py index 78e4086e..681ac03f 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -13,9 +13,10 @@ from flask_dance.consumer.storage.session import SessionStorage from api.auth.oauth_handlers import setup_oauth_handlers -from api.routes.auth import auth_bp +from api.routes.main import main_bp from api.routes.graphs import graphs_bp from api.routes.database import database_bp +from api.routes.organization import organization_bp # Load environment variables from .env file load_dotenv() @@ -59,9 +60,10 @@ def create_app(): setup_oauth_handlers(google_bp, github_bp) # Register blueprints - app.register_blueprint(auth_bp) + app.register_blueprint(main_bp) app.register_blueprint(graphs_bp) app.register_blueprint(database_bp) + app.register_blueprint(organization_bp) @app.errorhandler(Exception) def handle_oauth_error(error): @@ -70,7 +72,7 @@ def handle_oauth_error(error): if "token" in str(error).lower() or "oauth" in str(error).lower(): logging.warning("OAuth error occurred: %s", error) session.clear() - return redirect(url_for("auth.home")) + return redirect(url_for("main.home")) # If it's an HTTPException (like abort(403)), re-raise so Flask handles it properly if isinstance(error, HTTPException): diff --git a/api/auth/organization_management.py b/api/auth/organization_management.py new file mode 100644 index 00000000..410eeee1 --- /dev/null +++ b/api/auth/organization_management.py @@ -0,0 +1,514 @@ +"""Organization management functions for text2sql API.""" + +import logging +from typing import Tuple, Optional, Dict, Any + +from api.extensions import db + + +def extract_email_domain(email: str) -> str: + """Extract domain from email address.""" + if not email or "@" not in email: + return "" + return email.split("@")[-1].lower() + + +def check_or_create_organization(user_email: str) -> Tuple[bool, Optional[Dict[str, Any]]]: + """ + Check if organization exists for email domain, create if not. + Returns (is_new_organization, organization_info) + """ + domain = extract_email_domain(user_email) + if not domain: + logging.error("Invalid email domain for user: %s", user_email) + return False, None + + try: + organizations_graph = db.select_graph("Organizations") + + # Check if organization exists for this domain + check_query = """ + MATCH (org:Organization {domain: $domain}) + RETURN org + """ + + result = organizations_graph.query(check_query, {"domain": domain}) + + if result.result_set: + # Organization exists + organization = result.result_set[0][0] + logging.info("Found existing organization for domain: %s", domain) + return False, organization + else: + # Create new organization with first user as admin + create_query = """ + CREATE (org:Organization { + domain: $domain, + name: $organization_name, + created_at: timestamp(), + admin_email: $admin_email + }) + RETURN org + """ + + # Generate organization name from domain (e.g., example.com -> Example) + organization_name = domain.split('.')[0].capitalize() + + result = organizations_graph.query(create_query, { + "domain": domain, + "organization_name": organization_name, + "admin_email": user_email + }) + + if result.result_set: + organization = result.result_set[0][0] + logging.info("Created new organization for domain: %s", domain) + return True, organization + else: + logging.error("Failed to create organization for domain: %s", domain) + return False, None + + except Exception as e: + logging.error("Error managing organization for domain %s: %s", domain, e) + return False, None + + +def link_user_to_organization(user_email: str, organization_domain: str, is_admin: bool = False, is_pending: bool = False) -> bool: + """ + Link a user to an organization. + + Args: + user_email: The user's email + organization_domain: The organization's domain + is_admin: Whether the user should be an admin + is_pending: Whether the user needs admin approval + + Returns: + bool: Success status + """ + try: + organizations_graph = db.select_graph("Organizations") + + # Create the relationship between user and organization + link_query = """ + MATCH (user:User {email: $user_email}) + MATCH (org:Organization {domain: $domain}) + MERGE (user)-[r:BELONGS_TO]->(org) + SET r.is_admin = $is_admin, + r.is_pending = $is_pending, + r.joined_at = timestamp() + RETURN user, org, r + """ + + result = organizations_graph.query(link_query, { + "user_email": user_email, + "domain": organization_domain, + "is_admin": is_admin, + "is_pending": is_pending + }) + + if result.result_set: + logging.info("Linked user %s to organization %s (admin: %s, pending: %s)", + user_email, organization_domain, is_admin, is_pending) + return True + else: + logging.error("Failed to link user %s to organization %s", user_email, organization_domain) + return False + + except Exception as e: + logging.error("Error linking user %s to organization %s: %s", user_email, organization_domain, e) + return False + + +def get_user_organization_status(user_email: str) -> Optional[Dict[str, Any]]: + """ + Get user's organization status and details. + + Returns: + Dict with organization info, user role, and pending status, or None if no organization + """ + try: + organizations_graph = db.select_graph("Organizations") + + query = """ + MATCH (user:User {email: $user_email})-[r:BELONGS_TO]->(org:Organization) + RETURN org, r.is_admin as is_admin, r.is_pending as is_pending, r.joined_at as joined_at + """ + + result = organizations_graph.query(query, {"user_email": user_email}) + + if result.result_set: + org_data = result.result_set[0][0] + is_admin = result.result_set[0][1] + is_pending = result.result_set[0][2] + joined_at = result.result_set[0][3] + + # Convert Node object to dictionary + org_dict = {} + if hasattr(org_data, 'properties'): + org_dict = org_data.properties + elif hasattr(org_data, '__dict__'): + org_dict = {k: v for k, v in org_data.__dict__.items() if not k.startswith('_')} + else: + # Fallback for different Node implementations + org_dict = dict(org_data) if org_data else {} + + return { + "organization": org_dict, + "is_admin": is_admin, + "is_pending": is_pending, + "joined_at": joined_at + } + else: + return None + + except Exception as e: + logging.error("Error getting organization status for user %s: %s", user_email, e) + return None + + +def get_organization_users(organization_domain: str) -> list: + """ + Get all users in an organization. + + Returns: + List of user dictionaries with their roles and status + """ + try: + organizations_graph = db.select_graph("Organizations") + + query = """ + MATCH (user:User)-[r:BELONGS_TO]->(org:Organization {domain: $domain}) + RETURN user, r.is_admin as is_admin, r.is_pending as is_pending, r.joined_at as joined_at + ORDER BY r.is_admin DESC, user.email ASC + """ + + result = organizations_graph.query(query, {"domain": organization_domain}) + + users = [] + for row in result.result_set: + user_data = row[0] + is_admin = row[1] + is_pending = row[2] + joined_at = row[3] + + # Convert Node object to dictionary + user_dict = {} + if hasattr(user_data, 'properties'): + user_dict = user_data.properties + elif hasattr(user_data, '__dict__'): + user_dict = {k: v for k, v in user_data.__dict__.items() if not k.startswith('_')} + else: + # Fallback for different Node implementations + user_dict = dict(user_data) if user_data else {} + + users.append({ + "user": user_dict, + "is_admin": is_admin, + "is_pending": is_pending, + "joined_at": joined_at + }) + + return users + + except Exception as e: + logging.error("Error getting users for organization %s: %s", organization_domain, e) + return [] + + +def add_user_to_organization_by_email(admin_email: str, target_email: str, organization_domain: str) -> Tuple[bool, str]: + """ + Add a user to organization by email (admin function). + Creates a pending user entry that will be activated when they log in. + + Args: + admin_email: The admin user's email + target_email: The email of user to add + organization_domain: The organization domain + + Returns: + Tuple[bool, str]: (success, message) + """ + try: + # Validate admin permissions + admin_status = get_user_organization_status(admin_email) + if not admin_status or not admin_status.get("is_admin"): + return False, "Unauthorized: Only organization admins can add users" + + if admin_status["organization"]["domain"] != organization_domain: + return False, "Unauthorized: Admin can only add users to their own organization" + + # Validate target email domain matches organization + target_domain = extract_email_domain(target_email) + if target_domain != organization_domain: + return False, f"Email domain {target_domain} does not match organization domain {organization_domain}" + + organizations_graph = db.select_graph("Organizations") + + # Create or update user with pending organization relationship + query = """ + MATCH (org:Organization {domain: $domain}) + MERGE (user:User {email: $target_email}) + ON CREATE SET user.created_at = timestamp(), user.role = 'user' + MERGE (user)-[r:BELONGS_TO]->(org) + SET r.is_admin = false, + r.is_pending = true, + r.invited_by = $admin_email, + r.invited_at = timestamp() + RETURN user, r + """ + + result = organizations_graph.query(query, { + "domain": organization_domain, + "target_email": target_email, + "admin_email": admin_email + }) + + if result.result_set: + logging.info("Admin %s added user %s to organization %s (pending)", + admin_email, target_email, organization_domain) + return True, f"User {target_email} has been added to organization and will be activated when they log in" + else: + return False, "Failed to add user to organization" + + except Exception as e: + logging.error("Error adding user %s to organization %s by admin %s: %s", + target_email, organization_domain, admin_email, e) + return False, f"Error adding user: {str(e)}" + + +def approve_pending_user(admin_email: str, target_email: str, organization_domain: str) -> Tuple[bool, str]: + """ + Approve a pending user in the organization. + + Args: + admin_email: The admin user's email + target_email: The email of user to approve + organization_domain: The organization domain + + Returns: + Tuple[bool, str]: (success, message) + """ + try: + # Validate admin permissions + admin_status = get_user_organization_status(admin_email) + if not admin_status or not admin_status.get("is_admin"): + return False, "Unauthorized: Only organization admins can approve users" + + if admin_status["organization"]["domain"] != organization_domain: + return False, "Unauthorized: Admin can only approve users in their own organization" + + organizations_graph = db.select_graph("Organizations") + + # Update user's pending status + query = """ + MATCH (user:User {email: $target_email})-[r:BELONGS_TO]->(org:Organization {domain: $domain}) + WHERE r.is_pending = true + SET r.is_pending = false, + r.approved_by = $admin_email, + r.approved_at = timestamp() + RETURN user, r + """ + + result = organizations_graph.query(query, { + "target_email": target_email, + "domain": organization_domain, + "admin_email": admin_email + }) + + if result.result_set: + # Also ensure the user has the correct default role + update_user_role_direct(target_email, "user") + logging.info("Admin %s approved user %s in organization %s", + admin_email, target_email, organization_domain) + return True, f"User {target_email} has been approved" + else: + return False, "User not found or not pending approval" + + except Exception as e: + logging.error("Error approving user %s in organization %s by admin %s: %s", + target_email, organization_domain, admin_email, e) + return False, f"Error approving user: {str(e)}" + + +def get_pending_users(organization_domain: str) -> list: + """ + Get all pending users for an organization. + + Returns: + List of pending user dictionaries + """ + try: + organizations_graph = db.select_graph("Organizations") + + query = """ + MATCH (user:User)-[r:BELONGS_TO]->(org:Organization {domain: $domain}) + WHERE r.is_pending = true + RETURN user, r.invited_by as invited_by, r.invited_at as invited_at + ORDER BY r.invited_at DESC + """ + + result = organizations_graph.query(query, {"domain": organization_domain}) + + pending_users = [] + for row in result.result_set: + user_data = row[0] + invited_by = row[1] + invited_at = row[2] + + # Convert Node object to dictionary + user_dict = {} + if hasattr(user_data, 'properties'): + user_dict = user_data.properties + elif hasattr(user_data, '__dict__'): + user_dict = {k: v for k, v in user_data.__dict__.items() if not k.startswith('_')} + else: + # Fallback for different Node implementations + user_dict = dict(user_data) if user_data else {} + + pending_users.append({ + "user": user_dict, + "invited_by": invited_by, + "invited_at": invited_at + }) + + return pending_users + + except Exception as e: + logging.error("Error getting pending users for organization %s: %s", organization_domain, e) + return [] + + +def update_user_role(admin_email: str, target_email: str, new_role: str, organization_domain: str) -> Tuple[bool, str]: + """ + Update a user's role (admin function). + + Args: + admin_email: The admin user's email + target_email: The email of user whose role to update + new_role: The new role to assign (e.g., 'user', 'admin', 'analyst', 'viewer') + organization_domain: The organization domain + + Returns: + Tuple[bool, str]: (success, message) + """ + try: + # Validate admin permissions + admin_status = get_user_organization_status(admin_email) + if not admin_status or not admin_status.get("is_admin"): + return False, "Unauthorized: Only organization admins can update user roles" + + if admin_status["organization"]["domain"] != organization_domain: + return False, "Unauthorized: Admin can only update roles in their own organization" + + # Validate role value + allowed_roles = ['user', 'admin', 'analyst', 'viewer', 'manager'] + if new_role not in allowed_roles: + return False, f"Invalid role '{new_role}'. Allowed roles: {', '.join(allowed_roles)}" + + organizations_graph = db.select_graph("Organizations") + + # Update user's role + query = """ + MATCH (user:User {email: $target_email})-[r:BELONGS_TO]->(org:Organization {domain: $domain}) + WHERE r.is_pending = false + SET user.role = $new_role, + user.role_updated_by = $admin_email, + user.role_updated_at = timestamp() + RETURN user + """ + + result = organizations_graph.query(query, { + "target_email": target_email, + "domain": organization_domain, + "new_role": new_role, + "admin_email": admin_email + }) + + if result.result_set: + logging.info("Admin %s updated role of user %s to %s in organization %s", + admin_email, target_email, new_role, organization_domain) + return True, f"User {target_email} role updated to {new_role}" + else: + return False, "User not found or not a member of the organization" + + except Exception as e: + logging.error("Error updating role for user %s in organization %s by admin %s: %s", + target_email, organization_domain, admin_email, e) + return False, f"Error updating user role: {str(e)}" + + +def get_user_role(user_email: str) -> str: + """ + Get a user's role. + + Args: + user_email: The user's email + + Returns: + str: The user's role, or 'user' as default + """ + try: + organizations_graph = db.select_graph("Organizations") + + query = """ + MATCH (user:User {email: $email}) + RETURN user.role as role + """ + + result = organizations_graph.query(query, {"email": user_email}) + + if result.result_set and result.result_set[0][0]: + return result.result_set[0][0] + else: + return 'user' # Default role + + except Exception as e: + logging.error("Error getting role for user %s: %s", user_email, e) + return 'user' # Default role on error + + +def update_user_role_direct(user_email: str, new_role: str) -> bool: + """ + Update a user's role directly (internal function, no auth checks). + Used for system operations like making first user admin. + + Args: + user_email: The user's email + new_role: The new role to assign + + Returns: + bool: Success status + """ + try: + # Validate role value + allowed_roles = ['user', 'admin', 'analyst', 'viewer', 'manager'] + if new_role not in allowed_roles: + logging.error("Invalid role '%s' for user %s", new_role, user_email) + return False + + organizations_graph = db.select_graph("Organizations") + + # Update user's role directly + query = """ + MATCH (user:User {email: $email}) + SET user.role = $new_role, + user.role_updated_at = timestamp() + RETURN user + """ + + result = organizations_graph.query(query, { + "email": user_email, + "new_role": new_role + }) + + if result.result_set: + logging.info("Updated role of user %s to %s", user_email, new_role) + return True + else: + logging.warning("User %s not found when updating role to %s", user_email, new_role) + return False + + except Exception as e: + logging.error("Error updating role for user %s to %s: %s", user_email, new_role, e) + return False diff --git a/api/auth/user_management.py b/api/auth/user_management.py index adc2d921..96c54c3c 100644 --- a/api/auth/user_management.py +++ b/api/auth/user_management.py @@ -17,6 +17,7 @@ def ensure_user_in_organizations(provider_user_id, email, name, provider, pictur Check if identity exists in Organizations graph, create if not. Creates separate Identity and User nodes with proper relationships. Uses MERGE for atomic operations and better performance. + Now includes organization management based on email domain. Returns (is_new_user, user_info) """ # Input validation @@ -40,6 +41,16 @@ def ensure_user_in_organizations(provider_user_id, email, name, provider, pictur # Select the Organizations graph organizations_graph = db.select_graph("Organizations") + # Extract domain and check/create organization + from api.auth.organization_management import ( + check_or_create_organization, + link_user_to_organization, + extract_email_domain + ) + + domain = extract_email_domain(email) + is_new_org, _ = check_or_create_organization(email) + # Extract first and last name name_parts = (name or "").split(" ", 1) if name else ["", ""] first_name = name_parts[0] if len(name_parts) > 0 else "" @@ -52,6 +63,7 @@ def ensure_user_in_organizations(provider_user_id, email, name, provider, pictur ON CREATE SET user.first_name = $first_name, user.last_name = $last_name, + user.role = 'user', user.created_at = timestamp() // Then, merge identity and link to user @@ -95,6 +107,24 @@ def ensure_user_in_organizations(provider_user_id, email, name, provider, pictur is_new_identity = result.result_set[0][2] had_other_identities = result.result_set[0][3] + # Handle organization membership and role assignment + if is_new_org: + # User is first in their domain, make them admin + success = link_user_to_organization(email, domain, is_admin=True, is_pending=False) + if success: + # Update user role to admin in the User node + from api.auth.organization_management import update_user_role_direct + update_user_role_direct(email, "admin") + logging.info("User %s created organization %s and assigned as admin", + email, domain) + else: + # Organization exists, check if user needs to be linked + # For existing organizations, new users are pending until approved + success = link_user_to_organization(email, domain, is_admin=False, is_pending=True) + if success: + logging.info("User %s added to existing organization %s as pending", + email, domain) + # Determine the type of operation for logging if is_new_identity and not had_other_identities: # Brand new user (first identity) @@ -118,9 +148,6 @@ def ensure_user_in_organizations(provider_user_id, email, name, provider, pictur except (AttributeError, ValueError, KeyError) as e: logging.error("Error managing user in Organizations graph: %s", e) return False, None - except Exception as e: - logging.error("Unexpected error managing user in Organizations graph: %s", e) - return False, None def update_identity_last_login(provider, provider_user_id): diff --git a/api/routes/__init__.py b/api/routes/__init__.py index 375b53c1..b319248f 100644 --- a/api/routes/__init__.py +++ b/api/routes/__init__.py @@ -1,7 +1,7 @@ # Routes module for text2sql API -from .auth import auth_bp +from .main import main_bp from .graphs import graphs_bp from .database import database_bp -__all__ = ["auth_bp", "graphs_bp", "database_bp"] +__all__ = ["main_bp", "graphs_bp", "database_bp"] diff --git a/api/routes/auth.py b/api/routes/auth.py deleted file mode 100644 index d156c927..00000000 --- a/api/routes/auth.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Authentication routes for the text2sql API.""" - -import logging -import time - -import requests -from flask import Blueprint, render_template, redirect, url_for, session -from flask_dance.contrib.google import google -from flask_dance.contrib.github import github - -from api.auth.user_management import validate_and_cache_user - -auth_bp = Blueprint("auth", __name__) - - -@auth_bp.route("/") -def home(): - """Home route""" - user_info, is_authenticated = validate_and_cache_user() - - # If not authenticated through OAuth, check for any stale session data - if not is_authenticated and not google.authorized and not github.authorized: - session.pop("user_info", None) - - return render_template("chat.j2", is_authenticated=is_authenticated, user_info=user_info) - - -@auth_bp.route("/login") -def login_google(): - """Handle Google OAuth login route.""" - if not google.authorized: - return redirect(url_for("google.login")) - - try: - resp = google.get("/oauth2/v2/userinfo") - if resp.ok: - google_user = resp.json() - - # Validate required fields - if not google_user.get("id") or not google_user.get("email"): - logging.error("Invalid Google user data received during login") - session.clear() - return redirect(url_for("google.login")) - - # Normalize user info structure - user_info = { - "id": str(google_user.get("id")), # Ensure string type - "name": google_user.get("name", ""), - "email": google_user.get("email"), - "picture": google_user.get("picture", ""), - "provider": "google" - } - session["user_info"] = user_info - session["token_validated_at"] = time.time() - return redirect(url_for("auth.home")) - - # OAuth token might be expired, redirect to login - session.clear() - return redirect(url_for("google.login")) - except (requests.RequestException, KeyError, ValueError) as e: - logging.error("Google login error: %s", e) - session.clear() - return redirect(url_for("google.login")) - - -@auth_bp.route("/logout") -def logout(): - """Handle user logout and token revocation.""" - session.clear() - - # Revoke Google OAuth token if authorized - if google.authorized: - try: - google.get( - "https://accounts.google.com/o/oauth2/revoke", - params={"token": google.access_token} - ) - except (requests.RequestException, AttributeError) as e: - logging.warning("Error revoking Google token: %s", e) - - # Revoke GitHub OAuth token if authorized - if github.authorized: - try: - # GitHub doesn't have a simple revoke endpoint like Google - # The token will expire naturally or can be revoked from GitHub settings - pass - except AttributeError as e: - logging.warning("Error with GitHub token cleanup: %s", e) - - return redirect(url_for("auth.home")) diff --git a/api/routes/main.py b/api/routes/main.py new file mode 100644 index 00000000..8efd545b --- /dev/null +++ b/api/routes/main.py @@ -0,0 +1,50 @@ +"""Authentication routes for the text2sql API.""" + +import logging + +import requests +from flask import Blueprint, render_template, redirect, url_for, session +from flask_dance.contrib.google import google +from flask_dance.contrib.github import github + +from api.auth.user_management import validate_and_cache_user + +main_bp = Blueprint("main", __name__) + + +@main_bp.route("/") +def home(): + """Home route""" + user_info, is_authenticated = validate_and_cache_user() + + # If not authenticated through OAuth, check for any stale session data + if not is_authenticated and not google.authorized and not github.authorized: + session.pop("user_info", None) + + return render_template("chat.j2", is_authenticated=is_authenticated, user_info=user_info) + +@main_bp.route("/logout") +def logout(): + """Handle user logout and token revocation.""" + session.clear() + + # Revoke Google OAuth token if authorized + if google.authorized: + try: + google.get( + "https://accounts.google.com/o/oauth2/revoke", + params={"token": google.access_token} + ) + except (requests.RequestException, AttributeError) as e: + logging.warning("Error revoking Google token: %s", e) + + # Revoke GitHub OAuth token if authorized + if github.authorized: + try: + # GitHub doesn't have a simple revoke endpoint like Google + # The token will expire naturally or can be revoked from GitHub settings + pass + except AttributeError as e: + logging.warning("Error with GitHub token cleanup: %s", e) + + return redirect(url_for("main.home")) diff --git a/api/routes/organization.py b/api/routes/organization.py new file mode 100644 index 00000000..0fd39c91 --- /dev/null +++ b/api/routes/organization.py @@ -0,0 +1,302 @@ +"""Organization management routes for the text2sql API.""" + +import logging + +from flask import Blueprint, request, jsonify +from api.auth.user_management import validate_and_cache_user, token_required +from api.auth.organization_management import ( + get_user_organization_status, + get_organization_users, + add_user_to_organization_by_email, + approve_pending_user, + get_pending_users, + extract_email_domain +) + +organization_bp = Blueprint("organization", __name__, url_prefix="/api/organization") + + +@organization_bp.route("/status", methods=["GET"]) +@token_required +def get_organization_status(): + """Get current user's organization status.""" + try: + user_info, is_authenticated = validate_and_cache_user() + if not is_authenticated: + return jsonify({"error": "Unauthorized"}), 401 + + user_email = user_info.get("email") + if not user_email: + return jsonify({"error": "User email not found"}), 400 + + status = get_user_organization_status(user_email) + if status: + from api.auth.organization_management import get_user_role + user_role = get_user_role(user_email) + + return jsonify({ + "has_organization": True, + "organization": { + "domain": status["organization"]["domain"], + "name": status["organization"]["name"], + "created_at": status["organization"]["created_at"] + }, + "user_role": { + "is_admin": status["is_admin"], + "is_pending": status["is_pending"], + "joined_at": status["joined_at"], + "role": user_role + } + }), 200 + else: + return jsonify({ + "has_organization": False, + "message": "User is not part of any organization" + }), 200 + + except Exception as e: + logging.error("Error getting organization status: %s", e) + return jsonify({"error": "Internal server error"}), 500 + + +@organization_bp.route("/users", methods=["GET"]) +@token_required +def get_organization_members(): + """Get all users in the current user's organization (admin only).""" + try: + user_info, is_authenticated = validate_and_cache_user() + if not is_authenticated: + return jsonify({"error": "Unauthorized"}), 401 + + user_email = user_info.get("email") + if not user_email: + return jsonify({"error": "User email not found"}), 400 + + # Check if user is admin + status = get_user_organization_status(user_email) + if not status or not status.get("is_admin"): + return jsonify({"error": "Unauthorized: Admin access required"}), 403 + + organization_domain = status["organization"]["domain"] + users = get_organization_users(organization_domain) + + # Format response + formatted_users = [] + for user_data in users: + formatted_users.append({ + "email": user_data["user"]["email"], + "first_name": user_data["user"].get("first_name", ""), + "last_name": user_data["user"].get("last_name", ""), + "role": user_data["user"].get("role", "user"), + "is_admin": user_data["is_admin"], + "is_pending": user_data["is_pending"], + "joined_at": user_data["joined_at"] + }) + + return jsonify({ + "organization_domain": organization_domain, + "users": formatted_users + }), 200 + + except Exception as e: + logging.error("Error getting organization users: %s", e) + return jsonify({"error": "Internal server error"}), 500 + + +@organization_bp.route("/pending", methods=["GET"]) +@token_required +def get_pending_organization_users(): + """Get pending users in the organization (admin only).""" + try: + user_info, is_authenticated = validate_and_cache_user() + if not is_authenticated: + return jsonify({"error": "Unauthorized"}), 401 + + user_email = user_info.get("email") + if not user_email: + return jsonify({"error": "User email not found"}), 400 + + # Check if user is admin + status = get_user_organization_status(user_email) + if not status or not status.get("is_admin"): + return jsonify({"error": "Unauthorized: Admin access required"}), 403 + + organization_domain = status["organization"]["domain"] + pending_users = get_pending_users(organization_domain) + + # Format response + formatted_pending = [] + for user_data in pending_users: + formatted_pending.append({ + "email": user_data["user"]["email"], + "first_name": user_data["user"].get("first_name", ""), + "last_name": user_data["user"].get("last_name", ""), + "invited_by": user_data["invited_by"], + "invited_at": user_data["invited_at"] + }) + + return jsonify({ + "organization_domain": organization_domain, + "pending_users": formatted_pending + }), 200 + + except Exception as e: + logging.error("Error getting pending users: %s", e) + return jsonify({"error": "Internal server error"}), 500 + + +@organization_bp.route("/add-user", methods=["POST"]) +@token_required +def add_user_to_organization(): + """Add a user to the organization by email (admin only).""" + try: + user_info, is_authenticated = validate_and_cache_user() + if not is_authenticated: + return jsonify({"error": "Unauthorized"}), 401 + + admin_email = user_info.get("email") + if not admin_email: + return jsonify({"error": "User email not found"}), 400 + + # Check if user is admin + status = get_user_organization_status(admin_email) + if not status or not status.get("is_admin"): + return jsonify({"error": "Unauthorized: Admin access required"}), 403 + + # Get request data + data = request.get_json() + if not data or "email" not in data: + return jsonify({"error": "Email is required"}), 400 + + target_email = data["email"].strip().lower() + if not target_email: + return jsonify({"error": "Valid email is required"}), 400 + + organization_domain = status["organization"]["domain"] + + # Validate email domain matches organization + target_domain = extract_email_domain(target_email) + if target_domain != organization_domain: + return jsonify({ + "error": f"Email domain {target_domain} does not match organization domain {organization_domain}" + }), 400 + + # Add user to organization + success, message = add_user_to_organization_by_email( + admin_email, target_email, organization_domain + ) + + if success: + return jsonify({"message": message}), 200 + else: + return jsonify({"error": message}), 400 + + except Exception as e: + logging.error("Error adding user to organization: %s", e) + return jsonify({"error": "Internal server error"}), 500 + + +@organization_bp.route("/approve-user", methods=["POST"]) +@token_required +def approve_user_in_organization(): + """Approve a pending user in the organization (admin only).""" + try: + user_info, is_authenticated = validate_and_cache_user() + if not is_authenticated: + return jsonify({"error": "Unauthorized"}), 401 + + admin_email = user_info.get("email") + if not admin_email: + return jsonify({"error": "User email not found"}), 400 + + # Check if user is admin + status = get_user_organization_status(admin_email) + if not status or not status.get("is_admin"): + return jsonify({"error": "Unauthorized: Admin access required"}), 403 + + # Get request data + data = request.get_json() + if not data or "email" not in data: + return jsonify({"error": "Email is required"}), 400 + + target_email = data["email"].strip().lower() + if not target_email: + return jsonify({"error": "Valid email is required"}), 400 + + organization_domain = status["organization"]["domain"] + + # Approve user + success, message = approve_pending_user( + admin_email, target_email, organization_domain + ) + + if success: + return jsonify({"message": message}), 200 + else: + return jsonify({"error": message}), 400 + + except Exception as e: + logging.error("Error approving user: %s", e) + return jsonify({"error": "Internal server error"}), 500 + + +@organization_bp.route("/update-role", methods=["POST"]) +@token_required +def update_user_role_endpoint(): + """Update a user's role in the organization (admin only)""" + try: + from api.auth.user_management import validate_and_cache_user + from api.auth.organization_management import get_user_organization_status, update_user_role + + user_info, is_authenticated = validate_and_cache_user() + if not is_authenticated: + return jsonify({"error": "Authentication required"}), 401 + + admin_email = user_info["email"] + + # Get request data + data = request.get_json() + if not data: + return jsonify({"error": "JSON data required"}), 400 + + target_email = data.get("target_email") + new_role = data.get("new_role") + + if not target_email or not new_role: + return jsonify({"error": "target_email and new_role are required"}), 400 + + # Get admin's organization status + status = get_user_organization_status(admin_email) + if not status: + return jsonify({"error": "User not in any organization"}), 400 + + organization_domain = status["organization"]["domain"] + + # Update user role + success, message = update_user_role( + admin_email, target_email, new_role, organization_domain + ) + + if success: + return jsonify({"message": message}), 200 + else: + return jsonify({"error": message}), 400 + + except Exception as e: + logging.error("Error updating user role: %s", e) + return jsonify({"error": "Internal server error"}), 500 + + +@organization_bp.route("/user-role/", methods=["GET"]) +@token_required +def get_user_role_endpoint(email): + """Get a user's role""" + try: + from api.auth.organization_management import get_user_role + + role = get_user_role(email) + return jsonify({"email": email, "role": role}), 200 + + except Exception as e: + logging.error("Error getting user role: %s", e) + return jsonify({"error": "Internal server error"}), 500 diff --git a/api/static/css/modals.css b/api/static/css/modals.css index c3de6ac9..b72e4d3d 100644 --- a/api/static/css/modals.css +++ b/api/static/css/modals.css @@ -176,8 +176,20 @@ font-size: 0.9em; } -.user-profile-actions { +.user-profile-action { + width: 100%; padding: 10px; + background: var(--falkor-quaternary); + color: var(--text-primary); + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: bold; + transition: background 0.2s; +} + +.user-profile-action:hover { + background: var(--falkor-accent); } /* Destructive Confirmation Styles */ diff --git a/api/templates/chat.j2 b/api/templates/chat.j2 index 31e8471b..434afec5 100644 --- a/api/templates/chat.j2 +++ b/api/templates/chat.j2 @@ -32,6 +32,9 @@ {% include 'components/login_modal.j2' %} {% include 'components/postgres_modal.j2' %} {% include 'components/reset_modal.j2' %} + {% if is_authenticated %} + {% include 'components/organization_modal.j2' %} + {% endif %} {% endblock %} {% block scripts %} diff --git a/api/templates/components/organization_modal.j2 b/api/templates/components/organization_modal.j2 new file mode 100644 index 00000000..2f9dc0bd --- /dev/null +++ b/api/templates/components/organization_modal.j2 @@ -0,0 +1,551 @@ +{# Organization management component #} + + + + + + +.user-actions { + display: flex; + gap: 10px; + align-items: center; +} + +.role-select { + padding: 4px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 12px; + background-color: white; + cursor: pointer; +} + +.role-select:hover { + border-color: #007bff; +} + +.role-select:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); +} + diff --git a/api/templates/components/user_profile.j2 b/api/templates/components/user_profile.j2 index fd4b4846..868f3f47 100644 --- a/api/templates/components/user_profile.j2 +++ b/api/templates/components/user_profile.j2 @@ -9,6 +9,7 @@ From 03a025207779ff0a0c1a0bf6b242c40d6d557070 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Fri, 8 Aug 2025 00:21:57 +0300 Subject: [PATCH 2/2] add publish docker --- .github/workflows/publish-docker.yml | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/publish-docker.yml diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml new file mode 100644 index 00000000..61146289 --- /dev/null +++ b/.github/workflows/publish-docker.yml @@ -0,0 +1,33 @@ +name: Publish Docker image + +on: + release: + types: [created] + +jobs: + push_to_registry: + name: Build and push Docker image to Docker Hub + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: falkordb/queryweaver + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }}