mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
344
src/features/settings/components/api-tokens-form.tsx
Normal file
344
src/features/settings/components/api-tokens-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/features/settings/components/changelog-tab.tsx
Normal file
92
src/features/settings/components/changelog-tab.tsx
Normal 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">•</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>
|
||||
);
|
||||
}
|
||||
117
src/features/settings/components/companion-tab.tsx
Normal file
117
src/features/settings/components/companion-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
src/features/settings/components/delete-account-form.tsx
Normal file
136
src/features/settings/components/delete-account-form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
418
src/features/settings/components/passkeys-form.tsx
Normal file
418
src/features/settings/components/passkeys-form.tsx
Normal 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>
|
||||
Dê 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>
|
||||
);
|
||||
}
|
||||
279
src/features/settings/components/preferences-form.tsx
Normal file
279
src/features/settings/components/preferences-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
src/features/settings/components/update-email-form.tsx
Normal file
246
src/features/settings/components/update-email-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/features/settings/components/update-name-form.tsx
Normal file
75
src/features/settings/components/update-name-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
359
src/features/settings/components/update-password-form.tsx
Normal file
359
src/features/settings/components/update-password-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user