refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

View File

@@ -0,0 +1,344 @@
"use client";
import {
RiAddLine,
RiAlertLine,
RiCheckLine,
RiDeleteBinLine,
RiFileCopyLine,
RiSmartphoneLine,
} from "@remixicon/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
import {
createApiTokenAction,
revokeApiTokenAction,
} from "@/features/settings/actions";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { formatDateTime } from "@/shared/utils/date";
interface ApiToken {
id: string;
name: string;
tokenPrefix: string;
lastUsedAt: Date | null;
lastUsedIp: string | null;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
}
interface ApiTokensFormProps {
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 activeTokens = tokens.filter((t) => !t.revokedAt);
const handleCreate = async () => {
if (!tokenName.trim()) return;
setIsCreating(true);
setError(null);
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);
}
};
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);
}
};
const handleRevoke = async () => {
if (!revokeId) return;
setIsRevoking(true);
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);
}
};
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
OpenMonetis.
</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 OpenMonetis 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 OpenMonetis 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-success" />
) : (
<RiFileCopyLine className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="rounded-md bg-warning/10 p-3 text-sm text-warning">
<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 ? (
<div className="flex items-center gap-3 py-4 text-muted-foreground">
<RiSmartphoneLine className="h-5 w-5" />
<p className="text-sm">
Nenhum dispositivo conectado. Crie um token para começar.
</p>
</div>
) : (
<div className="divide-y py-2">
{activeTokens.map((token) => (
<div
key={token.id}
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted">
<RiSmartphoneLine className="h-4 w-4" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-bold">{token.name}</span>
<Badge variant="outline" className="text-xs font-mono">
{token.tokenPrefix}...
</Badge>
</div>
<p className="text-xs text-muted-foreground py-1">
{token.lastUsedAt ? (
<>
Usado{" "}
{formatDistanceToNow(token.lastUsedAt, {
addSuffix: true,
locale: ptBR,
})}
</>
) : (
"Nunca usado"
)}
{" · "}
Criado em{" "}
{formatDateTime(token.createdAt, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? "—"}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setRevokeId(token.id)}
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
</div>
))}
</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

@@ -0,0 +1,92 @@
import Link from "next/link";
import type { ChangelogVersion } from "@/features/settings/lib/parse-changelog";
import { Badge } from "@/shared/components/ui/badge";
import { Card } from "@/shared/components/ui/card";
/** Converte "[texto](url)" em link; texto simples fica como está */
function parseContributorLine(content: string) {
const linkMatch = content.match(/^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/);
if (linkMatch) {
return { label: linkMatch[1], url: linkMatch[2] };
}
return { label: content, url: null };
}
const sectionBadgeVariant: Record<
string,
"success" | "info" | "destructive" | "secondary"
> = {
Adicionado: "success",
Alterado: "info",
Corrigido: "destructive",
Removido: "secondary",
};
function getSectionVariant(type: string) {
return sectionBadgeVariant[type] ?? "secondary";
}
export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
return (
<div className="space-y-4">
{versions.map((version) => (
<Card key={version.version} className="p-6">
<div className="flex items-baseline gap-3">
<h3 className="text-lg font-bold">v{version.version}</h3>
<span className="text-sm text-muted-foreground">
{version.date}
</span>
</div>
<div className="space-y-4">
{version.sections.map((section) => (
<div key={section.type}>
<Badge
variant={getSectionVariant(section.type)}
className="mb-2"
>
{section.type}
</Badge>
<ul className="space-y-1.5 text-muted-foreground">
{section.items.map((item) => (
<li key={item} className="flex gap-2">
<span className="text-primary select-none">&bull;</span>
<span className="text-sm">{item}</span>
</li>
))}
</ul>
</div>
))}
{version.contributor && (
<div className="border-t pt-4 mt-4">
<span className="text-sm text-muted-foreground">
Contribuições: {(() => {
const { label, url } = parseContributorLine(
version.contributor,
);
if (url) {
return (
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-foreground underline underline-offset-2 hover:text-primary"
>
{label}
</Link>
);
}
return (
<span className="font-medium text-foreground">
{label}
</span>
);
})()}
</span>
</div>
)}
</div>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import {
RiAndroidLine,
RiDownload2Line,
RiExternalLinkLine,
RiNotification3Line,
RiQrCodeLine,
RiShieldCheckLine,
} from "@remixicon/react";
import type { ReactNode } from "react";
import { Card } from "@/shared/components/ui/card";
import { ApiTokensForm } from "./api-tokens-form";
interface ApiToken {
id: string;
name: string;
tokenPrefix: string;
lastUsedAt: Date | null;
lastUsedIp: string | null;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
}
interface CompanionTabProps {
tokens: ApiToken[];
}
const steps: {
icon: typeof RiDownload2Line;
title: string;
description: ReactNode;
}[] = [
{
icon: RiDownload2Line,
title: "Instale o app",
description: (
<>
Baixe o APK no{" "}
<a
href="https://github.com/felipegcoutinho/openmonetis-companion"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-primary hover:underline"
>
GitHub
<RiExternalLinkLine className="h-3 w-3" />
</a>
</>
),
},
{
icon: RiQrCodeLine,
title: "Gere um token",
description: "Crie um token abaixo para autenticar.",
},
{
icon: RiNotification3Line,
title: "Configure permissões",
description: "Conceda acesso às notificações.",
},
{
icon: RiShieldCheckLine,
title: "Pronto!",
description: "Notificações serão enviadas ao OpenMonetis.",
},
];
export function CompanionTab({ tokens }: CompanionTabProps) {
return (
<Card className="p-6">
<div className="space-y-6">
{/* Header */}
<div>
<div className="flex items-center gap-2 mb-1">
<h2 className="text-lg font-bold">OpenMonetis Companion</h2>
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
<RiAndroidLine className="h-3 w-3" />
Android
</span>
</div>
<p className="text-sm text-muted-foreground">
Capture notificações de transações dos seus apps de banco (Nubank,
Itaú, Bradesco, Inter, C6 e outros) e envie para sua caixa de
entrada.
</p>
</div>
{/* Steps */}
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
{steps.map((step, index) => (
<div key={step.title} className="flex items-start gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<step.icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">
{index + 1}. {step.title}
</p>
<p className="text-xs text-muted-foreground">
{step.description}
</p>
</div>
</div>
))}
</div>
{/* Divider */}
<div className="border-t" />
{/* Devices */}
<ApiTokensForm tokens={tokens} />
</div>
</Card>
);
}

View File

@@ -0,0 +1,136 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { deleteAccountAction } from "@/features/settings/actions";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { authClient } from "@/shared/lib/auth/client";
export function DeleteAccountForm() {
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,
});
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 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>
<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>
<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>
<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

@@ -0,0 +1,418 @@
"use client";
import {
RiAddLine,
RiAlertLine,
RiDeleteBinLine,
RiFingerprintLine,
RiLoader4Line,
RiPencilLine,
} from "@remixicon/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useCallback, useEffect, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { authClient } from "@/shared/lib/auth/client";
interface Passkey {
id: string;
name: string | null;
deviceType: string;
createdAt: Date | null;
}
export function PasskeysForm() {
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [passkeySupported, setPasskeySupported] = useState(false);
// Add passkey
const [isAddOpen, setIsAddOpen] = useState(false);
const [addName, setAddName] = useState("");
const [isAdding, setIsAdding] = useState(false);
// Rename passkey
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
// Delete passkey
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const isMutating = isAdding || isRenaming || isDeleting;
const fetchPasskeys = useCallback(
async (options?: { showLoader?: boolean }) => {
const showLoader = options?.showLoader ?? true;
if (showLoader) setIsLoading(true);
setError(null);
try {
const { data, error: fetchError } =
await authClient.passkey.listUserPasskeys();
if (fetchError) {
setError(fetchError.message || "Erro ao carregar passkeys.");
return;
}
setPasskeys(
(data ?? []).map((p) => ({
id: p.id,
name: p.name,
deviceType: p.deviceType,
createdAt: p.createdAt ? new Date(p.createdAt) : null,
})),
);
} catch {
setError("Erro ao carregar passkeys.");
} finally {
if (showLoader) setIsLoading(false);
}
},
[],
);
useEffect(() => {
if (typeof window === "undefined") return;
setPasskeySupported(typeof PublicKeyCredential !== "undefined");
fetchPasskeys();
}, [fetchPasskeys]);
const handleAdd = async () => {
if (!passkeySupported) {
setError("Passkeys não são suportadas neste navegador/dispositivo.");
return;
}
setIsAdding(true);
setError(null);
try {
const { error: addError } = await authClient.passkey.addPasskey({
name: addName.trim() || undefined,
});
if (addError) {
setError(addError.message || "Erro ao registrar passkey.");
return;
}
setAddName("");
setIsAddOpen(false);
await fetchPasskeys({ showLoader: false });
} catch {
setError("Erro ao registrar passkey.");
} finally {
setIsAdding(false);
}
};
const handleRename = async (id: string) => {
if (!editName.trim()) return;
setIsRenaming(true);
setError(null);
try {
const { error: renameError } = await authClient.passkey.updatePasskey({
id,
name: editName.trim(),
});
if (renameError) {
setError(renameError.message || "Erro ao renomear passkey.");
return;
}
setEditingId(null);
setEditName("");
await fetchPasskeys({ showLoader: false });
} catch {
setError("Erro ao renomear passkey.");
} finally {
setIsRenaming(false);
}
};
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
setError(null);
try {
const { error: deleteError } = await authClient.passkey.deletePasskey({
id: deleteId,
});
if (deleteError) {
setError(deleteError.message || "Erro ao remover passkey.");
return;
}
setDeleteId(null);
await fetchPasskeys({ showLoader: false });
} catch {
setError("Erro ao remover passkey.");
} finally {
setIsDeleting(false);
}
};
const startEditing = (passkey: Passkey) => {
setEditingId(passkey.id);
setEditName(passkey.name || "");
};
const cancelEditing = () => {
setEditingId(null);
setEditName("");
};
const deviceTypeLabel = (type: string) => {
switch (type) {
case "singleDevice":
return "Dispositivo único";
case "multiDevice":
return "Multi-dispositivo";
default:
return type;
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Suas passkeys</h3>
<p className="text-sm text-muted-foreground">
Gerencie suas passkeys para login sem senha.
</p>
</div>
<Dialog
open={isAddOpen}
onOpenChange={(open) => {
if (!open) {
setAddName("");
setError(null);
}
setIsAddOpen(open);
}}
>
<DialogTrigger asChild>
<Button size="sm" disabled={isMutating || !passkeySupported}>
<RiAddLine className="h-4 w-4 mr-1" />
Adicionar
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Registrar Passkey</DialogTitle>
<DialogDescription>
um nome para identificar esta passkey (opcional). Em seguida,
seu navegador solicitará a confirmação biométrica.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkeyName">Nome (opcional)</Label>
<Input
id="passkeyName"
placeholder="Ex: MacBook Pro, iPhone..."
value={addName}
onChange={(e) => setAddName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleAdd();
}
}}
disabled={isAdding}
/>
</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={() => setIsAddOpen(false)}
disabled={isAdding}
>
Cancelar
</Button>
<Button onClick={handleAdd} disabled={isAdding}>
{isAdding ? (
<>
<RiLoader4Line className="h-4 w-4 animate-spin mr-1" />
Registrando...
</>
) : (
"Registrar"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{error && !isAddOpen && (
<div className="flex items-center gap-2 text-sm text-destructive">
<RiAlertLine className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{!passkeySupported && !isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RiAlertLine className="h-4 w-4 shrink-0" />
Este navegador/dispositivo não suporta passkeys.
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-8">
<RiLoader4Line className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : passkeys.length === 0 ? (
<div className="flex items-center gap-3 py-4 text-muted-foreground">
<RiFingerprintLine className="h-5 w-5" />
<p className="text-sm">
Nenhuma passkey cadastrada. Adicione uma para login sem senha.
</p>
</div>
) : (
<div className="divide-y py-2">
{passkeys.map((pk) => (
<div
key={pk.id}
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted">
<RiFingerprintLine className="h-4 w-4" />
</div>
<div className="min-w-0">
{editingId === pk.id ? (
<div className="flex items-center gap-2">
<Input
className="h-7 text-sm w-40"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename(pk.id);
if (e.key === "Escape") cancelEditing();
}}
autoFocus
disabled={isRenaming}
/>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => handleRename(pk.id)}
disabled={isRenaming || !editName.trim()}
>
{isRenaming ? (
<RiLoader4Line className="h-3 w-3 animate-spin" />
) : (
"Salvar"
)}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={cancelEditing}
disabled={isRenaming}
>
Cancelar
</Button>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm font-bold truncate">
{pk.name || "Passkey sem nome"}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => startEditing(pk)}
disabled={isMutating}
>
<RiPencilLine className="h-3 w-3" />
</Button>
</div>
<p className="text-xs text-muted-foreground py-1">
{deviceTypeLabel(pk.deviceType)}
{pk.createdAt
? ` · Criada ${formatDistanceToNow(pk.createdAt, {
addSuffix: true,
locale: ptBR,
})}`
: " · Data de criação indisponível"}
</p>
</>
)}
</div>
</div>
{editingId !== pk.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setDeleteId(pk.id)}
disabled={isMutating}
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog
open={!!deleteId}
onOpenChange={(open) => !open && setDeleteId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remover passkey?</AlertDialogTitle>
<AlertDialogDescription>
Esta passkey não poderá mais ser usada para login. Esta ação não
pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
Cancelar
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{isDeleting ? "Removendo..." : "Remover"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,279 @@
"use client";
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { RiDragMove2Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import { updatePreferencesAction } from "@/features/settings/actions";
import {
DEFAULT_LANCAMENTOS_COLUMN_ORDER,
LANCAMENTOS_COLUMN_LABELS,
} from "@/features/transactions/column-order";
import { FONT_OPTIONS } from "@/public/fonts/font_index";
import { useFont } from "@/shared/components/providers/font-provider";
import { Button } from "@/shared/components/ui/button";
import { Label } from "@/shared/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { Switch } from "@/shared/components/ui/switch";
interface PreferencesFormProps {
extratoNoteAsColumn: boolean;
lancamentosColumnOrder: string[] | null;
systemFont: string;
moneyFont: string;
}
function SortableColumnItem({ id }: { id: string }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const label = LANCAMENTOS_COLUMN_LABELS[id] ?? id;
return (
<div
ref={setNodeRef}
style={style}
className={`flex cursor-grab active:cursor-grabbing items-center gap-2 rounded-md border bg-card px-3 py-2 text-sm touch-none select-none ${
isDragging ? "z-10 opacity-90 shadow-md" : ""
}`}
aria-label={`Arrastar ${label}`}
{...attributes}
{...listeners}
>
<RiDragMove2Line
className="size-4 shrink-0 text-muted-foreground"
aria-hidden
/>
<span>{label}</span>
</div>
);
}
export function PreferencesForm({
extratoNoteAsColumn: initialExtratoNoteAsColumn,
lancamentosColumnOrder: initialColumnOrder,
systemFont: initialSystemFont,
moneyFont: initialMoneyFont,
}: PreferencesFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [extratoNoteAsColumn, setExtratoNoteAsColumn] = useState(
initialExtratoNoteAsColumn,
);
const [columnOrder, setColumnOrder] = useState<string[]>(
initialColumnOrder && initialColumnOrder.length > 0
? initialColumnOrder
: DEFAULT_LANCAMENTOS_COLUMN_ORDER,
);
const [selectedSystemFont, setSelectedSystemFont] =
useState(initialSystemFont);
const [selectedMoneyFont, setSelectedMoneyFont] = useState(initialMoneyFont);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor),
);
const handleColumnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setColumnOrder((items) => {
const oldIndex = items.indexOf(active.id as string);
const newIndex = items.indexOf(over.id as string);
return arrayMove(items, oldIndex, newIndex);
});
}
};
const fontCtx = useFont();
// Live preview: update CSS vars when font selection changes
useEffect(() => {
fontCtx.setSystemFont(selectedSystemFont);
}, [selectedSystemFont, fontCtx.setSystemFont]);
useEffect(() => {
fontCtx.setMoneyFont(selectedMoneyFont);
}, [selectedMoneyFont, fontCtx.setMoneyFont]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
startTransition(async () => {
const result = await updatePreferencesAction({
extratoNoteAsColumn,
lancamentosColumnOrder: columnOrder,
systemFont: selectedSystemFont,
moneyFont: selectedMoneyFont,
});
if (result.success) {
toast.success(result.message);
router.refresh();
} else {
toast.error(result.error);
}
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
{/* Seção 1: Tipografia */}
<section className="space-y-5">
<div>
<h3 className="text-base font-semibold">Tipografia</h3>
<p className="text-sm text-muted-foreground">
Personalize as fontes usadas na interface e nos valores monetários.
</p>
</div>
{/* Fonte do sistema */}
<div className="space-y-2 max-w-md">
<Label htmlFor="system-font">Fonte do sistema</Label>
<Select
value={selectedSystemFont}
onValueChange={setSelectedSystemFont}
>
<SelectTrigger id="system-font">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((opt) => (
<SelectItem key={opt.key} value={opt.key}>
<span
style={{
fontFamily: opt.variable,
}}
>
{opt.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Fonte de valores */}
<div className="space-y-2 max-w-md">
<Label htmlFor="money-font">Fonte de valores</Label>
<Select
value={selectedMoneyFont}
onValueChange={setSelectedMoneyFont}
>
<SelectTrigger id="money-font">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((opt) => (
<SelectItem key={opt.key} value={opt.key}>
<span
style={{
fontFamily: opt.variable,
}}
>
{opt.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</section>
<div className="border-b" />
{/* Seção: Extrato / Lançamentos */}
<section className="space-y-4">
<div>
<h3 className="text-base font-semibold">Extrato e lançamentos</h3>
<p className="text-sm text-muted-foreground">
Como exibir anotações e a ordem das colunas na tabela de
movimentações.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md">
<div className="space-y-0.5">
<Label htmlFor="extrato-note-column" className="text-base">
Anotações em coluna
</Label>
<p className="text-sm text-muted-foreground">
Quando ativo, as anotações aparecem em uma coluna na tabela.
Quando desativado, aparecem em um balão ao passar o mouse no
ícone.
</p>
</div>
<Switch
id="extrato-note-column"
checked={extratoNoteAsColumn}
onCheckedChange={setExtratoNoteAsColumn}
disabled={isPending}
/>
</div>
<div className="space-y-2 max-w-md">
<Label className="text-base">Ordem das colunas</Label>
<p className="text-sm text-muted-foreground">
Arraste os itens para definir a ordem em que as colunas aparecem na
tabela do extrato e dos lançamentos.
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleColumnDragEnd}
>
<SortableContext
items={columnOrder}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-2 pt-2">
{columnOrder.map((id) => (
<SortableColumnItem key={id} id={id} />
))}
</div>
</SortableContext>
</DndContext>
</div>
</section>
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="w-fit">
{isPending ? "Salvando..." : "Salvar preferências"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,246 @@
"use client";
import {
RiCheckLine,
RiCloseLine,
RiEyeLine,
RiEyeOffLine,
} from "@remixicon/react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { updateEmailAction } from "@/features/settings/actions";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
type UpdateEmailFormProps = {
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);
// 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 = !confirmEmail
? null
: newEmail.toLowerCase() === confirmEmail.toLowerCase();
// Validação: novo e-mail é diferente do atual
const isEmailDifferent = !newEmail
? true
: newEmail.toLowerCase() !== currentEmail.toLowerCase();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validação frontend antes de enviar
if (newEmail.toLowerCase() !== confirmEmail.toLowerCase()) {
toast.error("Os e-mails não coincidem");
return;
}
if (newEmail.toLowerCase() === currentEmail.toLowerCase()) {
toast.error("O novo e-mail deve ser diferente do atual");
return;
}
startTransition(async () => {
const result = await updateEmailAction({
password: isGoogleAuth ? undefined : password,
newEmail,
confirmEmail,
});
if (result.success) {
toast.success(result.message);
setPassword("");
setNewEmail("");
setConfirmEmail("");
} else {
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>
{/* 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-destructive focus-visible:ring-destructive"
: ""
}
/>
{!isEmailDifferent && newEmail && (
<p
className="text-xs text-destructive 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-destructive focus-visible:ring-destructive pr-10"
: emailsMatch === true
? "border-success focus-visible:ring-success 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-success"
aria-label="Os e-mails coincidem"
/>
) : (
<RiCloseLine
className="h-5 w-5 text-destructive"
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-destructive 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-success 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>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { updateNameAction } from "@/features/settings/actions";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
type UpdateNameFormProps = {
currentName: string;
};
export function UpdateNameForm({ currentName }: UpdateNameFormProps) {
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(" ") || "";
const [firstName, setFirstName] = useState(initialFirstName);
const [lastName, setLastName] = useState(initialLastName);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
const result = await updateNameAction({
firstName,
lastName,
});
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>
<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>
);
}

View File

@@ -0,0 +1,359 @@
"use client";
import {
RiAlertLine,
RiCheckLine,
RiCloseLine,
RiEyeLine,
RiEyeOffLine,
} from "@remixicon/react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { updatePasswordAction } from "@/features/settings/actions";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { cn } from "@/shared/utils/ui";
interface PasswordValidation {
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;
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-success" : "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
};
export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
const [isPending, startTransition] = useTransition();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Verificar se o usuário usa login via Google
const isGoogleAuth = authProvider === "google";
// Validação em tempo real: senhas coincidem
const passwordsMatch = !confirmPassword
? null
: newPassword === confirmPassword;
// Validação de requisitos da senha
const passwordValidation = validatePassword(newPassword);
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;
}
if (newPassword !== confirmPassword) {
toast.error("As senhas não coincidem");
return;
}
startTransition(async () => {
const result = await updatePasswordAction({
currentPassword,
newPassword,
confirmPassword,
});
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-warning/30 bg-warning/10 p-4 dark:border-warning/20 dark:bg-warning/10">
<div className="flex gap-3">
<RiAlertLine className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-warning">
Alteração de senha não disponível
</h3>
<p className="mt-1 text-sm text-warning">
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>
{/* 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-destructive focus-visible:ring-destructive"
: passwordsMatch === true
? "border-success focus-visible:ring-success"
: ""
}
/>
<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-success"
aria-label="As senhas coincidem"
/>
) : (
<RiCloseLine
className="h-5 w-5 text-destructive"
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-destructive 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-success 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>
);
}