Feat(Fix): User & Org Setup Initial (Invite + Create on own) & Fix: User Suspension

This commit is contained in:
2026-03-01 20:12:55 +05:45
parent a0d4e59c24
commit 9875216861
4 changed files with 155 additions and 20 deletions
+66 -1
View File
@@ -79,11 +79,46 @@ def register():
# Create session # Create session
user_session = AuthService.create_session(user) 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( return api_response(
data={ data={
"user": user.to_dict(), "user": user.to_dict(),
"token": user_session.token, "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", message="Registration successful",
status=201, status=201,
@@ -210,6 +245,36 @@ def login():
if is_compliance_only: if is_compliance_only:
response_data["requires_mfa_enrollment"] = True 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( return api_response(
data=response_data, data=response_data,
message="Login successful", message="Login successful",
+8
View File
@@ -900,11 +900,19 @@ def handle_oauth_callback(provider: str):
# Organization creation needed (new user via OAuth with no org) # Organization creation needed (new user via OAuth with no org)
if result.get("requires_org_creation") and not cli_redirect_url: 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 = { params = {
"requires_org_creation": "1", "requires_org_creation": "1",
"state": result["state"], "state": result["state"],
"provider": provider, "provider": provider,
"flow": flow_type, "flow": flow_type,
"token": token,
"expires_in": str(expires_in),
"pending_invites": _json.dumps(pending_invites),
} }
if oidc_session_id: if oidc_session_id:
params["oidc_session_id"] = oidc_session_id params["oidc_session_id"] = oidc_session_id
+2 -2
View File
@@ -454,7 +454,7 @@ def admin_suspend_user(user_id):
if not admin_in_shared_org: if not admin_in_shared_org:
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR") 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") return api_response(success=False, message="User is already suspended", status=409, error_type="CONFLICT")
target.status = UserStatus.SUSPENDED target.status = UserStatus.SUSPENDED
@@ -501,7 +501,7 @@ def admin_unsuspend_user(user_id):
if not admin_in_shared_org: if not admin_in_shared_org:
return api_response(success=False, message="Access denied", status=403, error_type="AUTHORIZATION_ERROR") 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") return api_response(success=False, message="User is not suspended", status=409, error_type="CONFLICT")
target.status = UserStatus.ACTIVE target.status = UserStatus.ACTIVE
+79 -17
View File
@@ -515,25 +515,52 @@ class OAuthFlowService:
if not target_org and len(user_orgs) == 1: if not target_org and len(user_orgs) == 1:
target_org = user_orgs[0] 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: if not target_org and len(user_orgs) == 0:
import re from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
import uuid from gatehouse_app.services.auth_service import AuthService as _AS
from gatehouse_app.services.organization_service import OrganizationService _now = datetime.now(timezone.utc)
org_name = f"{user_info.get('name') or user.email.split('@')[0]}'s Workspace" _session = _AS.create_session(user=user, is_compliance_only=False)
# Build a URL-safe slug and ensure uniqueness with a short suffix _session_dict = _session.to_dict()
base_slug = re.sub(r"[^a-z0-9]+", "-", org_name.lower()).strip("-")[:40] _session_dict["token"] = _session.token
slug = f"{base_slug}-{uuid.uuid4().hex[:6]}" _expires_at = _session.expires_at
org = OrganizationService.create_organization( if _expires_at.tzinfo is None:
name=org_name, _expires_at = _expires_at.replace(tzinfo=timezone.utc)
slug=slug, _session_dict["expires_in"] = int((_expires_at - _now).total_seconds())
owner_user_id=user.id,
) _pending = OrgInviteToken.query.filter(
target_org = org 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( logger.info(
f"OAuth login: auto-created org '{org.name}' (id={org.id}) " f"OAuth login: user {user.id} has no org, redirecting to org-setup "
f"for new user {user.id}" 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 # Priority 4: Multiple orgs — need user to pick one
if not target_org: if not target_org:
@@ -805,7 +832,40 @@ class OAuthFlowService:
"session": session_dict, "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 { return {
"success": True, "success": True,
"flow_type": "register", "flow_type": "register",
@@ -815,6 +875,8 @@ class OAuthFlowService:
"email": user.email, "email": user.email,
"full_name": user.full_name, "full_name": user.full_name,
}, },
"session": _session_dict,
"pending_invites": _pending_list,
"state": state_record.state, "state": state_record.state,
} }