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:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

@@ -4,9 +4,10 @@
* Handles JWT generation, validation, and token hashing for device authentication.
*/
import crypto from "crypto";
import crypto from "node:crypto";
const JWT_SECRET = process.env.BETTER_AUTH_SECRET || "opensheets-secret-change-me";
const JWT_SECRET =
process.env.BETTER_AUTH_SECRET || "opensheets-secret-change-me";
const ACCESS_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds
const REFRESH_TOKEN_EXPIRY = 90 * 24 * 60 * 60; // 90 days in seconds
@@ -15,19 +16,19 @@ const REFRESH_TOKEN_EXPIRY = 90 * 24 * 60 * 60; // 90 days in seconds
// ============================================================================
export interface JwtPayload {
sub: string; // userId
type: "api_access" | "api_refresh";
tokenId: string;
deviceId?: string;
iat: number;
exp: number;
sub: string; // userId
type: "api_access" | "api_refresh";
tokenId: string;
deviceId?: string;
iat: number;
exp: number;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
tokenId: string;
expiresAt: Date;
accessToken: string;
refreshToken: string;
tokenId: string;
expiresAt: Date;
}
// ============================================================================
@@ -38,56 +39,59 @@ export interface TokenPair {
* Base64URL encode a string
*/
function base64UrlEncode(str: string): string {
return Buffer.from(str)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return Buffer.from(str)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Base64URL decode a string
*/
function base64UrlDecode(str: string): string {
str = str.replace(/-/g, "+").replace(/_/g, "/");
const pad = str.length % 4;
if (pad) {
str += "=".repeat(4 - pad);
}
return Buffer.from(str, "base64").toString();
str = str.replace(/-/g, "+").replace(/_/g, "/");
const pad = str.length % 4;
if (pad) {
str += "=".repeat(4 - pad);
}
return Buffer.from(str, "base64").toString();
}
/**
* Create HMAC-SHA256 signature
*/
function createSignature(data: string): string {
return crypto
.createHmac("sha256", JWT_SECRET)
.update(data)
.digest("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return crypto
.createHmac("sha256", JWT_SECRET)
.update(data)
.digest("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Create a JWT token
*/
export function createJwt(payload: Omit<JwtPayload, "iat" | "exp">, expiresIn: number): string {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
export function createJwt(
payload: Omit<JwtPayload, "iat" | "exp">,
expiresIn: number,
): string {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const fullPayload: JwtPayload = {
...payload,
iat: now,
exp: now + expiresIn,
};
const fullPayload: JwtPayload = {
...payload,
iat: now,
exp: now + expiresIn,
};
const headerEncoded = base64UrlEncode(JSON.stringify(header));
const payloadEncoded = base64UrlEncode(JSON.stringify(fullPayload));
const signature = createSignature(`${headerEncoded}.${payloadEncoded}`);
const headerEncoded = base64UrlEncode(JSON.stringify(header));
const payloadEncoded = base64UrlEncode(JSON.stringify(fullPayload));
const signature = createSignature(`${headerEncoded}.${payloadEncoded}`);
return `${headerEncoded}.${payloadEncoded}.${signature}`;
return `${headerEncoded}.${payloadEncoded}.${signature}`;
}
/**
@@ -95,30 +99,37 @@ export function createJwt(payload: Omit<JwtPayload, "iat" | "exp">, expiresIn: n
* @returns The decoded payload or null if invalid
*/
export function verifyJwt(token: string): JwtPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerEncoded, payloadEncoded, signature] = parts;
const expectedSignature = createSignature(`${headerEncoded}.${payloadEncoded}`);
const [headerEncoded, payloadEncoded, signature] = parts;
const expectedSignature = createSignature(
`${headerEncoded}.${payloadEncoded}`,
);
// Constant-time comparison to prevent timing attacks
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
return null;
}
// Constant-time comparison to prevent timing attacks
if (
!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
)
) {
return null;
}
const payload: JwtPayload = JSON.parse(base64UrlDecode(payloadEncoded));
const payload: JwtPayload = JSON.parse(base64UrlDecode(payloadEncoded));
// Check expiration
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
return null;
}
// Check expiration
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
return null;
}
return payload;
} catch {
return null;
}
return payload;
} catch {
return null;
}
}
// ============================================================================
@@ -129,76 +140,86 @@ export function verifyJwt(token: string): JwtPayload | null {
* Generate a random token ID
*/
export function generateTokenId(): string {
return crypto.randomUUID();
return crypto.randomUUID();
}
/**
* Generate a random API token with prefix
*/
export function generateApiToken(): string {
const randomPart = crypto.randomBytes(32).toString("base64url");
return `os_${randomPart}`;
const randomPart = crypto.randomBytes(32).toString("base64url");
return `os_${randomPart}`;
}
/**
* Hash a token using SHA-256
*/
export function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
return crypto.createHash("sha256").update(token).digest("hex");
}
/**
* Get the display prefix of a token (first 8 chars after prefix)
*/
export function getTokenPrefix(token: string): string {
// Remove "os_" prefix and get first 8 chars
const withoutPrefix = token.replace(/^os_/, "");
return `os_${withoutPrefix.substring(0, 8)}...`;
// Remove "os_" prefix and get first 8 chars
const withoutPrefix = token.replace(/^os_/, "");
return `os_${withoutPrefix.substring(0, 8)}...`;
}
/**
* Generate a complete token pair (access + refresh)
*/
export function generateTokenPair(userId: string, deviceId?: string): TokenPair {
const tokenId = generateTokenId();
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
export function generateTokenPair(
userId: string,
deviceId?: string,
): TokenPair {
const tokenId = generateTokenId();
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const accessToken = createJwt(
{ sub: userId, type: "api_access", tokenId, deviceId },
ACCESS_TOKEN_EXPIRY
);
const accessToken = createJwt(
{ sub: userId, type: "api_access", tokenId, deviceId },
ACCESS_TOKEN_EXPIRY,
);
const refreshToken = createJwt(
{ sub: userId, type: "api_refresh", tokenId, deviceId },
REFRESH_TOKEN_EXPIRY
);
const refreshToken = createJwt(
{ sub: userId, type: "api_refresh", tokenId, deviceId },
REFRESH_TOKEN_EXPIRY,
);
return {
accessToken,
refreshToken,
tokenId,
expiresAt,
};
return {
accessToken,
refreshToken,
tokenId,
expiresAt,
};
}
/**
* Refresh an access token using a refresh token
*/
export function refreshAccessToken(refreshToken: string): { accessToken: string; expiresAt: Date } | null {
const payload = verifyJwt(refreshToken);
export function refreshAccessToken(
refreshToken: string,
): { accessToken: string; expiresAt: Date } | null {
const payload = verifyJwt(refreshToken);
if (!payload || payload.type !== "api_refresh") {
return null;
}
if (!payload || payload.type !== "api_refresh") {
return null;
}
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const accessToken = createJwt(
{ sub: payload.sub, type: "api_access", tokenId: payload.tokenId, deviceId: payload.deviceId },
ACCESS_TOKEN_EXPIRY
);
const accessToken = createJwt(
{
sub: payload.sub,
type: "api_access",
tokenId: payload.tokenId,
deviceId: payload.deviceId,
},
ACCESS_TOKEN_EXPIRY,
);
return { accessToken, expiresAt };
return { accessToken, expiresAt };
}
// ============================================================================
@@ -209,9 +230,9 @@ export function refreshAccessToken(refreshToken: string): { accessToken: string;
* Extract bearer token from Authorization header
*/
export function extractBearerToken(authHeader: string | null): string | null {
if (!authHeader) return null;
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match ? match[1] : null;
if (!authHeader) return null;
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match ? match[1] : null;
}
/**
@@ -219,20 +240,23 @@ export function extractBearerToken(authHeader: string | null): string | null {
* @deprecated Use validateHashToken for os_xxx tokens
*/
export function validateApiToken(token: string): JwtPayload | null {
const payload = verifyJwt(token);
if (!payload || payload.type !== "api_access") {
return null;
}
return payload;
const payload = verifyJwt(token);
if (!payload || payload.type !== "api_access") {
return null;
}
return payload;
}
/**
* Validate a hash-based API token (os_xxx format)
* Returns the token hash for database lookup
*/
export function validateHashToken(token: string): { valid: boolean; tokenHash?: string } {
if (!token || !token.startsWith("os_")) {
return { valid: false };
}
return { valid: true, tokenHash: hashToken(token) };
export function validateHashToken(token: string): {
valid: boolean;
tokenHash?: string;
} {
if (!token || !token.startsWith("os_")) {
return { valid: false };
}
return { valid: true, tokenHash: hashToken(token) };
}

View File

@@ -3,7 +3,7 @@ import { createAuthClient } from "better-auth/react";
const baseURL = process.env.BETTER_AUTH_URL?.replace(/\/$/, "");
export const authClient = createAuthClient({
...(baseURL ? { baseURL } : {}),
...(baseURL ? { baseURL } : {}),
});
/**

View File

@@ -5,13 +5,13 @@
* Suporta email/password e Google OAuth.
*/
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { GoogleProfile } from "better-auth/social-providers";
import { seedDefaultCategoriesForUser } from "@/lib/categorias/defaults";
import { db, schema } from "@/lib/db";
import { ensureDefaultPagadorForUser } from "@/lib/pagadores/defaults";
import { normalizeNameFromEmail } from "@/lib/pagadores/utils";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { GoogleProfile } from "better-auth/social-providers";
// ============================================================================
// GOOGLE OAUTH CONFIGURATION
@@ -28,20 +28,20 @@ const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
* 4. "Usuário" (fallback final)
*/
function getNameFromGoogleProfile(profile: GoogleProfile): string {
const fullName = profile.name?.trim();
if (fullName) return fullName;
const fullName = profile.name?.trim();
if (fullName) return fullName;
const fromGivenFamily = [profile.given_name, profile.family_name]
.filter(Boolean)
.join(" ")
.trim();
if (fromGivenFamily) return fromGivenFamily;
const fromGivenFamily = [profile.given_name, profile.family_name]
.filter(Boolean)
.join(" ")
.trim();
if (fromGivenFamily) return fromGivenFamily;
const fromEmail = profile.email
? normalizeNameFromEmail(profile.email)
: undefined;
const fromEmail = profile.email
? normalizeNameFromEmail(profile.email)
: undefined;
return fromEmail ?? "Usuário";
return fromEmail ?? "Usuário";
}
// ============================================================================
@@ -49,99 +49,99 @@ function getNameFromGoogleProfile(profile: GoogleProfile): string {
// ============================================================================
export const auth = betterAuth({
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Trust host configuration for production environments
trustedOrigins: process.env.BETTER_AUTH_URL
? [process.env.BETTER_AUTH_URL]
: [],
// Trust host configuration for production environments
trustedOrigins: process.env.BETTER_AUTH_URL
? [process.env.BETTER_AUTH_URL]
: [],
// Email/Password authentication
emailAndPassword: {
enabled: true,
autoSignIn: true,
},
// Email/Password authentication
emailAndPassword: {
enabled: true,
autoSignIn: true,
},
// Database adapter (Drizzle + PostgreSQL)
database: drizzleAdapter(db, {
provider: "pg",
schema,
camelCase: true,
}),
// Database adapter (Drizzle + PostgreSQL)
database: drizzleAdapter(db, {
provider: "pg",
schema,
camelCase: true,
}),
// Session configuration - Safari compatibility
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
},
},
// Session configuration - Safari compatibility
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
},
},
// Advanced configuration for Safari compatibility
advanced: {
cookieOptions: {
sameSite: "lax", // Safari compatible
secure: process.env.NODE_ENV === "production", // HTTPS in production only
httpOnly: true,
},
crossSubDomainCookies: {
enabled: false, // Disable for better Safari compatibility
},
},
// Advanced configuration for Safari compatibility
advanced: {
cookieOptions: {
sameSite: "lax", // Safari compatible
secure: process.env.NODE_ENV === "production", // HTTPS in production only
httpOnly: true,
},
crossSubDomainCookies: {
enabled: false, // Disable for better Safari compatibility
},
},
// Google OAuth (se configurado)
socialProviders:
googleClientId && googleClientSecret
? {
google: {
clientId: googleClientId,
clientSecret: googleClientSecret,
mapProfileToUser: (profile) => ({
name: getNameFromGoogleProfile(profile),
email: profile.email,
image: profile.picture,
emailVerified: profile.email_verified,
}),
},
}
: undefined,
// Google OAuth (se configurado)
socialProviders:
googleClientId && googleClientSecret
? {
google: {
clientId: googleClientId,
clientSecret: googleClientSecret,
mapProfileToUser: (profile) => ({
name: getNameFromGoogleProfile(profile),
email: profile.email,
image: profile.picture,
emailVerified: profile.email_verified,
}),
},
}
: undefined,
// Database hooks - Executados após eventos do DB
databaseHooks: {
user: {
create: {
/**
* Após criar novo usuário, inicializa:
* 1. Categorias padrão (Receitas/Despesas)
* 2. Pagador padrão (vinculado ao usuário)
*/
after: async (user) => {
// Se falhar aqui, o usuário já foi criado - considere usar queue para retry
try {
await seedDefaultCategoriesForUser(user.id);
await ensureDefaultPagadorForUser({
id: user.id,
name: user.name ?? undefined,
email: user.email ?? undefined,
image: user.image ?? undefined,
});
} catch (error) {
console.error(
"[Auth] Falha ao criar dados padrão do usuário:",
error
);
// TODO: Considere enfileirar para retry ou notificar admin
}
},
},
},
},
// Database hooks - Executados após eventos do DB
databaseHooks: {
user: {
create: {
/**
* Após criar novo usuário, inicializa:
* 1. Categorias padrão (Receitas/Despesas)
* 2. Pagador padrão (vinculado ao usuário)
*/
after: async (user) => {
// Se falhar aqui, o usuário já foi criado - considere usar queue para retry
try {
await seedDefaultCategoriesForUser(user.id);
await ensureDefaultPagadorForUser({
id: user.id,
name: user.name ?? undefined,
email: user.email ?? undefined,
image: user.image ?? undefined,
});
} catch (error) {
console.error(
"[Auth] Falha ao criar dados padrão do usuário:",
error,
);
// TODO: Considere enfileirar para retry ou notificar admin
}
},
},
},
},
});
// Aviso em desenvolvimento se Google OAuth não estiver configurado
if (!googleClientId && process.env.NODE_ENV === "development") {
console.warn(
"[Auth] Google OAuth não configurado. Defina GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET."
);
console.warn(
"[Auth] Google OAuth não configurado. Defina GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET.",
);
}

View File

@@ -10,9 +10,9 @@
* to /login if the user is not authenticated.
*/
import { auth } from "@/lib/auth/config";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/config";
/**
* Gets the current authenticated user
@@ -20,13 +20,13 @@ import { redirect } from "next/navigation";
* @throws Redirects to /login if user is not authenticated
*/
export async function getUser() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect("/login");
}
if (!session?.user) {
redirect("/login");
}
return session.user;
return session.user;
}
/**
@@ -35,13 +35,13 @@ export async function getUser() {
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserId() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect("/login");
}
if (!session?.user) {
redirect("/login");
}
return session.user.id;
return session.user.id;
}
/**
@@ -50,13 +50,13 @@ export async function getUserId() {
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserSession() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect("/login");
}
if (!session?.user) {
redirect("/login");
}
return session;
return session;
}
/**
@@ -65,5 +65,5 @@ export async function getUserSession() {
* @note This function does not redirect if user is not authenticated
*/
export async function getOptionalUserSession() {
return auth.api.getSession({ headers: await headers() });
return auth.api.getSession({ headers: await headers() });
}