refactor: migrate from ESLint to Biome and extract SQL queries to data.ts

- Replace ESLint with Biome for linting and formatting
- Configure Biome with tabs, double quotes, and organized imports
- Move all SQL/Drizzle queries from page.tsx files to data.ts files
- Create new data.ts files for: ajustes, dashboard, relatorios/categorias
- Update existing data.ts files: extrato, fatura (add lancamentos queries)
- Remove all drizzle-orm imports from page.tsx files
- Update README.md with new tooling info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

@@ -1,335 +1,352 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
RiSmartphoneLine,
RiDeleteBinLine,
RiAddLine,
RiFileCopyLine,
RiCheckLine,
RiAlertLine,
RiAddLine,
RiAlertLine,
RiCheckLine,
RiDeleteBinLine,
RiFileCopyLine,
RiSmartphoneLine,
} from "@remixicon/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { createApiTokenAction, revokeApiTokenAction } from "@/app/(dashboard)/ajustes/actions";
import { useState } from "react";
import {
createApiTokenAction,
revokeApiTokenAction,
} from "@/app/(dashboard)/ajustes/actions";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface ApiToken {
id: string;
name: string;
tokenPrefix: string;
lastUsedAt: Date | null;
lastUsedIp: string | null;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
id: string;
name: string;
tokenPrefix: string;
lastUsedAt: Date | null;
lastUsedIp: string | null;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
}
interface ApiTokensFormProps {
tokens: ApiToken[];
tokens: ApiToken[];
}
export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [tokenName, setTokenName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [revokeId, setRevokeId] = useState<string | null>(null);
const [isRevoking, setIsRevoking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [tokenName, setTokenName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [revokeId, setRevokeId] = useState<string | null>(null);
const [isRevoking, setIsRevoking] = useState(false);
const [error, setError] = useState<string | null>(null);
const activeTokens = tokens.filter((t) => !t.revokedAt);
const activeTokens = tokens.filter((t) => !t.revokedAt);
const handleCreate = async () => {
if (!tokenName.trim()) return;
const handleCreate = async () => {
if (!tokenName.trim()) return;
setIsCreating(true);
setError(null);
setIsCreating(true);
setError(null);
try {
const result = await createApiTokenAction({ name: tokenName.trim() });
try {
const result = await createApiTokenAction({ name: tokenName.trim() });
if (result.success && result.data?.token) {
setNewToken(result.data.token);
setTokenName("");
} else {
setError(result.error || "Erro ao criar token");
}
} catch {
setError("Erro ao criar token");
} finally {
setIsCreating(false);
}
};
if (result.success && result.data?.token) {
setNewToken(result.data.token);
setTokenName("");
} else {
setError(result.error || "Erro ao criar token");
}
} catch {
setError("Erro ao criar token");
} finally {
setIsCreating(false);
}
};
const handleCopy = async () => {
if (!newToken) return;
const handleCopy = async () => {
if (!newToken) return;
try {
await navigator.clipboard.writeText(newToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for browsers that don't support clipboard API
const textArea = document.createElement("textarea");
textArea.value = newToken;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
try {
await navigator.clipboard.writeText(newToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for browsers that don't support clipboard API
const textArea = document.createElement("textarea");
textArea.value = newToken;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleRevoke = async () => {
if (!revokeId) return;
const handleRevoke = async () => {
if (!revokeId) return;
setIsRevoking(true);
setIsRevoking(true);
try {
const result = await revokeApiTokenAction({ tokenId: revokeId });
try {
const result = await revokeApiTokenAction({ tokenId: revokeId });
if (!result.success) {
setError(result.error || "Erro ao revogar token");
}
} catch {
setError("Erro ao revogar token");
} finally {
setIsRevoking(false);
setRevokeId(null);
}
};
if (!result.success) {
setError(result.error || "Erro ao revogar token");
}
} catch {
setError("Erro ao revogar token");
} finally {
setIsRevoking(false);
setRevokeId(null);
}
};
const handleCloseCreate = () => {
setIsCreateOpen(false);
setNewToken(null);
setTokenName("");
setError(null);
};
const handleCloseCreate = () => {
setIsCreateOpen(false);
setNewToken(null);
setTokenName("");
setError(null);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Dispositivos conectados</h3>
<p className="text-sm text-muted-foreground">
Gerencie os dispositivos que podem enviar notificações para o OpenSheets.
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={(open) => {
if (!open) handleCloseCreate();
else setIsCreateOpen(true);
}}>
<DialogTrigger asChild>
<Button size="sm">
<RiAddLine className="h-4 w-4 mr-1" />
Novo Token
</Button>
</DialogTrigger>
<DialogContent>
{!newToken ? (
<>
<DialogHeader>
<DialogTitle>Criar Token de API</DialogTitle>
<DialogDescription>
Crie um token para conectar o OpenSheets Companion no seu dispositivo Android.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="tokenName">Nome do dispositivo</Label>
<Input
id="tokenName"
placeholder="Ex: Meu Celular, Galaxy S24..."
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
/>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<RiAlertLine className="h-4 w-4" />
{error}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCloseCreate}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={isCreating || !tokenName.trim()}>
{isCreating ? "Criando..." : "Criar Token"}
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Token Criado</DialogTitle>
<DialogDescription>
Copie o token abaixo e cole no app OpenSheets Companion. Este token
<strong> não será exibido novamente</strong>.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Seu token de API</Label>
<div className="relative">
<Input
value={newToken}
readOnly
className="pr-10 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={handleCopy}
>
{copied ? (
<RiCheckLine className="h-4 w-4 text-green-500" />
) : (
<RiFileCopyLine className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-sm text-amber-800 dark:text-amber-200">
<p className="font-medium">Importante:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Guarde este token em local seguro</li>
<li>Ele não será exibido novamente</li>
<li>Use-o para configurar o app Android</li>
</ul>
</div>
</div>
<DialogFooter>
<Button onClick={handleCloseCreate}>Fechar</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Dispositivos conectados</h3>
<p className="text-sm text-muted-foreground">
Gerencie os dispositivos que podem enviar notificações para o
OpenSheets.
</p>
</div>
<Dialog
open={isCreateOpen}
onOpenChange={(open) => {
if (!open) handleCloseCreate();
else setIsCreateOpen(true);
}}
>
<DialogTrigger asChild>
<Button size="sm">
<RiAddLine className="h-4 w-4 mr-1" />
Novo Token
</Button>
</DialogTrigger>
<DialogContent>
{!newToken ? (
<>
<DialogHeader>
<DialogTitle>Criar Token de API</DialogTitle>
<DialogDescription>
Crie um token para conectar o OpenSheets Companion no seu
dispositivo Android.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="tokenName">Nome do dispositivo</Label>
<Input
id="tokenName"
placeholder="Ex: Meu Celular, Galaxy S24..."
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
/>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<RiAlertLine className="h-4 w-4" />
{error}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCloseCreate}>
Cancelar
</Button>
<Button
onClick={handleCreate}
disabled={isCreating || !tokenName.trim()}
>
{isCreating ? "Criando..." : "Criar Token"}
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Token Criado</DialogTitle>
<DialogDescription>
Copie o token abaixo e cole no app OpenSheets Companion.
Este token
<strong> não será exibido novamente</strong>.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Seu token de API</Label>
<div className="relative">
<Input
value={newToken}
readOnly
className="pr-10 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={handleCopy}
>
{copied ? (
<RiCheckLine className="h-4 w-4 text-green-500" />
) : (
<RiFileCopyLine className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-sm text-amber-800 dark:text-amber-200">
<p className="font-medium">Importante:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Guarde este token em local seguro</li>
<li>Ele não será exibido novamente</li>
<li>Use-o para configurar o app Android</li>
</ul>
</div>
</div>
<DialogFooter>
<Button onClick={handleCloseCreate}>Fechar</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
{activeTokens.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<RiSmartphoneLine className="h-10 w-10 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
Nenhum dispositivo conectado.
</p>
<p className="text-sm text-muted-foreground">
Crie um token para conectar o app OpenSheets Companion.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{activeTokens.map((token) => (
<Card key={token.id}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="rounded-full bg-muted p-2">
<RiSmartphoneLine className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{token.name}</span>
<Badge variant="outline" className="text-xs">
{token.tokenPrefix}...
</Badge>
</div>
<div className="text-sm text-muted-foreground mt-1">
{token.lastUsedAt ? (
<span>
Usado{" "}
{formatDistanceToNow(token.lastUsedAt, {
addSuffix: true,
locale: ptBR,
})}
{token.lastUsedIp && (
<span className="text-xs ml-1">
({token.lastUsedIp})
</span>
)}
</span>
) : (
<span>Nunca usado</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
Criado em{" "}
{new Date(token.createdAt).toLocaleDateString("pt-BR")}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setRevokeId(token.id)}
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{activeTokens.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<RiSmartphoneLine className="h-10 w-10 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
Nenhum dispositivo conectado.
</p>
<p className="text-sm text-muted-foreground">
Crie um token para conectar o app OpenSheets Companion.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{activeTokens.map((token) => (
<Card key={token.id}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="rounded-full bg-muted p-2">
<RiSmartphoneLine className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{token.name}</span>
<Badge variant="outline" className="text-xs">
{token.tokenPrefix}...
</Badge>
</div>
<div className="text-sm text-muted-foreground mt-1">
{token.lastUsedAt ? (
<span>
Usado{" "}
{formatDistanceToNow(token.lastUsedAt, {
addSuffix: true,
locale: ptBR,
})}
{token.lastUsedIp && (
<span className="text-xs ml-1">
({token.lastUsedIp})
</span>
)}
</span>
) : (
<span>Nunca usado</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
Criado em{" "}
{new Date(token.createdAt).toLocaleDateString("pt-BR")}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setRevokeId(token.id)}
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Revoke Confirmation Dialog */}
<AlertDialog open={!!revokeId} onOpenChange={(open) => !open && setRevokeId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revogar token?</AlertDialogTitle>
<AlertDialogDescription>
O dispositivo associado a este token será desconectado e não poderá mais
enviar notificações. Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRevoking}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevoke}
disabled={isRevoking}
className="bg-destructive text-white hover:bg-destructive/90"
>
{isRevoking ? "Revogando..." : "Revogar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
{/* Revoke Confirmation Dialog */}
<AlertDialog
open={!!revokeId}
onOpenChange={(open) => !open && setRevokeId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revogar token?</AlertDialogTitle>
<AlertDialogDescription>
O dispositivo associado a este token será desconectado e não
poderá mais enviar notificações. Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRevoking}>
Cancelar
</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevoke}
disabled={isRevoking}
className="bg-destructive text-white hover:bg-destructive/90"
>
{isRevoking ? "Revogando..." : "Revogar"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -1,136 +1,136 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth/client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
export function DeleteAccountForm() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isModalOpen, setIsModalOpen] = useState(false);
const [confirmation, setConfirmation] = useState("");
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isModalOpen, setIsModalOpen] = useState(false);
const [confirmation, setConfirmation] = useState("");
const handleDelete = () => {
startTransition(async () => {
const result = await deleteAccountAction({
confirmation,
});
const handleDelete = () => {
startTransition(async () => {
const result = await deleteAccountAction({
confirmation,
});
if (result.success) {
toast.success(result.message);
// Fazer logout e redirecionar para página de login
await authClient.signOut();
router.push("/");
} else {
toast.error(result.error);
}
});
};
if (result.success) {
toast.success(result.message);
// Fazer logout e redirecionar para página de login
await authClient.signOut();
router.push("/");
} else {
toast.error(result.error);
}
});
};
const handleOpenModal = () => {
setConfirmation("");
setIsModalOpen(true);
};
const handleOpenModal = () => {
setConfirmation("");
setIsModalOpen(true);
};
const handleCloseModal = () => {
if (isPending) return;
setConfirmation("");
setIsModalOpen(false);
};
const handleCloseModal = () => {
if (isPending) return;
setConfirmation("");
setIsModalOpen(false);
};
return (
<>
<div className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
<li>Lançamentos, orçamentos e anotações</li>
<li>Contas, cartões e categorias</li>
<li>Pagadores (incluindo o pagador padrão)</li>
<li>Preferências e configurações</li>
<li className="font-bold">
Resumindo tudo, sua conta será permanentemente removida
</li>
</ul>
</div>
return (
<>
<div className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
<li>Lançamentos, orçamentos e anotações</li>
<li>Contas, cartões e categorias</li>
<li>Pagadores (incluindo o pagador padrão)</li>
<li>Preferências e configurações</li>
<li className="font-bold">
Resumindo tudo, sua conta será permanentemente removida
</li>
</ul>
</div>
<div className="flex justify-end">
<Button
variant="destructive"
onClick={handleOpenModal}
disabled={isPending}
className="w-fit"
>
Deletar conta
</Button>
</div>
</div>
<div className="flex justify-end">
<Button
variant="destructive"
onClick={handleOpenModal}
disabled={isPending}
className="w-fit"
>
Deletar conta
</Button>
</div>
</div>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent
className="max-w-md"
onEscapeKeyDown={(e) => {
if (isPending) e.preventDefault();
}}
onPointerDownOutside={(e) => {
if (isPending) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>Você tem certeza?</DialogTitle>
<DialogDescription>
Essa ação não pode ser desfeita. Isso irá deletar permanentemente
sua conta e remover seus dados de nossos servidores.
</DialogDescription>
</DialogHeader>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent
className="max-w-md"
onEscapeKeyDown={(e) => {
if (isPending) e.preventDefault();
}}
onPointerDownOutside={(e) => {
if (isPending) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>Você tem certeza?</DialogTitle>
<DialogDescription>
Essa ação não pode ser desfeita. Isso irá deletar permanentemente
sua conta e remover seus dados de nossos servidores.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="confirmation">
Para confirmar, digite <strong>DELETAR</strong> no campo abaixo.
</Label>
<Input
id="confirmation"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={isPending}
placeholder="DELETAR"
autoComplete="off"
/>
</div>
</div>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="confirmation">
Para confirmar, digite <strong>DELETAR</strong> no campo abaixo.
</Label>
<Input
id="confirmation"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={isPending}
placeholder="DELETAR"
autoComplete="off"
/>
</div>
</div>
<DialogFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={handleCloseModal}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending || confirmation !== "DELETAR"}
>
{isPending ? "Deletando..." : "Deletar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
<DialogFooter className="sm:justify-end">
<Button
type="button"
variant="outline"
onClick={handleCloseModal}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={isPending || confirmation !== "DELETAR"}
>
{isPending ? "Deletando..." : "Deletar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,78 +1,72 @@
"use client";
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface PreferencesFormProps {
disableMagnetlines: boolean;
disableMagnetlines: boolean;
}
export function PreferencesForm({
disableMagnetlines,
}: PreferencesFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [magnetlinesDisabled, setMagnetlinesDisabled] =
useState(disableMagnetlines);
export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [magnetlinesDisabled, setMagnetlinesDisabled] =
useState(disableMagnetlines);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
startTransition(async () => {
const result = await updatePreferencesAction({
disableMagnetlines: magnetlinesDisabled,
});
startTransition(async () => {
const result = await updatePreferencesAction({
disableMagnetlines: magnetlinesDisabled,
});
if (result.success) {
toast.success(result.message);
// Recarregar a página para aplicar as mudanças nos componentes
router.refresh();
// Forçar reload completo para garantir que os hooks re-executem
setTimeout(() => {
window.location.reload();
}, 500);
} else {
toast.error(result.error);
}
});
};
if (result.success) {
toast.success(result.message);
// Recarregar a página para aplicar as mudanças nos componentes
router.refresh();
// Forçar reload completo para garantir que os hooks re-executem
setTimeout(() => {
window.location.reload();
}, 500);
} else {
toast.error(result.error);
}
});
};
return (
<form
onSubmit={handleSubmit}
className="flex flex-col space-y-6"
>
<div className="space-y-4 max-w-md">
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-0.5">
<Label htmlFor="magnetlines" className="text-base">
Desabilitar Magnetlines
</Label>
<p className="text-sm text-muted-foreground">
Remove o recurso de linhas magnéticas do sistema. Essa mudança
afeta a interface e interações visuais.
</p>
</div>
<Switch
id="magnetlines"
checked={magnetlinesDisabled}
onCheckedChange={setMagnetlinesDisabled}
disabled={isPending}
/>
</div>
</div>
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-0.5">
<Label htmlFor="magnetlines" className="text-base">
Desabilitar Magnetlines
</Label>
<p className="text-sm text-muted-foreground">
Remove o recurso de linhas magnéticas do sistema. Essa mudança
afeta a interface e interações visuais.
</p>
</div>
<Switch
id="magnetlines"
checked={magnetlinesDisabled}
onCheckedChange={setMagnetlinesDisabled}
disabled={isPending}
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">
{isPending ? "Salvando..." : "Salvar preferências"}
</Button>
</div>
</form>
);
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">
{isPending ? "Salvando..." : "Salvar preferências"}
</Button>
</div>
</form>
);
}

View File

@@ -1,220 +1,248 @@
"use client";
import {
RiCheckLine,
RiCloseLine,
RiEyeLine,
RiEyeOffLine,
} from "@remixicon/react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
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 { 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
currentEmail: string;
authProvider?: string; // 'google' | 'credential' | undefined
};
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);
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";
// 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 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]);
// 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();
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;
}
// 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;
}
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,
});
startTransition(async () => {
const result = await updateEmailAction({
password: isGoogleAuth ? undefined : password,
newEmail,
confirmEmail,
});
if (result.success) {
toast.success(result.message);
setPassword("");
setNewEmail("");
setConfirmEmail("");
} else {
toast.error(result.error);
}
});
};
if (result.success) {
toast.success(result.message);
setPassword("");
setNewEmail("");
setConfirmEmail("");
} else {
toast.error(result.error);
}
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
{/* E-mail atual (apenas informativo) */}
<div className="space-y-2">
<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>
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
{/* E-mail atual (apenas informativo) */}
<div className="space-y-2">
<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>
)}
{/* 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
id="newEmail"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
disabled={isPending}
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 && (
<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>
{/* Novo e-mail */}
<div className="space-y-2">
<Label htmlFor="newEmail">
Novo e-mail <span className="text-destructive">*</span>
</Label>
<Input
id="newEmail"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
disabled={isPending}
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 && (
<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>
{/* Confirmar novo e-mail */}
<div className="space-y-2">
<Label htmlFor="confirmEmail">
Confirmar novo e-mail <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="confirmEmail"
type="email"
value={confirmEmail}
onChange={(e) => 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 && (
<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>
{/* Confirmar novo e-mail */}
<div className="space-y-2">
<Label htmlFor="confirmEmail">
Confirmar novo e-mail <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="confirmEmail"
type="email"
value={confirmEmail}
onChange={(e) => 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 && (
<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>
<div className="flex justify-end">
<Button
type="submit"
disabled={isPending || emailsMatch === false || !isEmailDifferent}
className="w-fit"
>
{isPending ? "Atualizando..." : "Atualizar e-mail"}
</Button>
</div>
</form>
);
<div className="flex justify-end">
<Button
type="submit"
disabled={isPending || emailsMatch === false || !isEmailDifferent}
className="w-fit"
>
{isPending ? "Atualizando..." : "Atualizar e-mail"}
</Button>
</div>
</form>
);
}

View File

@@ -1,75 +1,75 @@
"use client";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { updateNameAction } 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 { toast } from "sonner";
type UpdateNameFormProps = {
currentName: string;
currentName: string;
};
export function UpdateNameForm({ currentName }: UpdateNameFormProps) {
const [isPending, startTransition] = useTransition();
const [isPending, startTransition] = useTransition();
// Dividir o nome atual em primeiro nome e sobrenome
const nameParts = currentName.split(" ");
const initialFirstName = nameParts[0] || "";
const initialLastName = nameParts.slice(1).join(" ") || "";
// Dividir o nome atual em primeiro nome e sobrenome
const nameParts = currentName.split(" ");
const initialFirstName = nameParts[0] || "";
const initialLastName = nameParts.slice(1).join(" ") || "";
const [firstName, setFirstName] = useState(initialFirstName);
const [lastName, setLastName] = useState(initialLastName);
const [firstName, setFirstName] = useState(initialFirstName);
const [lastName, setLastName] = useState(initialLastName);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
const result = await updateNameAction({
firstName,
lastName,
});
startTransition(async () => {
const result = await updateNameAction({
firstName,
lastName,
});
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.error);
}
});
};
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.error);
}
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="firstName">Primeiro nome</Label>
<Input
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={isPending}
required
/>
</div>
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="firstName">Primeiro nome</Label>
<Input
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={isPending}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Sobrenome</Label>
<Input
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={isPending}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Sobrenome</Label>
<Input
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={isPending}
required
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">
{isPending ? "Atualizando..." : "Atualizar nome"}
</Button>
</div>
</form>
);
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">
{isPending ? "Atualizando..." : "Atualizar nome"}
</Button>
</div>
</form>
);
}

View File

@@ -1,363 +1,365 @@
"use client";
import {
RiAlertLine,
RiCheckLine,
RiCloseLine,
RiEyeLine,
RiEyeOffLine,
} from "@remixicon/react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
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 { cn } from "@/lib/utils/ui";
import {
RiEyeLine,
RiEyeOffLine,
RiCheckLine,
RiCloseLine,
RiAlertLine,
} from "@remixicon/react";
import { useState, useTransition, useMemo } from "react";
import { toast } from "sonner";
interface PasswordValidation {
hasLowercase: boolean;
hasUppercase: boolean;
hasNumber: boolean;
hasSpecial: boolean;
hasMinLength: boolean;
hasMaxLength: boolean;
isValid: boolean;
hasLowercase: boolean;
hasUppercase: boolean;
hasNumber: boolean;
hasSpecial: boolean;
hasMinLength: boolean;
hasMaxLength: boolean;
isValid: boolean;
}
function validatePassword(password: string): PasswordValidation {
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]/.test(password);
const hasMinLength = password.length >= 7;
const hasMaxLength = password.length <= 23;
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
const hasMinLength = password.length >= 7;
const hasMaxLength = password.length <= 23;
return {
hasLowercase,
hasUppercase,
hasNumber,
hasSpecial,
hasMinLength,
hasMaxLength,
isValid:
hasLowercase &&
hasUppercase &&
hasNumber &&
hasSpecial &&
hasMinLength &&
hasMaxLength,
};
return {
hasLowercase,
hasUppercase,
hasNumber,
hasSpecial,
hasMinLength,
hasMaxLength,
isValid:
hasLowercase &&
hasUppercase &&
hasNumber &&
hasSpecial &&
hasMinLength &&
hasMaxLength,
};
}
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div
className={cn(
"flex items-center gap-1.5 text-xs transition-colors",
met ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground"
)}
>
{met ? (
<RiCheckLine className="h-3.5 w-3.5" />
) : (
<RiCloseLine className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</div>
);
return (
<div
className={cn(
"flex items-center gap-1.5 text-xs transition-colors",
met
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground",
)}
>
{met ? (
<RiCheckLine className="h-3.5 w-3.5" />
) : (
<RiCloseLine className="h-3.5 w-3.5" />
)}
<span>{label}</span>
</div>
);
}
type UpdatePasswordFormProps = {
authProvider?: string; // 'google' | 'credential' | undefined
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);
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";
// 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]);
// 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]);
// Validação de requisitos da senha
const passwordValidation = useMemo(
() => validatePassword(newPassword),
[newPassword]
);
// Validação de requisitos da senha
const passwordValidation = useMemo(
() => validatePassword(newPassword),
[newPassword],
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validação frontend antes de enviar
if (!passwordValidation.isValid) {
toast.error("A senha não atende aos requisitos de segurança");
return;
}
// Validação frontend antes de enviar
if (!passwordValidation.isValid) {
toast.error("A senha não atende aos requisitos de segurança");
return;
}
if (newPassword !== confirmPassword) {
toast.error("As senhas não coincidem");
return;
}
if (newPassword !== confirmPassword) {
toast.error("As senhas não coincidem");
return;
}
startTransition(async () => {
const result = await updatePasswordAction({
currentPassword,
newPassword,
confirmPassword,
});
startTransition(async () => {
const result = await updatePasswordAction({
currentPassword,
newPassword,
confirmPassword,
});
if (result.success) {
toast.success(result.message);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} else {
toast.error(result.error);
}
});
};
if (result.success) {
toast.success(result.message);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} else {
toast.error(result.error);
}
});
};
// 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 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>
);
}
// 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 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 (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
{/* Senha atual */}
<div className="space-y-2">
<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>
return (
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
{/* Senha atual */}
<div className="space-y-2">
<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">
<Input
id="newPassword"
type={showNewPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={isPending}
placeholder="Crie uma senha forte"
required
minLength={7}
maxLength={23}
aria-required="true"
aria-describedby="new-password-help"
aria-invalid={
newPassword.length > 0 && !passwordValidation.isValid
}
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
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 ? (
<RiEyeOffLine size={20} />
) : (
<RiEyeLine size={20} />
)}
</button>
</div>
{/* Indicadores de requisitos da senha */}
{newPassword.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<PasswordRequirement
met={passwordValidation.hasMinLength}
label="Mínimo 7 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasMaxLength}
label="Máximo 23 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasLowercase}
label="Letra minúscula"
/>
<PasswordRequirement
met={passwordValidation.hasUppercase}
label="Letra maiúscula"
/>
<PasswordRequirement
met={passwordValidation.hasNumber}
label="Número"
/>
<PasswordRequirement
met={passwordValidation.hasSpecial}
label="Caractere especial"
/>
</div>
)}
</div>
{/* Nova senha */}
<div className="space-y-2">
<Label htmlFor="newPassword">
Nova senha <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="newPassword"
type={showNewPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={isPending}
placeholder="Crie uma senha forte"
required
minLength={7}
maxLength={23}
aria-required="true"
aria-describedby="new-password-help"
aria-invalid={
newPassword.length > 0 && !passwordValidation.isValid
}
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
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 ? (
<RiEyeOffLine size={20} />
) : (
<RiEyeLine size={20} />
)}
</button>
</div>
{/* Indicadores de requisitos da senha */}
{newPassword.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<PasswordRequirement
met={passwordValidation.hasMinLength}
label="Mínimo 7 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasMaxLength}
label="Máximo 23 caracteres"
/>
<PasswordRequirement
met={passwordValidation.hasLowercase}
label="Letra minúscula"
/>
<PasswordRequirement
met={passwordValidation.hasUppercase}
label="Letra maiúscula"
/>
<PasswordRequirement
met={passwordValidation.hasNumber}
label="Número"
/>
<PasswordRequirement
met={passwordValidation.hasSpecial}
label="Caractere especial"
/>
</div>
)}
</div>
{/* Confirmar nova senha */}
<div className="space-y-2">
<Label htmlFor="confirmPassword">
Confirmar nova senha <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isPending}
placeholder="Repita a senha"
required
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
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
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 ? (
<RiEyeOffLine size={20} />
) : (
<RiEyeLine size={20} />
)}
</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>
{/* 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>
{/* Confirmar nova senha */}
<div className="space-y-2">
<Label htmlFor="confirmPassword">
Confirmar nova senha <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isPending}
placeholder="Repita a senha"
required
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
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
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 ? (
<RiEyeOffLine size={20} />
) : (
<RiEyeLine size={20} />
)}
</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>
{/* 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>
<div className="flex justify-end">
<Button
type="submit"
disabled={
isPending ||
passwordsMatch === false ||
(newPassword.length > 0 && !passwordValidation.isValid)
}
className="w-fit"
>
{isPending ? "Atualizando..." : "Atualizar senha"}
</Button>
</div>
</form>
);
<div className="flex justify-end">
<Button
type="submit"
disabled={
isPending ||
passwordsMatch === false ||
(newPassword.length > 0 && !passwordValidation.isValid)
}
className="w-fit"
>
{isPending ? "Atualizando..." : "Atualizar senha"}
</Button>
</div>
</form>
);
}