diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 5b1d9a0..d8e8b72 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -5,6 +5,8 @@ import { connection } from "next/server"; import { CompanionTab } from "@/features/settings/components/companion-tab"; import { DeleteAccountForm } from "@/features/settings/components/delete-account-form"; +import { DiagnosticsTab } from "@/features/settings/components/diagnostics-tab"; +import { fetchDiagnosticsData } from "@/features/settings/diagnostics-queries"; import { PasskeysForm } from "@/features/settings/components/passkeys-form"; import { PreferencesForm } from "@/features/settings/components/preferences-form"; import { UpdateEmailForm } from "@/features/settings/components/update-email-form"; @@ -37,6 +39,19 @@ export default async function Page() { const { authProvider, userPreferences, userApiTokens } = await fetchSettingsPageData(session.user.id); + const diagnosticsData = await fetchDiagnosticsData( + session.user.id, + { + id: session.user.id, + name: session.user.name ?? "", + email: session.user.email ?? "", + }, + { + createdAt: session.session.createdAt, + expiresAt: session.session.expiresAt, + }, + ); + return (
@@ -50,6 +65,7 @@ export default async function Page() { Alterar senha Passkeys Alterar e-mail + Diagnóstico Deletar conta @@ -180,13 +196,27 @@ export default async function Page() { + + +
+
+

Diagnóstico

+

+ Informações técnicas sobre sua conta, sessão e estado do + servidor. Nenhuma credencial ou dado sensível é exibido. +

+
+ + +
+
+
+
-

- Ações perigosas -

+

Ações perigosas

Você pode zerar os dados do OpenMonetis e manter seu acesso, ou excluir sua conta inteira de forma irreversível. diff --git a/src/features/settings/components/diagnostics-tab.tsx b/src/features/settings/components/diagnostics-tab.tsx new file mode 100644 index 0000000..d62a4d9 --- /dev/null +++ b/src/features/settings/components/diagnostics-tab.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { + RiCheckLine, + RiCloseCircleLine, + RiFileCopyLine, + RiWifiLine, +} from "@remixicon/react"; +import { useState } from "react"; +import { Badge } from "@/shared/components/ui/badge"; +import { Separator } from "@/shared/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; +import type { DiagnosticsData } from "../diagnostics-queries"; + +function CopyButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + + function handleCopy() { + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + + + + + {copied ? "Copiado!" : "Copiar"} + + ); +} + +function Row({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +

+ {label} + + {children} + +
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function StatusBadge({ ok, labelOk = "Configurado", labelFail = "Não configurado" }: { ok: boolean; labelOk?: string; labelFail?: string }) { + return ok ? ( + + + {labelOk} + + ) : ( + + + {labelFail} + + ); +} + +function formatDate(date: Date) { + return new Intl.DateTimeFormat("pt-BR", { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(date)); +} + +export function DiagnosticsTab({ data }: { data: DiagnosticsData }) { + return ( +
+
+ + {data.identity.userId} + + + {data.identity.name} + {data.identity.email} +
+ + + +
+ {formatDate(data.session.createdAt)} + {formatDate(data.session.expiresAt)} +
+ + + +
+ + v{data.app.version} + + + + {data.app.nodeEnv} + + + {data.app.buildSha && ( + + {data.app.buildSha.slice(0, 8)} + + + )} +
+ + + +
+ + + + + + + + {data.server.publicDomain ? ( + {data.server.publicDomain} + ) : ( + Não definido + )} + +
+ + + +
+ + {data.health.db === "ok" ? ( + + + Online + {data.health.dbLatencyMs !== null && ( + + · {data.health.dbLatencyMs}ms + + )} + + ) : ( + + + Erro + + )} + +
+ + + +
+ + {data.usage.transactions.toLocaleString("pt-BR")} + + + {data.usage.attachments.toLocaleString("pt-BR")} + + + {data.usage.notes.toLocaleString("pt-BR")} + + + {data.usage.inboxItems.toLocaleString("pt-BR")} + +
+
+ ); +} diff --git a/src/features/settings/diagnostics-queries.ts b/src/features/settings/diagnostics-queries.ts new file mode 100644 index 0000000..3faa0a5 --- /dev/null +++ b/src/features/settings/diagnostics-queries.ts @@ -0,0 +1,96 @@ +import { count, eq } from "drizzle-orm"; +import { + attachments, + inboxItems, + notes, + transactions, +} from "@/db/schema"; +import { db } from "@/shared/lib/db"; + +export type DiagnosticsData = { + identity: { + userId: string; + name: string; + email: string; + }; + session: { + createdAt: Date; + expiresAt: Date; + }; + app: { + version: string; + nodeEnv: string; + buildSha: string | null; + }; + server: { + s3Configured: boolean; + emailConfigured: boolean; + publicDomain: string | null; + }; + health: { + db: "ok" | "error"; + dbLatencyMs: number | null; + }; + usage: { + transactions: number; + attachments: number; + notes: number; + inboxItems: number; + }; +}; + +export async function fetchDiagnosticsData( + userId: string, + user: { id: string; name: string; email: string }, + session: { createdAt: Date; expiresAt: Date }, +): Promise { + const dbStart = Date.now(); + let dbStatus: "ok" | "error" = "ok"; + let dbLatencyMs: number | null = null; + + try { + await db.execute("SELECT 1"); + dbLatencyMs = Date.now() - dbStart; + } catch { + dbStatus = "error"; + } + + const [txCount, attachCount, notesCount, inboxCount] = await Promise.all([ + db.select({ value: count() }).from(transactions).where(eq(transactions.userId, userId)), + db.select({ value: count() }).from(attachments).where(eq(attachments.userId, userId)), + db.select({ value: count() }).from(notes).where(eq(notes.userId, userId)), + db.select({ value: count() }).from(inboxItems).where(eq(inboxItems.userId, userId)), + ]); + + return { + identity: { + userId: user.id, + name: user.name, + email: user.email, + }, + session: { + createdAt: session.createdAt, + expiresAt: session.expiresAt, + }, + app: { + version: process.env.npm_package_version ?? "—", + nodeEnv: process.env.NODE_ENV ?? "—", + buildSha: process.env.BUILD_SHA ?? null, + }, + server: { + s3Configured: !!(process.env.S3_ENDPOINT && process.env.S3_ACCESS_KEY_ID), + emailConfigured: !!process.env.RESEND_FROM_EMAIL, + publicDomain: process.env.PUBLIC_DOMAIN ?? null, + }, + health: { + db: dbStatus, + dbLatencyMs, + }, + usage: { + transactions: txCount[0]?.value ?? 0, + attachments: attachCount[0]?.value ?? 0, + notes: notesCount[0]?.value ?? 0, + inboxItems: inboxCount[0]?.value ?? 0, + }, + }; +} diff --git a/src/shared/components/navigation/navbar/navbar-user.tsx b/src/shared/components/navigation/navbar/navbar-user.tsx index cbfd8a4..47cfd1d 100644 --- a/src/shared/components/navigation/navbar/navbar-user.tsx +++ b/src/shared/components/navigation/navbar/navbar-user.tsx @@ -1,6 +1,8 @@ "use client"; import { + RiCheckLine, + RiFileCopyLine, RiHistoryLine, RiLogoutCircleLine, RiMegaphoneLine, @@ -15,6 +17,11 @@ import { version } from "@/package.json"; import { FeedbackDialogBody } from "@/shared/components/navigation/navbar/feedback-dialog"; import { Badge } from "@/shared/components/ui/badge"; import { Dialog, DialogTrigger } from "@/shared/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; import { DropdownMenu, DropdownMenuContent, @@ -50,6 +57,13 @@ export function NavbarUser({ const router = useRouter(); const [logoutLoading, setLogoutLoading] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false); + const [copied, setCopied] = useState(false); + + function handleCopyId() { + navigator.clipboard.writeText(user.id); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } const avatarSrc = pagadorAvatarUrl ? getAvatarSrc(pagadorAvatarUrl) @@ -106,9 +120,30 @@ export function NavbarUser({
{user.name} - - {user.email} - +
+ + {user.email} + + + + + + + {copied ? "Copiado!" : "Copiar ID do usuário"} + + +