forked from git.gladyson/openmonetis
refactor: migrate from ESLint to Biome and extract SQL queries to data.ts
- Replace ESLint with Biome for linting and formatting - Configure Biome with tabs, double quotes, and organized imports - Move all SQL/Drizzle queries from page.tsx files to data.ts files - Create new data.ts files for: ajustes, dashboard, relatorios/categorias - Update existing data.ts files: extrato, fatura (add lancamentos queries) - Remove all drizzle-orm imports from page.tsx files - Update README.md with new tooling info Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,335 +1,352 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
RiSmartphoneLine,
|
||||
RiDeleteBinLine,
|
||||
RiAddLine,
|
||||
RiFileCopyLine,
|
||||
RiCheckLine,
|
||||
RiAlertLine,
|
||||
RiAddLine,
|
||||
RiAlertLine,
|
||||
RiCheckLine,
|
||||
RiDeleteBinLine,
|
||||
RiFileCopyLine,
|
||||
RiSmartphoneLine,
|
||||
} from "@remixicon/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { createApiTokenAction, revokeApiTokenAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
createApiTokenAction,
|
||||
revokeApiTokenAction,
|
||||
} from "@/app/(dashboard)/ajustes/actions";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
lastUsedAt: Date | null;
|
||||
lastUsedIp: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
lastUsedAt: Date | null;
|
||||
lastUsedIp: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
interface ApiTokensFormProps {
|
||||
tokens: ApiToken[];
|
||||
tokens: ApiToken[];
|
||||
}
|
||||
|
||||
export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [tokenName, setTokenName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [revokeId, setRevokeId] = useState<string | null>(null);
|
||||
const [isRevoking, setIsRevoking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [tokenName, setTokenName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [revokeId, setRevokeId] = useState<string | null>(null);
|
||||
const [isRevoking, setIsRevoking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const activeTokens = tokens.filter((t) => !t.revokedAt);
|
||||
const activeTokens = tokens.filter((t) => !t.revokedAt);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!tokenName.trim()) return;
|
||||
const handleCreate = async () => {
|
||||
if (!tokenName.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await createApiTokenAction({ name: tokenName.trim() });
|
||||
try {
|
||||
const result = await createApiTokenAction({ name: tokenName.trim() });
|
||||
|
||||
if (result.success && result.data?.token) {
|
||||
setNewToken(result.data.token);
|
||||
setTokenName("");
|
||||
} else {
|
||||
setError(result.error || "Erro ao criar token");
|
||||
}
|
||||
} catch {
|
||||
setError("Erro ao criar token");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
if (result.success && result.data?.token) {
|
||||
setNewToken(result.data.token);
|
||||
setTokenName("");
|
||||
} else {
|
||||
setError(result.error || "Erro ao criar token");
|
||||
}
|
||||
} catch {
|
||||
setError("Erro ao criar token");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!newToken) return;
|
||||
const handleCopy = async () => {
|
||||
if (!newToken) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(newToken);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for browsers that don't support clipboard API
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = newToken;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await navigator.clipboard.writeText(newToken);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for browsers that don't support clipboard API
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = newToken;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!revokeId) return;
|
||||
const handleRevoke = async () => {
|
||||
if (!revokeId) return;
|
||||
|
||||
setIsRevoking(true);
|
||||
setIsRevoking(true);
|
||||
|
||||
try {
|
||||
const result = await revokeApiTokenAction({ tokenId: revokeId });
|
||||
try {
|
||||
const result = await revokeApiTokenAction({ tokenId: revokeId });
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || "Erro ao revogar token");
|
||||
}
|
||||
} catch {
|
||||
setError("Erro ao revogar token");
|
||||
} finally {
|
||||
setIsRevoking(false);
|
||||
setRevokeId(null);
|
||||
}
|
||||
};
|
||||
if (!result.success) {
|
||||
setError(result.error || "Erro ao revogar token");
|
||||
}
|
||||
} catch {
|
||||
setError("Erro ao revogar token");
|
||||
} finally {
|
||||
setIsRevoking(false);
|
||||
setRevokeId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseCreate = () => {
|
||||
setIsCreateOpen(false);
|
||||
setNewToken(null);
|
||||
setTokenName("");
|
||||
setError(null);
|
||||
};
|
||||
const handleCloseCreate = () => {
|
||||
setIsCreateOpen(false);
|
||||
setNewToken(null);
|
||||
setTokenName("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">Dispositivos conectados</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gerencie os dispositivos que podem enviar notificações para o OpenSheets.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isCreateOpen} onOpenChange={(open) => {
|
||||
if (!open) handleCloseCreate();
|
||||
else setIsCreateOpen(true);
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<RiAddLine className="h-4 w-4 mr-1" />
|
||||
Novo Token
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
{!newToken ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Criar Token de API</DialogTitle>
|
||||
<DialogDescription>
|
||||
Crie um token para conectar o OpenSheets Companion no seu dispositivo Android.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tokenName">Nome do dispositivo</Label>
|
||||
<Input
|
||||
id="tokenName"
|
||||
placeholder="Ex: Meu Celular, Galaxy S24..."
|
||||
value={tokenName}
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<RiAlertLine className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCloseCreate}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating || !tokenName.trim()}>
|
||||
{isCreating ? "Criando..." : "Criar Token"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Token Criado</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copie o token abaixo e cole no app OpenSheets Companion. Este token
|
||||
<strong> não será exibido novamente</strong>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Seu token de API</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={newToken}
|
||||
readOnly
|
||||
className="pr-10 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<RiCheckLine className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<RiFileCopyLine className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-sm text-amber-800 dark:text-amber-200">
|
||||
<p className="font-medium">Importante:</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Guarde este token em local seguro</li>
|
||||
<li>Ele não será exibido novamente</li>
|
||||
<li>Use-o para configurar o app Android</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleCloseCreate}>Fechar</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">Dispositivos conectados</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gerencie os dispositivos que podem enviar notificações para o
|
||||
OpenSheets.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog
|
||||
open={isCreateOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleCloseCreate();
|
||||
else setIsCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<RiAddLine className="h-4 w-4 mr-1" />
|
||||
Novo Token
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
{!newToken ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Criar Token de API</DialogTitle>
|
||||
<DialogDescription>
|
||||
Crie um token para conectar o OpenSheets Companion no seu
|
||||
dispositivo Android.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tokenName">Nome do dispositivo</Label>
|
||||
<Input
|
||||
id="tokenName"
|
||||
placeholder="Ex: Meu Celular, Galaxy S24..."
|
||||
value={tokenName}
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<RiAlertLine className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCloseCreate}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating || !tokenName.trim()}
|
||||
>
|
||||
{isCreating ? "Criando..." : "Criar Token"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Token Criado</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copie o token abaixo e cole no app OpenSheets Companion.
|
||||
Este token
|
||||
<strong> não será exibido novamente</strong>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Seu token de API</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={newToken}
|
||||
readOnly
|
||||
className="pr-10 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<RiCheckLine className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<RiFileCopyLine className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-sm text-amber-800 dark:text-amber-200">
|
||||
<p className="font-medium">Importante:</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Guarde este token em local seguro</li>
|
||||
<li>Ele não será exibido novamente</li>
|
||||
<li>Use-o para configurar o app Android</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleCloseCreate}>Fechar</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{activeTokens.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<RiSmartphoneLine className="h-10 w-10 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhum dispositivo conectado.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Crie um token para conectar o app OpenSheets Companion.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activeTokens.map((token) => (
|
||||
<Card key={token.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-full bg-muted p-2">
|
||||
<RiSmartphoneLine className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{token.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{token.tokenPrefix}...
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{token.lastUsedAt ? (
|
||||
<span>
|
||||
Usado{" "}
|
||||
{formatDistanceToNow(token.lastUsedAt, {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
})}
|
||||
{token.lastUsedIp && (
|
||||
<span className="text-xs ml-1">
|
||||
({token.lastUsedIp})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>Nunca usado</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Criado em{" "}
|
||||
{new Date(token.createdAt).toLocaleDateString("pt-BR")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setRevokeId(token.id)}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{activeTokens.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<RiSmartphoneLine className="h-10 w-10 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhum dispositivo conectado.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Crie um token para conectar o app OpenSheets Companion.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activeTokens.map((token) => (
|
||||
<Card key={token.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-full bg-muted p-2">
|
||||
<RiSmartphoneLine className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{token.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{token.tokenPrefix}...
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{token.lastUsedAt ? (
|
||||
<span>
|
||||
Usado{" "}
|
||||
{formatDistanceToNow(token.lastUsedAt, {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
})}
|
||||
{token.lastUsedIp && (
|
||||
<span className="text-xs ml-1">
|
||||
({token.lastUsedIp})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>Nunca usado</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Criado em{" "}
|
||||
{new Date(token.createdAt).toLocaleDateString("pt-BR")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setRevokeId(token.id)}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revoke Confirmation Dialog */}
|
||||
<AlertDialog open={!!revokeId} onOpenChange={(open) => !open && setRevokeId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revogar token?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
O dispositivo associado a este token será desconectado e não poderá mais
|
||||
enviar notificações. Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isRevoking}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRevoke}
|
||||
disabled={isRevoking}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{isRevoking ? "Revogando..." : "Revogar"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
{/* Revoke Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={!!revokeId}
|
||||
onOpenChange={(open) => !open && setRevokeId(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revogar token?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
O dispositivo associado a este token será desconectado e não
|
||||
poderá mais enviar notificações. Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isRevoking}>
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRevoke}
|
||||
disabled={isRevoking}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{isRevoking ? "Revogando..." : "Revogar"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,136 +1,136 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function DeleteAccountForm() {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
|
||||
const handleDelete = () => {
|
||||
startTransition(async () => {
|
||||
const result = await deleteAccountAction({
|
||||
confirmation,
|
||||
});
|
||||
const handleDelete = () => {
|
||||
startTransition(async () => {
|
||||
const result = await deleteAccountAction({
|
||||
confirmation,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
// Fazer logout e redirecionar para página de login
|
||||
await authClient.signOut();
|
||||
router.push("/");
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
// Fazer logout e redirecionar para página de login
|
||||
await authClient.signOut();
|
||||
router.push("/");
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setConfirmation("");
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
const handleOpenModal = () => {
|
||||
setConfirmation("");
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
if (isPending) return;
|
||||
setConfirmation("");
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
const handleCloseModal = () => {
|
||||
if (isPending) return;
|
||||
setConfirmation("");
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
|
||||
<li>Lançamentos, orçamentos e anotações</li>
|
||||
<li>Contas, cartões e categorias</li>
|
||||
<li>Pagadores (incluindo o pagador padrão)</li>
|
||||
<li>Preferências e configurações</li>
|
||||
<li className="font-bold">
|
||||
Resumindo tudo, sua conta será permanentemente removida
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
|
||||
<li>Lançamentos, orçamentos e anotações</li>
|
||||
<li>Contas, cartões e categorias</li>
|
||||
<li>Pagadores (incluindo o pagador padrão)</li>
|
||||
<li>Preferências e configurações</li>
|
||||
<li className="font-bold">
|
||||
Resumindo tudo, sua conta será permanentemente removida
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOpenModal}
|
||||
disabled={isPending}
|
||||
className="w-fit"
|
||||
>
|
||||
Deletar conta
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOpenModal}
|
||||
disabled={isPending}
|
||||
className="w-fit"
|
||||
>
|
||||
Deletar conta
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (isPending) e.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isPending) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Você tem certeza?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Essa ação não pode ser desfeita. Isso irá deletar permanentemente
|
||||
sua conta e remover seus dados de nossos servidores.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (isPending) e.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isPending) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Você tem certeza?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Essa ação não pode ser desfeita. Isso irá deletar permanentemente
|
||||
sua conta e remover seus dados de nossos servidores.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmation">
|
||||
Para confirmar, digite <strong>DELETAR</strong> no campo abaixo.
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmation"
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="DELETAR"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmation">
|
||||
Para confirmar, digite <strong>DELETAR</strong> no campo abaixo.
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmation"
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="DELETAR"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCloseModal}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending || confirmation !== "DELETAR"}
|
||||
>
|
||||
{isPending ? "Deletando..." : "Deletar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCloseModal}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending || confirmation !== "DELETAR"}
|
||||
>
|
||||
{isPending ? "Deletando..." : "Deletar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface PreferencesFormProps {
|
||||
disableMagnetlines: boolean;
|
||||
disableMagnetlines: boolean;
|
||||
}
|
||||
|
||||
export function PreferencesForm({
|
||||
disableMagnetlines,
|
||||
}: PreferencesFormProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [magnetlinesDisabled, setMagnetlinesDisabled] =
|
||||
useState(disableMagnetlines);
|
||||
export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [magnetlinesDisabled, setMagnetlinesDisabled] =
|
||||
useState(disableMagnetlines);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updatePreferencesAction({
|
||||
disableMagnetlines: magnetlinesDisabled,
|
||||
});
|
||||
startTransition(async () => {
|
||||
const result = await updatePreferencesAction({
|
||||
disableMagnetlines: magnetlinesDisabled,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
// Recarregar a página para aplicar as mudanças nos componentes
|
||||
router.refresh();
|
||||
// Forçar reload completo para garantir que os hooks re-executem
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
// Recarregar a página para aplicar as mudanças nos componentes
|
||||
router.refresh();
|
||||
// Forçar reload completo para garantir que os hooks re-executem
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col space-y-6"
|
||||
>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="magnetlines" className="text-base">
|
||||
Desabilitar Magnetlines
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Remove o recurso de linhas magnéticas do sistema. Essa mudança
|
||||
afeta a interface e interações visuais.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="magnetlines"
|
||||
checked={magnetlinesDisabled}
|
||||
onCheckedChange={setMagnetlinesDisabled}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="magnetlines" className="text-base">
|
||||
Desabilitar Magnetlines
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Remove o recurso de linhas magnéticas do sistema. Essa mudança
|
||||
afeta a interface e interações visuais.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="magnetlines"
|
||||
checked={magnetlinesDisabled}
|
||||
onCheckedChange={setMagnetlinesDisabled}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="w-fit">
|
||||
{isPending ? "Salvando..." : "Salvar preferências"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="w-fit">
|
||||
{isPending ? "Salvando..." : "Salvar preferências"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,220 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiEyeLine,
|
||||
RiEyeOffLine,
|
||||
} from "@remixicon/react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateEmailAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RiCheckLine, RiCloseLine, RiEyeLine, RiEyeOffLine } from "@remixicon/react";
|
||||
import { useState, useTransition, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type UpdateEmailFormProps = {
|
||||
currentEmail: string;
|
||||
authProvider?: string; // 'google' | 'credential' | undefined
|
||||
currentEmail: string;
|
||||
authProvider?: string; // 'google' | 'credential' | undefined
|
||||
};
|
||||
|
||||
export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [password, setPassword] = useState("");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [confirmEmail, setConfirmEmail] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
export function UpdateEmailForm({
|
||||
currentEmail,
|
||||
authProvider,
|
||||
}: UpdateEmailFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [password, setPassword] = useState("");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [confirmEmail, setConfirmEmail] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Verificar se o usuário usa login via Google (não precisa de senha)
|
||||
const isGoogleAuth = authProvider === "google";
|
||||
// Verificar se o usuário usa login via Google (não precisa de senha)
|
||||
const isGoogleAuth = authProvider === "google";
|
||||
|
||||
// Validação em tempo real: e-mails coincidem
|
||||
const emailsMatch = useMemo(() => {
|
||||
if (!confirmEmail) return null; // Não mostrar erro se campo vazio
|
||||
return newEmail.toLowerCase() === confirmEmail.toLowerCase();
|
||||
}, [newEmail, confirmEmail]);
|
||||
// Validação em tempo real: e-mails coincidem
|
||||
const emailsMatch = useMemo(() => {
|
||||
if (!confirmEmail) return null; // Não mostrar erro se campo vazio
|
||||
return newEmail.toLowerCase() === confirmEmail.toLowerCase();
|
||||
}, [newEmail, confirmEmail]);
|
||||
|
||||
// Validação: novo e-mail é diferente do atual
|
||||
const isEmailDifferent = useMemo(() => {
|
||||
if (!newEmail) return true;
|
||||
return newEmail.toLowerCase() !== currentEmail.toLowerCase();
|
||||
}, [newEmail, currentEmail]);
|
||||
// Validação: novo e-mail é diferente do atual
|
||||
const isEmailDifferent = useMemo(() => {
|
||||
if (!newEmail) return true;
|
||||
return newEmail.toLowerCase() !== currentEmail.toLowerCase();
|
||||
}, [newEmail, currentEmail]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validação frontend antes de enviar
|
||||
if (newEmail.toLowerCase() !== confirmEmail.toLowerCase()) {
|
||||
toast.error("Os e-mails não coincidem");
|
||||
return;
|
||||
}
|
||||
// Validação frontend antes de enviar
|
||||
if (newEmail.toLowerCase() !== confirmEmail.toLowerCase()) {
|
||||
toast.error("Os e-mails não coincidem");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newEmail.toLowerCase() === currentEmail.toLowerCase()) {
|
||||
toast.error("O novo e-mail deve ser diferente do atual");
|
||||
return;
|
||||
}
|
||||
if (newEmail.toLowerCase() === currentEmail.toLowerCase()) {
|
||||
toast.error("O novo e-mail deve ser diferente do atual");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateEmailAction({
|
||||
password: isGoogleAuth ? undefined : password,
|
||||
newEmail,
|
||||
confirmEmail,
|
||||
});
|
||||
startTransition(async () => {
|
||||
const result = await updateEmailAction({
|
||||
password: isGoogleAuth ? undefined : password,
|
||||
newEmail,
|
||||
confirmEmail,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setPassword("");
|
||||
setNewEmail("");
|
||||
setConfirmEmail("");
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setPassword("");
|
||||
setNewEmail("");
|
||||
setConfirmEmail("");
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
{/* E-mail atual (apenas informativo) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentEmail">E-mail atual</Label>
|
||||
<Input
|
||||
id="currentEmail"
|
||||
type="email"
|
||||
value={currentEmail}
|
||||
disabled
|
||||
className="bg-muted cursor-not-allowed"
|
||||
aria-describedby="current-email-help"
|
||||
/>
|
||||
<p id="current-email-help" className="text-xs text-muted-foreground">
|
||||
Este é seu e-mail atual cadastrado
|
||||
</p>
|
||||
</div>
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
{/* E-mail atual (apenas informativo) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentEmail">E-mail atual</Label>
|
||||
<Input
|
||||
id="currentEmail"
|
||||
type="email"
|
||||
value={currentEmail}
|
||||
disabled
|
||||
className="bg-muted cursor-not-allowed"
|
||||
aria-describedby="current-email-help"
|
||||
/>
|
||||
<p id="current-email-help" className="text-xs text-muted-foreground">
|
||||
Este é seu e-mail atual cadastrado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */}
|
||||
{!isGoogleAuth && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Senha atual <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite sua senha para confirmar"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="password-help"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p id="password-help" className="text-xs text-muted-foreground">
|
||||
Por segurança, confirme sua senha antes de alterar seu e-mail
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */}
|
||||
{!isGoogleAuth && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
Senha atual <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite sua senha para confirmar"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="password-help"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p id="password-help" className="text-xs text-muted-foreground">
|
||||
Por segurança, confirme sua senha antes de alterar seu e-mail
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Novo e-mail */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newEmail">
|
||||
Novo e-mail <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="newEmail"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite o novo e-mail"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="new-email-help"
|
||||
aria-invalid={!isEmailDifferent}
|
||||
className={!isEmailDifferent ? "border-red-500 focus-visible:ring-red-500" : ""}
|
||||
/>
|
||||
{!isEmailDifferent && newEmail && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
O novo e-mail deve ser diferente do atual
|
||||
</p>
|
||||
)}
|
||||
{!newEmail && (
|
||||
<p id="new-email-help" className="text-xs text-muted-foreground">
|
||||
Digite o novo endereço de e-mail para sua conta
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Novo e-mail */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newEmail">
|
||||
Novo e-mail <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="newEmail"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite o novo e-mail"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="new-email-help"
|
||||
aria-invalid={!isEmailDifferent}
|
||||
className={
|
||||
!isEmailDifferent
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{!isEmailDifferent && newEmail && (
|
||||
<p
|
||||
className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
|
||||
role="alert"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />O novo e-mail deve ser
|
||||
diferente do atual
|
||||
</p>
|
||||
)}
|
||||
{!newEmail && (
|
||||
<p id="new-email-help" className="text-xs text-muted-foreground">
|
||||
Digite o novo endereço de e-mail para sua conta
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmar novo e-mail */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmEmail">
|
||||
Confirmar novo e-mail <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmEmail"
|
||||
type="email"
|
||||
value={confirmEmail}
|
||||
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Repita o novo e-mail"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="confirm-email-help"
|
||||
aria-invalid={emailsMatch === false}
|
||||
className={
|
||||
emailsMatch === false
|
||||
? "border-red-500 focus-visible:ring-red-500 pr-10"
|
||||
: emailsMatch === true
|
||||
? "border-green-500 focus-visible:ring-green-500 pr-10"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{/* Indicador visual de match */}
|
||||
{emailsMatch !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{emailsMatch ? (
|
||||
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="Os e-mails coincidem" />
|
||||
) : (
|
||||
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="Os e-mails não coincidem" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mensagem de erro em tempo real */}
|
||||
{emailsMatch === false && (
|
||||
<p id="confirm-email-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert">
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
Os e-mails não coincidem
|
||||
</p>
|
||||
)}
|
||||
{emailsMatch === true && (
|
||||
<p id="confirm-email-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
Os e-mails coincidem
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Confirmar novo e-mail */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmEmail">
|
||||
Confirmar novo e-mail <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmEmail"
|
||||
type="email"
|
||||
value={confirmEmail}
|
||||
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Repita o novo e-mail"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="confirm-email-help"
|
||||
aria-invalid={emailsMatch === false}
|
||||
className={
|
||||
emailsMatch === false
|
||||
? "border-red-500 focus-visible:ring-red-500 pr-10"
|
||||
: emailsMatch === true
|
||||
? "border-green-500 focus-visible:ring-green-500 pr-10"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{/* Indicador visual de match */}
|
||||
{emailsMatch !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{emailsMatch ? (
|
||||
<RiCheckLine
|
||||
className="h-5 w-5 text-green-500"
|
||||
aria-label="Os e-mails coincidem"
|
||||
/>
|
||||
) : (
|
||||
<RiCloseLine
|
||||
className="h-5 w-5 text-red-500"
|
||||
aria-label="Os e-mails não coincidem"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mensagem de erro em tempo real */}
|
||||
{emailsMatch === false && (
|
||||
<p
|
||||
id="confirm-email-help"
|
||||
className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
|
||||
role="alert"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
Os e-mails não coincidem
|
||||
</p>
|
||||
)}
|
||||
{emailsMatch === true && (
|
||||
<p
|
||||
id="confirm-email-help"
|
||||
className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"
|
||||
>
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
Os e-mails coincidem
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || emailsMatch === false || !isEmailDifferent}
|
||||
className="w-fit"
|
||||
>
|
||||
{isPending ? "Atualizando..." : "Atualizar e-mail"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || emailsMatch === false || !isEmailDifferent}
|
||||
className="w-fit"
|
||||
>
|
||||
{isPending ? "Atualizando..." : "Atualizar e-mail"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,75 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateNameAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type UpdateNameFormProps = {
|
||||
currentName: string;
|
||||
currentName: string;
|
||||
};
|
||||
|
||||
export function UpdateNameForm({ currentName }: UpdateNameFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Dividir o nome atual em primeiro nome e sobrenome
|
||||
const nameParts = currentName.split(" ");
|
||||
const initialFirstName = nameParts[0] || "";
|
||||
const initialLastName = nameParts.slice(1).join(" ") || "";
|
||||
// Dividir o nome atual em primeiro nome e sobrenome
|
||||
const nameParts = currentName.split(" ");
|
||||
const initialFirstName = nameParts[0] || "";
|
||||
const initialLastName = nameParts.slice(1).join(" ") || "";
|
||||
|
||||
const [firstName, setFirstName] = useState(initialFirstName);
|
||||
const [lastName, setLastName] = useState(initialLastName);
|
||||
const [firstName, setFirstName] = useState(initialFirstName);
|
||||
const [lastName, setLastName] = useState(initialLastName);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateNameAction({
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
startTransition(async () => {
|
||||
const result = await updateNameAction({
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Primeiro nome</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Primeiro nome</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Sobrenome</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Sobrenome</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="w-fit">
|
||||
{isPending ? "Atualizando..." : "Atualizar nome"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="w-fit">
|
||||
{isPending ? "Atualizando..." : "Atualizar nome"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,363 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiAlertLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiEyeLine,
|
||||
RiEyeOffLine,
|
||||
} from "@remixicon/react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updatePasswordAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import {
|
||||
RiEyeLine,
|
||||
RiEyeOffLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiAlertLine,
|
||||
} from "@remixicon/react";
|
||||
import { useState, useTransition, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PasswordValidation {
|
||||
hasLowercase: boolean;
|
||||
hasUppercase: boolean;
|
||||
hasNumber: boolean;
|
||||
hasSpecial: boolean;
|
||||
hasMinLength: boolean;
|
||||
hasMaxLength: boolean;
|
||||
isValid: boolean;
|
||||
hasLowercase: boolean;
|
||||
hasUppercase: boolean;
|
||||
hasNumber: boolean;
|
||||
hasSpecial: boolean;
|
||||
hasMinLength: boolean;
|
||||
hasMaxLength: boolean;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
function validatePassword(password: string): PasswordValidation {
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]/.test(password);
|
||||
const hasMinLength = password.length >= 7;
|
||||
const hasMaxLength = password.length <= 23;
|
||||
const hasLowercase = /[a-z]/.test(password);
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
|
||||
const hasMinLength = password.length >= 7;
|
||||
const hasMaxLength = password.length <= 23;
|
||||
|
||||
return {
|
||||
hasLowercase,
|
||||
hasUppercase,
|
||||
hasNumber,
|
||||
hasSpecial,
|
||||
hasMinLength,
|
||||
hasMaxLength,
|
||||
isValid:
|
||||
hasLowercase &&
|
||||
hasUppercase &&
|
||||
hasNumber &&
|
||||
hasSpecial &&
|
||||
hasMinLength &&
|
||||
hasMaxLength,
|
||||
};
|
||||
return {
|
||||
hasLowercase,
|
||||
hasUppercase,
|
||||
hasNumber,
|
||||
hasSpecial,
|
||||
hasMinLength,
|
||||
hasMaxLength,
|
||||
isValid:
|
||||
hasLowercase &&
|
||||
hasUppercase &&
|
||||
hasNumber &&
|
||||
hasSpecial &&
|
||||
hasMinLength &&
|
||||
hasMaxLength,
|
||||
};
|
||||
}
|
||||
|
||||
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs transition-colors",
|
||||
met ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{met ? (
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs transition-colors",
|
||||
met
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{met ? (
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type UpdatePasswordFormProps = {
|
||||
authProvider?: string; // 'google' | 'credential' | undefined
|
||||
authProvider?: string; // 'google' | 'credential' | undefined
|
||||
};
|
||||
|
||||
export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
// Verificar se o usuário usa login via Google
|
||||
const isGoogleAuth = authProvider === "google";
|
||||
// Verificar se o usuário usa login via Google
|
||||
const isGoogleAuth = authProvider === "google";
|
||||
|
||||
// Validação em tempo real: senhas coincidem
|
||||
const passwordsMatch = useMemo(() => {
|
||||
if (!confirmPassword) return null; // Não mostrar erro se campo vazio
|
||||
return newPassword === confirmPassword;
|
||||
}, [newPassword, confirmPassword]);
|
||||
// Validação em tempo real: senhas coincidem
|
||||
const passwordsMatch = useMemo(() => {
|
||||
if (!confirmPassword) return null; // Não mostrar erro se campo vazio
|
||||
return newPassword === confirmPassword;
|
||||
}, [newPassword, confirmPassword]);
|
||||
|
||||
// Validação de requisitos da senha
|
||||
const passwordValidation = useMemo(
|
||||
() => validatePassword(newPassword),
|
||||
[newPassword]
|
||||
);
|
||||
// Validação de requisitos da senha
|
||||
const passwordValidation = useMemo(
|
||||
() => validatePassword(newPassword),
|
||||
[newPassword],
|
||||
);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validação frontend antes de enviar
|
||||
if (!passwordValidation.isValid) {
|
||||
toast.error("A senha não atende aos requisitos de segurança");
|
||||
return;
|
||||
}
|
||||
// Validação frontend antes de enviar
|
||||
if (!passwordValidation.isValid) {
|
||||
toast.error("A senha não atende aos requisitos de segurança");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("As senhas não coincidem");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("As senhas não coincidem");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updatePasswordAction({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
startTransition(async () => {
|
||||
const result = await updatePasswordAction({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Se o usuário usa Google OAuth, mostrar aviso
|
||||
if (isGoogleAuth) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<div className="flex gap-3">
|
||||
<RiAlertLine className="h-5 w-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-amber-900 dark:text-amber-400">
|
||||
Alteração de senha não disponível
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-800 dark:text-amber-500">
|
||||
Você fez login usando sua conta do Google. A senha é gerenciada
|
||||
diretamente pelo Google e não pode ser alterada aqui. Para
|
||||
modificar sua senha, acesse as configurações de segurança da sua
|
||||
conta Google.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Se o usuário usa Google OAuth, mostrar aviso
|
||||
if (isGoogleAuth) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
|
||||
<div className="flex gap-3">
|
||||
<RiAlertLine className="h-5 w-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-amber-900 dark:text-amber-400">
|
||||
Alteração de senha não disponível
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-800 dark:text-amber-500">
|
||||
Você fez login usando sua conta do Google. A senha é gerenciada
|
||||
diretamente pelo Google e não pode ser alterada aqui. Para
|
||||
modificar sua senha, acesse as configurações de segurança da sua
|
||||
conta Google.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
{/* Senha atual */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">
|
||||
Senha atual <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite sua senha atual"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="current-password-help"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showCurrentPassword
|
||||
? "Ocultar senha atual"
|
||||
: "Mostrar senha atual"
|
||||
}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="current-password-help"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Por segurança, confirme sua senha atual antes de alterá-la
|
||||
</p>
|
||||
</div>
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
|
||||
<div className="space-y-4 max-w-md">
|
||||
{/* Senha atual */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">
|
||||
Senha atual <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Digite sua senha atual"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="current-password-help"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showCurrentPassword
|
||||
? "Ocultar senha atual"
|
||||
: "Mostrar senha atual"
|
||||
}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="current-password-help"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Por segurança, confirme sua senha atual antes de alterá-la
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nova senha */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">
|
||||
Nova senha <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Crie uma senha forte"
|
||||
required
|
||||
minLength={7}
|
||||
maxLength={23}
|
||||
aria-required="true"
|
||||
aria-describedby="new-password-help"
|
||||
aria-invalid={
|
||||
newPassword.length > 0 && !passwordValidation.isValid
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"
|
||||
}
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Indicadores de requisitos da senha */}
|
||||
{newPassword.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMinLength}
|
||||
label="Mínimo 7 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMaxLength}
|
||||
label="Máximo 23 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasLowercase}
|
||||
label="Letra minúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasUppercase}
|
||||
label="Letra maiúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasNumber}
|
||||
label="Número"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasSpecial}
|
||||
label="Caractere especial"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Nova senha */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">
|
||||
Nova senha <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Crie uma senha forte"
|
||||
required
|
||||
minLength={7}
|
||||
maxLength={23}
|
||||
aria-required="true"
|
||||
aria-describedby="new-password-help"
|
||||
aria-invalid={
|
||||
newPassword.length > 0 && !passwordValidation.isValid
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"
|
||||
}
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Indicadores de requisitos da senha */}
|
||||
{newPassword.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMinLength}
|
||||
label="Mínimo 7 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasMaxLength}
|
||||
label="Máximo 23 caracteres"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasLowercase}
|
||||
label="Letra minúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasUppercase}
|
||||
label="Letra maiúscula"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasNumber}
|
||||
label="Número"
|
||||
/>
|
||||
<PasswordRequirement
|
||||
met={passwordValidation.hasSpecial}
|
||||
label="Caractere especial"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmar nova senha */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
Confirmar nova senha <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Repita a senha"
|
||||
required
|
||||
minLength={6}
|
||||
aria-required="true"
|
||||
aria-describedby="confirm-password-help"
|
||||
aria-invalid={passwordsMatch === false}
|
||||
className={
|
||||
passwordsMatch === false
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: passwordsMatch === true
|
||||
? "border-green-500 focus-visible:ring-green-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showConfirmPassword
|
||||
? "Ocultar confirmação de senha"
|
||||
: "Mostrar confirmação de senha"
|
||||
}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
{/* Indicador visual de match */}
|
||||
{passwordsMatch !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{passwordsMatch ? (
|
||||
<RiCheckLine
|
||||
className="h-5 w-5 text-green-500"
|
||||
aria-label="As senhas coincidem"
|
||||
/>
|
||||
) : (
|
||||
<RiCloseLine
|
||||
className="h-5 w-5 text-red-500"
|
||||
aria-label="As senhas não coincidem"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mensagem de erro em tempo real */}
|
||||
{passwordsMatch === false && (
|
||||
<p
|
||||
id="confirm-password-help"
|
||||
className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
|
||||
role="alert"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
As senhas não coincidem
|
||||
</p>
|
||||
)}
|
||||
{passwordsMatch === true && (
|
||||
<p
|
||||
id="confirm-password-help"
|
||||
className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"
|
||||
>
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
As senhas coincidem
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Confirmar nova senha */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
Confirmar nova senha <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Repita a senha"
|
||||
required
|
||||
minLength={6}
|
||||
aria-required="true"
|
||||
aria-describedby="confirm-password-help"
|
||||
aria-invalid={passwordsMatch === false}
|
||||
className={
|
||||
passwordsMatch === false
|
||||
? "border-red-500 focus-visible:ring-red-500"
|
||||
: passwordsMatch === true
|
||||
? "border-green-500 focus-visible:ring-green-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showConfirmPassword
|
||||
? "Ocultar confirmação de senha"
|
||||
: "Mostrar confirmação de senha"
|
||||
}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
{/* Indicador visual de match */}
|
||||
{passwordsMatch !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{passwordsMatch ? (
|
||||
<RiCheckLine
|
||||
className="h-5 w-5 text-green-500"
|
||||
aria-label="As senhas coincidem"
|
||||
/>
|
||||
) : (
|
||||
<RiCloseLine
|
||||
className="h-5 w-5 text-red-500"
|
||||
aria-label="As senhas não coincidem"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mensagem de erro em tempo real */}
|
||||
{passwordsMatch === false && (
|
||||
<p
|
||||
id="confirm-password-help"
|
||||
className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
|
||||
role="alert"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
As senhas não coincidem
|
||||
</p>
|
||||
)}
|
||||
{passwordsMatch === true && (
|
||||
<p
|
||||
id="confirm-password-help"
|
||||
className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"
|
||||
>
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
As senhas coincidem
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isPending ||
|
||||
passwordsMatch === false ||
|
||||
(newPassword.length > 0 && !passwordValidation.isValid)
|
||||
}
|
||||
className="w-fit"
|
||||
>
|
||||
{isPending ? "Atualizando..." : "Atualizar senha"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isPending ||
|
||||
passwordsMatch === false ||
|
||||
(newPassword.length > 0 && !passwordValidation.isValid)
|
||||
}
|
||||
className="w-fit"
|
||||
>
|
||||
{isPending ? "Atualizando..." : "Atualizar senha"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user