From 9875216861458d904605ed4dd7a9a81567836a40 Mon Sep 17 00:00:00 2001 From: James Bhattarai Date: Sun, 1 Mar 2026 20:12:55 +0545 Subject: [PATCH] Feat(Fix): User & Org Setup Initial (Invite + Create on own) & Fix: User Suspension --- gatehouse_app/api/v1/auth.py | 67 +++++++++++++- gatehouse_app/api/v1/external_auth.py | 8 ++ gatehouse_app/api/v1/users.py | 4 +- gatehouse_app/services/oauth_flow_service.py | 96 ++++++++++++++++---- 4 files changed, 155 insertions(+), 20 deletions(-) diff --git a/gatehouse_app/api/v1/auth.py b/gatehouse_app/api/v1/auth.py index 712d07f..d1ccc07 100644 --- a/gatehouse_app/api/v1/auth.py +++ b/gatehouse_app/api/v1/auth.py @@ -79,11 +79,46 @@ def register(): # Create session user_session = AuthService.create_session(user) + # ── Post-registration hints ───────────────────────────────────────── + from gatehouse_app.models.organization.org_invite_token import OrgInviteToken + from gatehouse_app.models.user.user import User as _User + from datetime import datetime, timezone as _tz + + now = datetime.now(_tz.utc) + pending_invites = OrgInviteToken.query.filter( + OrgInviteToken.email == user.email, + OrgInviteToken.accepted_at.is_(None), + OrgInviteToken.expires_at > now, + OrgInviteToken.deleted_at.is_(None), + ).all() + + # Determine if this is the very first user ever registered on this + # instance (exactly 1 active user means it must be this one). + total_users = _User.query.filter(_User.deleted_at.is_(None)).count() + is_first_user = total_users == 1 + + expires_str = user_session.expires_at.isoformat() + if expires_str[-1] != "Z": + expires_str += "Z" + return api_response( data={ "user": user.to_dict(), "token": user_session.token, - "expires_at": user_session.expires_at.isoformat() + "Z" if user_session.expires_at.isoformat()[-1] != "Z" else user_session.expires_at.isoformat(), + "expires_at": expires_str, + "is_first_user": is_first_user, + "pending_invites": [ + { + "token": inv.token, + "organization": { + "id": str(inv.organization_id), + "name": inv.organization.name, + }, + "role": inv.role, + "expires_at": inv.expires_at.isoformat(), + } + for inv in pending_invites + ], }, message="Registration successful", status=201, @@ -210,6 +245,36 @@ def login(): if is_compliance_only: response_data["requires_mfa_enrollment"] = True + # ── Org-setup hint for org-less users ──────────────────────────────── + # If the user has no organisation memberships, surface any pending + # invitations so the UI can redirect straight to /org-setup instead of + # showing an empty dashboard. + user_orgs = user.get_organizations() + if not user_orgs: + from gatehouse_app.models.organization.org_invite_token import OrgInviteToken + from datetime import datetime, timezone as _tz + _now = datetime.now(_tz.utc) + pending_invites = OrgInviteToken.query.filter( + OrgInviteToken.email == user.email, + OrgInviteToken.accepted_at.is_(None), + OrgInviteToken.expires_at > _now, + OrgInviteToken.deleted_at.is_(None), + ).all() + response_data["pending_invites"] = [ + { + "token": inv.token, + "organization": { + "id": str(inv.organization_id), + "name": inv.organization.name, + }, + "role": inv.role, + "expires_at": inv.expires_at.isoformat(), + } + for inv in pending_invites + ] + # Flag so the UI knows to send this user through org-setup + response_data["requires_org_setup"] = True + return api_response( data=response_data, message="Login successful", diff --git a/gatehouse_app/api/v1/external_auth.py b/gatehouse_app/api/v1/external_auth.py index 846b58d..a23101c 100644 --- a/gatehouse_app/api/v1/external_auth.py +++ b/gatehouse_app/api/v1/external_auth.py @@ -900,11 +900,19 @@ def handle_oauth_callback(provider: str): # Organization creation needed (new user via OAuth with no org) if result.get("requires_org_creation") and not cli_redirect_url: + import json as _json + session_data = result.get("session", {}) + token = session_data.get("token", "") + expires_in = session_data.get("expires_in", 86400) + pending_invites = result.get("pending_invites", []) params = { "requires_org_creation": "1", "state": result["state"], "provider": provider, "flow": flow_type, + "token": token, + "expires_in": str(expires_in), + "pending_invites": _json.dumps(pending_invites), } if oidc_session_id: params["oidc_session_id"] = oidc_session_id diff --git a/gatehouse_app/api/v1/users.py b/gatehouse_app/api/v1/users.py index e6ccfb6..fd555cb 100644 --- a/gatehouse_app/api/v1/users.py +++ b/gatehouse_app/api/v1/users.py @@ -454,7 +454,7 @@ def admin_suspend_user(user_id): if not admin_in_shared_org: return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR") - if target.status == UserStatus.SUSPENDED: + if target.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED): return api_response(success=False, message="User is already suspended", status=409, error_type="CONFLICT") target.status = UserStatus.SUSPENDED @@ -501,7 +501,7 @@ def admin_unsuspend_user(user_id): if not admin_in_shared_org: return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR") - if target.status != UserStatus.SUSPENDED: + if target.status not in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED): return api_response(success=False, message="User is not suspended", status=409, error_type="CONFLICT") target.status = UserStatus.ACTIVE diff --git a/gatehouse_app/services/oauth_flow_service.py b/gatehouse_app/services/oauth_flow_service.py index b624cb1..2889a7c 100644 --- a/gatehouse_app/services/oauth_flow_service.py +++ b/gatehouse_app/services/oauth_flow_service.py @@ -515,25 +515,52 @@ class OAuthFlowService: if not target_org and len(user_orgs) == 1: target_org = user_orgs[0] - # Priority 3: No orgs at all — auto-create a personal org and log in + # Priority 3: No orgs at all — send to org-setup instead of auto-creating if not target_org and len(user_orgs) == 0: - import re - import uuid - from gatehouse_app.services.organization_service import OrganizationService - org_name = f"{user_info.get('name') or user.email.split('@')[0]}'s Workspace" - # Build a URL-safe slug and ensure uniqueness with a short suffix - base_slug = re.sub(r"[^a-z0-9]+", "-", org_name.lower()).strip("-")[:40] - slug = f"{base_slug}-{uuid.uuid4().hex[:6]}" - org = OrganizationService.create_organization( - name=org_name, - slug=slug, - owner_user_id=user.id, - ) - target_org = org + from gatehouse_app.models.organization.org_invite_token import OrgInviteToken + from gatehouse_app.services.auth_service import AuthService as _AS + _now = datetime.now(timezone.utc) + _session = _AS.create_session(user=user, is_compliance_only=False) + _session_dict = _session.to_dict() + _session_dict["token"] = _session.token + _expires_at = _session.expires_at + if _expires_at.tzinfo is None: + _expires_at = _expires_at.replace(tzinfo=timezone.utc) + _session_dict["expires_in"] = int((_expires_at - _now).total_seconds()) + + _pending = OrgInviteToken.query.filter( + OrgInviteToken.email == user.email, + OrgInviteToken.accepted_at.is_(None), + OrgInviteToken.expires_at > _now, + OrgInviteToken.deleted_at.is_(None), + ).all() + _pending_list = [ + { + "token": inv.token, + "organization": { + "id": str(inv.organization_id), + "name": inv.organization.name, + }, + "role": inv.role, + "expires_at": inv.expires_at.isoformat(), + } + for inv in _pending + ] + + state_record.mark_used() logger.info( - f"OAuth login: auto-created org '{org.name}' (id={org.id}) " - f"for new user {user.id}" + f"OAuth login: user {user.id} has no org, redirecting to org-setup " + f"(pending_invites={len(_pending_list)})" ) + return { + "success": True, + "flow_type": "login", + "requires_org_creation": True, + "user": {"id": user.id, "email": user.email, "full_name": user.full_name}, + "session": _session_dict, + "pending_invites": _pending_list, + "state": state_record.state, + } # Priority 4: Multiple orgs — need user to pick one if not target_org: @@ -805,7 +832,40 @@ class OAuthFlowService: "session": session_dict, } - # No organization hint or invalid - need to create/select org + # No organization hint or invalid - need to create/select org. + # Still create a session so the frontend can call /organizations + # and /invites after redirecting to /org-setup. + from gatehouse_app.services.auth_service import AuthService as _AS + from gatehouse_app.models.organization.org_invite_token import OrgInviteToken + _session = _AS.create_session(user=user, is_compliance_only=False) + _session_dict = _session.to_dict() + _session_dict["token"] = _session.token + _expires_at = _session.expires_at + if _expires_at.tzinfo is None: + _expires_at = _expires_at.replace(tzinfo=timezone.utc) + _now = datetime.now(timezone.utc) + _session_dict["expires_in"] = int((_expires_at - _now).total_seconds()) + + # Surface pending invitations so the UI can offer "join vs create" + _pending = OrgInviteToken.query.filter( + OrgInviteToken.email == user.email, + OrgInviteToken.accepted_at.is_(None), + OrgInviteToken.expires_at > _now, + OrgInviteToken.deleted_at.is_(None), + ).all() + _pending_list = [ + { + "token": inv.token, + "organization": { + "id": str(inv.organization_id), + "name": inv.organization.name, + }, + "role": inv.role, + "expires_at": inv.expires_at.isoformat(), + } + for inv in _pending + ] + return { "success": True, "flow_type": "register", @@ -815,6 +875,8 @@ class OAuthFlowService: "email": user.email, "full_name": user.full_name, }, + "session": _session_dict, + "pending_invites": _pending_list, "state": state_record.state, }