From cd64ecb703ae8d42e254a748f763cbcd967c2969 Mon Sep 17 00:00:00 2001 From: Ejiro Asiuwhu Date: Wed, 25 Mar 2026 01:24:57 +0100 Subject: [PATCH] feat: add team membership, invitations, and member profiles - Add add_team_member and remove_team_member via /team-memberships - Add send_team_invitation, list_team_invitations, revoke_team_invitation - Add list_team_member_profiles and get_team_member_profile - Add user_id field to TeamMemberProfile for profile filtering - Both sync and async facades have identical method surfaces --- .../python/src/stack_auth/_app.py | 148 ++++++++++++++++++ .../python/src/stack_auth/models/teams.py | 1 + 2 files changed, 149 insertions(+) diff --git a/sdks/implementations/python/src/stack_auth/_app.py b/sdks/implementations/python/src/stack_auth/_app.py index 06e5b603a..18532775a 100644 --- a/sdks/implementations/python/src/stack_auth/_app.py +++ b/sdks/implementations/python/src/stack_auth/_app.py @@ -335,6 +335,78 @@ class StackServerApp: return None return self.get_team(data["team_id"]) + # -- team membership ----------------------------------------------------- + + def add_team_member(self, team_id: str, user_id: str) -> None: + """Add a user to a team.""" + self._client.request( + "POST", f"/team-memberships/{team_id}/{user_id}", body={} + ) + + def remove_team_member(self, team_id: str, user_id: str) -> None: + """Remove a user from a team.""" + self._client.request( + "DELETE", f"/team-memberships/{team_id}/{user_id}" + ) + + # -- team invitations ---------------------------------------------------- + + def send_team_invitation( + self, + team_id: str, + email: str, + *, + callback_url: Optional[str] = None, + ) -> None: + """Send an invitation email to join a team.""" + body = _build_params( + email=email, team_id=team_id, callback_url=callback_url + ) + self._client.request("POST", "/team-invitations/send-code", body=body) + + def list_team_invitations(self, team_id: str) -> list[TeamInvitation]: + """List pending invitations for a team.""" + data = self._client.request( + "GET", f"/teams/{team_id}/invitations" + ) + return [ + TeamInvitation.model_validate(item) + for item in (data or {}).get("items", []) + ] + + def revoke_team_invitation( + self, team_id: str, invitation_id: str + ) -> None: + """Revoke (delete) a team invitation.""" + self._client.request( + "DELETE", f"/teams/{team_id}/invitations/{invitation_id}" + ) + + # -- team member profiles ------------------------------------------------ + + def list_team_member_profiles( + self, team_id: str + ) -> list[TeamMemberProfile]: + """List member profiles for a team.""" + data = self._client.request( + "GET", "/team-member-profiles", params={"team_id": team_id} + ) + return [ + TeamMemberProfile.model_validate(item) + for item in (data or {}).get("items", []) + ] + + def get_team_member_profile( + self, team_id: str, user_id: str + ) -> TeamMemberProfile | None: + """Get a specific team member's profile. + + Fetches all member profiles for the team and filters by *user_id*. + Returns ``None`` if no profile is found. + """ + profiles = self.list_team_member_profiles(team_id) + return next((p for p in profiles if p.user_id == user_id), None) + # --------------------------------------------------------------------------- # AsyncStackServerApp (async) @@ -642,3 +714,79 @@ class AsyncStackServerApp: if data is None or "team_id" not in data: return None return await self.get_team(data["team_id"]) + + # -- team membership ----------------------------------------------------- + + async def add_team_member(self, team_id: str, user_id: str) -> None: + """Add a user to a team.""" + await self._client.request( + "POST", f"/team-memberships/{team_id}/{user_id}", body={} + ) + + async def remove_team_member(self, team_id: str, user_id: str) -> None: + """Remove a user from a team.""" + await self._client.request( + "DELETE", f"/team-memberships/{team_id}/{user_id}" + ) + + # -- team invitations ---------------------------------------------------- + + async def send_team_invitation( + self, + team_id: str, + email: str, + *, + callback_url: Optional[str] = None, + ) -> None: + """Send an invitation email to join a team.""" + body = _build_params( + email=email, team_id=team_id, callback_url=callback_url + ) + await self._client.request( + "POST", "/team-invitations/send-code", body=body + ) + + async def list_team_invitations( + self, team_id: str + ) -> list[TeamInvitation]: + """List pending invitations for a team.""" + data = await self._client.request( + "GET", f"/teams/{team_id}/invitations" + ) + return [ + TeamInvitation.model_validate(item) + for item in (data or {}).get("items", []) + ] + + async def revoke_team_invitation( + self, team_id: str, invitation_id: str + ) -> None: + """Revoke (delete) a team invitation.""" + await self._client.request( + "DELETE", f"/teams/{team_id}/invitations/{invitation_id}" + ) + + # -- team member profiles ------------------------------------------------ + + async def list_team_member_profiles( + self, team_id: str + ) -> list[TeamMemberProfile]: + """List member profiles for a team.""" + data = await self._client.request( + "GET", "/team-member-profiles", params={"team_id": team_id} + ) + return [ + TeamMemberProfile.model_validate(item) + for item in (data or {}).get("items", []) + ] + + async def get_team_member_profile( + self, team_id: str, user_id: str + ) -> TeamMemberProfile | None: + """Get a specific team member's profile. + + Fetches all member profiles for the team and filters by *user_id*. + Returns ``None`` if no profile is found. + """ + profiles = await self.list_team_member_profiles(team_id) + return next((p for p in profiles if p.user_id == user_id), None) diff --git a/sdks/implementations/python/src/stack_auth/models/teams.py b/sdks/implementations/python/src/stack_auth/models/teams.py index d7dd944fe..634ae0d64 100644 --- a/sdks/implementations/python/src/stack_auth/models/teams.py +++ b/sdks/implementations/python/src/stack_auth/models/teams.py @@ -37,6 +37,7 @@ class ServerTeam(Team): class TeamMemberProfile(StackAuthModel): """A user's profile within a specific team.""" + user_id: str = Field(alias="userId") display_name: str | None = Field(None, alias="displayName") profile_image_url: str | None = Field(None, alias="profileImageUrl")