feat: add user authentication UI to header component

Add login and register dialogs, user dropdown menu with profile link and logout option, and integrate with session management hook. Prevent hydration mismatches by conditionally rendering auth elements after mount. Update package-lock.json with new @esbuild dependencies for build tooling.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-04 02:33:29 -03:00
1 parent e641138887
commit 55eebde0ba
55 files changed
+7353 -7

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
import { NextRequest, NextResponse } from "next/server";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
import { loginSchema } from "@/lib/validation-schemas";
import { comparePassword } from "@/lib/auth/password";
import {
generateAccessToken,
generateRefreshToken,
} from "@/lib/auth/jwt";
import {
getUserByEmail,
getUserByUsername,
updateLastLogin,
} from "@/lib/db/queries/users";
import { setSession } from "@/lib/redis/session";
import { randomUUID } from "crypto";
export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = getClientIP(request);
const rateLimit = checkRateLimit(ip, "/api/auth/login");
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit.resetAt);
}
const body = await request.json();
secureLogger.info("📥 POST /api/auth/login - Recebido", {
ip,
timestamp: new Date().toISOString(),
});
// Validação com Zod
const validationResult = loginSchema.safeParse(body);
if (!validationResult.success) {
const errors = validationResult.error.issues.map((err) => ({
field: err.path.join("."),
message: err.message,
}));
return NextResponse.json(
{
success: false,
error: "Dados de entrada inválidos",
details: errors,
},
{ status: 400 }
);
}
const { emailOrUsername, password } = validationResult.data;
// Buscar usuário por email ou username
const user =
(await getUserByEmail(emailOrUsername)) ||
(await getUserByUsername(emailOrUsername));
if (!user) {
secureLogger.warn("⚠️ Tentativa de login com credenciais inválidas", {
emailOrUsername: secureLogger.mask({ emailOrUsername }),
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{
success: false,
error: "Email/username ou senha incorretos",
},
{ status: 401 }
);
}
// Verificar se usuário está ativo
if (!user.is_active) {
return NextResponse.json(
{
success: false,
error: "Conta desativada. Entre em contato com o suporte.",
},
{ status: 403 }
);
}
// Verificar senha
if (!user.password_hash) {
secureLogger.error("❌ Usuário sem password_hash", {
userId: user.user_id,
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{
success: false,
error: "Erro interno. Tente novamente mais tarde.",
},
{ status: 500 }
);
}
const passwordMatch = await comparePassword(password, user.password_hash);
if (!passwordMatch) {
secureLogger.warn("⚠️ Senha incorreta", {
userId: user.user_id,
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{
success: false,
error: "Email/username ou senha incorretos",
},
{ status: 401 }
);
}
// Atualizar último login
await updateLastLogin(user.user_id);
// Gerar tokens
const accessToken = generateAccessToken(user.user_id, user.username);
const refreshToken = generateRefreshToken(user.user_id, user.username);
// Criar sessão no Redis
const sessionId = randomUUID();
await setSession(sessionId, {
userId: user.user_id,
username: user.username,
email: user.email,
accessToken,
createdAt: Date.now(),
});
// Criar resposta
const response = NextResponse.json(
{
success: true,
user: {
userId: user.user_id,
username: user.username,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
accountLevel: user.account_level,
hasDonated: user.has_donated,
},
accessToken,
},
{ status: 200 }
);
// Adicionar refresh token e sessionId como cookies httpOnly
response.cookies.set("refreshToken", refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 dias
path: "/",
});
response.cookies.set("sessionId", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 dias
path: "/",
});
secureLogger.info("✅ Login realizado com sucesso", {
userId: user.user_id,
username: user.username,
timestamp: new Date().toISOString(),
});
return response;
} catch (error) {
secureLogger.error("❌ Erro ao fazer login", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
);
}
}
+92
View File
@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import { secureLogger } from "@/lib/secure-logger";
import { requireAuth } from "@/lib/auth/middleware";
import { getUserById } from "@/lib/db/queries/users";
import { getUserCharacters } from "@/lib/db/queries/characters";
export async function GET(request: NextRequest) {
try {
// Middleware de autenticação
const authResult = await requireAuth(request);
// Se retornou NextResponse, é um erro de autenticação
if (authResult instanceof NextResponse) {
return authResult;
}
const auth = authResult;
// Buscar dados do usuário
const user = await getUserById(auth.userId);
if (!user) {
return NextResponse.json(
{
success: false,
error: "Usuário não encontrado",
},
{ status: 404 }
);
}
// Buscar personagens do usuário
const characters = await getUserCharacters(auth.userId);
secureLogger.info("✅ Perfil do usuário recuperado", {
userId: auth.userId,
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{
success: true,
user: {
userId: user.user_id,
username: user.username,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
cpf: user.cpf,
accountLevel: user.account_level,
hasDonated: user.has_donated,
whatsappNotifications: user.whatsapp_notifications,
createdAt: user.created_at,
updatedAt: user.updated_at,
lastLogin: user.last_login,
isActive: user.is_active,
isVerified: user.is_verified,
},
characters: characters.map((char) => ({
characterId: char.character_id,
characterName: char.character_name,
serverName: char.server_name,
isMain: char.is_main,
verified: char.verified,
createdAt: char.created_at,
})),
},
{ status: 200 }
);
} catch (error) {
// Se for NextResponse (erro de autenticação), re-lançar
if (error instanceof NextResponse) {
return error;
}
secureLogger.error("❌ Erro ao buscar perfil do usuário", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
);
}
}
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
import { verifyRefreshToken, generateAccessToken } from "@/lib/auth/jwt";
export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = getClientIP(request);
const rateLimit = checkRateLimit(ip, "/api/auth/refresh");
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit.resetAt);
}
// Extrair refresh token do cookie
const refreshToken = request.cookies.get("refreshToken")?.value;
if (!refreshToken) {
return NextResponse.json(
{
success: false,
error: "Refresh token não fornecido",
},
{ status: 401 }
);
}
// Validar refresh token
const payload = verifyRefreshToken(refreshToken);
if (!payload) {
// Token inválido - limpar cookie
const response = NextResponse.json(
{
success: false,
error: "Refresh token inválido ou expirado",
},
{ status: 401 }
);
response.cookies.delete("refreshToken");
return response;
}
// Gerar novo access token
const accessToken = generateAccessToken(payload.userId, payload.username);
secureLogger.info("✅ Token renovado com sucesso", {
userId: payload.userId,
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{
success: true,
accessToken,
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao renovar token", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
);
}
}
+270
View File
@@ -0,0 +1,270 @@
import { NextRequest, NextResponse } from "next/server";
import {
checkRateLimit,
getClientIP,
createRateLimitResponse,
} from "@/lib/rate-limiter";
import { secureLogger } from "@/lib/secure-logger";
import { registerSchema } from "@/lib/validation-schemas";
import { hashPassword } from "@/lib/auth/password";
import {
generateAccessToken,
generateRefreshToken,
} from "@/lib/auth/jwt";
import {
getUserByEmail,
getUserByUsername,
} from "@/lib/db/queries/users";
import {
isServerActive,
incrementUsersForServers,
} from "@/lib/db/queries/servers";
import { setSession } from "@/lib/redis/session";
import { randomUUID } from "crypto";
import { getPool } from "@/lib/db";
export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = getClientIP(request);
const rateLimit = checkRateLimit(ip, "/api/auth/register");
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit.resetAt);
}
const body = await request.json();
secureLogger.info("📥 POST /api/auth/register - Recebido", {
ip,
timestamp: new Date().toISOString(),
});
// Validação com Zod
const validationResult = registerSchema.safeParse(body);
if (!validationResult.success) {
const errors = validationResult.error.issues.map((err) => ({
field: err.path.join("."),
message: err.message,
}));
secureLogger.error("❌ Validação falhou", {
errors: JSON.stringify(errors),
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{
success: false,
error: "Dados de entrada inválidos",
details: errors,
},
{ status: 400 }
);
}
const validatedData = validationResult.data;
// Verificar duplicidade: email
const existingEmail = await getUserByEmail(validatedData.email);
if (existingEmail) {
return NextResponse.json(
{
success: false,
error: "Email já está em uso",
},
{ status: 409 }
);
}
// Verificar duplicidade: username
const existingUsername = await getUserByUsername(validatedData.username);
if (existingUsername) {
return NextResponse.json(
{
success: false,
error: "Username já está em uso",
},
{ status: 409 }
);
}
// Validar servidores: verificar se existem e estão ativos
const serverNames = validatedData.servers.map((s) => s.serverName);
const uniqueServerNames = [...new Set(serverNames)]; // Remover duplicatas
for (const serverName of uniqueServerNames) {
const isActive = await isServerActive(serverName);
if (!isActive) {
return NextResponse.json(
{
success: false,
error: `Servidor "${serverName}" não existe ou não está ativo`,
},
{ status: 400 }
);
}
}
// Hash da senha
const passwordHash = await hashPassword(validatedData.password);
// Criar usuário e personagens em transação atômica
const pool = getPool();
const client = await pool.connect();
let newUser: { userId: number; username: string; email: string };
const createdCharacters: Array<{ characterName: string; serverName: string }> = [];
try {
await client.query("BEGIN");
// Criar usuário
const userResult = await client.query<{
user_id: number;
username: string;
email: string;
}>(
`
INSERT INTO users (username, email, password_hash, first_name, last_name, phone, cpf)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING user_id, username, email
`,
[
validatedData.username,
validatedData.email,
passwordHash,
validatedData.firstName,
validatedData.lastName,
validatedData.phone || null,
null, // CPF não é mais obrigatório
]
);
newUser = {
userId: userResult.rows[0].user_id,
username: userResult.rows[0].username,
email: userResult.rows[0].email,
};
// Criar personagens (primeiro é main por padrão)
for (let i = 0; i < validatedData.servers.length; i++) {
const serverData = validatedData.servers[i];
const isMain = i === 0 || serverData.isMain === true;
// Se este personagem será main e não é o primeiro, desmarcar outros
if (isMain && i > 0) {
await client.query(
`
UPDATE user_characters
SET is_main = FALSE
WHERE user_id = $1
`,
[newUser.userId]
);
}
// Inserir personagem
const charResult = await client.query<{
character_id: number;
character_name: string;
server_name: string;
}>(
`
INSERT INTO user_characters (user_id, character_name, server_name, is_main)
VALUES ($1, $2, $3, $4)
RETURNING character_id, character_name, server_name
`,
[newUser.userId, serverData.characterName, serverData.serverName, isMain]
);
createdCharacters.push({
characterName: charResult.rows[0].character_name,
serverName: charResult.rows[0].server_name,
});
}
// Incrementar total_users dos servidores
await incrementUsersForServers(uniqueServerNames);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
// Gerar tokens
const accessToken = generateAccessToken(newUser.userId, newUser.username);
const refreshToken = generateRefreshToken(
newUser.userId,
newUser.username
);
// Criar sessão no Redis
const sessionId = randomUUID();
await setSession(sessionId, {
userId: newUser.userId,
username: newUser.username,
email: newUser.email,
accessToken,
createdAt: Date.now(),
});
// Criar resposta
const response = NextResponse.json(
{
success: true,
user: {
userId: newUser.userId,
username: newUser.username,
email: newUser.email,
},
accessToken,
},
{ status: 201 }
);
// Adicionar refresh token e sessionId como cookies httpOnly
response.cookies.set("refreshToken", refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 dias
path: "/",
});
response.cookies.set("sessionId", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 dias
path: "/",
});
secureLogger.info("✅ Usuário registrado com sucesso", {
userId: newUser.userId,
username: newUser.username,
timestamp: new Date().toISOString(),
});
return response;
} catch (error) {
secureLogger.error("❌ Erro ao registrar usuário", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
);
}
}
+136
View File
@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { secureLogger } from "@/lib/secure-logger";
import { getSession, refreshSession, deleteSession } from "@/lib/redis/session";
import { verifyAccessToken } from "@/lib/auth/jwt";
/**
* GET /api/auth/session
* Verifica e retorna dados da sessão atual
*/
export async function GET(request: NextRequest) {
try {
// Buscar sessionId do cookie
const cookieStore = await cookies();
const sessionId = cookieStore.get("sessionId")?.value;
if (!sessionId) {
return NextResponse.json(
{
success: false,
authenticated: false,
error: "Nenhuma sessão encontrada",
},
{ status: 401 }
);
}
// Buscar sessão no Redis
const session = await getSession(sessionId);
if (!session) {
// Limpar cookie se sessão não existe
const response = NextResponse.json(
{
success: false,
authenticated: false,
error: "Sessão expirada ou inválida",
},
{ status: 401 }
);
response.cookies.delete("sessionId");
return response;
}
// Verificar se o access token ainda é válido
const tokenPayload = verifyAccessToken(session.accessToken);
if (!tokenPayload) {
// Token expirado - remover sessão
await deleteSession(sessionId);
const response = NextResponse.json(
{
success: false,
authenticated: false,
error: "Token expirado",
},
{ status: 401 }
);
response.cookies.delete("sessionId");
return response;
}
// Atualizar TTL da sessão
await refreshSession(sessionId);
return NextResponse.json(
{
success: true,
authenticated: true,
user: {
userId: session.userId,
username: session.username,
email: session.email,
},
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao verificar sessão", {
error: error instanceof Error ? error.message : "Erro desconhecido",
stack: error instanceof Error ? error.stack : undefined,
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{
success: false,
authenticated: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
);
}
}
/**
* DELETE /api/auth/session
* Encerra a sessão atual
*/
export async function DELETE(request: NextRequest) {
try {
const cookieStore = await cookies();
const sessionId = cookieStore.get("sessionId")?.value;
if (sessionId) {
await deleteSession(sessionId);
}
const response = NextResponse.json(
{
success: true,
message: "Sessão encerrada",
},
{ status: 200 }
);
response.cookies.delete("sessionId");
return response;
} catch (error) {
secureLogger.error("❌ Erro ao encerrar sessão", {
error: error instanceof Error ? error.message : "Erro desconhecido",
timestamp: new Date().toISOString(),
});
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Erro interno",
},
{ status: 500 }
);
}
}