feat: Gravatar opt-in (off by default), configurable in Settings

Previously every account without a custom avatar automatically got
its email MD5-hashed and sent to gravatar.com at signup, with no way
to turn it off. Adds users.useGravatar (default false): removed the
automatic signup-time lookup entirely, and "remove photo" now falls
back to the initials placeholder instead of silently re-deriving a
Gravatar URL. New toggle in Settings -> Profile, off by default,
description explains the MD5-hash-to-third-party tradeoff. Existing
accounts' current avatarUrl is left untouched either way — no
retroactive avatar changes for anyone already using one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-14 09:37:29 +02:00
parent 38516bff63
commit a08588cf85
14 changed files with 5132 additions and 14 deletions
@@ -24,6 +24,7 @@ type UserProps = {
isPrivate: boolean;
hasCustomAvatar: boolean;
username: string | null;
useGravatar: boolean;
};
export function SettingsForm({ user }: { user: UserProps }) {
@@ -43,6 +44,8 @@ export function SettingsForm({ user }: { user: UserProps }) {
const [username, setUsername] = useState(user.username ?? "");
const [savingUsername, setSavingUsername] = useState(false);
const [usernameError, setUsernameError] = useState<string | null>(null);
const [useGravatar, setUseGravatar] = useState(user.useGravatar);
const [savingGravatar, setSavingGravatar] = useState(false);
async function saveProfile() {
setSaving(true);
@@ -102,6 +105,36 @@ export function SettingsForm({ user }: { user: UserProps }) {
}
}
async function saveUseGravatar(checked: boolean) {
setSavingGravatar(true);
const previous = useGravatar;
setUseGravatar(checked);
try {
const res = await fetch("/api/v1/users/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ useGravatar: checked }),
});
if (res.ok) {
// Only affects the displayed avatar for accounts without a custom
// upload — matches the server's own condition in api/v1/users/me.
if (!hasCustomAvatar) {
const data = await res.json() as { avatarUrl?: string | null };
setAvatarImage(data.avatarUrl ?? null);
}
toast.success(t_common("saved"));
} else {
setUseGravatar(previous);
toast.error(t_common("saveFailed"));
}
} catch {
setUseGravatar(previous);
toast.error(t_common("saveFailed"));
} finally {
setSavingGravatar(false);
}
}
async function savePrivacy(checked: boolean) {
setSavingPrivacy(true);
const previous = isPrivate;
@@ -138,6 +171,18 @@ export function SettingsForm({ user }: { user: UserProps }) {
setHasCustomAvatar(custom);
}}
/>
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<p className="text-sm font-medium">{t("useGravatar")}</p>
<p className="text-xs text-muted-foreground max-w-prose">{t("useGravatarDescription")}</p>
</div>
<Switch
id="use-gravatar"
checked={useGravatar}
disabled={savingGravatar}
onCheckedChange={(checked) => { void saveUseGravatar(checked); }}
/>
</div>
<div className="space-y-2">
<Label>{t("displayName")}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />