From b2ba3efd63a29e772aec4f87ba7d505c435b22fd Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Fri, 23 Jan 2026 13:02:00 +0000 Subject: [PATCH] feat(ajustes): add API tokens management UI for OpenSheets Companion Add a new "Dispositivos" tab in settings page that allows users to: - Generate new API tokens for connecting Android devices - View connected devices with last used time and IP - Revoke tokens to disconnect devices This provides the web UI needed for users to obtain tokens for the OpenSheets Companion Android app. Co-Authored-By: Claude Opus 4.5 --- app/(dashboard)/ajustes/actions.ts | 152 ++++++++++- app/(dashboard)/ajustes/page.tsx | 37 ++- components/ajustes/api-tokens-form.tsx | 335 +++++++++++++++++++++++++ 3 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 components/ajustes/api-tokens-form.tsx diff --git a/app/(dashboard)/ajustes/actions.ts b/app/(dashboard)/ajustes/actions.ts index 920dc7a..e1bd098 100644 --- a/app/(dashboard)/ajustes/actions.ts +++ b/app/(dashboard)/ajustes/actions.ts @@ -2,12 +2,13 @@ import { auth } from "@/lib/auth/config"; import { db, schema } from "@/lib/db"; -import { pagadores } from "@/db/schema"; +import { apiTokens, pagadores } from "@/db/schema"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; -import { eq, and, ne } from "drizzle-orm"; +import { eq, and, ne, isNull } from "drizzle-orm"; import { headers } from "next/headers"; import { revalidatePath } from "next/cache"; import { z } from "zod"; +import { createHash, randomBytes } from "crypto"; type ActionResponse = { success: boolean; @@ -412,3 +413,150 @@ export async function updatePreferencesAction( }; } } + +// API Token Actions + +const createApiTokenSchema = z.object({ + name: z.string().min(1, "Nome do dispositivo é obrigatório").max(100), +}); + +const revokeApiTokenSchema = z.object({ + tokenId: z.string().uuid("ID do token inválido"), +}); + +function generateSecureToken(): string { + const prefix = "os"; + const randomPart = randomBytes(32).toString("base64url"); + return `${prefix}_${randomPart}`; +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export async function createApiTokenAction( + data: z.infer +): Promise> { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return { + success: false, + error: "Não autenticado", + }; + } + + const validated = createApiTokenSchema.parse(data); + + // Generate token + const token = generateSecureToken(); + const tokenHash = hashToken(token); + const tokenPrefix = token.substring(0, 10); + + // Save to database + const [newToken] = await db + .insert(apiTokens) + .values({ + userId: session.user.id, + name: validated.name, + tokenHash, + tokenPrefix, + expiresAt: null, // No expiration for now + }) + .returning({ id: apiTokens.id }); + + revalidatePath("/ajustes"); + + return { + success: true, + message: "Token criado com sucesso", + data: { + token, + tokenId: newToken.id, + }, + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message || "Dados inválidos", + }; + } + + console.error("Erro ao criar token:", error); + return { + success: false, + error: "Erro ao criar token. Tente novamente.", + }; + } +} + +export async function revokeApiTokenAction( + data: z.infer +): Promise { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return { + success: false, + error: "Não autenticado", + }; + } + + const validated = revokeApiTokenSchema.parse(data); + + // Find token and verify ownership + const [existingToken] = await db + .select() + .from(apiTokens) + .where( + and( + eq(apiTokens.id, validated.tokenId), + eq(apiTokens.userId, session.user.id), + isNull(apiTokens.revokedAt) + ) + ) + .limit(1); + + if (!existingToken) { + return { + success: false, + error: "Token não encontrado", + }; + } + + // Revoke token + await db + .update(apiTokens) + .set({ + revokedAt: new Date(), + }) + .where(eq(apiTokens.id, validated.tokenId)); + + revalidatePath("/ajustes"); + + return { + success: true, + message: "Token revogado com sucesso", + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message || "Dados inválidos", + }; + } + + console.error("Erro ao revogar token:", error); + return { + success: false, + error: "Erro ao revogar token. Tente novamente.", + }; + } +} diff --git a/app/(dashboard)/ajustes/page.tsx b/app/(dashboard)/ajustes/page.tsx index a72b52f..f15b2d2 100644 --- a/app/(dashboard)/ajustes/page.tsx +++ b/app/(dashboard)/ajustes/page.tsx @@ -1,3 +1,4 @@ +import { ApiTokensForm } from "@/components/ajustes/api-tokens-form"; import { DeleteAccountForm } from "@/components/ajustes/delete-account-form"; import { UpdateEmailForm } from "@/components/ajustes/update-email-form"; import { UpdateNameForm } from "@/components/ajustes/update-name-form"; @@ -7,7 +8,8 @@ import { Card } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { auth } from "@/lib/auth/config"; import { db, schema } from "@/lib/db"; -import { eq } from "drizzle-orm"; +import { apiTokens } from "@/db/schema"; +import { eq, desc } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; @@ -42,11 +44,28 @@ export default async function Page() { // Se o providerId for "google", o usuário usa Google OAuth const authProvider = userAccount?.providerId || "credential"; + // Buscar tokens de API do usuário + const userApiTokens = await db + .select({ + id: apiTokens.id, + name: apiTokens.name, + tokenPrefix: apiTokens.tokenPrefix, + lastUsedAt: apiTokens.lastUsedAt, + lastUsedIp: apiTokens.lastUsedIp, + createdAt: apiTokens.createdAt, + expiresAt: apiTokens.expiresAt, + revokedAt: apiTokens.revokedAt, + }) + .from(apiTokens) + .where(eq(apiTokens.userId, session.user.id)) + .orderBy(desc(apiTokens.createdAt)); + return (
Preferências + Dispositivos Alterar nome Alterar senha Alterar e-mail @@ -74,6 +93,22 @@ export default async function Page() { + + +
+
+

OpenSheets Companion

+

+ Conecte o app Android OpenSheets Companion para capturar + automaticamente notificações de transações financeiras e + enviá-las para sua caixa de entrada. +

+
+ +
+
+
+
diff --git a/components/ajustes/api-tokens-form.tsx b/components/ajustes/api-tokens-form.tsx new file mode 100644 index 0000000..c540d4d --- /dev/null +++ b/components/ajustes/api-tokens-form.tsx @@ -0,0 +1,335 @@ +"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, +} from "@remixicon/react"; +import { formatDistanceToNow } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { createApiTokenAction, revokeApiTokenAction } from "@/app/(dashboard)/ajustes/actions"; + +interface ApiToken { + id: string; + name: string; + tokenPrefix: string; + lastUsedAt: Date | null; + lastUsedIp: string | null; + createdAt: Date; + expiresAt: Date | null; + revokedAt: Date | null; +} + +interface ApiTokensFormProps { + tokens: ApiToken[]; +} + +export function ApiTokensForm({ tokens }: ApiTokensFormProps) { + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [tokenName, setTokenName] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [newToken, setNewToken] = useState(null); + const [copied, setCopied] = useState(false); + const [revokeId, setRevokeId] = useState(null); + const [isRevoking, setIsRevoking] = useState(false); + const [error, setError] = useState(null); + + const activeTokens = tokens.filter((t) => !t.revokedAt); + + const handleCreate = async () => { + if (!tokenName.trim()) return; + + setIsCreating(true); + setError(null); + + try { + const result = await createApiTokenAction({ name: tokenName.trim() }); + + if (result.success && result.data?.token) { + setNewToken(result.data.token); + setTokenName(""); + } else { + setError(result.error || "Erro ao criar token"); + } + } catch { + setError("Erro ao criar token"); + } finally { + setIsCreating(false); + } + }; + + const handleCopy = async () => { + if (!newToken) return; + + try { + await navigator.clipboard.writeText(newToken); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for browsers that don't support clipboard API + const textArea = document.createElement("textarea"); + textArea.value = newToken; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleRevoke = async () => { + if (!revokeId) return; + + setIsRevoking(true); + + try { + const result = await revokeApiTokenAction({ tokenId: revokeId }); + + if (!result.success) { + setError(result.error || "Erro ao revogar token"); + } + } catch { + setError("Erro ao revogar token"); + } finally { + setIsRevoking(false); + setRevokeId(null); + } + }; + + const handleCloseCreate = () => { + setIsCreateOpen(false); + setNewToken(null); + setTokenName(""); + setError(null); + }; + + return ( +
+
+
+

Dispositivos conectados

+

+ Gerencie os dispositivos que podem enviar notificações para o OpenSheets. +

+
+ { + if (!open) handleCloseCreate(); + else setIsCreateOpen(true); + }}> + + + + + {!newToken ? ( + <> + + Criar Token de API + + Crie um token para conectar o OpenSheets Companion no seu dispositivo Android. + + +
+
+ + setTokenName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + /> +
+ {error && ( +
+ + {error} +
+ )} +
+ + + + + + ) : ( + <> + + Token Criado + + Copie o token abaixo e cole no app OpenSheets Companion. Este token + não será exibido novamente. + + +
+
+ +
+ + +
+
+
+

Importante:

+
    +
  • Guarde este token em local seguro
  • +
  • Ele não será exibido novamente
  • +
  • Use-o para configurar o app Android
  • +
+
+
+ + + + + )} +
+
+
+ + {activeTokens.length === 0 ? ( + + + +

+ Nenhum dispositivo conectado. +

+

+ Crie um token para conectar o app OpenSheets Companion. +

+
+
+ ) : ( +
+ {activeTokens.map((token) => ( + + +
+
+
+ +
+
+
+ {token.name} + + {token.tokenPrefix}... + +
+
+ {token.lastUsedAt ? ( + + Usado{" "} + {formatDistanceToNow(token.lastUsedAt, { + addSuffix: true, + locale: ptBR, + })} + {token.lastUsedIp && ( + + ({token.lastUsedIp}) + + )} + + ) : ( + Nunca usado + )} +
+
+ Criado em{" "} + {new Date(token.createdAt).toLocaleDateString("pt-BR")} +
+
+
+ +
+
+
+ ))} +
+ )} + + {/* Revoke Confirmation Dialog */} + !open && setRevokeId(null)}> + + + Revogar token? + + O dispositivo associado a este token será desconectado e não poderá mais + enviar notificações. Esta ação não pode ser desfeita. + + + + Cancelar + + {isRevoking ? "Revogando..." : "Revogar"} + + + + +
+ ); +}