forked from git.gladyson/openmonetis
feat: melhorar UX/UI e segurança do módulo de ajustes de usuário
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
This commit is contained in:
@@ -22,6 +22,7 @@ const updateNameSchema = z.object({
|
|||||||
|
|
||||||
const updatePasswordSchema = z
|
const updatePasswordSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
currentPassword: z.string().min(1, "Senha atual é obrigatória"),
|
||||||
newPassword: z.string().min(6, "A senha deve ter no mínimo 6 caracteres"),
|
newPassword: z.string().min(6, "A senha deve ter no mínimo 6 caracteres"),
|
||||||
confirmPassword: z.string(),
|
confirmPassword: z.string(),
|
||||||
})
|
})
|
||||||
@@ -32,6 +33,7 @@ const updatePasswordSchema = z
|
|||||||
|
|
||||||
const updateEmailSchema = z
|
const updateEmailSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
password: z.string().optional(), // Opcional para usuários Google OAuth
|
||||||
newEmail: z.string().email("E-mail inválido"),
|
newEmail: z.string().email("E-mail inválido"),
|
||||||
confirmEmail: 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) {
|
if (error instanceof z.ZodError) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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);
|
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
|
// Usar a API do Better Auth para atualizar a senha
|
||||||
try {
|
try {
|
||||||
await auth.api.changePassword({
|
await auth.api.changePassword({
|
||||||
body: {
|
body: {
|
||||||
newPassword: validated.newPassword,
|
newPassword: validated.newPassword,
|
||||||
currentPassword: "", // Better Auth pode não exigir a senha atual dependendo da configuração
|
currentPassword: validated.currentPassword,
|
||||||
},
|
},
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
});
|
});
|
||||||
@@ -125,19 +142,27 @@ export async function updatePasswordAction(
|
|||||||
success: true,
|
success: true,
|
||||||
message: "Senha atualizada com sucesso",
|
message: "Senha atualizada com sucesso",
|
||||||
};
|
};
|
||||||
} catch (authError) {
|
} catch (authError: any) {
|
||||||
console.error("Erro na API do Better Auth:", authError);
|
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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Erro ao atualizar senha. Tente novamente.",
|
error: "Senha atual incorreta",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Erro ao atualizar senha. Verifique se a senha atual está correta.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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(),
|
headers: await headers(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id || !session?.user?.email) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Não autenticado",
|
error: "Não autenticado",
|
||||||
@@ -166,6 +191,45 @@ export async function updateEmailAction(
|
|||||||
|
|
||||||
const validated = updateEmailSchema.parse(data);
|
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
|
// Verificar se o e-mail já está em uso por outro usuário
|
||||||
const existingUser = await db.query.user.findFirst({
|
const existingUser = await db.query.user.findFirst({
|
||||||
where: and(
|
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
|
// Atualizar e-mail
|
||||||
await db
|
await db
|
||||||
.update(schema.user)
|
.update(schema.user)
|
||||||
@@ -202,7 +274,7 @@ export async function updateEmailAction(
|
|||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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) {
|
if (error instanceof z.ZodError) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error.errors[0]?.message || "Dados inválidos",
|
error: error.issues[0]?.message || "Dados inválidos",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { UpdatePasswordForm } from "@/components/ajustes/update-password-form";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { auth } from "@/lib/auth/config";
|
import { auth } from "@/lib/auth/config";
|
||||||
|
import { db, schema } from "@/lib/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
@@ -20,6 +22,14 @@ export default async function Page() {
|
|||||||
const userName = session.user.name || "";
|
const userName = session.user.name || "";
|
||||||
const userEmail = session.user.email || "";
|
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 (
|
return (
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<Tabs defaultValue="nome" className="w-full">
|
<Tabs defaultValue="nome" className="w-full">
|
||||||
@@ -51,7 +61,7 @@ export default async function Page() {
|
|||||||
Defina uma nova senha para sua conta. Guarde-a em local seguro.
|
Defina uma nova senha para sua conta. Guarde-a em local seguro.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<UpdatePasswordForm />
|
<UpdatePasswordForm authProvider={authProvider} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="email" className="space-y-4">
|
<TabsContent value="email" className="space-y-4">
|
||||||
@@ -63,7 +73,7 @@ export default async function Page() {
|
|||||||
atual (quando aplicável) para concluir a alteração.
|
atual (quando aplicável) para concluir a alteração.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<UpdateEmailForm currentEmail={userEmail} />
|
<UpdateEmailForm currentEmail={userEmail} authProvider={authProvider} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="deletar" className="space-y-4">
|
<TabsContent value="deletar" className="space-y-4">
|
||||||
|
|||||||
@@ -4,29 +4,61 @@ import { updateEmailAction } from "@/app/(dashboard)/ajustes/actions";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
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";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type UpdateEmailFormProps = {
|
type UpdateEmailFormProps = {
|
||||||
currentEmail: string;
|
currentEmail: string;
|
||||||
|
authProvider?: string; // 'google' | 'credential' | undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UpdateEmailForm({ currentEmail }: UpdateEmailFormProps) {
|
export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormProps) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
const [newEmail, setNewEmail] = useState("");
|
const [newEmail, setNewEmail] = useState("");
|
||||||
const [confirmEmail, setConfirmEmail] = 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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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 () => {
|
startTransition(async () => {
|
||||||
const result = await updateEmailAction({
|
const result = await updateEmailAction({
|
||||||
|
password: isGoogleAuth ? undefined : password,
|
||||||
newEmail,
|
newEmail,
|
||||||
confirmEmail,
|
confirmEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
|
setPassword("");
|
||||||
setNewEmail("");
|
setNewEmail("");
|
||||||
setConfirmEmail("");
|
setConfirmEmail("");
|
||||||
} else {
|
} else {
|
||||||
@@ -37,33 +69,145 @@ export function UpdateEmailForm({ currentEmail }: UpdateEmailFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* E-mail atual (apenas informativo) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="newEmail">Novo e-mail</Label>
|
<Label htmlFor="currentEmail">E-mail atual</Label>
|
||||||
|
<Input
|
||||||
|
id="currentEmail"
|
||||||
|
type="email"
|
||||||
|
value={currentEmail}
|
||||||
|
disabled
|
||||||
|
className="bg-muted cursor-not-allowed"
|
||||||
|
aria-describedby="current-email-help"
|
||||||
|
/>
|
||||||
|
<p id="current-email-help" className="text-xs text-muted-foreground">
|
||||||
|
Este é seu e-mail atual cadastrado
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */}
|
||||||
|
{!isGoogleAuth && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">
|
||||||
|
Senha atual <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="Digite sua senha para confirmar"
|
||||||
|
required
|
||||||
|
aria-required="true"
|
||||||
|
aria-describedby="password-help"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<RiEyeOffLine size={20} />
|
||||||
|
) : (
|
||||||
|
<RiEyeLine size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="password-help" className="text-xs text-muted-foreground">
|
||||||
|
Por segurança, confirme sua senha antes de alterar seu e-mail
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Novo e-mail */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newEmail">
|
||||||
|
Novo e-mail <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="newEmail"
|
id="newEmail"
|
||||||
type="email"
|
type="email"
|
||||||
value={newEmail}
|
value={newEmail}
|
||||||
onChange={(e) => setNewEmail(e.target.value)}
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder={currentEmail}
|
placeholder="Digite o novo e-mail"
|
||||||
required
|
required
|
||||||
|
aria-required="true"
|
||||||
|
aria-describedby="new-email-help"
|
||||||
|
aria-invalid={!isEmailDifferent}
|
||||||
|
className={!isEmailDifferent ? "border-red-500 focus-visible:ring-red-500" : ""}
|
||||||
/>
|
/>
|
||||||
|
{!isEmailDifferent && newEmail && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||||
|
<RiCloseLine className="h-3.5 w-3.5" />
|
||||||
|
O novo e-mail deve ser diferente do atual
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!newEmail && (
|
||||||
|
<p id="new-email-help" className="text-xs text-muted-foreground">
|
||||||
|
Digite o novo endereço de e-mail para sua conta
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmar novo e-mail */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirmEmail">Confirmar novo e-mail</Label>
|
<Label htmlFor="confirmEmail">
|
||||||
|
Confirmar novo e-mail <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="confirmEmail"
|
id="confirmEmail"
|
||||||
type="email"
|
type="email"
|
||||||
value={confirmEmail}
|
value={confirmEmail}
|
||||||
onChange={(e) => setConfirmEmail(e.target.value)}
|
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder="repita o e-mail"
|
placeholder="Repita o novo e-mail"
|
||||||
required
|
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 && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{emailsMatch ? (
|
||||||
|
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="Os e-mails coincidem" />
|
||||||
|
) : (
|
||||||
|
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="Os e-mails não coincidem" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Mensagem de erro em tempo real */}
|
||||||
|
{emailsMatch === false && (
|
||||||
|
<p id="confirm-email-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||||
|
<RiCloseLine className="h-3.5 w-3.5" />
|
||||||
|
Os e-mails não coincidem
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{emailsMatch === true && (
|
||||||
|
<p id="confirm-email-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||||
|
<RiCheckLine className="h-3.5 w-3.5" />
|
||||||
|
Os e-mails coincidem
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isPending}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending || emailsMatch === false || !isEmailDifferent}
|
||||||
|
>
|
||||||
{isPending ? "Atualizando..." : "Atualizar e-mail"}
|
{isPending ? "Atualizando..." : "Atualizar e-mail"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,28 +4,69 @@ import { updatePasswordAction } from "@/app/(dashboard)/ajustes/actions";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { RiEyeLine, RiEyeOffLine } from "@remixicon/react";
|
import { RiEyeLine, RiEyeOffLine, RiCheckLine, RiCloseLine, RiAlertLine } from "@remixicon/react";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition, useMemo } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function UpdatePasswordForm() {
|
type UpdatePasswordFormProps = {
|
||||||
|
authProvider?: string; // 'google' | 'credential' | undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = 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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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 () => {
|
startTransition(async () => {
|
||||||
const result = await updatePasswordAction({
|
const result = await updatePasswordAction({
|
||||||
|
currentPassword,
|
||||||
newPassword,
|
newPassword,
|
||||||
confirmPassword,
|
confirmPassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
|
setCurrentPassword("");
|
||||||
setNewPassword("");
|
setNewPassword("");
|
||||||
setConfirmPassword("");
|
setConfirmPassword("");
|
||||||
} else {
|
} else {
|
||||||
@@ -34,10 +75,69 @@ export function UpdatePasswordForm() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Se o usuário usa Google OAuth, mostrar aviso
|
||||||
|
if (isGoogleAuth) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<RiAlertLine className="h-5 w-5 text-amber-600 dark:text-amber-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-amber-900 dark:text-amber-400">
|
||||||
|
Alteração de senha não disponível
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-amber-800 dark:text-amber-500">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Senha atual */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="newPassword">Nova senha</Label>
|
<Label htmlFor="currentPassword">
|
||||||
|
Senha atual <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="currentPassword"
|
||||||
|
type={showCurrentPassword ? "text" : "password"}
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="Digite sua senha atual"
|
||||||
|
required
|
||||||
|
aria-required="true"
|
||||||
|
aria-describedby="current-password-help"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={showCurrentPassword ? "Ocultar senha atual" : "Mostrar senha atual"}
|
||||||
|
>
|
||||||
|
{showCurrentPassword ? (
|
||||||
|
<RiEyeOffLine size={20} />
|
||||||
|
) : (
|
||||||
|
<RiEyeLine size={20} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="current-password-help" className="text-xs text-muted-foreground">
|
||||||
|
Por segurança, confirme sua senha atual antes de alterá-la
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nova senha */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newPassword">
|
||||||
|
Nova senha <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="newPassword"
|
id="newPassword"
|
||||||
@@ -48,11 +148,15 @@ export function UpdatePasswordForm() {
|
|||||||
placeholder="Mínimo de 6 caracteres"
|
placeholder="Mínimo de 6 caracteres"
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
|
aria-required="true"
|
||||||
|
aria-describedby="new-password-help"
|
||||||
|
aria-invalid={newPassword.length > 0 && newPassword.length < 6}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"}
|
||||||
>
|
>
|
||||||
{showNewPassword ? (
|
{showNewPassword ? (
|
||||||
<RiEyeOffLine size={20} />
|
<RiEyeOffLine size={20} />
|
||||||
@@ -61,10 +165,49 @@ export function UpdatePasswordForm() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p id="new-password-help" className="text-xs text-muted-foreground">
|
||||||
|
Use no mínimo 6 caracteres. Recomendado: 12+ caracteres com letras, números e símbolos
|
||||||
|
</p>
|
||||||
|
{/* Indicador de força da senha */}
|
||||||
|
{passwordStrength && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-300 ${
|
||||||
|
passwordStrength === "weak"
|
||||||
|
? "w-1/3 bg-red-500"
|
||||||
|
: passwordStrength === "medium"
|
||||||
|
? "w-2/3 bg-amber-500"
|
||||||
|
: "w-full bg-green-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
passwordStrength === "weak"
|
||||||
|
? "text-red-600 dark:text-red-400"
|
||||||
|
: passwordStrength === "medium"
|
||||||
|
? "text-amber-600 dark:text-amber-400"
|
||||||
|
: "text-green-600 dark:text-green-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{passwordStrength === "weak"
|
||||||
|
? "Fraca"
|
||||||
|
: passwordStrength === "medium"
|
||||||
|
? "Média"
|
||||||
|
: "Forte"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmar nova senha */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirmPassword">Confirmar nova senha</Label>
|
<Label htmlFor="confirmPassword">
|
||||||
|
Confirmar nova senha <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
@@ -75,11 +218,22 @@ export function UpdatePasswordForm() {
|
|||||||
placeholder="Repita a senha"
|
placeholder="Repita a senha"
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
|
aria-required="true"
|
||||||
|
aria-describedby="confirm-password-help"
|
||||||
|
aria-invalid={passwordsMatch === false}
|
||||||
|
className={
|
||||||
|
passwordsMatch === false
|
||||||
|
? "border-red-500 focus-visible:ring-red-500"
|
||||||
|
: passwordsMatch === true
|
||||||
|
? "border-green-500 focus-visible:ring-green-500"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
className="absolute right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={showConfirmPassword ? "Ocultar confirmação de senha" : "Mostrar confirmação de senha"}
|
||||||
>
|
>
|
||||||
{showConfirmPassword ? (
|
{showConfirmPassword ? (
|
||||||
<RiEyeOffLine size={20} />
|
<RiEyeOffLine size={20} />
|
||||||
@@ -87,10 +241,33 @@ export function UpdatePasswordForm() {
|
|||||||
<RiEyeLine size={20} />
|
<RiEyeLine size={20} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Indicador visual de match */}
|
||||||
|
{passwordsMatch !== null && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{passwordsMatch ? (
|
||||||
|
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="As senhas coincidem" />
|
||||||
|
) : (
|
||||||
|
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="As senhas não coincidem" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Mensagem de erro em tempo real */}
|
||||||
|
{passwordsMatch === false && (
|
||||||
|
<p id="confirm-password-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||||
|
<RiCloseLine className="h-3.5 w-3.5" />
|
||||||
|
As senhas não coincidem
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{passwordsMatch === true && (
|
||||||
|
<p id="confirm-password-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||||
|
<RiCheckLine className="h-3.5 w-3.5" />
|
||||||
|
As senhas coincidem
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" disabled={isPending || passwordsMatch === false}>
|
||||||
{isPending ? "Atualizando..." : "Atualizar senha"}
|
{isPending ? "Atualizando..." : "Atualizar senha"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user