Primeiro commit
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { clearSession, updateSession, useSession } from "@tanstack/react-start/server";
|
||||
|
||||
import { assertSessionConfig, sessionConfig, type SessionUser } from "@/lib/session.server";
|
||||
|
||||
export async function getSessionUserId(): Promise<string | null> {
|
||||
assertSessionConfig();
|
||||
const session = await useSession<SessionUser>(sessionConfig);
|
||||
return session.data.userId ?? null;
|
||||
}
|
||||
|
||||
export async function setSessionUserId(userId: string): Promise<void> {
|
||||
assertSessionConfig();
|
||||
await updateSession<SessionUser>(sessionConfig, { userId });
|
||||
}
|
||||
|
||||
export async function clearAuthSession(): Promise<void> {
|
||||
assertSessionConfig();
|
||||
await clearSession(sessionConfig);
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toLocalDateTimeString } from "@/lib/datetime";
|
||||
|
||||
export interface Perfil {
|
||||
id: string;
|
||||
email: string;
|
||||
nome: string;
|
||||
aprovado: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SessaoAtual {
|
||||
userId: string;
|
||||
nome: string;
|
||||
admin: boolean;
|
||||
perfil: Perfil;
|
||||
}
|
||||
|
||||
interface PerfilRow {
|
||||
id: string;
|
||||
email: string;
|
||||
nome: string;
|
||||
aprovado: boolean;
|
||||
created_at: Date | string;
|
||||
}
|
||||
|
||||
const loginSchema = z.object({
|
||||
nome: z.string().min(3),
|
||||
senha: z.string().min(6),
|
||||
});
|
||||
|
||||
function toPerfil(row: PerfilRow): Perfil {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
nome: row.nome,
|
||||
aprovado: Boolean(row.aprovado),
|
||||
created_at: toLocalDateTimeString(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
async function getSessionUserId(): Promise<string | null> {
|
||||
const { getSessionUserId: getServerSessionUserId } = await import("@/lib/auth.server");
|
||||
return await getServerSessionUserId();
|
||||
}
|
||||
|
||||
async function fetchSessionProfile(userId: string): Promise<SessaoAtual | null> {
|
||||
const [{ getPool, sql }] = await Promise.all([import("@/lib/db.server")]);
|
||||
const pool = await getPool();
|
||||
|
||||
const result = await pool
|
||||
.request()
|
||||
.input("userId", sql.UniqueIdentifier, userId)
|
||||
.query<PerfilRow & { admin: boolean }>(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.nome,
|
||||
u.aprovado,
|
||||
u.created_at,
|
||||
CAST(CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM slalog.usuarios_papeis up
|
||||
WHERE up.user_id = u.id AND up.role = 'admin'
|
||||
) THEN 1
|
||||
ELSE 0
|
||||
END AS bit) AS admin
|
||||
FROM slalog.usuarios u
|
||||
WHERE u.id = @userId AND u.ativo = 1
|
||||
`);
|
||||
|
||||
const row = result.recordset[0];
|
||||
if (!row) return null;
|
||||
|
||||
const perfil = toPerfil(row);
|
||||
return {
|
||||
userId: perfil.id,
|
||||
nome: perfil.nome,
|
||||
admin: Boolean(row.admin),
|
||||
perfil,
|
||||
};
|
||||
}
|
||||
|
||||
async function requireSession(): Promise<SessaoAtual> {
|
||||
const userId = await getSessionUserId();
|
||||
if (!userId) throw new Error("Não autenticado");
|
||||
|
||||
const sessao = await fetchSessionProfile(userId);
|
||||
if (!sessao) throw new Error("Sessão inválida");
|
||||
return sessao;
|
||||
}
|
||||
|
||||
async function requireAdminSession(): Promise<SessaoAtual> {
|
||||
const sessao = await requireSession();
|
||||
if (!sessao.admin) throw new Error("Apenas administradores podem executar esta ação.");
|
||||
return sessao;
|
||||
}
|
||||
|
||||
async function requireApprovedSession(): Promise<SessaoAtual> {
|
||||
const sessao = await requireSession();
|
||||
if (!sessao.perfil.aprovado) throw new Error("Usuário aguardando aprovação.");
|
||||
return sessao;
|
||||
}
|
||||
|
||||
export function normalizarNome(nome: string): string {
|
||||
return nome
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9._-]/g, "");
|
||||
}
|
||||
|
||||
export function emailDeNome(nome: string): string {
|
||||
return `${normalizarNome(nome)}@coleta.local`;
|
||||
}
|
||||
|
||||
export const obterSessaoAtual = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const userId = await getSessionUserId();
|
||||
if (!userId) return null;
|
||||
return await fetchSessionProfile(userId);
|
||||
});
|
||||
|
||||
export const entrar = createServerFn({ method: "POST" })
|
||||
.validator(loginSchema)
|
||||
.handler(async ({ data }) => {
|
||||
const nome = normalizarNome(data.nome);
|
||||
const [{ getPool, sql }, bcrypt] = await Promise.all([
|
||||
import("@/lib/db.server"),
|
||||
import("bcryptjs"),
|
||||
]);
|
||||
const pool = await getPool();
|
||||
|
||||
const result = await pool
|
||||
.request()
|
||||
.input("nome", sql.NVarChar(150), nome)
|
||||
.query<(PerfilRow & { senha_hash: string; admin: boolean; ativo: boolean })>(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.nome,
|
||||
u.aprovado,
|
||||
u.ativo,
|
||||
u.created_at,
|
||||
u.senha_hash,
|
||||
CAST(CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM slalog.usuarios_papeis up
|
||||
WHERE up.user_id = u.id AND up.role = 'admin'
|
||||
) THEN 1
|
||||
ELSE 0
|
||||
END AS bit) AS admin
|
||||
FROM slalog.usuarios u
|
||||
WHERE LOWER(u.nome) = LOWER(@nome)
|
||||
`);
|
||||
|
||||
const row = result.recordset[0];
|
||||
if (!row || !row.ativo) throw new Error("Nome de usuário ou senha incorretos.");
|
||||
|
||||
const ok = await bcrypt.compare(data.senha, row.senha_hash);
|
||||
if (!ok) throw new Error("Nome de usuário ou senha incorretos.");
|
||||
|
||||
const { setSessionUserId } = await import("@/lib/auth.server");
|
||||
await setSessionUserId(row.id);
|
||||
|
||||
const perfil = toPerfil(row);
|
||||
return {
|
||||
userId: row.id,
|
||||
nome: row.nome,
|
||||
admin: Boolean(row.admin),
|
||||
perfil,
|
||||
} satisfies SessaoAtual;
|
||||
});
|
||||
|
||||
export const criarConta = createServerFn({ method: "POST" })
|
||||
.validator(loginSchema)
|
||||
.handler(async ({ data }) => {
|
||||
const nome = normalizarNome(data.nome);
|
||||
const email = emailDeNome(nome);
|
||||
const [{ getPool, sql }, bcrypt] = await Promise.all([
|
||||
import("@/lib/db.server"),
|
||||
import("bcryptjs"),
|
||||
]);
|
||||
const pool = await getPool();
|
||||
|
||||
const existente = await pool
|
||||
.request()
|
||||
.input("nome", sql.NVarChar(150), nome)
|
||||
.query<{ id: string }>(`
|
||||
SELECT TOP 1 id
|
||||
FROM slalog.usuarios
|
||||
WHERE LOWER(nome) = LOWER(@nome)
|
||||
`);
|
||||
|
||||
if (existente.recordset[0]) throw new Error("Este nome de usuário já existe. Faça login.");
|
||||
|
||||
const primeiro = await pool.request().query<{ total: number }>(`
|
||||
SELECT COUNT(1) AS total
|
||||
FROM slalog.usuarios
|
||||
`);
|
||||
|
||||
const senhaHash = await bcrypt.hash(data.senha, 10);
|
||||
const aprovado = (primeiro.recordset[0]?.total ?? 0) === 0;
|
||||
|
||||
const criado = await pool
|
||||
.request()
|
||||
.input("id", sql.UniqueIdentifier, crypto.randomUUID())
|
||||
.input("email", sql.NVarChar(255), email)
|
||||
.input("nome", sql.NVarChar(150), nome)
|
||||
.input("aprovado", sql.Bit, aprovado)
|
||||
.input("senhaHash", sql.NVarChar(255), senhaHash)
|
||||
.query<PerfilRow>(`
|
||||
INSERT INTO slalog.usuarios (
|
||||
id,
|
||||
email,
|
||||
nome,
|
||||
aprovado,
|
||||
ativo,
|
||||
senha_hash,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
OUTPUT inserted.id, inserted.email, inserted.nome, inserted.aprovado, inserted.created_at
|
||||
VALUES (
|
||||
@id,
|
||||
@email,
|
||||
@nome,
|
||||
@aprovado,
|
||||
1,
|
||||
@senhaHash,
|
||||
SYSDATETIME(),
|
||||
SYSDATETIME()
|
||||
)
|
||||
`);
|
||||
|
||||
if (aprovado) {
|
||||
await pool
|
||||
.request()
|
||||
.input("id", sql.UniqueIdentifier, crypto.randomUUID())
|
||||
.input("userId", sql.UniqueIdentifier, criado.recordset[0].id)
|
||||
.input("role", sql.NVarChar(20), "admin")
|
||||
.query(`
|
||||
INSERT INTO slalog.usuarios_papeis (id, user_id, role, created_at)
|
||||
VALUES (@id, @userId, @role, SYSDATETIME())
|
||||
`);
|
||||
}
|
||||
|
||||
return {
|
||||
perfil: toPerfil(criado.recordset[0]),
|
||||
aprovado,
|
||||
};
|
||||
});
|
||||
|
||||
export const sair = createServerFn({ method: "POST" }).handler(async () => {
|
||||
const { clearAuthSession } = await import("@/lib/auth.server");
|
||||
await clearAuthSession();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const listarPerfis = createServerFn({ method: "GET" }).handler(async () => {
|
||||
await requireAdminSession();
|
||||
const [{ getPool }] = await Promise.all([import("@/lib/db.server")]);
|
||||
const pool = await getPool();
|
||||
const result = await pool.query<PerfilRow>(`
|
||||
SELECT id, email, nome, aprovado, created_at
|
||||
FROM slalog.usuarios
|
||||
WHERE ativo = 1
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
return result.recordset.map(toPerfil);
|
||||
});
|
||||
|
||||
export const definirAprovacao = createServerFn({ method: "POST" })
|
||||
.validator(z.object({ id: z.string().uuid(), aprovado: z.boolean() }))
|
||||
.handler(async ({ data }) => {
|
||||
await requireAdminSession();
|
||||
const [{ getPool, sql }] = await Promise.all([import("@/lib/db.server")]);
|
||||
const pool = await getPool();
|
||||
await pool
|
||||
.request()
|
||||
.input("id", sql.UniqueIdentifier, data.id)
|
||||
.input("aprovado", sql.Bit, data.aprovado)
|
||||
.query(`
|
||||
UPDATE slalog.usuarios
|
||||
SET aprovado = @aprovado,
|
||||
updated_at = SYSDATETIME()
|
||||
WHERE id = @id
|
||||
`);
|
||||
});
|
||||
|
||||
export async function obterSessaoAutenticada(): Promise<SessaoAtual> {
|
||||
return await requireSession();
|
||||
}
|
||||
|
||||
export async function obterSessaoAprovada(): Promise<SessaoAtual> {
|
||||
return await requireApprovedSession();
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { z } from "zod";
|
||||
|
||||
import { obterSessaoAprovada } from "@/lib/auth";
|
||||
|
||||
export interface Coleta {
|
||||
id: number;
|
||||
usuario: string;
|
||||
codigo_barras: string;
|
||||
inicio_coleta: string;
|
||||
fim_coleta: string | null;
|
||||
}
|
||||
|
||||
interface ColetaRow {
|
||||
id: number;
|
||||
usuario: string;
|
||||
codigo_barras: string;
|
||||
inicio_coleta: string;
|
||||
fim_coleta: string | null;
|
||||
}
|
||||
|
||||
function toColeta(row: ColetaRow): Coleta {
|
||||
return {
|
||||
id: row.id,
|
||||
usuario: row.usuario,
|
||||
codigo_barras: row.codigo_barras,
|
||||
inicio_coleta: row.inicio_coleta,
|
||||
fim_coleta: row.fim_coleta,
|
||||
};
|
||||
}
|
||||
|
||||
export const iniciarColeta = createServerFn({ method: "POST" })
|
||||
.validator(z.object({ codigo: z.string().min(1) }))
|
||||
.handler(async ({ data }) => {
|
||||
const sessao = await obterSessaoAprovada();
|
||||
const [{ getPool, sql }] = await Promise.all([import("@/lib/db.server")]);
|
||||
const pool = await getPool();
|
||||
|
||||
const dataAberta = await pool
|
||||
.request()
|
||||
.input("usuario", sql.NVarChar(150), sessao.nome)
|
||||
.query<{ id: number }>(`
|
||||
SELECT TOP 1 id
|
||||
FROM slalog.coletas
|
||||
WHERE usuario = @usuario AND fim_coleta IS NULL
|
||||
ORDER BY inicio_coleta DESC
|
||||
`);
|
||||
|
||||
if (dataAberta.recordset[0]) throw new Error("Já existe uma coleta em andamento.");
|
||||
|
||||
const result = await pool
|
||||
.request()
|
||||
.input("usuario", sql.NVarChar(150), sessao.nome)
|
||||
.input("codigo", sql.NVarChar(100), data.codigo)
|
||||
.query<ColetaRow>(`
|
||||
INSERT INTO slalog.coletas (usuario, codigo_barras, inicio_coleta, created_at)
|
||||
OUTPUT
|
||||
inserted.id AS id,
|
||||
inserted.usuario AS usuario,
|
||||
inserted.codigo_barras AS codigo_barras,
|
||||
CONVERT(varchar(19), inserted.inicio_coleta, 126) AS inicio_coleta,
|
||||
CASE
|
||||
WHEN inserted.fim_coleta IS NULL THEN NULL
|
||||
ELSE CONVERT(varchar(19), inserted.fim_coleta, 126)
|
||||
END AS fim_coleta
|
||||
VALUES (@usuario, @codigo, SYSDATETIME(), SYSDATETIME())
|
||||
`);
|
||||
|
||||
return toColeta(result.recordset[0]);
|
||||
});
|
||||
|
||||
export const finalizarColeta = createServerFn({ method: "POST" }).handler(async () => {
|
||||
const sessao = await obterSessaoAprovada();
|
||||
const [{ getPool, sql }] = await Promise.all([import("@/lib/db.server")]);
|
||||
const pool = await getPool();
|
||||
|
||||
const result = await pool
|
||||
.request()
|
||||
.input("usuario", sql.NVarChar(150), sessao.nome)
|
||||
.query<ColetaRow>(`
|
||||
UPDATE c
|
||||
SET fim_coleta = SYSDATETIME()
|
||||
OUTPUT
|
||||
inserted.id AS id,
|
||||
inserted.usuario AS usuario,
|
||||
inserted.codigo_barras AS codigo_barras,
|
||||
CONVERT(varchar(19), inserted.inicio_coleta, 126) AS inicio_coleta,
|
||||
CASE
|
||||
WHEN inserted.fim_coleta IS NULL THEN NULL
|
||||
ELSE CONVERT(varchar(19), inserted.fim_coleta, 126)
|
||||
END AS fim_coleta
|
||||
FROM slalog.coletas c
|
||||
INNER JOIN (
|
||||
SELECT TOP 1 id
|
||||
FROM slalog.coletas
|
||||
WHERE usuario = @usuario
|
||||
AND fim_coleta IS NULL
|
||||
ORDER BY inicio_coleta DESC
|
||||
) aberta ON aberta.id = c.id
|
||||
`);
|
||||
|
||||
const coleta = result.recordset[0];
|
||||
if (!coleta) throw new Error("Coleta não encontrada ou já finalizada.");
|
||||
return toColeta(coleta);
|
||||
});
|
||||
|
||||
export const buscarColetaAberta = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const sessao = await obterSessaoAprovada();
|
||||
const [{ getPool, sql }] = await Promise.all([import("@/lib/db.server")]);
|
||||
const pool = await getPool();
|
||||
|
||||
const result = await pool
|
||||
.request()
|
||||
.input("usuario", sql.NVarChar(150), sessao.nome)
|
||||
.query<ColetaRow>(`
|
||||
SELECT TOP 1
|
||||
id,
|
||||
usuario,
|
||||
codigo_barras,
|
||||
CONVERT(varchar(19), inicio_coleta, 126) AS inicio_coleta,
|
||||
CASE WHEN fim_coleta IS NULL THEN NULL ELSE CONVERT(varchar(19), fim_coleta, 126) END AS fim_coleta
|
||||
FROM slalog.coletas
|
||||
WHERE usuario = @usuario AND fim_coleta IS NULL
|
||||
ORDER BY inicio_coleta DESC
|
||||
`);
|
||||
|
||||
return result.recordset[0] ? toColeta(result.recordset[0]) : null;
|
||||
});
|
||||
|
||||
export const listarColetas = createServerFn({ method: "POST" })
|
||||
.validator(z.object({ somenteMinhas: z.boolean(), limite: z.number().int().positive().max(500) }))
|
||||
.handler(async ({ data }) => {
|
||||
const sessao = await obterSessaoAprovada();
|
||||
const [{ getPool, sql }] = await Promise.all([import("@/lib/db.server")]);
|
||||
const pool = await getPool();
|
||||
|
||||
const request = pool.request().input("limite", sql.Int, data.limite);
|
||||
let query = `
|
||||
SELECT TOP (@limite)
|
||||
id,
|
||||
usuario,
|
||||
codigo_barras,
|
||||
CONVERT(varchar(19), inicio_coleta, 126) AS inicio_coleta,
|
||||
CASE WHEN fim_coleta IS NULL THEN NULL ELSE CONVERT(varchar(19), fim_coleta, 126) END AS fim_coleta
|
||||
FROM slalog.coletas
|
||||
`;
|
||||
|
||||
if (data.somenteMinhas) {
|
||||
request.input("usuario", sql.NVarChar(150), sessao.nome);
|
||||
query += ` WHERE usuario = @usuario`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY inicio_coleta DESC`;
|
||||
|
||||
const result = await request.query<ColetaRow>(query);
|
||||
return result.recordset.map(toColeta);
|
||||
});
|
||||
|
||||
export function formatarDuracao(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const h = String(Math.floor(total / 3600)).padStart(2, "0");
|
||||
const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
|
||||
const s = String(total % 60).padStart(2, "0");
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
export function toLocalDateTimeString(value: Date | string): string {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
].join("-") + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function parseLocalDateTime(value: string): Date {
|
||||
const match = value.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/,
|
||||
);
|
||||
|
||||
if (!match) return new Date(value);
|
||||
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
return new Date(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import net from "node:net";
|
||||
import sql from "mssql";
|
||||
|
||||
let poolPromise: Promise<sql.ConnectionPool> | null = null;
|
||||
|
||||
function getRequiredEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing required environment variable: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function envFlag(name: string, defaultValue: boolean): boolean {
|
||||
const raw = process.env[name];
|
||||
if (raw == null) return defaultValue;
|
||||
return ["1", "true", "yes", "y"].includes(raw.toLowerCase());
|
||||
}
|
||||
|
||||
function getServerName(host: string): string | undefined {
|
||||
const configured = process.env["DB_SERVER_NAME"]?.trim();
|
||||
if (configured) return configured;
|
||||
|
||||
if (net.isIP(host)) {
|
||||
// Avoid sending an IP as TLS SNI; with trustServerCertificate=true we only
|
||||
// need a non-IP placeholder to suppress the Node deprecation warning.
|
||||
return "sqlserver.local";
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
function getConfig(): sql.config {
|
||||
const server = getRequiredEnv("DB_HOST");
|
||||
|
||||
return {
|
||||
server,
|
||||
port: Number(process.env["DB_PORT"] ?? 1433),
|
||||
database: getRequiredEnv("DB_NAME"),
|
||||
user: getRequiredEnv("DB_USER"),
|
||||
password: getRequiredEnv("DB_PASSWORD"),
|
||||
options: {
|
||||
encrypt: envFlag("DB_ENCRYPT", true),
|
||||
serverName: getServerName(server),
|
||||
trustServerCertificate: envFlag("DB_TRUST_SERVER_CERT", true),
|
||||
},
|
||||
pool: {
|
||||
max: 10,
|
||||
min: 0,
|
||||
idleTimeoutMillis: 30000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPool(): Promise<sql.ConnectionPool> {
|
||||
if (!poolPromise) {
|
||||
poolPromise = new sql.ConnectionPool(getConfig()).connect().catch((error) => {
|
||||
poolPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return poolPromise;
|
||||
}
|
||||
|
||||
export { sql };
|
||||
@@ -0,0 +1,81 @@
|
||||
// Captures the original Error out-of-band so server.ts can recover the stack
|
||||
// when h3 has already swallowed the throw into a generic 500 Response.
|
||||
|
||||
let lastCapturedError: { error: unknown; at: number } | undefined;
|
||||
const TTL_MS = 5_000;
|
||||
|
||||
function record(error: unknown) {
|
||||
lastCapturedError = { error, at: Date.now() };
|
||||
}
|
||||
|
||||
// h3's HTTPError serializes to {"status":500,"unhandled":true,"message":"HTTPError"} —
|
||||
// no stack, no cause — so a plain console.error(error) reaches the log pipeline with
|
||||
// the failure detail stripped. Expand Error-like args into a string that keeps the
|
||||
// message, stack, and the full cause chain.
|
||||
const CAUSE_DEPTH_LIMIT = 5;
|
||||
const DESCRIPTION_LENGTH_LIMIT = 8_000;
|
||||
|
||||
export function describeError(error: unknown): string {
|
||||
const parts: string[] = [];
|
||||
let current: unknown = error;
|
||||
for (let depth = 0; depth < CAUSE_DEPTH_LIMIT && current != null; depth++) {
|
||||
if (!(current instanceof Error)) {
|
||||
parts.push(typeof current === "string" ? current : safeStringify(current));
|
||||
break;
|
||||
}
|
||||
const label = depth === 0 ? "" : "caused by: ";
|
||||
const status = describeStatus(current);
|
||||
parts.push(`${label}${current.stack ?? `${current.name}: ${current.message}`}${status}`);
|
||||
current = current.cause;
|
||||
}
|
||||
return parts.join("\n").slice(0, DESCRIPTION_LENGTH_LIMIT);
|
||||
}
|
||||
|
||||
function describeStatus(error: Error): string {
|
||||
const { status, statusCode } = error as { status?: unknown; statusCode?: unknown };
|
||||
const value = status ?? statusCode;
|
||||
return typeof value === "number" ? ` (status ${value})` : "";
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isErrorLike(value: unknown): value is Error {
|
||||
return value instanceof Error;
|
||||
}
|
||||
|
||||
// Wrap console.error so errors logged by any layer — including h3's internal
|
||||
// unhandled-error logging, which this file cannot hook directly — are both
|
||||
// recorded for consumeLastCapturedError and expanded before serialization.
|
||||
const originalConsoleError = console.error.bind(console);
|
||||
console.error = (...args: unknown[]) => {
|
||||
const expanded = args.map((arg) => {
|
||||
if (!isErrorLike(arg)) return arg;
|
||||
record(arg);
|
||||
return describeError(arg);
|
||||
});
|
||||
originalConsoleError(...expanded);
|
||||
};
|
||||
|
||||
if (typeof globalThis.addEventListener === "function") {
|
||||
globalThis.addEventListener("error", (event) => record((event as ErrorEvent).error ?? event));
|
||||
globalThis.addEventListener("unhandledrejection", (event) =>
|
||||
record((event as PromiseRejectionEvent).reason),
|
||||
);
|
||||
}
|
||||
|
||||
export function consumeLastCapturedError(): unknown {
|
||||
if (!lastCapturedError) return undefined;
|
||||
if (Date.now() - lastCapturedError.at > TTL_MS) {
|
||||
lastCapturedError = undefined;
|
||||
return undefined;
|
||||
}
|
||||
const { error } = lastCapturedError;
|
||||
lastCapturedError = undefined;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export function renderErrorPage(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>This page didn't load</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
body { font: 15px/1.5 system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: grid; place-items: center; min-height: 100vh; margin: 0; padding: 1.5rem; }
|
||||
.card { max-width: 28rem; width: 100%; text-align: center; padding: 2rem; }
|
||||
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
|
||||
p { color: #4b5563; margin: 0 0 1.5rem; }
|
||||
.actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
|
||||
a, button { padding: 0.5rem 1rem; border-radius: 0.375rem; font: inherit; cursor: pointer; text-decoration: none; border: 1px solid transparent; }
|
||||
.primary { background: #111; color: #fff; }
|
||||
.secondary { background: #fff; color: #111; border-color: #d1d5db; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>This page didn't load</h1>
|
||||
<p>Something went wrong on our end. You can try refreshing or head back home.</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick="location.reload()">Try again</button>
|
||||
<a class="secondary" href="/">Go home</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
type LovableErrorOptions = {
|
||||
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
|
||||
handled?: boolean;
|
||||
severity?: "error" | "warning" | "info";
|
||||
};
|
||||
|
||||
type LovableEvents = {
|
||||
captureException?: (
|
||||
error: unknown,
|
||||
context?: Record<string, unknown>,
|
||||
options?: LovableErrorOptions,
|
||||
) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__lovableEvents?: LovableEvents;
|
||||
__lovableReportRuntimeError?: (payload: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
filename?: string;
|
||||
}) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.__lovableEvents?.captureException?.(
|
||||
error,
|
||||
{
|
||||
source: "react_error_boundary",
|
||||
route: window.location.pathname,
|
||||
...context,
|
||||
},
|
||||
{
|
||||
mechanism: "react_error_boundary",
|
||||
handled: false,
|
||||
severity: "error",
|
||||
},
|
||||
);
|
||||
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
|
||||
// editor's telemetry never sees them. Forward to lovable.js's reporting hook,
|
||||
// which is present only inside the editor preview.
|
||||
// Loaders and server fns commonly throw a raw Response; String(it) is the
|
||||
// opaque "[object Response]", so pull out the status and URL instead.
|
||||
const message =
|
||||
error instanceof Response
|
||||
? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
window.__lovableReportRuntimeError?.({
|
||||
message,
|
||||
...(stack !== undefined && { stack }),
|
||||
filename: window.location.pathname,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { Perfil } from "@/lib/auth";
|
||||
|
||||
export interface Sessao {
|
||||
userId: string;
|
||||
nome: string;
|
||||
admin: boolean;
|
||||
perfil: Perfil;
|
||||
}
|
||||
|
||||
export const SessaoContext = createContext<Sessao | null>(null);
|
||||
|
||||
export function useSessao(): Sessao {
|
||||
const ctx = useContext(SessaoContext);
|
||||
if (!ctx) throw new Error("useSessao deve ser usado dentro da área autenticada");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const sessionConfig = {
|
||||
password: process.env["SESSION_SECRET"] || "",
|
||||
name: "slalog-session",
|
||||
maxAge: 60 * 60 * 12,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
secure: process.env["NODE_ENV"] === "production",
|
||||
},
|
||||
};
|
||||
|
||||
export interface SessionUser {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function assertSessionConfig() {
|
||||
if (!sessionConfig.password || sessionConfig.password.length < 32) {
|
||||
throw new Error("SESSION_SECRET must be set with at least 32 characters.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user