|
@@ -1,3 +1,4 @@
|
|
|
|
|
+import secrets
|
|
|
from uuid import UUID
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
@@ -6,11 +7,16 @@ from sqlalchemy.exc import IntegrityError
|
|
|
from sqlalchemy.orm import Session, selectinload
|
|
from sqlalchemy.orm import Session, selectinload
|
|
|
|
|
|
|
|
from app.api import deps
|
|
from app.api import deps
|
|
|
|
|
+from app.api.utils import issue_reset_token
|
|
|
|
|
+from app.core.config import settings
|
|
|
|
|
+from app.core.email import EmailSender, get_email_sender
|
|
|
|
|
+from app.core.security import get_password_hash
|
|
|
from app.models import GlobalRole, Organization, OrgRole, User, UserOrganization
|
|
from app.models import GlobalRole, Organization, OrgRole, User, UserOrganization
|
|
|
from app.schemas.requests import (
|
|
from app.schemas.requests import (
|
|
|
OrganizationMembershipCreateRequest,
|
|
OrganizationMembershipCreateRequest,
|
|
|
OrganizationMembershipUpdateRequest,
|
|
OrganizationMembershipUpdateRequest,
|
|
|
OrganizationRequest,
|
|
OrganizationRequest,
|
|
|
|
|
+ UserInviteRequest,
|
|
|
)
|
|
)
|
|
|
from app.schemas.responses import (
|
|
from app.schemas.responses import (
|
|
|
OrganizationDetailResponse,
|
|
OrganizationDetailResponse,
|
|
@@ -167,11 +173,63 @@ async def update_member_role(
|
|
|
return membership
|
|
return membership
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+@router.post("/{organization_id}/invite-member", response_model=OrganizationMembershipResponse)
|
|
|
|
|
+async def invite_member(
|
|
|
|
|
+ organization_id: UUID,
|
|
|
|
|
+ payload: UserInviteRequest,
|
|
|
|
|
+ current_user: User = Depends(deps.require_organization_role(OrgRole.ORG_ADMIN)),
|
|
|
|
|
+ session: Session = Depends(deps.get_session),
|
|
|
|
|
+ email_sender: EmailSender = Depends(get_email_sender),
|
|
|
|
|
+):
|
|
|
|
|
+ """Invite a user into this organization with a given role.
|
|
|
|
|
+ If no account exists for the email yet, one is created with an unusable placeholder password and a reset-token email is sent so the person can
|
|
|
|
|
+ set their own password.
|
|
|
|
|
+ If they already have an account, they're simply added as a member (no email sent -- they already have credentials).
|
|
|
|
|
+ Idempotent on membership: inviting someone already in the org is a no-op on the membership row, not an error,
|
|
|
|
|
+ since re-sending an invite to fix a typo'd name/role shouldn't fail outright."""
|
|
|
|
|
+ org = session.get(Organization, str(organization_id))
|
|
|
|
|
+ if org is None:
|
|
|
|
|
+ raise HTTPException(status_code=404, detail="Organization not found")
|
|
|
|
|
+
|
|
|
|
|
+ user = session.scalar(select(User).where(User.email == payload.email))
|
|
|
|
|
+ is_new_user = user is None
|
|
|
|
|
+
|
|
|
|
|
+ if is_new_user:
|
|
|
|
|
+ user = User(
|
|
|
|
|
+ email=payload.email,
|
|
|
|
|
+ name=payload.name,
|
|
|
|
|
+ phone_number=payload.phone_number,
|
|
|
|
|
+ hashed_password=get_password_hash(secrets.token_urlsafe(32)), # unusable placeholder
|
|
|
|
|
+ must_change_password=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ session.add(user)
|
|
|
|
|
+ session.flush()
|
|
|
|
|
+
|
|
|
|
|
+ membership = session.get(UserOrganization, (user.id, org.id))
|
|
|
|
|
+ if membership is None:
|
|
|
|
|
+ membership = UserOrganization(
|
|
|
|
|
+ user_id=user.id,
|
|
|
|
|
+ organization_id=org.id,
|
|
|
|
|
+ role=payload.org_role,
|
|
|
|
|
+ )
|
|
|
|
|
+ session.add(membership)
|
|
|
|
|
+ else:
|
|
|
|
|
+ membership.role = payload.org_role
|
|
|
|
|
+
|
|
|
|
|
+ session.commit()
|
|
|
|
|
+ session.refresh(membership)
|
|
|
|
|
+
|
|
|
|
|
+ if is_new_user:
|
|
|
|
|
+ await issue_reset_token(session, user, "invite", email_sender, settings.FRONT_END_URL)
|
|
|
|
|
+
|
|
|
|
|
+ return membership
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@router.delete("/{organization_id}/members/{user_id}")
|
|
@router.delete("/{organization_id}/members/{user_id}")
|
|
|
async def remove_member(
|
|
async def remove_member(
|
|
|
organization_id: UUID,
|
|
organization_id: UUID,
|
|
|
user_id: UUID,
|
|
user_id: UUID,
|
|
|
- current_user: User = Depends(deps.require_super_admin),
|
|
|
|
|
|
|
+ current_user: User = Depends(deps.require_organization_role(OrgRole.ORG_ADMIN)),
|
|
|
session: Session = Depends(deps.get_session),
|
|
session: Session = Depends(deps.get_session),
|
|
|
):
|
|
):
|
|
|
"""Remove a user from an organization. super_admin only."""
|
|
"""Remove a user from an organization. super_admin only."""
|