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