mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-10 11:21:45 +00:00
feat: endurece mutações financeiras e permite zerar conta
This commit is contained in:
@@ -2,14 +2,22 @@
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { verifyPassword } from "better-auth/crypto";
|
||||
import { and, eq, isNull, ne } from "drizzle-orm";
|
||||
import { and, eq, isNull, ne, or } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { account, apiTokens, payers } from "@/db/schema";
|
||||
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
||||
import { auth } from "@/shared/lib/auth/config";
|
||||
import { DEFAULT_CATEGORIES } from "@/shared/lib/categories/defaults";
|
||||
import { db, schema } from "@/shared/lib/db";
|
||||
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
import {
|
||||
DEFAULT_PAYER_AVATAR,
|
||||
PAYER_ROLE_ADMIN,
|
||||
PAYER_STATUS_OPTIONS,
|
||||
} from "@/shared/lib/payers/constants";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
|
||||
|
||||
type ActionResponse<T = void> = {
|
||||
success: boolean;
|
||||
@@ -50,11 +58,96 @@ const deleteAccountSchema = z.object({
|
||||
confirmation: z.literal("DELETAR"),
|
||||
});
|
||||
|
||||
const resetAccountSchema = z.object({
|
||||
confirmation: z.literal("ZERAR"),
|
||||
});
|
||||
|
||||
const updatePreferencesSchema = z.object({
|
||||
statementNoteAsColumn: z.boolean(),
|
||||
transactionsColumnOrder: z.array(z.string()).nullable(),
|
||||
});
|
||||
|
||||
type ResettableUser = {
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
async function resetUserAppData(
|
||||
userId: string,
|
||||
user: ResettableUser,
|
||||
): Promise<void> {
|
||||
const payerName =
|
||||
(user.name && user.name.trim().length > 0
|
||||
? user.name.trim()
|
||||
: normalizeNameFromEmail(user.email)) || "Payer principal";
|
||||
const avatarUrl = user.image ?? DEFAULT_PAYER_AVATAR;
|
||||
const defaultPayerStatus = PAYER_STATUS_OPTIONS[0];
|
||||
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
await tx
|
||||
.delete(schema.payerShares)
|
||||
.where(
|
||||
or(
|
||||
eq(schema.payerShares.sharedWithUserId, userId),
|
||||
eq(schema.payerShares.createdByUserId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
await tx
|
||||
.delete(schema.userPreferences)
|
||||
.where(eq(schema.userPreferences.userId, userId));
|
||||
await tx
|
||||
.delete(schema.apiTokens)
|
||||
.where(eq(schema.apiTokens.userId, userId));
|
||||
await tx
|
||||
.delete(schema.savedInsights)
|
||||
.where(eq(schema.savedInsights.userId, userId));
|
||||
await tx.delete(schema.notes).where(eq(schema.notes.userId, userId));
|
||||
await tx
|
||||
.delete(schema.inboxItems)
|
||||
.where(eq(schema.inboxItems.userId, userId));
|
||||
await tx.delete(schema.budgets).where(eq(schema.budgets.userId, userId));
|
||||
await tx
|
||||
.delete(schema.installmentAnticipations)
|
||||
.where(eq(schema.installmentAnticipations.userId, userId));
|
||||
await tx
|
||||
.delete(schema.transactions)
|
||||
.where(eq(schema.transactions.userId, userId));
|
||||
await tx.delete(schema.invoices).where(eq(schema.invoices.userId, userId));
|
||||
await tx.delete(schema.cards).where(eq(schema.cards.userId, userId));
|
||||
await tx
|
||||
.delete(schema.financialAccounts)
|
||||
.where(eq(schema.financialAccounts.userId, userId));
|
||||
await tx.delete(schema.payers).where(eq(schema.payers.userId, userId));
|
||||
await tx
|
||||
.delete(schema.categories)
|
||||
.where(eq(schema.categories.userId, userId));
|
||||
|
||||
if (DEFAULT_CATEGORIES.length > 0) {
|
||||
await tx.insert(schema.categories).values(
|
||||
DEFAULT_CATEGORIES.map((category) => ({
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
icon: category.icon,
|
||||
userId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
await tx.insert(schema.payers).values({
|
||||
name: payerName,
|
||||
email: user.email,
|
||||
avatarUrl,
|
||||
status: defaultPayerStatus,
|
||||
note: null,
|
||||
role: PAYER_ROLE_ADMIN,
|
||||
isAutoSend: false,
|
||||
userId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Actions
|
||||
|
||||
export async function updateNameAction(
|
||||
@@ -74,6 +167,7 @@ export async function updateNameAction(
|
||||
|
||||
const validated = updateNameSchema.parse(data);
|
||||
const fullName = `${validated.firstName} ${validated.lastName}`;
|
||||
const adminPayerId = await getAdminPayerId(session.user.id);
|
||||
|
||||
// Atualizar nome do usuário
|
||||
await db
|
||||
@@ -82,15 +176,14 @@ export async function updateNameAction(
|
||||
.where(eq(schema.user.id, session.user.id));
|
||||
|
||||
// Sincronizar nome com o pagador admin
|
||||
await db
|
||||
.update(payers)
|
||||
.set({ name: fullName })
|
||||
.where(
|
||||
and(
|
||||
eq(payers.userId, session.user.id),
|
||||
eq(payers.role, PAYER_ROLE_ADMIN),
|
||||
),
|
||||
);
|
||||
if (adminPayerId) {
|
||||
await db
|
||||
.update(payers)
|
||||
.set({ name: fullName })
|
||||
.where(
|
||||
and(eq(payers.userId, session.user.id), eq(payers.id, adminPayerId)),
|
||||
);
|
||||
}
|
||||
|
||||
// Revalidar o layout do dashboard para atualizar a sidebar
|
||||
revalidatePath("/", "layout");
|
||||
@@ -251,7 +344,7 @@ export async function updateEmailAction(
|
||||
if (!storedHash) {
|
||||
return {
|
||||
success: false,
|
||||
error: "FinancialAccount de credencial não encontrada.",
|
||||
error: "Conta de credencial não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -348,7 +441,7 @@ export async function deleteAccountAction(
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "FinancialAccount deletada com sucesso",
|
||||
message: "Conta deletada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
@@ -366,6 +459,75 @@ export async function deleteAccountAction(
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetAccountAction(
|
||||
data: z.infer<typeof resetAccountSchema>,
|
||||
): Promise<ActionResponse> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Não autenticado",
|
||||
};
|
||||
}
|
||||
|
||||
resetAccountSchema.parse(data);
|
||||
|
||||
const currentUser = await db.query.user.findFirst({
|
||||
columns: {
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
where: eq(schema.user.id, session.user.id),
|
||||
});
|
||||
|
||||
if (!currentUser) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Usuário não encontrado.",
|
||||
};
|
||||
}
|
||||
|
||||
await resetUserAppData(session.user.id, currentUser);
|
||||
|
||||
revalidateForEntity("accounts", session.user.id);
|
||||
revalidateForEntity("cards", session.user.id);
|
||||
revalidateForEntity("categories", session.user.id);
|
||||
revalidateForEntity("budgets", session.user.id);
|
||||
revalidateForEntity("payers", session.user.id);
|
||||
revalidateForEntity("notes", session.user.id);
|
||||
revalidateForEntity("transactions", session.user.id);
|
||||
revalidateForEntity("inbox", session.user.id);
|
||||
revalidatePath("/settings");
|
||||
revalidatePath("/insights");
|
||||
revalidatePath("/reports");
|
||||
revalidatePath("/calendar");
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta zerada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.issues[0]?.message || "Dados inválidos",
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Erro ao zerar conta:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Erro ao zerar conta. Tente novamente.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePreferencesAction(
|
||||
data: z.infer<typeof updatePreferencesSchema>,
|
||||
): Promise<ActionResponse> {
|
||||
@@ -557,7 +719,12 @@ export async function revokeApiTokenAction(
|
||||
.set({
|
||||
revokedAt: new Date(),
|
||||
})
|
||||
.where(eq(apiTokens.id, validated.tokenId));
|
||||
.where(
|
||||
and(
|
||||
eq(apiTokens.id, validated.tokenId),
|
||||
eq(apiTokens.userId, session.user.id),
|
||||
),
|
||||
);
|
||||
|
||||
revalidatePath("/settings");
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteAccountAction } from "@/features/settings/actions";
|
||||
import {
|
||||
deleteAccountAction,
|
||||
resetAccountAction,
|
||||
} from "@/features/settings/actions";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,68 +19,145 @@ import { Input } from "@/shared/components/ui/input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import { authClient } from "@/shared/lib/auth/client";
|
||||
|
||||
const RESET_CONFIRMATION = "ZERAR";
|
||||
const DELETE_CONFIRMATION = "DELETAR";
|
||||
|
||||
type DangerAction = "reset" | "delete";
|
||||
|
||||
export function DeleteAccountForm() {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [dangerAction, setDangerAction] = useState<DangerAction | null>(null);
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
|
||||
const handleDelete = () => {
|
||||
const handleAction = () => {
|
||||
if (!dangerAction) return;
|
||||
|
||||
const currentAction = dangerAction;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await deleteAccountAction({
|
||||
confirmation: confirmation as "DELETAR",
|
||||
});
|
||||
const result =
|
||||
currentAction === "reset"
|
||||
? await resetAccountAction({
|
||||
confirmation: confirmation as typeof RESET_CONFIRMATION,
|
||||
})
|
||||
: await deleteAccountAction({
|
||||
confirmation: confirmation as typeof DELETE_CONFIRMATION,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
// Fazer logout e redirecionar para página de login
|
||||
await authClient.signOut();
|
||||
router.push("/");
|
||||
|
||||
if (currentAction === "delete") {
|
||||
await authClient.signOut();
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmation("");
|
||||
setDangerAction(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenModal = () => {
|
||||
const handleOpenModal = (action: DangerAction) => {
|
||||
setConfirmation("");
|
||||
setIsModalOpen(true);
|
||||
setDangerAction(action);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
if (isPending) return;
|
||||
setConfirmation("");
|
||||
setIsModalOpen(false);
|
||||
setDangerAction(null);
|
||||
};
|
||||
|
||||
const confirmationWord =
|
||||
dangerAction === "reset" ? RESET_CONFIRMATION : DELETE_CONFIRMATION;
|
||||
const isResetAction = dangerAction === "reset";
|
||||
|
||||
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 className="rounded-lg border p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold">Zerar conta</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Apaga todos os dados do OpenMonetis e deixa sua conta no estado
|
||||
inicial, mantendo seu login e credenciais de acesso.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
<li>Lançamentos, faturas, antecipações e pré-lançamentos</li>
|
||||
<li>Contas, cartões, orçamentos e anotações</li>
|
||||
<li>Pagadores próprios e compartilhamentos recebidos</li>
|
||||
<li>
|
||||
Preferências do app, insights salvos e tokens do Companion
|
||||
</li>
|
||||
<li className="font-medium text-foreground">
|
||||
Categorias padrão e pagador admin serão recriados
|
||||
automaticamente
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenModal("reset")}
|
||||
disabled={isPending}
|
||||
className="w-fit border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
Zerar conta
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOpenModal}
|
||||
disabled={isPending}
|
||||
className="w-fit"
|
||||
>
|
||||
Deletar conta
|
||||
</Button>
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-destructive">Deletar conta</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Remove seu usuário e todos os dados associados de forma
|
||||
permanente.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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, credenciais e configurações</li>
|
||||
<li className="font-bold">
|
||||
Resumindo tudo, sua conta será permanentemente removida
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleOpenModal("delete")}
|
||||
disabled={isPending}
|
||||
className="w-fit"
|
||||
>
|
||||
Deletar conta
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<Dialog
|
||||
open={dangerAction !== null}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
handleCloseModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onEscapeKeyDown={(e) => {
|
||||
@@ -88,24 +168,28 @@ export function DeleteAccountForm() {
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Você tem certeza?</DialogTitle>
|
||||
<DialogTitle>
|
||||
{isResetAction ? "Zerar sua conta?" : "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.
|
||||
{isResetAction
|
||||
? "Essa ação não pode ser desfeita. Todos os dados do app serão apagados e sua conta voltará ao estado inicial, mas seu login continuará existindo."
|
||||
: "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.
|
||||
Para confirmar, digite <strong>{confirmationWord}</strong> no
|
||||
campo abaixo.
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmation"
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="DELETAR"
|
||||
placeholder={confirmationWord}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -122,11 +206,22 @@ export function DeleteAccountForm() {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending || confirmation !== "DELETAR"}
|
||||
variant={isResetAction ? "outline" : "destructive"}
|
||||
onClick={handleAction}
|
||||
disabled={isPending || confirmation !== confirmationWord}
|
||||
className={
|
||||
isResetAction
|
||||
? "border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isPending ? "Deletando..." : "Deletar"}
|
||||
{isPending
|
||||
? isResetAction
|
||||
? "Zerando..."
|
||||
: "Deletando..."
|
||||
: isResetAction
|
||||
? "Zerar conta"
|
||||
: "Deletar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user