mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat(settings): aba de diagnóstico e cópia de user ID no menu do usuário
- Nova aba "Diagnóstico" em Settings com: - Identidade: user ID (copiável), nome, e-mail - Sessão: criada em / expira em - Aplicação: versão, NODE_ENV, build SHA (se definido) - Configuração do servidor: S3, e-mail e domínio público — apenas booleans, sem expor credenciais - Saúde: status e latência do banco de dados - Uso: contagem de lançamentos, anexos, anotações e itens no inbox - Botão de cópia do user ID no dropdown do avatar (ao lado do e-mail) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ import { connection } from "next/server";
|
|||||||
|
|
||||||
import { CompanionTab } from "@/features/settings/components/companion-tab";
|
import { CompanionTab } from "@/features/settings/components/companion-tab";
|
||||||
import { DeleteAccountForm } from "@/features/settings/components/delete-account-form";
|
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 { PasskeysForm } from "@/features/settings/components/passkeys-form";
|
||||||
import { PreferencesForm } from "@/features/settings/components/preferences-form";
|
import { PreferencesForm } from "@/features/settings/components/preferences-form";
|
||||||
import { UpdateEmailForm } from "@/features/settings/components/update-email-form";
|
import { UpdateEmailForm } from "@/features/settings/components/update-email-form";
|
||||||
@@ -37,6 +39,19 @@ export default async function Page() {
|
|||||||
const { authProvider, userPreferences, userApiTokens } =
|
const { authProvider, userPreferences, userApiTokens } =
|
||||||
await fetchSettingsPageData(session.user.id);
|
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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Tabs defaultValue="preferencias" className="w-full">
|
<Tabs defaultValue="preferencias" className="w-full">
|
||||||
@@ -50,6 +65,7 @@ export default async function Page() {
|
|||||||
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
|
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
|
||||||
<TabsTrigger value="passkeys">Passkeys</TabsTrigger>
|
<TabsTrigger value="passkeys">Passkeys</TabsTrigger>
|
||||||
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
|
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
|
||||||
|
<TabsTrigger value="diagnostico">Diagnóstico</TabsTrigger>
|
||||||
<TabsTrigger value="deletar" className="text-destructive">
|
<TabsTrigger value="deletar" className="text-destructive">
|
||||||
Deletar conta
|
Deletar conta
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -180,13 +196,27 @@ export default async function Page() {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="diagnostico" className="mt-4">
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-1">Diagnóstico</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Informações técnicas sobre sua conta, sessão e estado do
|
||||||
|
servidor. Nenhuma credencial ou dado sensível é exibido.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<DiagnosticsTab data={diagnosticsData} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="deletar" className="mt-4">
|
<TabsContent value="deletar" className="mt-4">
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold mb-1 text-destructive">
|
<h2 className="text-xl font-semibold mb-1">Ações perigosas</h2>
|
||||||
Ações perigosas
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Você pode zerar os dados do OpenMonetis e manter seu acesso,
|
Você pode zerar os dados do OpenMonetis e manter seu acesso,
|
||||||
ou excluir sua conta inteira de forma irreversível.
|
ou excluir sua conta inteira de forma irreversível.
|
||||||
|
|||||||
201
src/features/settings/components/diagnostics-tab.tsx
Normal file
201
src/features/settings/components/diagnostics-tab.tsx
Normal file
@@ -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 (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="shrink-0 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||||
|
aria-label="Copiar"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<RiCheckLine className="size-3.5 text-success" />
|
||||||
|
) : (
|
||||||
|
<RiFileCopyLine className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{copied ? "Copiado!" : "Copiar"}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<span className="text-sm text-muted-foreground shrink-0">{label}</span>
|
||||||
|
<span className="text-sm font-medium text-right flex items-center gap-1.5 min-w-0">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<div className="divide-y">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ ok, labelOk = "Configurado", labelFail = "Não configurado" }: { ok: boolean; labelOk?: string; labelFail?: string }) {
|
||||||
|
return ok ? (
|
||||||
|
<Badge variant="outline" className="gap-1 text-success border-success/30 bg-success/5 font-medium">
|
||||||
|
<RiCheckLine className="size-3" />
|
||||||
|
{labelOk}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="gap-1 text-muted-foreground font-medium">
|
||||||
|
<RiCloseCircleLine className="size-3" />
|
||||||
|
{labelFail}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Section title="Identidade">
|
||||||
|
<Row label="User ID">
|
||||||
|
<span className="font-mono text-xs truncate">{data.identity.userId}</span>
|
||||||
|
<CopyButton value={data.identity.userId} />
|
||||||
|
</Row>
|
||||||
|
<Row label="Nome">{data.identity.name}</Row>
|
||||||
|
<Row label="E-mail">{data.identity.email}</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Section title="Sessão">
|
||||||
|
<Row label="Criada em">{formatDate(data.session.createdAt)}</Row>
|
||||||
|
<Row label="Expira em">{formatDate(data.session.expiresAt)}</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Section title="Aplicação">
|
||||||
|
<Row label="Versão">
|
||||||
|
<span className="font-mono">v{data.app.version}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="Ambiente">
|
||||||
|
<Badge variant="outline" className="font-mono font-medium">
|
||||||
|
{data.app.nodeEnv}
|
||||||
|
</Badge>
|
||||||
|
</Row>
|
||||||
|
{data.app.buildSha && (
|
||||||
|
<Row label="Build SHA">
|
||||||
|
<span className="font-mono text-xs">{data.app.buildSha.slice(0, 8)}</span>
|
||||||
|
<CopyButton value={data.app.buildSha} />
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Section title="Configuração do servidor">
|
||||||
|
<Row label="Storage S3">
|
||||||
|
<StatusBadge ok={data.server.s3Configured} />
|
||||||
|
</Row>
|
||||||
|
<Row label="E-mail (Resend)">
|
||||||
|
<StatusBadge ok={data.server.emailConfigured} />
|
||||||
|
</Row>
|
||||||
|
<Row label="Domínio público">
|
||||||
|
{data.server.publicDomain ? (
|
||||||
|
<span className="font-mono text-xs">{data.server.publicDomain}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">Não definido</span>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Section title="Saúde">
|
||||||
|
<Row label="Banco de dados">
|
||||||
|
{data.health.db === "ok" ? (
|
||||||
|
<Badge variant="outline" className="gap-1 text-success border-success/30 bg-success/5 font-medium">
|
||||||
|
<RiWifiLine className="size-3" />
|
||||||
|
Online
|
||||||
|
{data.health.dbLatencyMs !== null && (
|
||||||
|
<span className="text-muted-foreground font-normal">
|
||||||
|
· {data.health.dbLatencyMs}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="gap-1 text-destructive border-destructive/30 bg-destructive/5 font-medium">
|
||||||
|
<RiCloseCircleLine className="size-3" />
|
||||||
|
Erro
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Section title="Uso">
|
||||||
|
<Row label="Lançamentos">
|
||||||
|
<span>{data.usage.transactions.toLocaleString("pt-BR")}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="Anexos">
|
||||||
|
<span>{data.usage.attachments.toLocaleString("pt-BR")}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="Anotações">
|
||||||
|
<span>{data.usage.notes.toLocaleString("pt-BR")}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="Itens no Inbox">
|
||||||
|
<span>{data.usage.inboxItems.toLocaleString("pt-BR")}</span>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/features/settings/diagnostics-queries.ts
Normal file
96
src/features/settings/diagnostics-queries.ts
Normal file
@@ -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<DiagnosticsData> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
RiCheckLine,
|
||||||
|
RiFileCopyLine,
|
||||||
RiHistoryLine,
|
RiHistoryLine,
|
||||||
RiLogoutCircleLine,
|
RiLogoutCircleLine,
|
||||||
RiMegaphoneLine,
|
RiMegaphoneLine,
|
||||||
@@ -15,6 +17,11 @@ import { version } from "@/package.json";
|
|||||||
import { FeedbackDialogBody } from "@/shared/components/navigation/navbar/feedback-dialog";
|
import { FeedbackDialogBody } from "@/shared/components/navigation/navbar/feedback-dialog";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Dialog, DialogTrigger } from "@/shared/components/ui/dialog";
|
import { Dialog, DialogTrigger } from "@/shared/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -50,6 +57,13 @@ export function NavbarUser({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [logoutLoading, setLogoutLoading] = useState(false);
|
const [logoutLoading, setLogoutLoading] = useState(false);
|
||||||
const [feedbackOpen, setFeedbackOpen] = 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
|
const avatarSrc = pagadorAvatarUrl
|
||||||
? getAvatarSrc(pagadorAvatarUrl)
|
? getAvatarSrc(pagadorAvatarUrl)
|
||||||
@@ -106,9 +120,30 @@ export function NavbarUser({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<span className="text-sm font-medium truncate">{user.name}</span>
|
<span className="text-sm font-medium truncate">{user.name}</span>
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
{user.email}
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
</span>
|
{user.email}
|
||||||
|
</span>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyId}
|
||||||
|
className="shrink-0 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||||
|
aria-label="Copiar ID do usuário"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<RiCheckLine className="size-3 text-success" />
|
||||||
|
) : (
|
||||||
|
<RiFileCopyLine className="size-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{copied ? "Copiado!" : "Copiar ID do usuário"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user