Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .semgrep/rules/security/idor-team-scoped-models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,7 @@ rules:
|PersonalAPIKey
|ScoreDefinitionVersion
|SignalUserAutonomyConfig
|UserPersonalization
|UserPushToken
|WebauthnCredential
)$
Expand Down Expand Up @@ -922,6 +923,7 @@ rules:
|PersonalAPIKey
|ScoreDefinitionVersion
|SignalUserAutonomyConfig
|UserPersonalization
|UserPushToken
|WebauthnCredential
)$
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/generated/core/api.schemas.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

77 changes: 77 additions & 0 deletions frontend/src/generated/core/api.zod.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions posthog/api/organization_member.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, cast
from typing import Any, Optional, cast

from django.db.models import F, Model, Prefetch, QuerySet
from django.shortcuts import get_object_or_404
Expand Down Expand Up @@ -59,7 +59,7 @@ def organization_members_base_queryset() -> QuerySet:
return (
OrganizationMembership.objects.exclude(user__email__endswith=INTERNAL_BOT_EMAIL_SUFFIX)
.filter(user__is_active=True)
.select_related("user")
.select_related("user", "user__personalization")
)


Expand Down Expand Up @@ -88,6 +88,9 @@ class OrganizationMemberSerializer(SearchMatchTypeSerializerMixin, serializers.M
is_2fa_enabled = serializers.SerializerMethodField()
has_social_auth = serializers.SerializerMethodField()
last_login = serializers.DateTimeField(read_only=True)
avatar_url = serializers.SerializerMethodField(
help_text="The member's profile picture URL, when they have set one."
)

class Meta:
model = OrganizationMembership
Expand All @@ -100,10 +103,15 @@ class Meta:
"is_2fa_enabled",
"has_social_auth",
"last_login",
"avatar_url",
"search_match_type",
]
read_only_fields = ["id", "joined_at", "updated_at"]

def get_avatar_url(self, instance: OrganizationMembership) -> Optional[str]:
personalization = getattr(instance.user, "personalization", None)
return personalization.avatar_url if personalization else None

def get_is_2fa_enabled(self, instance: OrganizationMembership) -> bool:
# Uses prefetched relations to avoid N+1 queries
user = instance.user
Expand Down
39 changes: 39 additions & 0 deletions posthog/api/test/test_user_avatar_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from posthog.test.base import APIBaseTest

from rest_framework import status

from posthog.models import UserPersonalization


class TestUserAvatarUrl(APIBaseTest):
def test_can_set_and_remove_avatar_url(self) -> None:
response = self.client.patch("/api/users/@me/", {"avatar_url": "https://example.com/me.png"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["avatar_url"], "https://example.com/me.png")
self.assertEqual(
UserPersonalization.objects.get(user=self.user).avatar_url,
"https://example.com/me.png",
)

response = self.client.patch("/api/users/@me/", {"avatar_url": None})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsNone(response.json()["avatar_url"])
self.assertIsNone(UserPersonalization.objects.get(user=self.user).avatar_url)

def test_avatar_url_defaults_to_none_without_personalization_row(self) -> None:
response = self.client.get("/api/users/@me/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsNone(response.json()["avatar_url"])

def test_rejects_non_https_avatar_url(self) -> None:
for bad_url in ["http://example.com/me.png", "javascript:alert(1)", "not a url"]:
response = self.client.patch("/api/users/@me/", {"avatar_url": bad_url})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, bad_url)
self.assertFalse(UserPersonalization.objects.filter(user=self.user).exists())

def test_organization_members_expose_avatar_url(self) -> None:
UserPersonalization.objects.create(user=self.user, avatar_url="https://example.com/me.png")
response = self.client.get("/api/organizations/@current/members/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
member = next(m for m in response.json()["results"] if m["user"]["uuid"] == str(self.user.uuid))
self.assertEqual(member["avatar_url"], "https://example.com/me.png")
Loading
Loading