feat(avatar): foto de perfil com editor de recorte circular

Carregar, enquadrar num circulo com zoom e arrasto, e guardar. A foto aparece
na barra lateral, na lista de mensagens e no leitor, caindo para as iniciais
quando nao ha nenhuma.

A geometria vai em funcoes puras com testes, porque e onde os erros moram: a
escala minima cobre o quadrado em vez de caber nele (caber deixa um canto
vazio e o ficheiro exportado leva uma cunha transparente), e o deslocamento e
preso a saliencia da imagem para que nenhum arrasto consiga trazer uma borda
para dentro do circulo. Uma dimensao NaN -- o que uma imagem que nao
descodificou reporta -- passava pelo guarda `<= 0`, porque NaN falha qualquer
comparacao, e propagava-se ate apagar o editor.

O recorte e exportado a partir dos pixeis da origem e nao da pre-visualizacao,
por isso a foto guardada e tao nitida quanto o original permite em vez de
ficar limitada ao tamanho do editor no ecra.

Do lado do servidor o tipo e decidido pelos bytes, nunca pelo Content-Type nem
pelo nome do ficheiro, que sao ambos escolhidos por quem carrega. SVG e
recusado apesar de ser imagem: e um documento que pode levar script e seria
servido de volta a partir da nossa propria origem.

A leitura exige sessao, verificada na propria rota porque o guarda de prefixo
so cobre /user/ e /admin/. Um endpoint aberto indexado por endereco responde
"esta caixa existe" a quem quer que pergunte, para todos os enderecos que
quiser experimentar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-09-07 08:04:12 -03:00
1 parent 82b4c8ce17
commit fcfee6a3f9
17 files changed
+978 -16

No files matched your search

@@ -27,7 +27,7 @@ import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
import { useAccount, isAdminRole } from "../../../lib/useAccount";
import { useFolders } from "../../../lib/useFolders";
import { folderBadge, customFolders } from "../../../lib/mailCounts";
import { initialsFor } from "../../../lib/initials";
import { Avatar } from "../../../components/Avatar";
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations();
@@ -279,9 +279,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
onClick={() => setMobileOpen(false)}
className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge transition-colors hover:bg-dark-card"
>
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
{initialsFor(account?.email)}
</span>
<Avatar address={account?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]" />
<span className="min-w-0 flex-1">
<span
className="block truncate text-[12.5px] leading-tight"
@@ -38,6 +38,7 @@ import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../..
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
import { Button } from "../../../../../components/ui/Button";
import { Avatar } from "../../../../../components/Avatar";
interface MessageSummary {
uid: number;
@@ -457,16 +458,16 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
data-active={isSelected}
className="lm-row w-full text-left"
>
<span
className={`flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full text-xs ${
<Avatar
address={msg.sender_address}
initials={initials(msg.from_display_name, msg.sender_address)}
size={38}
className={
msg.seen
? "bg-dark-card text-slate-400"
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
}`}
aria-hidden="true"
>
{initials(msg.from_display_name, msg.sender_address)}
</span>
}
/>
<span className="flex min-w-0 flex-1 flex-col gap-[3px]">
<span className="flex items-baseline gap-2">
@@ -652,9 +653,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
</h1>
<div className="flex items-start gap-3">
<div className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[13px] text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
{initials(selectedRow?.from_display_name ?? "", selected.from)}
</div>
<Avatar
address={selectedRow?.sender_address ?? selected.from}
initials={initials(selectedRow?.from_display_name ?? "", selected.from)}
size={38}
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
/>
<div className="flex min-w-0 flex-1 flex-col gap-[3px]">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<span className="break-all text-sm text-slate-100">{selected.from}</span>
@@ -15,6 +15,7 @@ import {
Plus,
Lock,
Languages,
UserRound,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Badge } from "../../../../components/ui/Badge";
@@ -24,6 +25,8 @@ import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules";
import { useFolders } from "../../../../lib/useFolders";
import { AvatarEditor } from "../../../../components/AvatarEditor";
import { useAccount } from "../../../../lib/useAccount";
import { moveTargets } from "../../../../lib/mailCounts";
interface SieveRule {
@@ -51,11 +54,20 @@ interface WebSession {
current: boolean;
}
type SettingsTab = "security" | "filters" | "vacation" | "signature" | "language";
type SettingsTab = "profile" | "security" | "filters" | "vacation" | "signature" | "language";
export default function UserSettingsPage() {
const t = useTranslations();
const [activeTab, setActiveTab] = useState<SettingsTab>("security");
const [activeTab, setActiveTab] = useState<SettingsTab>("profile");
// Bumped after a save so the browser refetches instead of showing the photo
// it already holds under the same URL.
const [avatarVersion, setAvatarVersion] = useState(0);
const account = useAccount("user");
// Null until the account loads, so the editor is not told "no photo" before
// it is known whether there is one.
const avatarSrc = account?.email
? `/api/v1/avatar?address=${encodeURIComponent(account.email)}&v=${avatarVersion}`
: null;
const [mfaEnabled, setMfaEnabled] = useState(false);
const [qrSecret, setQrSecret] = useState<string | null>(null);
@@ -285,6 +297,7 @@ export default function UserSettingsPage() {
separated by everything else. The same panels now sit behind a tab strip,
in the order the redesign groups them. */
const tabs = [
{ id: "profile" as const, label: t("avatar.title"), icon: UserRound },
{ id: "security" as const, label: t("settings.security"), icon: Shield },
{ id: "filters" as const, label: t("settings.sieveTitle"), icon: Filter },
{ id: "vacation" as const, label: t("settings.vacationTitle"), icon: Palmtree },
@@ -336,6 +349,21 @@ export default function UserSettingsPage() {
</div>
)}
{activeTab === "profile" && (
<section className={panel}>
<h2 className="text-[17px] font-medium leading-tight text-slate-100">{t("avatar.title")}</h2>
<p className="text-[12.5px] leading-relaxed text-slate-400">{t("avatar.explainer")}</p>
<AvatarEditor
src={avatarSrc}
onSaved={() => {
// The query string changes so the browser fetches the new
// photo instead of the one it already has under this URL.
setAvatarVersion((v) => v + 1);
}}
/>
</section>
)}
{activeTab === "security" && (
<div className="flex flex-col gap-4">
{/* 2FA TOTP SECTION */}