diff --git a/.semgrep/rules/security/idor-team-scoped-models.yaml b/.semgrep/rules/security/idor-team-scoped-models.yaml
index b34ccc2cd1dd..130d025912d8 100644
--- a/.semgrep/rules/security/idor-team-scoped-models.yaml
+++ b/.semgrep/rules/security/idor-team-scoped-models.yaml
@@ -868,6 +868,7 @@ rules:
|PersonalAPIKey
|ScoreDefinitionVersion
|SignalUserAutonomyConfig
+ |UserPersonalization
|UserPushToken
|WebauthnCredential
)$
@@ -922,6 +923,7 @@ rules:
|PersonalAPIKey
|ScoreDefinitionVersion
|SignalUserAutonomyConfig
+ |UserPersonalization
|UserPushToken
|WebauthnCredential
)$
diff --git a/frontend/src/generated/core/api.schemas.ts b/frontend/src/generated/core/api.schemas.ts
index 4a6ebe54c419..7a8dae32f98f 100644
--- a/frontend/src/generated/core/api.schemas.ts
+++ b/frontend/src/generated/core/api.schemas.ts
@@ -3512,6 +3512,12 @@ export interface UserApi {
readonly scene_personalisation: readonly ScenePersonalisationBasicApi[]
theme_mode?: ThemeModeEnumApi | BlankEnumApi | null
hedgehog_config?: unknown
+ /**
+ * Profile picture URL, shown across PostHog apps in place of the Gravatar/initials fallback.
+ * @maxLength 800
+ * @nullable
+ */
+ avatar_url?: string | null
/** @nullable */
allow_sidebar_suggestions?: boolean | null
shortcut_position?: ShortcutPositionEnumApi | BlankEnumApi | null
@@ -3619,6 +3625,12 @@ export interface PatchedUserApi {
readonly scene_personalisation?: readonly ScenePersonalisationBasicApi[]
theme_mode?: ThemeModeEnumApi | BlankEnumApi | null
hedgehog_config?: unknown
+ /**
+ * Profile picture URL, shown across PostHog apps in place of the Gravatar/initials fallback.
+ * @maxLength 800
+ * @nullable
+ */
+ avatar_url?: string | null
/** @nullable */
allow_sidebar_suggestions?: boolean | null
shortcut_position?: ShortcutPositionEnumApi | BlankEnumApi | null
diff --git a/frontend/src/generated/core/api.zod.ts b/frontend/src/generated/core/api.zod.ts
index 0c78dd3e7876..3893f0c8f8cb 100644
--- a/frontend/src/generated/core/api.zod.ts
+++ b/frontend/src/generated/core/api.zod.ts
@@ -9688,6 +9688,8 @@ export const usersUpdateBodyEmailMax = 254
export const usersUpdateBodyPasswordMax = 128
+export const usersUpdateBodyAvatarUrlMax = 800
+
export const UsersUpdateBody = /* @__PURE__ */ zod.object({
first_name: zod.string().max(usersUpdateBodyFirstNameMax).optional(),
last_name: zod.string().max(usersUpdateBodyLastNameMax).optional(),
@@ -9732,6 +9734,11 @@ export const UsersUpdateBody = /* @__PURE__ */ zod.object({
])
.optional(),
hedgehog_config: zod.unknown().optional(),
+ avatar_url: zod
+ .url()
+ .max(usersUpdateBodyAvatarUrlMax)
+ .nullish()
+ .describe('Profile picture URL, shown across PostHog apps in place of the Gravatar\/initials fallback.'),
allow_sidebar_suggestions: zod.boolean().nullish(),
shortcut_position: zod
.union([
@@ -9773,6 +9780,8 @@ export const usersPartialUpdateBodyEmailMax = 254
export const usersPartialUpdateBodyPasswordMax = 128
+export const usersPartialUpdateBodyAvatarUrlMax = 800
+
export const UsersPartialUpdateBody = /* @__PURE__ */ zod.object({
first_name: zod.string().max(usersPartialUpdateBodyFirstNameMax).optional(),
last_name: zod.string().max(usersPartialUpdateBodyLastNameMax).optional(),
@@ -9817,6 +9826,11 @@ export const UsersPartialUpdateBody = /* @__PURE__ */ zod.object({
])
.optional(),
hedgehog_config: zod.unknown().optional(),
+ avatar_url: zod
+ .url()
+ .max(usersPartialUpdateBodyAvatarUrlMax)
+ .nullish()
+ .describe('Profile picture URL, shown across PostHog apps in place of the Gravatar\/initials fallback.'),
allow_sidebar_suggestions: zod.boolean().nullish(),
shortcut_position: zod
.union([
@@ -9855,6 +9869,8 @@ export const usersHedgehogConfigPartialUpdateBodyEmailMax = 254
export const usersHedgehogConfigPartialUpdateBodyPasswordMax = 128
+export const usersHedgehogConfigPartialUpdateBodyAvatarUrlMax = 800
+
export const UsersHedgehogConfigPartialUpdateBody = /* @__PURE__ */ zod.object({
first_name: zod.string().max(usersHedgehogConfigPartialUpdateBodyFirstNameMax).optional(),
last_name: zod.string().max(usersHedgehogConfigPartialUpdateBodyLastNameMax).optional(),
@@ -9899,6 +9915,11 @@ export const UsersHedgehogConfigPartialUpdateBody = /* @__PURE__ */ zod.object({
])
.optional(),
hedgehog_config: zod.unknown().optional(),
+ avatar_url: zod
+ .url()
+ .max(usersHedgehogConfigPartialUpdateBodyAvatarUrlMax)
+ .nullish()
+ .describe('Profile picture URL, shown across PostHog apps in place of the Gravatar\/initials fallback.'),
allow_sidebar_suggestions: zod.boolean().nullish(),
shortcut_position: zod
.union([
@@ -10071,6 +10092,8 @@ export const usersScenePersonalisationCreateBodyEmailMax = 254
export const usersScenePersonalisationCreateBodyPasswordMax = 128
+export const usersScenePersonalisationCreateBodyAvatarUrlMax = 800
+
export const UsersScenePersonalisationCreateBody = /* @__PURE__ */ zod.object({
first_name: zod.string().max(usersScenePersonalisationCreateBodyFirstNameMax).optional(),
last_name: zod.string().max(usersScenePersonalisationCreateBodyLastNameMax).optional(),
@@ -10115,6 +10138,11 @@ export const UsersScenePersonalisationCreateBody = /* @__PURE__ */ zod.object({
])
.optional(),
hedgehog_config: zod.unknown().optional(),
+ avatar_url: zod
+ .url()
+ .max(usersScenePersonalisationCreateBodyAvatarUrlMax)
+ .nullish()
+ .describe('Profile picture URL, shown across PostHog apps in place of the Gravatar\/initials fallback.'),
allow_sidebar_suggestions: zod.boolean().nullish(),
shortcut_position: zod
.union([
@@ -10156,6 +10184,8 @@ export const usersTwoFactorBackupCodesCreateBodyEmailMax = 254
export const usersTwoFactorBackupCodesCreateBodyPasswordMax = 128
+export const usersTwoFactorBackupCodesCreateBodyAvatarUrlMax = 800
+
export const UsersTwoFactorBackupCodesCreateBody = /* @__PURE__ */ zod.object({
first_name: zod.string().max(usersTwoFactorBackupCodesCreateBodyFirstNameMax).optional(),
last_name: zod.string().max(usersTwoFactorBackupCodesCreateBodyLastNameMax).optional(),
@@ -10200,6 +10230,11 @@ export const UsersTwoFactorBackupCodesCreateBody = /* @__PURE__ */ zod.object({
])
.optional(),
hedgehog_config: zod.unknown().optional(),
+ avatar_url: zod
+ .url()
+ .max(usersTwoFactorBackupCodesCreateBodyAvatarUrlMax)
+ .nullish()
+ .describe('Profile picture URL, shown across PostHog apps in place of the Gravatar\/initials fallback.'),
allow_sidebar_suggestions: zod.boolean().nullish(),
shortcut_position: zod
.union([
@@ -10241,6 +10276,8 @@ export const usersTwoFactorDisableCreateBodyEmailMax = 254
export const usersTwoFactorDisableCreateBodyPasswordMax = 128
+export const usersTwoFactorDisableCreateBodyAvatarUrlMax = 800
+
export const UsersTwoFactorDisableCreateBody = /* @__PURE__ */ zod.object({
first_name: zod.string().max(usersTwoFactorDisableCreateBodyFirstNameMax).optional(),
last_name: zod.string().max(usersTwoFactorDisableCreateBodyLastNameMax).optional(),
@@ -10285,6 +10322,11 @@ export const UsersTwoFactorDisableCreateBody = /* @__PURE__ */ zod.object({
])
.optional(),
hedgehog_config: zod.unknown().optional(),
+ avatar_url: zod
+ .url()
+ .max(usersTwoFactorDisableCreateBodyAvatarUrlMax)
+ .nullish()
+ .describe('Profile picture URL, shown across PostHog apps in place of the Gravatar\/initials fallback.'),
allow_sidebar_suggestions: zod.boolean().nullish(),
shortcut_position: zod
.union([
@@ -10323,6 +10365,8 @@ export const usersTwoFactorValidateCreateBodyEmailMax = 254
export const usersTwoFactorValidateCreateBodyPasswordMax = 128
+export const usersTwoFactorValidateCreateBodyAvatarUrlMax = 800
+
export const UsersTwoFactorValidateCreateBody = /* @__PURE__ */ zod.object({
first_name: zod.string().max(usersTwoFactorValidateCreateBodyFirstNameMax).optional(),
last_name: zod.string().max(usersTwoFactorValidateCreateBodyLastNameMax).optional(),
@@ -10367,6 +10411,11 @@ export const UsersTwoFactorValidateCreateBody = /* @__PURE__ */ zod.object({
])
.optional(),
hedgehog_config: zod.unknown().optional(),
+ avatar_url: zod
+ .url()
+ .max(usersTwoFactorValidateCreateBodyAvatarUrlMax)
+ .nullish()
+ .describe('Profile picture URL, shown across PostHog apps in place of the Gravatar\/initials fallback.'),
allow_sidebar_suggestions: zod.boolean().nullish(),
shortcut_position: zod
.union([
@@ -10405,6 +10454,8 @@ export const usersValidate2faCreateBodyEmailMax = 254
export const usersValidate2faCreateBodyPasswordMax = 128
+export const usersValidate2faCreateBodyAvatarUrlMax = 800
+
export const UsersValidate2faCreateBody = /* @__PURE__ */ zod.object({
first_name: zod.string().max(usersValidate2faCreateBodyFirstNameMax).optional(),
last_name: zod.string().max(usersValidate2faCreateBodyLastNameMax).optional(),
@@ -10449,6 +10500,11 @@ export const UsersValidate2faCreateBody = /* @__PURE__ */ zod.object({
])
.optional(),
hedgehog_config: zod.unknown().optional(),
+ avatar_url: zod
+ .url()
+ .max(usersValidate2faCreateBodyAvatarUrlMax)
+ .nullish()
+ .describe('Profile picture URL, shown across PostHog apps in place of the Gravatar\/initials fallback.'),
allow_sidebar_suggestions: zod.boolean().nullish(),
shortcut_position: zod
.union([
@@ -10487,6 +10543,8 @@ export const usersCancelEmailChangeRequestPartialUpdateBodyEmailMax = 254
export const usersCancelEmailChangeRequestPartialUpdateBodyPasswordMax = 128
+export const usersCancelEmailChangeRequestPartialUpdateBodyAvatarUrlMax = 800
+
export const UsersCancelEmailChangeRequestPartialUpdateBody = /* @__PURE__ */ zod.object({
first_name: zod.string().max(usersCancelEmailChangeRequestPartialUpdateBodyFirstNameMax).optional(),
last_name: zod.string().max(usersCancelEmailChangeRequestPartialUpdateBodyLastNameMax).optional(),
@@ -10531,6 +10589,11 @@ export const UsersCancelEmailChangeRequestPartialUpdateBody = /* @__PURE__ */ zo
])
.optional(),
hedgehog_config: zod.unknown().optional(),
+ avatar_url: zod
+ .url()
+ .max(usersCancelEmailChangeRequestPartialUpdateBodyAvatarUrlMax)
+ .nullish()
+ .describe('Profile picture URL, shown across PostHog apps in place of the Gravatar\/initials fallback.'),
allow_sidebar_suggestions: zod.boolean().nullish(),
shortcut_position: zod
.union([
@@ -10569,6 +10632,8 @@ export const usersRequestEmailVerificationCreateBodyEmailMax = 254
export const usersRequestEmailVerificationCreateBodyPasswordMax = 128
+export const usersRequestEmailVerificationCreateBodyAvatarUrlMax = 800
+
export const UsersRequestEmailVerificationCreateBody = /* @__PURE__ */ zod.object({
first_name: zod.string().max(usersRequestEmailVerificationCreateBodyFirstNameMax).optional(),
last_name: zod.string().max(usersRequestEmailVerificationCreateBodyLastNameMax).optional(),
@@ -10613,6 +10678,11 @@ export const UsersRequestEmailVerificationCreateBody = /* @__PURE__ */ zod.objec
])
.optional(),
hedgehog_config: zod.unknown().optional(),
+ avatar_url: zod
+ .url()
+ .max(usersRequestEmailVerificationCreateBodyAvatarUrlMax)
+ .nullish()
+ .describe('Profile picture URL, shown across PostHog apps in place of the Gravatar\/initials fallback.'),
allow_sidebar_suggestions: zod.boolean().nullish(),
shortcut_position: zod
.union([
@@ -10651,6 +10721,8 @@ export const usersVerifyEmailCreateBodyEmailMax = 254
export const usersVerifyEmailCreateBodyPasswordMax = 128
+export const usersVerifyEmailCreateBodyAvatarUrlMax = 800
+
export const UsersVerifyEmailCreateBody = /* @__PURE__ */ zod.object({
first_name: zod.string().max(usersVerifyEmailCreateBodyFirstNameMax).optional(),
last_name: zod.string().max(usersVerifyEmailCreateBodyLastNameMax).optional(),
@@ -10695,6 +10767,11 @@ export const UsersVerifyEmailCreateBody = /* @__PURE__ */ zod.object({
])
.optional(),
hedgehog_config: zod.unknown().optional(),
+ avatar_url: zod
+ .url()
+ .max(usersVerifyEmailCreateBodyAvatarUrlMax)
+ .nullish()
+ .describe('Profile picture URL, shown across PostHog apps in place of the Gravatar\/initials fallback.'),
allow_sidebar_suggestions: zod.boolean().nullish(),
shortcut_position: zod
.union([
diff --git a/posthog/api/organization_member.py b/posthog/api/organization_member.py
index 14a8423c104f..7f92e1256568 100644
--- a/posthog/api/organization_member.py
+++ b/posthog/api/organization_member.py
@@ -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
@@ -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")
)
@@ -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
@@ -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
diff --git a/posthog/api/test/test_user_avatar_url.py b/posthog/api/test/test_user_avatar_url.py
new file mode 100644
index 000000000000..01e7ce93211e
--- /dev/null
+++ b/posthog/api/test/test_user_avatar_url.py
@@ -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")
diff --git a/posthog/api/user.py b/posthog/api/user.py
index b1932d6c41f3..96e4b1cce914 100644
--- a/posthog/api/user.py
+++ b/posthog/api/user.py
@@ -83,7 +83,7 @@
get_impersonated_session_expires_at,
is_read_only_impersonation,
)
-from posthog.models import OrganizationInvite, Team, User, UserScenePersonalisation
+from posthog.models import OrganizationInvite, Team, User, UserPersonalization, UserScenePersonalisation
from posthog.models.onboarding_delegation import cancel_pending_delegation, clear_delegation_state
from posthog.models.organization import Organization, OrganizationMembership
from posthog.models.organization_domain import OrganizationDomain
@@ -236,6 +236,14 @@ class UserSerializer(serializers.ModelSerializer):
help_text="Whether PostHog should anonymize events captured for this user when identified."
)
role_at_organization = serializers.ChoiceField(choices=ROLE_CHOICES, required=False)
+ avatar_url = serializers.URLField(
+ source="personalization.avatar_url",
+ max_length=800,
+ required=False,
+ allow_null=True,
+ default=None,
+ help_text="Profile picture URL, shown across PostHog apps in place of the Gravatar/initials fallback.",
+ )
# Intentionally NOT declared explicitly — Meta.read_only_fields below is silently ignored
# for explicitly declared fields (a well-known DRF gotcha that drf-spectacular replicates
# in its generated OpenAPI), so any explicit declaration here would re-open the writable
@@ -301,6 +309,7 @@ class Meta:
"scene_personalisation",
"theme_mode",
"hedgehog_config",
+ "avatar_url",
"allow_sidebar_suggestions",
"shortcut_position",
"role_at_organization",
@@ -355,6 +364,12 @@ class Meta:
def validate_first_name(self, value: str) -> str:
return validate_display_name(value)
+ def validate_avatar_url(self, value: Optional[str]) -> Optional[str]:
+ # Rendered as an
src across PostHog apps, so only allow https.
+ if value and not value.startswith("https://"):
+ raise serializers.ValidationError("Avatar URL must use https.")
+ return value
+
def validate_last_name(self, value: str) -> str:
return validate_display_name(value)
@@ -675,6 +690,15 @@ def validate_passkeys_enabled_for_2fa(self, value: bool) -> bool:
return value
def update(self, instance: "User", validated_data: Any) -> Any:
+ # The dotted avatar_url source arrives nested; it lives on a side
+ # table so posthog_user itself is never written for avatar changes.
+ personalization_data = validated_data.pop("personalization", None)
+ if personalization_data is not None and "avatar_url" in personalization_data:
+ UserPersonalization.objects.update_or_create(
+ user=instance,
+ defaults={"avatar_url": personalization_data["avatar_url"]},
+ )
+
# Update current_organization and current_team
current_organization = validated_data.pop("set_current_organization", None)
current_team = validated_data.pop("set_current_team", None)
diff --git a/posthog/migrations/1259_userpersonalization.py b/posthog/migrations/1259_userpersonalization.py
new file mode 100644
index 000000000000..c327590e8d7d
--- /dev/null
+++ b/posthog/migrations/1259_userpersonalization.py
@@ -0,0 +1,56 @@
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+import posthog.models.utils
+from posthog.migration_helpers import AddForeignKeyNotValid
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("posthog", "1258_duckgressinkschemastate_queue_last_applied_at"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="UserPersonalization",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=posthog.models.utils.uuid7,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ (
+ "avatar_url",
+ models.URLField(
+ blank=True,
+ help_text="Profile picture URL, shown across PostHog apps in place of the Gravatar/initials fallback.",
+ max_length=800,
+ null=True,
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "user",
+ models.OneToOneField(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="personalization",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ AddForeignKeyNotValid(
+ model_name="userpersonalization",
+ name="posthog_userpersonalization_user_id_fk",
+ column="user_id",
+ to_table="posthog_user",
+ ),
+ ]
diff --git a/posthog/migrations/1260_validate_userpersonalization_fk.py b/posthog/migrations/1260_validate_userpersonalization_fk.py
new file mode 100644
index 000000000000..6e684c659e46
--- /dev/null
+++ b/posthog/migrations/1260_validate_userpersonalization_fk.py
@@ -0,0 +1,19 @@
+from django.db import migrations
+
+from posthog.migration_helpers import ValidateForeignKey
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("posthog", "1259_userpersonalization"),
+ ]
+
+ operations = [
+ # Phase 2 of the NOT VALID foreign key added in 1259; the scan takes
+ # only SHARE UPDATE EXCLUSIVE and the table is brand new, so this is
+ # instant and never blocks posthog_user traffic.
+ ValidateForeignKey(
+ model_name="userpersonalization",
+ name="posthog_userpersonalization_user_id_fk",
+ ),
+ ]
diff --git a/posthog/migrations/max_migration.txt b/posthog/migrations/max_migration.txt
index 1b7bef7b2072..cd099e2ad5b2 100644
--- a/posthog/migrations/max_migration.txt
+++ b/posthog/migrations/max_migration.txt
@@ -1 +1 @@
-1258_duckgressinkschemastate_queue_last_applied_at
+1260_validate_userpersonalization_fk
diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py
index 6248a96a2ca5..f529ebc4922d 100644
--- a/posthog/models/__init__.py
+++ b/posthog/models/__init__.py
@@ -77,6 +77,7 @@
from .user_repo_preference import UserRepoPreference
from .user_scene_personalisation import UserScenePersonalisation
from .user_home_settings import UserHomeSettings
+from .user_personalization import UserPersonalization
from .oauth import (
CIMDVerificationToken,
OAuthAccessToken,
@@ -181,6 +182,7 @@
"UserRepoPreference",
"UserScenePersonalisation",
"UserHomeSettings",
+ "UserPersonalization",
"UserManager",
"UserGroup",
"UserGroupMembership",
diff --git a/posthog/models/user_personalization.py b/posthog/models/user_personalization.py
new file mode 100644
index 000000000000..0608eec8079b
--- /dev/null
+++ b/posthog/models/user_personalization.py
@@ -0,0 +1,27 @@
+from django.db import models
+
+from posthog.models.utils import UUIDModel
+
+
+class UserPersonalization(UUIDModel):
+ """
+ Per-user appearance/profile preferences. A side table rather than columns
+ on posthog_user, which is too hot to ALTER casually.
+ """
+
+ # db_constraint=False so CreateModel takes no lock on hot posthog_user;
+ # the FK constraint is added NOT VALID + validated in the migration.
+ user = models.OneToOneField(
+ "posthog.User",
+ on_delete=models.CASCADE,
+ related_name="personalization",
+ db_constraint=False,
+ )
+ avatar_url = models.URLField(
+ max_length=800,
+ null=True,
+ blank=True,
+ help_text="Profile picture URL, shown across PostHog apps in place of the Gravatar/initials fallback.",
+ )
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
diff --git a/products/platform_features/frontend/generated/api.schemas.ts b/products/platform_features/frontend/generated/api.schemas.ts
index bf5c535ca959..0d7a4795854b 100644
--- a/products/platform_features/frontend/generated/api.schemas.ts
+++ b/products/platform_features/frontend/generated/api.schemas.ts
@@ -316,6 +316,11 @@ export interface OrganizationMemberApi {
readonly is_2fa_enabled: boolean
readonly has_social_auth: boolean
readonly last_login: string
+ /**
+ * The member's profile picture URL, when they have set one.
+ * @nullable
+ */
+ readonly avatar_url: string | null
/** How this row matched the `search` query parameter: `exact` (the term is a case-insensitive substring of a searched field) or `similar` (a fuzzy trigram match, returned only when no exact match exists). Null when the list is not filtered by `search`. */
readonly search_match_type: SearchMatchTypeEnumApi | null
}
@@ -338,6 +343,11 @@ export interface PatchedOrganizationMemberApi {
readonly is_2fa_enabled?: boolean
readonly has_social_auth?: boolean
readonly last_login?: string
+ /**
+ * The member's profile picture URL, when they have set one.
+ * @nullable
+ */
+ readonly avatar_url?: string | null
/** How this row matched the `search` query parameter: `exact` (the term is a case-insensitive substring of a searched field) or `similar` (a fuzzy trigram match, returned only when no exact match exists). Null when the list is not filtered by `search`. */
readonly search_match_type?: SearchMatchTypeEnumApi | null
}
diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts
index d04e759cbd2c..9f792fad0e74 100644
--- a/services/mcp/src/api/generated.ts
+++ b/services/mcp/src/api/generated.ts
@@ -36714,6 +36714,11 @@ export namespace Schemas {
readonly is_2fa_enabled: boolean;
readonly has_social_auth: boolean;
readonly last_login: string;
+ /**
+ * The member's profile picture URL, when they have set one.
+ * @nullable
+ */
+ readonly avatar_url: string | null;
/** How this row matched the `search` query parameter: `exact` (the term is a case-insensitive substring of a searched field) or `similar` (a fuzzy trigram match, returned only when no exact match exists). Null when the list is not filtered by `search`. */
readonly search_match_type: SearchMatchTypeEnum | null;
}
@@ -41883,6 +41888,12 @@ export namespace Schemas {
readonly scene_personalisation: readonly ScenePersonalisationBasic[];
theme_mode?: ThemeModeEnum | BlankEnum | null;
hedgehog_config?: unknown;
+ /**
+ * Profile picture URL, shown across PostHog apps in place of the Gravatar/initials fallback.
+ * @maxLength 800
+ * @nullable
+ */
+ avatar_url?: string | null;
/** @nullable */
allow_sidebar_suggestions?: boolean | null;
shortcut_position?: ShortcutPositionEnum | BlankEnum | null;
@@ -46046,6 +46057,11 @@ export namespace Schemas {
readonly is_2fa_enabled?: boolean;
readonly has_social_auth?: boolean;
readonly last_login?: string;
+ /**
+ * The member's profile picture URL, when they have set one.
+ * @nullable
+ */
+ readonly avatar_url?: string | null;
/** How this row matched the `search` query parameter: `exact` (the term is a case-insensitive substring of a searched field) or `similar` (a fuzzy trigram match, returned only when no exact match exists). Null when the list is not filtered by `search`. */
readonly search_match_type?: SearchMatchTypeEnum | null;
}
@@ -49417,6 +49433,12 @@ export namespace Schemas {
readonly scene_personalisation?: readonly ScenePersonalisationBasic[];
theme_mode?: ThemeModeEnum | BlankEnum | null;
hedgehog_config?: unknown;
+ /**
+ * Profile picture URL, shown across PostHog apps in place of the Gravatar/initials fallback.
+ * @maxLength 800
+ * @nullable
+ */
+ avatar_url?: string | null;
/** @nullable */
allow_sidebar_suggestions?: boolean | null;
shortcut_position?: ShortcutPositionEnum | BlankEnum | null;
diff --git a/services/mcp/src/generated/core/api.ts b/services/mcp/src/generated/core/api.ts
index 405ecd88d55d..e3bddfda3242 100644
--- a/services/mcp/src/generated/core/api.ts
+++ b/services/mcp/src/generated/core/api.ts
@@ -789,6 +789,8 @@ export const usersPartialUpdateBodyEmailMax = 254
export const usersPartialUpdateBodyPasswordMax = 128
+export const usersPartialUpdateBodyAvatarUrlMax = 800
+
export const UsersPartialUpdateBody = /* @__PURE__ */ zod.object({
first_name: zod.string().max(usersPartialUpdateBodyFirstNameMax).optional(),
last_name: zod.string().max(usersPartialUpdateBodyLastNameMax).optional(),
@@ -830,6 +832,11 @@ export const UsersPartialUpdateBody = /* @__PURE__ */ zod.object({
])
.optional(),
hedgehog_config: zod.unknown().optional(),
+ avatar_url: zod
+ .url()
+ .max(usersPartialUpdateBodyAvatarUrlMax)
+ .nullish()
+ .describe('Profile picture URL, shown across PostHog apps in place of the Gravatar/initials fallback.'),
allow_sidebar_suggestions: zod.boolean().nullish(),
shortcut_position: zod
.union([
diff --git a/services/mcp/src/tools/generated/core.ts b/services/mcp/src/tools/generated/core.ts
index 3db2c1ae1fb0..82d8b06fd7b4 100644
--- a/services/mcp/src/tools/generated/core.ts
+++ b/services/mcp/src/tools/generated/core.ts
@@ -568,6 +568,9 @@ const userSettingsUpdate = (): ToolBase