From fcd4ebc608e7d0e9f6f0eb106ba7f53177d28d05 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Nov 2025 19:43:50 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20melhorar=20UX/UI=20e=20seguran=C3=A7a?= =?UTF-8?q?=20do=20m=C3=B3dulo=20de=20ajustes=20de=20usu=C3=A1rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa melhorias abrangentes de UX, UI e segurança nos formulários de alteração de senha e e-mail: ✨ Funcionalidades adicionadas: - Validação em tempo real para campos duplicados (senha e e-mail) - Campo de senha atual obrigatório para alterações de senha - Campo de senha para confirmar identidade ao alterar e-mail - Detecção de método de autenticação (Google OAuth vs Email/Senha) - Indicador de força de senha com feedback visual - Bloqueio de alteração de senha para usuários Google OAuth 🎨 Melhorias de UI: - Feedback visual instantâneo com ícones de check/close - Bordas coloridas indicando status de validação (verde/vermelho) - Mensagens de erro claras e específicas em tempo real - Alerta amigável para usuários Google OAuth - Indicador de progresso de força de senha 🔒 Segurança: - Validação de senha atual no backend usando Better Auth - Prevenção de alteração para o mesmo e-mail - Verificação de e-mails duplicados no sistema - Bloqueio de submissão quando validações falham ♿ Acessibilidade: - Atributos aria-label, aria-required, aria-invalid - role="alert" para mensagens de erro - aria-describedby para textos auxiliares - Labels descritivas e navegação por teclado aprimorada 🐛 Correções: - Corrigido uso de error.errors para error.issues no Zod - Validação backend de senha atual implementada - Mensagens de erro específicas (não genéricas) Ref: Análise completa de UX/UI solicitada para módulo de ajustes --- app/(dashboard)/ajustes/actions.ts | 90 ++++++++- app/(dashboard)/ajustes/page.tsx | 14 +- components/ajustes/update-email-form.tsx | 174 ++++++++++++++++-- components/ajustes/update-password-form.tsx | 191 +++++++++++++++++++- 4 files changed, 436 insertions(+), 33 deletions(-) diff --git a/app/(dashboard)/ajustes/actions.ts b/app/(dashboard)/ajustes/actions.ts index 457abda..a29f1fe 100644 --- a/app/(dashboard)/ajustes/actions.ts +++ b/app/(dashboard)/ajustes/actions.ts @@ -22,6 +22,7 @@ const updateNameSchema = z.object({ const updatePasswordSchema = z .object({ + currentPassword: z.string().min(1, "Senha atual é obrigatória"), newPassword: z.string().min(6, "A senha deve ter no mínimo 6 caracteres"), confirmPassword: z.string(), }) @@ -32,6 +33,7 @@ const updatePasswordSchema = z const updateEmailSchema = z .object({ + password: z.string().optional(), // Opcional para usuários Google OAuth newEmail: z.string().email("E-mail inválido"), confirmEmail: z.string().email("E-mail inválido"), }) @@ -82,7 +84,7 @@ export async function updateNameAction( if (error instanceof z.ZodError) { return { success: false, - error: error.errors[0]?.message || "Dados inválidos", + error: error.issues[0]?.message || "Dados inválidos", }; } @@ -111,12 +113,27 @@ export async function updatePasswordAction( const validated = updatePasswordSchema.parse(data); + // Verificar se o usuário tem conta com provedor Google + const userAccount = await db.query.account.findFirst({ + where: and( + eq(schema.account.userId, session.user.id), + eq(schema.account.providerId, "google") + ), + }); + + if (userAccount) { + return { + success: false, + error: "Não é possível alterar senha para contas autenticadas via Google", + }; + } + // Usar a API do Better Auth para atualizar a senha try { await auth.api.changePassword({ body: { newPassword: validated.newPassword, - currentPassword: "", // Better Auth pode não exigir a senha atual dependendo da configuração + currentPassword: validated.currentPassword, }, headers: await headers(), }); @@ -125,19 +142,27 @@ export async function updatePasswordAction( success: true, message: "Senha atualizada com sucesso", }; - } catch (authError) { + } catch (authError: any) { console.error("Erro na API do Better Auth:", authError); - // Se a API do Better Auth falhar, retornar erro genérico + + // Verificar se o erro é de senha incorreta + if (authError?.message?.includes("password") || authError?.message?.includes("incorrect")) { + return { + success: false, + error: "Senha atual incorreta", + }; + } + return { success: false, - error: "Erro ao atualizar senha. Tente novamente.", + error: "Erro ao atualizar senha. Verifique se a senha atual está correta.", }; } } catch (error) { if (error instanceof z.ZodError) { return { success: false, - error: error.errors[0]?.message || "Dados inválidos", + error: error.issues[0]?.message || "Dados inválidos", }; } @@ -157,7 +182,7 @@ export async function updateEmailAction( headers: await headers(), }); - if (!session?.user?.id) { + if (!session?.user?.id || !session?.user?.email) { return { success: false, error: "Não autenticado", @@ -166,6 +191,45 @@ export async function updateEmailAction( const validated = updateEmailSchema.parse(data); + // Verificar se o usuário tem conta com provedor Google + const userAccount = await db.query.account.findFirst({ + where: and( + eq(schema.account.userId, session.user.id), + eq(schema.account.providerId, "google") + ), + }); + + const isGoogleAuth = !!userAccount; + + // Se não for Google OAuth, validar senha + if (!isGoogleAuth) { + if (!validated.password) { + return { + success: false, + error: "Senha é obrigatória para confirmar a alteração", + }; + } + + // Validar senha tentando fazer changePassword para a mesma senha + // Se falhar, a senha atual está incorreta + try { + await auth.api.changePassword({ + body: { + newPassword: validated.password, + currentPassword: validated.password, + }, + headers: await headers(), + }); + } catch (authError: any) { + // Se der erro é porque a senha está incorreta + console.error("Erro ao validar senha:", authError); + return { + success: false, + error: "Senha incorreta", + }; + } + } + // Verificar se o e-mail já está em uso por outro usuário const existingUser = await db.query.user.findFirst({ where: and( @@ -181,6 +245,14 @@ export async function updateEmailAction( }; } + // Verificar se o novo e-mail é diferente do atual + if (validated.newEmail.toLowerCase() === session.user.email.toLowerCase()) { + return { + success: false, + error: "O novo e-mail deve ser diferente do atual", + }; + } + // Atualizar e-mail await db .update(schema.user) @@ -202,7 +274,7 @@ export async function updateEmailAction( if (error instanceof z.ZodError) { return { success: false, - error: error.errors[0]?.message || "Dados inválidos", + error: error.issues[0]?.message || "Dados inválidos", }; } @@ -244,7 +316,7 @@ export async function deleteAccountAction( if (error instanceof z.ZodError) { return { success: false, - error: error.errors[0]?.message || "Dados inválidos", + error: error.issues[0]?.message || "Dados inválidos", }; } diff --git a/app/(dashboard)/ajustes/page.tsx b/app/(dashboard)/ajustes/page.tsx index 0296d65..3502e46 100644 --- a/app/(dashboard)/ajustes/page.tsx +++ b/app/(dashboard)/ajustes/page.tsx @@ -5,6 +5,8 @@ import { UpdatePasswordForm } from "@/components/ajustes/update-password-form"; import { Card } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { auth } from "@/lib/auth/config"; +import { db, schema } from "@/lib/db"; +import { eq } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; @@ -20,6 +22,14 @@ export default async function Page() { const userName = session.user.name || ""; const userEmail = session.user.email || ""; + // Detectar método de autenticação (Google OAuth vs E-mail/Senha) + const userAccount = await db.query.account.findFirst({ + where: eq(schema.account.userId, session.user.id), + }); + + // Se o providerId for "google", o usuário usa Google OAuth + const authProvider = userAccount?.providerId || "credential"; + return (
@@ -51,7 +61,7 @@ export default async function Page() { Defina uma nova senha para sua conta. Guarde-a em local seguro.

- + @@ -63,7 +73,7 @@ export default async function Page() { atual (quando aplicável) para concluir a alteração.

- +
diff --git a/components/ajustes/update-email-form.tsx b/components/ajustes/update-email-form.tsx index ed862f1..d81b2cc 100644 --- a/components/ajustes/update-email-form.tsx +++ b/components/ajustes/update-email-form.tsx @@ -4,29 +4,61 @@ import { updateEmailAction } from "@/app/(dashboard)/ajustes/actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { useState, useTransition } from "react"; +import { RiCheckLine, RiCloseLine, RiEyeLine, RiEyeOffLine } from "@remixicon/react"; +import { useState, useTransition, useMemo } from "react"; import { toast } from "sonner"; type UpdateEmailFormProps = { currentEmail: string; + authProvider?: string; // 'google' | 'credential' | undefined }; -export function UpdateEmailForm({ currentEmail }: UpdateEmailFormProps) { +export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormProps) { const [isPending, startTransition] = useTransition(); + const [password, setPassword] = useState(""); const [newEmail, setNewEmail] = useState(""); const [confirmEmail, setConfirmEmail] = useState(""); + const [showPassword, setShowPassword] = useState(false); + + // Verificar se o usuário usa login via Google (não precisa de senha) + const isGoogleAuth = authProvider === "google"; + + // Validação em tempo real: e-mails coincidem + const emailsMatch = useMemo(() => { + if (!confirmEmail) return null; // Não mostrar erro se campo vazio + return newEmail.toLowerCase() === confirmEmail.toLowerCase(); + }, [newEmail, confirmEmail]); + + // Validação: novo e-mail é diferente do atual + const isEmailDifferent = useMemo(() => { + if (!newEmail) return true; + return newEmail.toLowerCase() !== currentEmail.toLowerCase(); + }, [newEmail, currentEmail]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + // Validação frontend antes de enviar + if (newEmail.toLowerCase() !== confirmEmail.toLowerCase()) { + toast.error("Os e-mails não coincidem"); + return; + } + + if (newEmail.toLowerCase() === currentEmail.toLowerCase()) { + toast.error("O novo e-mail deve ser diferente do atual"); + return; + } + startTransition(async () => { const result = await updateEmailAction({ + password: isGoogleAuth ? undefined : password, newEmail, confirmEmail, }); if (result.success) { toast.success(result.message); + setPassword(""); setNewEmail(""); setConfirmEmail(""); } else { @@ -37,33 +69,145 @@ export function UpdateEmailForm({ currentEmail }: UpdateEmailFormProps) { return (
+ {/* E-mail atual (apenas informativo) */}
- + + +

+ Este é seu e-mail atual cadastrado +

+
+ + {/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */} + {!isGoogleAuth && ( +
+ +
+ setPassword(e.target.value)} + disabled={isPending} + placeholder="Digite sua senha para confirmar" + required + aria-required="true" + aria-describedby="password-help" + /> + +
+

+ Por segurança, confirme sua senha antes de alterar seu e-mail +

+
+ )} + + {/* Novo e-mail */} +
+ setNewEmail(e.target.value)} disabled={isPending} - placeholder={currentEmail} + placeholder="Digite o novo e-mail" required + aria-required="true" + aria-describedby="new-email-help" + aria-invalid={!isEmailDifferent} + className={!isEmailDifferent ? "border-red-500 focus-visible:ring-red-500" : ""} /> + {!isEmailDifferent && newEmail && ( +

+ + O novo e-mail deve ser diferente do atual +

+ )} + {!newEmail && ( +

+ Digite o novo endereço de e-mail para sua conta +

+ )}
+ {/* Confirmar novo e-mail */}
- - setConfirmEmail(e.target.value)} - disabled={isPending} - placeholder="repita o e-mail" - required - /> + +
+ setConfirmEmail(e.target.value)} + disabled={isPending} + placeholder="Repita o novo e-mail" + required + aria-required="true" + aria-describedby="confirm-email-help" + aria-invalid={emailsMatch === false} + className={ + emailsMatch === false + ? "border-red-500 focus-visible:ring-red-500 pr-10" + : emailsMatch === true + ? "border-green-500 focus-visible:ring-green-500 pr-10" + : "" + } + /> + {/* Indicador visual de match */} + {emailsMatch !== null && ( +
+ {emailsMatch ? ( + + ) : ( + + )} +
+ )} +
+ {/* Mensagem de erro em tempo real */} + {emailsMatch === false && ( + + )} + {emailsMatch === true && ( +

+ + Os e-mails coincidem +

+ )}
-
diff --git a/components/ajustes/update-password-form.tsx b/components/ajustes/update-password-form.tsx index 0f3d361..6103a68 100644 --- a/components/ajustes/update-password-form.tsx +++ b/components/ajustes/update-password-form.tsx @@ -4,28 +4,69 @@ import { updatePasswordAction } from "@/app/(dashboard)/ajustes/actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { RiEyeLine, RiEyeOffLine } from "@remixicon/react"; -import { useState, useTransition } from "react"; +import { RiEyeLine, RiEyeOffLine, RiCheckLine, RiCloseLine, RiAlertLine } from "@remixicon/react"; +import { useState, useTransition, useMemo } from "react"; import { toast } from "sonner"; -export function UpdatePasswordForm() { +type UpdatePasswordFormProps = { + authProvider?: string; // 'google' | 'credential' | undefined +}; + +export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) { const [isPending, startTransition] = useTransition(); + const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); + // Verificar se o usuário usa login via Google + const isGoogleAuth = authProvider === "google"; + + // Validação em tempo real: senhas coincidem + const passwordsMatch = useMemo(() => { + if (!confirmPassword) return null; // Não mostrar erro se campo vazio + return newPassword === confirmPassword; + }, [newPassword, confirmPassword]); + + // Indicador de força da senha (básico) + const passwordStrength = useMemo(() => { + if (!newPassword) return null; + if (newPassword.length < 6) return "weak"; + if (newPassword.length >= 12 && /[A-Z]/.test(newPassword) && /[0-9]/.test(newPassword) && /[^A-Za-z0-9]/.test(newPassword)) { + return "strong"; + } + if (newPassword.length >= 8 && (/[A-Z]/.test(newPassword) || /[0-9]/.test(newPassword))) { + return "medium"; + } + return "weak"; + }, [newPassword]); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + // Validação frontend antes de enviar + if (newPassword !== confirmPassword) { + toast.error("As senhas não coincidem"); + return; + } + + if (newPassword.length < 6) { + toast.error("A senha deve ter no mínimo 6 caracteres"); + return; + } + startTransition(async () => { const result = await updatePasswordAction({ + currentPassword, newPassword, confirmPassword, }); if (result.success) { toast.success(result.message); + setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); } else { @@ -34,10 +75,69 @@ export function UpdatePasswordForm() { }); }; + // Se o usuário usa Google OAuth, mostrar aviso + if (isGoogleAuth) { + return ( +
+
+ +
+

+ Alteração de senha não disponível +

+

+ Você fez login usando sua conta do Google. A senha é gerenciada diretamente pelo Google + e não pode ser alterada aqui. Para modificar sua senha, acesse as configurações de + segurança da sua conta Google. +

+
+
+
+ ); + } + return (
+ {/* Senha atual */}
- + +
+ setCurrentPassword(e.target.value)} + disabled={isPending} + placeholder="Digite sua senha atual" + required + aria-required="true" + aria-describedby="current-password-help" + /> + +
+

+ Por segurança, confirme sua senha atual antes de alterá-la +

+
+ + {/* Nova senha */} +
+
0 && newPassword.length < 6} />
+
+

+ Use no mínimo 6 caracteres. Recomendado: 12+ caracteres com letras, números e símbolos +

+ {/* Indicador de força da senha */} + {passwordStrength && ( +
+
+
+
+ + {passwordStrength === "weak" + ? "Fraca" + : passwordStrength === "medium" + ? "Média" + : "Forte"} + +
+ )} +
+ {/* Confirmar nova senha */}
- +
+ {/* Indicador visual de match */} + {passwordsMatch !== null && ( +
+ {passwordsMatch ? ( + + ) : ( + + )} +
+ )}
+ {/* Mensagem de erro em tempo real */} + {passwordsMatch === false && ( + + )} + {passwordsMatch === true && ( +

+ + As senhas coincidem +

+ )}
-