2026-03-04 18:49:04 +05:45
|
|
|
"""Current user (self-service) endpoints."""
|
|
|
|
|
from flask import g, request
|
|
|
|
|
from marshmallow import ValidationError
|
|
|
|
|
from gatehouse_app.api.v1 import api_v1_bp
|
|
|
|
|
from gatehouse_app.utils.response import api_response
|
|
|
|
|
from gatehouse_app.utils.decorators import login_required, full_access_required
|
|
|
|
|
from gatehouse_app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema
|
|
|
|
|
from gatehouse_app.services.user_service import UserService
|
|
|
|
|
from gatehouse_app.services.auth_service import AuthService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@api_v1_bp.route("/users/me", methods=["GET"])
|
|
|
|
|
@login_required
|
|
|
|
|
def get_me():
|
|
|
|
|
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
|
|
|
|
from gatehouse_app.utils.constants import AuthMethodType
|
|
|
|
|
|
|
|
|
|
user = g.current_user
|
|
|
|
|
user_dict = user.to_dict()
|
|
|
|
|
|
|
|
|
|
OAUTH_TYPES = {
|
|
|
|
|
AuthMethodType.GOOGLE, AuthMethodType.GITHUB,
|
|
|
|
|
AuthMethodType.MICROSOFT, AuthMethodType.OIDC,
|
|
|
|
|
}
|
|
|
|
|
auth_methods = AuthenticationMethod.query.filter_by(user_id=user.id, deleted_at=None).all()
|
|
|
|
|
|
|
|
|
|
has_password = any(m.method_type == AuthMethodType.PASSWORD and m.password_hash for m in auth_methods)
|
|
|
|
|
totp_enabled = any(m.method_type == AuthMethodType.TOTP and m.verified for m in auth_methods)
|
|
|
|
|
linked_providers = [m.method_type.value for m in auth_methods if m.method_type in OAUTH_TYPES]
|
|
|
|
|
|
|
|
|
|
user_dict["has_password"] = has_password
|
|
|
|
|
user_dict["totp_enabled"] = totp_enabled
|
|
|
|
|
user_dict["linked_providers"] = linked_providers
|
|
|
|
|
|
|
|
|
|
return api_response(data={"user": user_dict}, message="User profile retrieved successfully")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@api_v1_bp.route("/users/me", methods=["PATCH"])
|
|
|
|
|
@login_required
|
|
|
|
|
@full_access_required
|
|
|
|
|
def update_me():
|
|
|
|
|
try:
|
|
|
|
|
schema = UserUpdateSchema()
|
|
|
|
|
data = schema.load(request.json)
|
|
|
|
|
user = UserService.update_user(g.current_user, **data)
|
|
|
|
|
return api_response(data={"user": user.to_dict()}, message="Profile updated successfully")
|
|
|
|
|
except ValidationError as e:
|
|
|
|
|
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@api_v1_bp.route("/users/me", methods=["DELETE"])
|
|
|
|
|
@login_required
|
|
|
|
|
@full_access_required
|
|
|
|
|
def delete_me():
|
|
|
|
|
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
|
|
|
|
from gatehouse_app.utils.constants import OrganizationRole
|
|
|
|
|
from gatehouse_app.services.organization_service import OrganizationService
|
|
|
|
|
|
|
|
|
|
user = g.current_user
|
|
|
|
|
|
|
|
|
|
owned_memberships = OrganizationMember.query.filter_by(
|
|
|
|
|
user_id=user.id, role=OrganizationRole.OWNER, deleted_at=None,
|
|
|
|
|
).all()
|
|
|
|
|
|
|
|
|
|
transfer_needed = []
|
|
|
|
|
auto_delete = []
|
|
|
|
|
|
|
|
|
|
for membership in owned_memberships:
|
|
|
|
|
org = membership.organization
|
|
|
|
|
if org.deleted_at is not None:
|
|
|
|
|
continue
|
|
|
|
|
if org.get_member_count() > 1:
|
|
|
|
|
transfer_needed.append(org.name)
|
|
|
|
|
else:
|
|
|
|
|
auto_delete.append(org)
|
|
|
|
|
|
|
|
|
|
if transfer_needed:
|
|
|
|
|
names = ", ".join(f'"{n}"' for n in transfer_needed)
|
|
|
|
|
return api_response(
|
|
|
|
|
success=False,
|
|
|
|
|
message=(
|
|
|
|
|
f"You are the owner of {len(transfer_needed)} organization"
|
|
|
|
|
f"{'s' if len(transfer_needed) > 1 else ''} that still "
|
|
|
|
|
f"{'have' if len(transfer_needed) > 1 else 'has'} other members "
|
|
|
|
|
f"({names}). Transfer ownership to another member first."
|
|
|
|
|
),
|
|
|
|
|
status=409,
|
|
|
|
|
error_type="USER_IS_SOLE_OWNER",
|
|
|
|
|
error_details={"transfer_ownership": transfer_needed},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for org in auto_delete:
|
|
|
|
|
OrganizationService.force_delete_organization(org, user_id=user.id)
|
|
|
|
|
|
|
|
|
|
UserService.delete_user(user, soft=True)
|
|
|
|
|
return api_response(message="Account deleted successfully")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@api_v1_bp.route("/users/me/password", methods=["POST"])
|
|
|
|
|
@login_required
|
|
|
|
|
@full_access_required
|
|
|
|
|
def change_password():
|
|
|
|
|
try:
|
|
|
|
|
schema = ChangePasswordSchema()
|
|
|
|
|
data = schema.load(request.json)
|
|
|
|
|
|
|
|
|
|
if data["new_password"] != data["new_password_confirm"]:
|
|
|
|
|
return api_response(
|
|
|
|
|
success=False, message="New passwords do not match", status=400,
|
|
|
|
|
error_type="VALIDATION_ERROR",
|
|
|
|
|
error_details={"new_password_confirm": ["Passwords do not match"]},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
AuthService.change_password(
|
|
|
|
|
user=g.current_user,
|
|
|
|
|
current_password=data["current_password"],
|
|
|
|
|
new_password=data["new_password"],
|
|
|
|
|
)
|
|
|
|
|
return api_response(message="Password changed successfully")
|
|
|
|
|
except ValidationError as e:
|
|
|
|
|
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@api_v1_bp.route("/users/me/organizations", methods=["GET"])
|
|
|
|
|
@login_required
|
|
|
|
|
@full_access_required
|
|
|
|
|
def get_my_organizations():
|
|
|
|
|
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
|
|
|
|
|
|
|
|
|
user = g.current_user
|
|
|
|
|
memberships = OrganizationMember.query.filter_by(user_id=user.id, deleted_at=None).all()
|
|
|
|
|
|
|
|
|
|
orgs = []
|
|
|
|
|
for membership in memberships:
|
|
|
|
|
org = membership.organization
|
|
|
|
|
if not org or org.deleted_at is not None:
|
|
|
|
|
continue
|
|
|
|
|
org_dict = org.to_dict()
|
|
|
|
|
org_dict["role"] = membership.role.value if hasattr(membership.role, "value") else str(membership.role)
|
|
|
|
|
orgs.append(org_dict)
|
|
|
|
|
|
|
|
|
|
return api_response(data={"organizations": orgs, "count": len(orgs)}, message="Organizations retrieved successfully")
|
|
|
|
|
|
|
|
|
|
|
2026-04-24 22:27:24 +09:30
|
|
|
@api_v1_bp.route("/users/me/organizations/simple", methods=["GET"])
|
|
|
|
|
@login_required
|
|
|
|
|
def get_my_organizations_simple():
|
|
|
|
|
"""Lightweight organization list for CLI tool.
|
|
|
|
|
|
|
|
|
|
Returns organizations with CA status indicators for CLI users.
|
|
|
|
|
"""
|
|
|
|
|
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
|
|
|
|
from gatehouse_app.models.ssh_ca.ca import CA, CaType
|
|
|
|
|
|
|
|
|
|
user = g.current_user
|
|
|
|
|
memberships = OrganizationMember.query.filter_by(user_id=user.id, deleted_at=None).all()
|
|
|
|
|
|
|
|
|
|
orgs = []
|
|
|
|
|
for membership in memberships:
|
|
|
|
|
org = membership.organization
|
|
|
|
|
if not org or org.deleted_at is not None:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Check for active CAs
|
|
|
|
|
user_ca = CA.query.filter_by(
|
|
|
|
|
organization_id=org.id,
|
|
|
|
|
ca_type=CaType.USER,
|
|
|
|
|
is_active=True,
|
|
|
|
|
deleted_at=None,
|
|
|
|
|
).first()
|
|
|
|
|
|
|
|
|
|
host_ca = CA.query.filter_by(
|
|
|
|
|
organization_id=org.id,
|
|
|
|
|
ca_type=CaType.HOST,
|
|
|
|
|
is_active=True,
|
|
|
|
|
deleted_at=None,
|
|
|
|
|
).first()
|
|
|
|
|
|
|
|
|
|
orgs.append({
|
|
|
|
|
"id": str(org.id),
|
|
|
|
|
"name": org.name,
|
|
|
|
|
"slug": getattr(org, 'slug', None),
|
|
|
|
|
"role": membership.role.value if hasattr(membership.role, "value") else str(membership.role),
|
|
|
|
|
"has_user_ca": user_ca is not None,
|
|
|
|
|
"has_host_ca": host_ca is not None,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return api_response(
|
|
|
|
|
data={"organizations": orgs, "count": len(orgs)},
|
|
|
|
|
message="Organizations retrieved successfully",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 18:49:04 +05:45
|
|
|
@api_v1_bp.route("/users/me/principals", methods=["GET"])
|
|
|
|
|
@login_required
|
|
|
|
|
@full_access_required
|
|
|
|
|
def get_my_principals():
|
|
|
|
|
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
|
|
|
|
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
|
|
|
|
|
from gatehouse_app.models.organization.department import DepartmentMembership, DepartmentPrincipal
|
|
|
|
|
from gatehouse_app.utils.constants import OrganizationRole
|
|
|
|
|
|
|
|
|
|
user = g.current_user
|
|
|
|
|
user_id = user.id
|
|
|
|
|
|
|
|
|
|
memberships = OrganizationMember.query.filter_by(user_id=user_id, deleted_at=None).all()
|
|
|
|
|
|
|
|
|
|
orgs_result = []
|
|
|
|
|
for membership in memberships:
|
|
|
|
|
org = membership.organization
|
|
|
|
|
if not org or org.deleted_at is not None:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
role = membership.role
|
|
|
|
|
is_admin = role in (OrganizationRole.ADMIN, OrganizationRole.OWNER)
|
|
|
|
|
|
|
|
|
|
direct_principal_ids = set()
|
|
|
|
|
via_dept_principal_ids = set()
|
|
|
|
|
|
|
|
|
|
for pm in PrincipalMembership.query.filter_by(user_id=user_id, deleted_at=None).all():
|
|
|
|
|
if pm.principal and pm.principal.organization_id == org.id and pm.principal.deleted_at is None:
|
|
|
|
|
direct_principal_ids.add(pm.principal_id)
|
|
|
|
|
|
|
|
|
|
for dm in DepartmentMembership.query.filter_by(user_id=user_id, deleted_at=None).all():
|
|
|
|
|
if dm.department and dm.department.organization_id == org.id and dm.department.deleted_at is None:
|
|
|
|
|
for dp in DepartmentPrincipal.query.filter_by(department_id=dm.department_id, deleted_at=None).all():
|
|
|
|
|
if dp.principal and dp.principal.deleted_at is None:
|
|
|
|
|
via_dept_principal_ids.add(dp.principal_id)
|
|
|
|
|
|
|
|
|
|
effective_principal_ids = direct_principal_ids | via_dept_principal_ids
|
|
|
|
|
|
|
|
|
|
my_principals = []
|
|
|
|
|
if effective_principal_ids:
|
2026-04-24 22:27:24 +09:30
|
|
|
for p in Principal.query.filter(Principal.id.in_(list(effective_principal_ids)), Principal.deleted_at == None).all():
|
2026-03-04 18:49:04 +05:45
|
|
|
my_principals.append({
|
2026-04-24 22:27:24 +09:30
|
|
|
"id": p.id,
|
|
|
|
|
"name": p.name,
|
|
|
|
|
"description": p.description,
|
2026-03-04 18:49:04 +05:45
|
|
|
"direct": p.id in direct_principal_ids,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
all_principals = []
|
|
|
|
|
if is_admin:
|
|
|
|
|
for p in Principal.query.filter_by(organization_id=org.id, deleted_at=None).all():
|
|
|
|
|
all_principals.append({"id": p.id, "name": p.name, "description": p.description})
|
|
|
|
|
|
|
|
|
|
orgs_result.append({
|
2026-04-24 22:27:24 +09:30
|
|
|
"org_id": org.id,
|
|
|
|
|
"org_name": org.name,
|
2026-03-04 18:49:04 +05:45
|
|
|
"role": role.value if hasattr(role, "value") else role,
|
|
|
|
|
"is_admin": is_admin,
|
|
|
|
|
"my_principals": my_principals,
|
|
|
|
|
"all_principals": all_principals,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return api_response(data={"orgs": orgs_result}, message="Principals retrieved successfully")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@api_v1_bp.route("/users/me/invites", methods=["GET"])
|
|
|
|
|
@login_required
|
|
|
|
|
def get_my_pending_invites():
|
|
|
|
|
from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
user = g.current_user
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
|
|
|
|
invites = OrgInviteToken.query.filter(
|
|
|
|
|
OrgInviteToken.email == user.email,
|
|
|
|
|
OrgInviteToken.accepted_at.is_(None),
|
|
|
|
|
OrgInviteToken.expires_at > now,
|
|
|
|
|
OrgInviteToken.deleted_at.is_(None),
|
|
|
|
|
).all()
|
|
|
|
|
|
|
|
|
|
return api_response(
|
|
|
|
|
data={
|
|
|
|
|
"invites": [
|
|
|
|
|
{
|
|
|
|
|
"token": i.token,
|
|
|
|
|
"organization": {"id": str(i.organization_id), "name": i.organization.name},
|
|
|
|
|
"role": i.role,
|
|
|
|
|
"expires_at": i.expires_at.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
for i in invites
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
message="Pending invitations retrieved",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@api_v1_bp.route("/users/me/memberships", methods=["GET"])
|
|
|
|
|
@login_required
|
2026-04-24 22:27:24 +09:30
|
|
|
@full_access_required
|
2026-03-04 18:49:04 +05:45
|
|
|
def get_my_memberships():
|
|
|
|
|
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
|
|
|
|
from gatehouse_app.models.organization.department import DepartmentMembership, DepartmentPrincipal, Department
|
|
|
|
|
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
|
|
|
|
|
|
|
|
|
|
user = g.current_user
|
|
|
|
|
|
|
|
|
|
memberships = OrganizationMember.query.filter_by(user_id=user.id, deleted_at=None).all()
|
|
|
|
|
|
|
|
|
|
orgs_result = []
|
|
|
|
|
for membership in memberships:
|
|
|
|
|
org = membership.organization
|
|
|
|
|
if not org or org.deleted_at is not None:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
dept_memberships = DepartmentMembership.query.filter_by(user_id=user.id, deleted_at=None).all()
|
|
|
|
|
user_depts = [
|
2026-04-24 22:27:24 +09:30
|
|
|
dm.department
|
|
|
|
|
for dm in dept_memberships
|
|
|
|
|
if dm.department and dm.department.organization_id == org.id and dm.department.deleted_at is None
|
2026-03-04 18:49:04 +05:45
|
|
|
]
|
|
|
|
|
|
|
|
|
|
direct_pm = PrincipalMembership.query.filter_by(user_id=user.id, deleted_at=None).all()
|
|
|
|
|
direct_principal_ids = {
|
2026-04-24 22:27:24 +09:30
|
|
|
pm.principal_id
|
|
|
|
|
for pm in direct_pm
|
2026-03-04 18:49:04 +05:45
|
|
|
if pm.principal and pm.principal.organization_id == org.id and pm.principal.deleted_at is None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
via_dept_principal_ids = set()
|
|
|
|
|
for dept in user_depts:
|
|
|
|
|
for dp in DepartmentPrincipal.query.filter_by(department_id=dept.id, deleted_at=None).all():
|
|
|
|
|
if dp.principal and dp.principal.deleted_at is None:
|
|
|
|
|
via_dept_principal_ids.add(dp.principal_id)
|
|
|
|
|
|
|
|
|
|
all_principal_ids = direct_principal_ids | via_dept_principal_ids
|
|
|
|
|
principals_list = []
|
|
|
|
|
if all_principal_ids:
|
2026-04-24 22:27:24 +09:30
|
|
|
for p in Principal.query.filter(Principal.id.in_(list(all_principal_ids)), Principal.deleted_at == None).all():
|
2026-03-04 18:49:04 +05:45
|
|
|
principals_list.append({
|
2026-04-24 22:27:24 +09:30
|
|
|
"id": str(p.id),
|
|
|
|
|
"name": p.name,
|
|
|
|
|
"description": p.description,
|
2026-03-04 18:49:04 +05:45
|
|
|
"via_department": p.id not in direct_principal_ids,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
role = membership.role
|
|
|
|
|
orgs_result.append({
|
2026-04-24 22:27:24 +09:30
|
|
|
"org_id": str(org.id),
|
|
|
|
|
"org_name": org.name,
|
2026-03-04 18:49:04 +05:45
|
|
|
"role": role.value if hasattr(role, "value") else role,
|
|
|
|
|
"departments": [{"id": str(d.id), "name": d.name, "description": d.description} for d in user_depts],
|
|
|
|
|
"principals": principals_list,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return api_response(data={"orgs": orgs_result}, message="Memberships retrieved")
|