fix: adiciona timeout no login para evitar spinner infinito - #65
fix: adiciona timeout no login para evitar spinner infinito#65marketingtopconta wants to merge 5 commits into
Conversation
Quando a requisição de login trava (bloqueio de rede/proxy/firewall antes de chegar ao servidor), o fetch nunca resolvia e o botão ficava girando para sempre sem feedback. Agora aborta após 15s e mostra uma mensagem explicando que não é senha incorreta.
|
@claude is attempting to deploy a commit to the Thales Laray Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughThe login form now cancels ChangesLogin timeout handling
Inbound WhatsApp media
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant MetaWebhook
participant MetaGraphAPI
participant SupabaseStorage
participant Inbox
MetaWebhook->>MetaGraphAPI: validate phone_number_id and resolve media_id
MetaGraphAPI-->>MetaWebhook: return media binary and MIME type
MetaWebhook->>SupabaseStorage: upload inbound media
SupabaseStorage-->>MetaWebhook: return durable media URL
MetaWebhook->>Inbox: persist message with media URL and caption
Inbox-->>MetaWebhook: render media message in MessageBubble
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
app/api/webhook/route.tsESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox. lib/whatsapp/inbound-media.tsESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
app/(auth)/login/page.tsx (1)
13-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueExtract logic to keep the page component thin.
LoginFormcontains a significant amount of state and data-fetching logic. Consider extracting this logic into a custom hook (e.g.,useLogin) or moving theLoginFormcomponent to a separate file in thecomponents/directory to improve separation of concerns.As per path instructions,
app/**/{page,layout}.tsxfiles should be thin components that only connect hooks to views.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/`(auth)/login/page.tsx at line 13, Extract the state and data-fetching logic from LoginForm into a dedicated useLogin hook or a separate component under components/, leaving the app page component responsible only for connecting the hook or component to the rendered view. Preserve the existing login behavior and UI while keeping page.tsx thin.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@app/`(auth)/login/page.tsx:
- Line 13: Extract the state and data-fetching logic from LoginForm into a
dedicated useLogin hook or a separate component under components/, leaving the
app page component responsible only for connecting the hook or component to the
rendered view. Preserve the existing login behavior and UI while keeping
page.tsx thin.
…Inbox A Meta não envia URL de mídia no payload do webhook, só um media_id — o código anterior tentava ler message.image?.url (sempre undefined) e o MessageBubble não tinha renderização nenhuma para tipos de mídia. - lib/whatsapp/inbound-media.ts: resolve o media_id via Graph API, baixa o binário com o access token e sobe pro bucket whatsapp-inbound-media (criado automaticamente, mesmo padrão do wa-template-media). - app/api/webhook/route.ts: chama o download antes de persistir a mensagem no Inbox; extractInboundText agora também captura caption e filename de mídia. - MessageBubble.tsx: renderiza imagem (thumbnail clicável), vídeo e áudio (player nativo) e documento (card de download), com fallback visual quando o download da mídia falha. Só afeta mensagens recebidas a partir de agora — mídia de mensagens antigas não pode ser recuperada (URL temporária da Meta já expirou).
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@components/features/inbox/MessageBubble.tsx`:
- Around line 317-384: Update MediaContent so database fallback values such as
“[image]”, “[video]”, “[audio]”, and “[document]” are treated as an empty
caption before rendering. Preserve genuine captions and existing media behavior,
including the document label fallback.
In `@lib/whatsapp/inbound-media.ts`:
- Line 10: Replace the custom supabase import with getSupabaseAdmin and update
ensureBucket, the guard clause, and the upload/public-URL logic to obtain and
use the admin client’s storage API. Ensure all server-side bucket checks,
uploads, and URL generation go through the client returned by getSupabaseAdmin,
avoiding direct access to the wrapper’s nonexistent storage property.
- Around line 72-85: Add AbortSignal.timeout() to both fetch calls in the
inbound media flow: the metadata request and the binary download using
metaBody.url. Set a timeout safely below Meta’s 15-second webhook deadline, and
preserve the existing graceful null-return behavior when either fetch fails or
is aborted.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 220c4d62-874c-4a02-baf0-bbe1ceaa90e6
📒 Files selected for processing (3)
app/api/webhook/route.tscomponents/features/inbox/MessageBubble.tsxlib/whatsapp/inbound-media.ts
| function MediaContent({ | ||
| type, | ||
| mediaUrl, | ||
| caption, | ||
| }: { | ||
| type: 'image' | 'audio' | 'video' | 'document' | ||
| mediaUrl: string | null | ||
| caption: string | ||
| }) { | ||
| if (!mediaUrl) { | ||
| return ( | ||
| <div className="flex items-center gap-2 text-sm text-[var(--ds-text-muted)] bg-black/10 rounded-lg px-3 py-2"> | ||
| <ImageOff className="h-4 w-4 flex-shrink-0" /> | ||
| <span>Mídia indisponível</span> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| if (type === 'image') { | ||
| return ( | ||
| <div className="flex flex-col gap-1.5"> | ||
| <a href={mediaUrl} target="_blank" rel="noopener noreferrer"> | ||
| <img | ||
| src={mediaUrl} | ||
| alt={caption || 'Imagem'} | ||
| loading="lazy" | ||
| className="max-w-[280px] max-h-[320px] w-auto rounded-lg object-cover cursor-zoom-in" | ||
| /> | ||
| </a> | ||
| {caption && ( | ||
| <p className="text-base leading-relaxed whitespace-pre-wrap break-words"> | ||
| <WhatsAppFormattedText text={caption} /> | ||
| </p> | ||
| )} | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| if (type === 'video') { | ||
| return ( | ||
| <div className="flex flex-col gap-1.5"> | ||
| <video controls src={mediaUrl} className="max-w-[280px] max-h-[320px] rounded-lg" /> | ||
| {caption && ( | ||
| <p className="text-base leading-relaxed whitespace-pre-wrap break-words"> | ||
| <WhatsAppFormattedText text={caption} /> | ||
| </p> | ||
| )} | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| if (type === 'audio') { | ||
| return <audio controls src={mediaUrl} className="h-10 max-w-[260px]" /> | ||
| } | ||
|
|
||
| // document | ||
| return ( | ||
| <a | ||
| href={mediaUrl} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="flex items-center gap-2.5 bg-black/10 hover:bg-black/20 transition-colors rounded-lg px-3 py-2.5 max-w-[260px]" | ||
| > | ||
| <FileText className="h-5 w-5 flex-shrink-0" /> | ||
| <span className="text-sm truncate flex-1">{caption || 'Documento'}</span> | ||
| <Download className="h-3.5 w-3.5 flex-shrink-0 opacity-70" /> | ||
| </a> | ||
| ) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Prevent the database fallback text from rendering as a visual caption.
If an inbound media message has no caption, the webhook persistence logic writes a fallback string like [image], [video], [audio], or [document] to the database content field. Currently, this raw string is passed as the caption prop and visually rendered to the user below the media.
Strip this fallback string so that it isn't rendered as an ugly caption.
✨ Proposed fix
function MediaContent({
type,
mediaUrl,
caption,
}: {
type: 'image' | 'audio' | 'video' | 'document'
mediaUrl: string | null
caption: string
}) {
+ // Avoid rendering the database fallback text as an actual caption
+ const displayCaption = caption === `[${type}]` ? '' : caption || ''
+
if (!mediaUrl) {
return (
<div className="flex items-center gap-2 text-sm text-[var(--ds-text-muted)] bg-black/10 rounded-lg px-3 py-2">
<ImageOff className="h-4 w-4 flex-shrink-0" />
<span>Mídia indisponível</span>
</div>
)
}
if (type === 'image') {
return (
<div className="flex flex-col gap-1.5">
<a href={mediaUrl} target="_blank" rel="noopener noreferrer">
<img
src={mediaUrl}
- alt={caption || 'Imagem'}
+ alt={displayCaption || 'Imagem'}
loading="lazy"
className="max-w-[280px] max-h-[320px] w-auto rounded-lg object-cover cursor-zoom-in"
/>
</a>
- {caption && (
+ {displayCaption && (
<p className="text-base leading-relaxed whitespace-pre-wrap break-words">
- <WhatsAppFormattedText text={caption} />
+ <WhatsAppFormattedText text={displayCaption} />
</p>
)}
</div>
)
}
if (type === 'video') {
return (
<div className="flex flex-col gap-1.5">
<video controls src={mediaUrl} className="max-w-[280px] max-h-[320px] rounded-lg" />
- {caption && (
+ {displayCaption && (
<p className="text-base leading-relaxed whitespace-pre-wrap break-words">
- <WhatsAppFormattedText text={caption} />
+ <WhatsAppFormattedText text={displayCaption} />
</p>
)}
</div>
)
}
if (type === 'audio') {
return <audio controls src={mediaUrl} className="h-10 max-w-[260px]" />
}
// document
return (
<a
href={mediaUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 bg-black/10 hover:bg-black/20 transition-colors rounded-lg px-3 py-2.5 max-w-[260px]"
>
<FileText className="h-5 w-5 flex-shrink-0" />
- <span className="text-sm truncate flex-1">{caption || 'Documento'}</span>
+ <span className="text-sm truncate flex-1">{displayCaption || 'Documento'}</span>
<Download className="h-3.5 w-3.5 flex-shrink-0 opacity-70" />
</a>
)
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function MediaContent({ | |
| type, | |
| mediaUrl, | |
| caption, | |
| }: { | |
| type: 'image' | 'audio' | 'video' | 'document' | |
| mediaUrl: string | null | |
| caption: string | |
| }) { | |
| if (!mediaUrl) { | |
| return ( | |
| <div className="flex items-center gap-2 text-sm text-[var(--ds-text-muted)] bg-black/10 rounded-lg px-3 py-2"> | |
| <ImageOff className="h-4 w-4 flex-shrink-0" /> | |
| <span>Mídia indisponível</span> | |
| </div> | |
| ) | |
| } | |
| if (type === 'image') { | |
| return ( | |
| <div className="flex flex-col gap-1.5"> | |
| <a href={mediaUrl} target="_blank" rel="noopener noreferrer"> | |
| <img | |
| src={mediaUrl} | |
| alt={caption || 'Imagem'} | |
| loading="lazy" | |
| className="max-w-[280px] max-h-[320px] w-auto rounded-lg object-cover cursor-zoom-in" | |
| /> | |
| </a> | |
| {caption && ( | |
| <p className="text-base leading-relaxed whitespace-pre-wrap break-words"> | |
| <WhatsAppFormattedText text={caption} /> | |
| </p> | |
| )} | |
| </div> | |
| ) | |
| } | |
| if (type === 'video') { | |
| return ( | |
| <div className="flex flex-col gap-1.5"> | |
| <video controls src={mediaUrl} className="max-w-[280px] max-h-[320px] rounded-lg" /> | |
| {caption && ( | |
| <p className="text-base leading-relaxed whitespace-pre-wrap break-words"> | |
| <WhatsAppFormattedText text={caption} /> | |
| </p> | |
| )} | |
| </div> | |
| ) | |
| } | |
| if (type === 'audio') { | |
| return <audio controls src={mediaUrl} className="h-10 max-w-[260px]" /> | |
| } | |
| // document | |
| return ( | |
| <a | |
| href={mediaUrl} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="flex items-center gap-2.5 bg-black/10 hover:bg-black/20 transition-colors rounded-lg px-3 py-2.5 max-w-[260px]" | |
| > | |
| <FileText className="h-5 w-5 flex-shrink-0" /> | |
| <span className="text-sm truncate flex-1">{caption || 'Documento'}</span> | |
| <Download className="h-3.5 w-3.5 flex-shrink-0 opacity-70" /> | |
| </a> | |
| ) | |
| function MediaContent({ | |
| type, | |
| mediaUrl, | |
| caption, | |
| }: { | |
| type: 'image' | 'audio' | 'video' | 'document' | |
| mediaUrl: string | null | |
| caption: string | |
| }) { | |
| // Avoid rendering the database fallback text as an actual caption | |
| const displayCaption = caption === `[${type}]` ? '' : caption || '' | |
| if (!mediaUrl) { | |
| return ( | |
| <div className="flex items-center gap-2 text-sm text-[var(--ds-text-muted)] bg-black/10 rounded-lg px-3 py-2"> | |
| <ImageOff className="h-4 w-4 flex-shrink-0" /> | |
| <span>Mídia indisponível</span> | |
| </div> | |
| ) | |
| } | |
| if (type === 'image') { | |
| return ( | |
| <div className="flex flex-col gap-1.5"> | |
| <a href={mediaUrl} target="_blank" rel="noopener noreferrer"> | |
| <img | |
| src={mediaUrl} | |
| alt={displayCaption || 'Imagem'} | |
| loading="lazy" | |
| className="max-w-[280px] max-h-[320px] w-auto rounded-lg object-cover cursor-zoom-in" | |
| /> | |
| </a> | |
| {displayCaption && ( | |
| <p className="text-base leading-relaxed whitespace-pre-wrap break-words"> | |
| <WhatsAppFormattedText text={displayCaption} /> | |
| </p> | |
| )} | |
| </div> | |
| ) | |
| } | |
| if (type === 'video') { | |
| return ( | |
| <div className="flex flex-col gap-1.5"> | |
| <video controls src={mediaUrl} className="max-w-[280px] max-h-[320px] rounded-lg" /> | |
| {displayCaption && ( | |
| <p className="text-base leading-relaxed whitespace-pre-wrap break-words"> | |
| <WhatsAppFormattedText text={displayCaption} /> | |
| </p> | |
| )} | |
| </div> | |
| ) | |
| } | |
| if (type === 'audio') { | |
| return <audio controls src={mediaUrl} className="h-10 max-w-[260px]" /> | |
| } | |
| // document | |
| return ( | |
| <a | |
| href={mediaUrl} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="flex items-center gap-2.5 bg-black/10 hover:bg-black/20 transition-colors rounded-lg px-3 py-2.5 max-w-[260px]" | |
| > | |
| <FileText className="h-5 w-5 flex-shrink-0" /> | |
| <span className="text-sm truncate flex-1">{displayCaption || 'Documento'}</span> | |
| <Download className="h-3.5 w-3.5 flex-shrink-0 opacity-70" /> | |
| </a> | |
| ) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/features/inbox/MessageBubble.tsx` around lines 317 - 384, Update
MediaContent so database fallback values such as “[image]”, “[video]”,
“[audio]”, and “[document]” are treated as an empty caption before rendering.
Preserve genuine captions and existing media behavior, including the document
label fallback.
Source: Linked repositories
| const metaRes = await fetch(`https://graph.facebook.com/v24.0/${mediaId}`, { | ||
| headers: { Authorization: `Bearer ${credentials.accessToken}` }, | ||
| }) | ||
| if (!metaRes.ok) { | ||
| console.warn(`[InboundMedia] Falha ao resolver media_id ${mediaId}: HTTP ${metaRes.status}`) | ||
| return null | ||
| } | ||
| const metaBody = (await metaRes.json()) as { url?: string; mime_type?: string } | ||
| if (!metaBody.url) return null | ||
|
|
||
| // 2. Baixa o binário usando o mesmo access token | ||
| const fileRes = await fetch(metaBody.url, { | ||
| headers: { Authorization: `Bearer ${credentials.accessToken}` }, | ||
| }) |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Add a timeout to Meta API fetches to prevent webhook hangs.
The Meta Webhook requires a 200 OK response within 15 seconds. A slow network response or large media download from the Graph API could block the request thread, causing the webhook to timeout and Meta to blindly retry the event.
Add an AbortSignal.timeout() to guarantee the fetches fail fast enough for the webhook to gracefully degrade (by saving the message without media) rather than failing the entire webhook execution.
🛡️ Proposed fix to enforce timeouts
// 1. Resolve o media_id em uma URL temporária + mime_type
const metaRes = await fetch(`https://graph.facebook.com/v24.0/${mediaId}`, {
headers: { Authorization: `Bearer ${credentials.accessToken}` },
+ signal: AbortSignal.timeout(10000)
})
if (!metaRes.ok) {
console.warn(`[InboundMedia] Falha ao resolver media_id ${mediaId}: HTTP ${metaRes.status}`)
return null
}
const metaBody = (await metaRes.json()) as { url?: string; mime_type?: string }
if (!metaBody.url) return null
// 2. Baixa o binário usando o mesmo access token
const fileRes = await fetch(metaBody.url, {
headers: { Authorization: `Bearer ${credentials.accessToken}` },
+ signal: AbortSignal.timeout(10000)
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const metaRes = await fetch(`https://graph.facebook.com/v24.0/${mediaId}`, { | |
| headers: { Authorization: `Bearer ${credentials.accessToken}` }, | |
| }) | |
| if (!metaRes.ok) { | |
| console.warn(`[InboundMedia] Falha ao resolver media_id ${mediaId}: HTTP ${metaRes.status}`) | |
| return null | |
| } | |
| const metaBody = (await metaRes.json()) as { url?: string; mime_type?: string } | |
| if (!metaBody.url) return null | |
| // 2. Baixa o binário usando o mesmo access token | |
| const fileRes = await fetch(metaBody.url, { | |
| headers: { Authorization: `Bearer ${credentials.accessToken}` }, | |
| }) | |
| const metaRes = await fetch(`https://graph.facebook.com/v24.0/${mediaId}`, { | |
| headers: { Authorization: `Bearer ${credentials.accessToken}` }, | |
| signal: AbortSignal.timeout(10000), | |
| }) | |
| if (!metaRes.ok) { | |
| console.warn(`[InboundMedia] Falha ao resolver media_id ${mediaId}: HTTP ${metaRes.status}`) | |
| return null | |
| } | |
| const metaBody = (await metaRes.json()) as { url?: string; mime_type?: string } | |
| if (!metaBody.url) return null | |
| // 2. Baixa o binário usando o mesmo access token | |
| const fileRes = await fetch(metaBody.url, { | |
| headers: { Authorization: `Bearer ${credentials.accessToken}` }, | |
| signal: AbortSignal.timeout(10000), | |
| }) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/whatsapp/inbound-media.ts` around lines 72 - 85, Add
AbortSignal.timeout() to both fetch calls in the inbound media flow: the
metadata request and the binary download using metaBody.url. Set a timeout
safely below Meta’s 15-second webhook deadline, and preserve the existing
graceful null-return behavior when either fetch fails or is aborted.
O objeto supabase importado de lib/supabase é um facade (.from/.rpc) sem propriedade .storage — só o SupabaseClient real retornado por getSupabaseAdmin() tem acesso ao Storage API. Corrige o erro de build "Property 'storage' does not exist" do deploy anterior.
O SDK do Supabase Storage retorna { error } em vez de lançar exceção —
o try/catch anterior não capturava esse erro, então createBucket falhava
silenciosamente e o bucket nunca era criado (confirmado: 0 buckets no
projeto). Causava "Bucket not found" no upload da mídia recebida.
Bucket whatsapp-inbound-media já foi criado manualmente para destravar
produção; este commit só corrige a visibilidade do erro pra próxima vez.
Uma WABA pode ter múltiplos números de telefone, cada um usado por uma plataforma diferente (ex: SmartZap e outro sistema conectados à mesma conta comercial da Meta). Sem filtrar por phone_number_id, este app processava mensagens/status que eram na verdade de outro número — causando mensagens do SmartZap aparecerem "vazadas" em outra plataforma conectada à mesma WABA, e vice-versa. Agora comparamos change.value.metadata.phone_number_id contra o phoneNumberId configurado (getWhatsAppCredentials) e ignoramos (continue) eventos de mensagens/status que não são deste número. Template status updates continuam passando — são a nível de WABA, sem phone_number_id.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/api/webhook/route.ts (1)
984-985: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick winInclude
voiceandstickermedia types.WhatsApp heavily uses voice notes (
voice) and stickers (sticker), which have their own properties in the Meta webhook payload. If these are omitted, they will not be passed to the download handler, causing them to appear without their associated media in the Inbox.🐛 Proposed fix
- const mediaId = - message.image?.id || message.video?.id || message.audio?.id || message.document?.id || null + const mediaId = + message.image?.id || message.video?.id || message.audio?.id || message.voice?.id || message.document?.id || message.sticker?.id || null🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/api/webhook/route.ts` around lines 984 - 985, Update the mediaId extraction in the webhook message handling flow to also check message.voice?.id and message.sticker?.id, preserving the existing image, video, audio, document, and null fallback behavior so these media types reach the download handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@app/api/webhook/route.ts`:
- Around line 984-985: Update the mediaId extraction in the webhook message
handling flow to also check message.voice?.id and message.sticker?.id,
preserving the existing image, video, audio, document, and null fallback
behavior so these media types reach the download handler.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: e8ebb5bc-33e1-4677-bd48-8c1efb70a387
📒 Files selected for processing (2)
app/api/webhook/route.tslib/whatsapp/inbound-media.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- lib/whatsapp/inbound-media.ts
Quando a requisição de login trava (bloqueio de rede/proxy/firewall antes de chegar ao servidor), o fetch nunca resolvia e o botão ficava girando para sempre sem feedback. Agora aborta após 15s e mostra uma mensagem explicando que não é senha incorreta.
Summary by CodeRabbit