Merge pull request #13 from esdrasrenan/feat/convex-tickets-core

feat: migrate auth stack and admin portal
This commit is contained in:
esdrasrenan 2025-10-05 17:23:56 -03:00 committed by GitHub
commit 14f0049cf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2564 additions and 178 deletions

View file

@ -42,19 +42,24 @@ Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garanti
- SLAs (BullMQ + Redis), notificacoes, ingest de e-mail, portal cliente, etc.
## Backlog imediato
- [x] Scaffold Next.js + Tailwind + shadcn/ui.
- [x] Ajustar layout shell (dashboard + sidebar) com tema solicitado.
- [x] Criar modulos base de dominio (schemas Prisma/Zod) ainda com dados mockados.
- [x] Preparar estrutura de paginas: `/tickets`, `/tickets/[id]`, `/play`.
- [x] Implementar Auth placeholder (cookie + middleware).
- [x] Conectar APIs/mutations reais (Convex) e sincronizar tipos no frontend.
- [ ] Expor portal do cliente com listagem de tickets filtrada por `viewerId` (Convex + UI)
- [ ] Completar painel administrativo (gestão de agentes, filas, categorias) com RBAC server/client
- [ ] Consolidar sincronização Better Auth ↔ Convex para fluxo de convites e resets de senha
- [ ] Expandir suite de testes (UI + Convex) cobrindo guardas e mapeadores críticos
### Iniciativa atual — Autenticação real e personas
- [x] Migrar placeholder para Better Auth + Prisma (handlers Next, cliente React e sync Convex).
- [x] Expor roles (`admin`, `agent`, `customer`) e aplicar guardas (`requireUser/Staff/Admin/Customer`) no Convex.
- [x] Ajustar middleware e componentes para usar `viewerId`/`actorId`, evitando vazamento de dados entre tenants.
- [ ] Criar portal do cliente para abertura/consulta de chamados e comentários públicos.
- [ ] Consolidar painel administrativo (agentes, filas, categorias) com fluxos completos de convite.
## Proximas entregas sugeridas
1. Finalizar Auth placeholder e guardas de rota (Auth.js + middleware).
2. Implementar camada de dados real (Prisma Client + server actions) para tickets.
3. Adicionar formularios de criacao/edicao de ticket com validacao (React Hook Form + Zod).
4. Conectar timeline/comentarios a mutations otimizadas (UI otimista + websockets futuro).
5. Preparar testes basicos (unit + e2e mockados) e pipeline de CI inicial.
1. Entregar portal do cliente (listagem, detalhes e criação de ticket) consumindo RBAC e mapeadores atualizados.
2. Evoluir painel administrativo com gerenciamento de filas/categorias e convites com Better Auth.
3. Introduzir relatórios e métricas (workSummary, SLA) protegidos por `requireStaff/requireAdmin`.
4. Automatizar pipeline CI (lint + vitest) integrando checagens obrigatórias.
5. Revisar UX dos fluxos de atendimento (play next, comentários) com feedback otimista e trilha de auditoria.
## Acompanhamento
Atualizar este arquivo a cada marco relevante (setup concluido, nucleo funcional, etc.).
@ -72,10 +77,10 @@ Este repositório foi atualizado para usar Convex como backend em tempo real par
- Upload de arquivos: `web/convex/files.ts` (Convex Storage).
- Filas: `web/convex/queues.ts` (resumo por fila).
- Seed/bootstrap: `web/convex/seed.ts`, `web/convex/bootstrap.ts`.
- Auth placeholder: cookie + middleware
- Login: `web/src/app/login/page.tsx`
- Middleware: `web/middleware.ts`
- Provider: `web/src/lib/auth-client.tsx` (garante usuário no Convex)
- Autenticação: Better Auth + Prisma (SQLite) com roles (`admin`, `agent`, `customer`) sincronizadas com Convex
- Login: `web/src/app/login/page.tsx` + `web/src/components/login/login-form.tsx`
- Middleware e guards: `web/middleware.ts`, helpers em `web/src/lib/auth{,z, -server}.ts`
- Cliente React: `web/src/lib/auth-client.tsx` (sincroniza sessão Better Auth ↔ Convex, expõe helpers de role)
- Frontend (Next.js + shadcn/ui)
- Páginas principais: `/tickets`, `/tickets/[id]`, `/tickets/new`, `/play`.
- UI ligada ao Convex com `convex/react`.
@ -210,6 +215,12 @@ Observações:
---
## Atualizações recentes (dez/2025)
- RBAC do Convex reforçado: `tickets.list`, `tickets.getById`, `workSummary` e mutações sensíveis (`changeQueue`, `updateCategories`, `startWork/pauseWork`, `updatePriority`) agora exigem `viewerId/actorId` e validam `requireStaff` com `tenantId`.
- Componentes de tickets (tabela, painel de recentes, play next, cabeçalho/detalhe) passam a usar o contexto Better Auth para prover `viewerId`, com `useQuery` protegido por `"skip"` enquanto não há sessão.
- Testes (`pnpm vitest run`) executados após as alterações para garantir regressão zero.
## Progresso recente (mar/2025)
Resumo do que foi implementado desde o último marco:
@ -313,6 +324,16 @@ Observações de codificação
- Linhas da tabela de tickets agora são totalmente clicáveis (mouse e teclado), reforçando acessibilidade e atalho de navegação.
- Toasts e layouts refinados para manter consistência entre criação, listagem e detalhe dos tickets.
## Atualizações recentes (out/2025)
- Tabela de tickets refinada com ícones de canal, prioridade ajustável inline e indicadores suavizados (fila/status/categoria) para reduzir ruído visual.
- Definido plano de migração para Better Auth com RBAC (admin/agent/customer), portal do cliente e painel administrativo para filas/categorias/agentes.
- Próximo passo: iniciar fase de implementação da autenticação real, substituindo middleware placeholder e alinhando Convex aos novos papéis.
- Better Auth agora usa banco SQLite local (`db.sqlite`) e o schema Prisma foi migrado com sucesso via `pnpm exec prisma migrate dev --name init`.
- Configuração do `postcss.config.mjs` corrigida para usar `@tailwindcss/postcss` como plugin executável, liberando a suíte do Vitest (`pnpm exec vitest run`).
- Script `pnpm auth:seed` cria/atualiza o usuário inicial (`admin@sistema.dev` / `admin123`) usando `better-auth/crypto` para hash de senha.
- Página de login refeita com layout em duas colunas (header + imagem lateral) e formulário integrado ao Better Auth (`LoginForm`).
- Middleware atualizado aplica RBAC inicial (clientes direcionados ao portal, rotas `/admin` reservadas a administradores) e helpers de role expostos em `src/lib/authz.ts`; página `/portal` criada como placeholder do futuro autosserviço.
## Próximos passos estratégicos
### Produto / Experiência
@ -335,3 +356,14 @@ Observações de codificação
- [ ] Documentar fluxo de suporte interno (quem revisa PRs, janelas de deploy, rollback).
Manter este arquivo atualizado ao concluir cada item estratégico ou quando surgirem novas dependências administrativas.
## Atualizações recentes (mai/2026)
- Login corporativo refinado com instruções revisadas para primeiro acesso e mensagens de erro totalmente em PT-BR.
- Script `pnpm auth:seed` executado para garantir o usuário administrador padrão (`admin@sistema.dev` / `admin123`).
- Toast de autenticação inválida agora informa "E-mail ou senha inválidos", alinhando o feedback com o restante da interface.
### Próximos passos imediatos
- [ ] Consolidar o painel administrativo com fluxo completo de convites (criação, exibição e revogação) utilizando Better Auth.
- [ ] Iniciar o portal do cliente com listagem de tickets filtrada por `viewerId` e detalhamento básico.
- [ ] Cobrir o fluxo de autenticação (login/convite/reset) com testes Vitest focados em regressões críticas.

View file

@ -606,3 +606,78 @@
.ProseMirror > * + ol {
margin-top: 2rem;
}
@layer base {
.shader-surface {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.1 0 0 / 0.1);
--card-foreground: oklch(1 0 0);
--popover: oklch(0 0 0 / 0.8);
--popover-foreground: oklch(1 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.65 0.25 25);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(1 0 0 / 0.5);
--muted-foreground: oklch(0.7 0 0);
--accent: oklch(0.65 0.25 25);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.6 0.25 15);
--destructive-foreground: oklch(1 0 0);
--border: oklch(1 0 0 / 0.2);
--input: oklch(1 0 0 / 0.1);
--ring: oklch(1 0 0 / 0.3);
--chart-1: oklch(0.65 0.25 25);
--chart-2: oklch(0.8 0.15 85);
--chart-3: oklch(0.7 0.2 140);
--chart-4: oklch(0.7 0.2 240);
--chart-5: oklch(0.6 0.25 300);
--radius: 0.5rem;
--sidebar: oklch(0 0 0 / 0.9);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(0.65 0.25 25);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.65 0.25 25);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(1 0 0 / 0.2);
--sidebar-ring: oklch(1 0 0 / 0.3);
}
.shader-surface.dark {
--background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.1 0 0 / 0.1);
--card-foreground: oklch(1 0 0);
--popover: oklch(0 0 0 / 0.8);
--popover-foreground: oklch(1 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.65 0.25 25);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(1 0 0 / 0.5);
--muted-foreground: oklch(0.7 0 0);
--accent: oklch(0.65 0.25 25);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.6 0.25 15);
--destructive-foreground: oklch(1 0 0);
--border: oklch(1 0 0 / 0.2);
--input: oklch(1 0 0 / 0.1);
--ring: oklch(1 0 0 / 0.3);
--chart-1: oklch(0.65 0.25 25);
--chart-2: oklch(0.8 0.15 85);
--chart-3: oklch(0.7 0.2 140);
--chart-4: oklch(0.7 0.2 240);
--chart-5: oklch(0.6 0.25 300);
--sidebar: oklch(0 0 0 / 0.9);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(0.65 0.25 25);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.65 0.25 25);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(1 0 0 / 0.2);
--sidebar-ring: oklch(1 0 0 / 0.3);
}
}

1
web/.gitignore vendored
View file

@ -23,6 +23,7 @@
# misc
.DS_Store
*.pem
*.sqlite
# debug
npm-debug.log*

1
web/auth.ts Normal file
View file

@ -0,0 +1 @@
export { auth } from "./src/lib/auth"

View file

@ -12,6 +12,7 @@ import type * as bootstrap from "../bootstrap.js";
import type * as categories from "../categories.js";
import type * as files from "../files.js";
import type * as queues from "../queues.js";
import type * as rbac from "../rbac.js";
import type * as seed from "../seed.js";
import type * as tickets from "../tickets.js";
import type * as users from "../users.js";
@ -35,6 +36,7 @@ declare const fullApi: ApiFromModules<{
categories: typeof categories;
files: typeof files;
queues: typeof queues;
rbac: typeof rbac;
seed: typeof seed;
tickets: typeof tickets;
users: typeof users;

53
web/convex/rbac.ts Normal file
View file

@ -0,0 +1,53 @@
import { ConvexError } from "convex/values"
import type { Id } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server"
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
const CUSTOMER_ROLE = "CUSTOMER"
type Ctx = QueryCtx | MutationCtx
function normalizeRole(role?: string | null) {
return role?.toUpperCase() ?? null
}
async function getUser(ctx: Ctx, userId: Id<"users">) {
const user = await ctx.db.get(userId)
if (!user) {
throw new ConvexError("Usuário não encontrado")
}
return user
}
export async function requireUser(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
const user = await getUser(ctx, userId)
if (tenantId && user.tenantId !== tenantId) {
throw new ConvexError("Usuário não pertence a este tenant")
}
return { user, role: normalizeRole(user.role) }
}
export async function requireStaff(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
const result = await requireUser(ctx, userId, tenantId)
if (!result.role || !STAFF_ROLES.has(result.role)) {
throw new ConvexError("Acesso restrito à equipe interna")
}
return result
}
export async function requireAdmin(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
const result = await requireStaff(ctx, userId, tenantId)
if (result.role !== "ADMIN") {
throw new ConvexError("Apenas administradores podem executar esta ação")
}
return result
}
export async function requireCustomer(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
const result = await requireUser(ctx, userId, tenantId)
if (result.role !== CUSTOMER_ROLE) {
throw new ConvexError("Acesso restrito ao portal do cliente")
}
return result
}

View file

@ -2,6 +2,8 @@ import { mutation, query } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
import { requireAdmin, requireCustomer, requireStaff, requireUser } from "./rbac";
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados",
"suporte-n1": "Chamados",
@ -37,6 +39,7 @@ function normalizeTeams(teams?: string[] | null): string[] {
export const list = query({
args: {
viewerId: v.optional(v.id("users")),
tenantId: v.string(),
status: v.optional(v.string()),
priority: v.optional(v.string()),
@ -46,6 +49,11 @@ export const list = query({
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
if (!args.viewerId) {
return []
}
const { user, role } = await requireUser(ctx, args.viewerId, args.tenantId)
// Choose best index based on provided args for efficiency
let base: Doc<"tickets">[] = [];
if (args.status) {
@ -66,6 +74,10 @@ export const list = query({
}
let filtered = base;
if (role === "CUSTOMER") {
filtered = filtered.filter((t) => t.requesterId === args.viewerId);
}
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
if (args.search) {
@ -164,10 +176,14 @@ export const list = query({
});
export const getById = query({
args: { tenantId: v.string(), id: v.id("tickets") },
handler: async (ctx, { tenantId, id }) => {
args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") },
handler: async (ctx, { tenantId, id, viewerId }) => {
const { role } = await requireUser(ctx, viewerId, tenantId)
const t = await ctx.db.get(id);
if (!t || t.tenantId !== tenantId) return null;
if (role === "CUSTOMER" && t.requesterId !== viewerId) {
throw new ConvexError("Acesso restrito ao solicitante")
}
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
@ -297,6 +313,7 @@ export const getById = query({
export const create = mutation({
args: {
actorId: v.id("users"),
tenantId: v.string(),
subject: v.string(),
summary: v.optional(v.string()),
@ -308,6 +325,11 @@ export const create = mutation({
subcategoryId: v.id("ticketSubcategories"),
},
handler: async (ctx, args) => {
const { role } = await requireUser(ctx, args.actorId, args.tenantId)
if (role === "CUSTOMER" && args.requesterId !== args.actorId) {
throw new ConvexError("Clientes só podem abrir chamados para si mesmos")
}
const subject = args.subject.trim();
if (subject.length < 3) {
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
@ -382,6 +404,20 @@ export const addComment = mutation({
),
},
handler: async (ctx, args) => {
const ticket = await ctx.db.get(args.ticketId);
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
if (ticket.requesterId === args.authorId) {
await requireCustomer(ctx, args.authorId, ticket.tenantId)
if (args.visibility !== "PUBLIC") {
throw new ConvexError("Clientes só podem registrar comentários públicos")
}
} else {
await requireStaff(ctx, args.authorId, ticket.tenantId)
}
const now = Date.now();
const id = await ctx.db.insert("ticketComments", {
ticketId: args.ticketId,
@ -413,6 +449,10 @@ export const updateComment = mutation({
body: v.string(),
},
handler: async (ctx, { ticketId, commentId, actorId, body }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
@ -420,6 +460,11 @@ export const updateComment = mutation({
if (comment.authorId !== actorId) {
throw new ConvexError("Você não tem permissão para editar este comentário");
}
if (ticket.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticket.tenantId)
} else {
await requireStaff(ctx, actorId, ticket.tenantId)
}
const now = Date.now();
await ctx.db.patch(commentId, {
@ -452,10 +497,23 @@ export const removeCommentAttachment = mutation({
actorId: v.id("users"),
},
handler: async (ctx, { ticketId, commentId, attachmentId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
}
if (comment.authorId !== actorId) {
throw new ConvexError("Você não pode alterar anexos de outro usuário")
}
if (ticket.requesterId === actorId) {
await requireCustomer(ctx, actorId, ticket.tenantId)
} else {
await requireStaff(ctx, actorId, ticket.tenantId)
}
const attachments = comment.attachments ?? [];
const target = attachments.find((att) => att.storageId === attachmentId);
@ -492,6 +550,11 @@ export const removeCommentAttachment = mutation({
export const updateStatus = mutation({
args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, status, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const now = Date.now();
await ctx.db.patch(ticketId, { status, updatedAt: now });
const statusPt: Record<string, string> = {
@ -514,13 +577,21 @@ export const updateStatus = mutation({
export const changeAssignee = mutation({
args: { ticketId: v.id("tickets"), assigneeId: v.id("users"), actorId: v.id("users") },
handler: async (ctx, { ticketId, assigneeId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
if (!assignee || assignee.tenantId !== ticket.tenantId) {
throw new ConvexError("Responsável inválido")
}
const now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
const user = (await ctx.db.get(assigneeId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ASSIGNEE_CHANGED",
payload: { assigneeId, assigneeName: user?.name, actorId },
payload: { assigneeId, assigneeName: assignee.name, actorId },
createdAt: now,
});
},
@ -529,9 +600,17 @@ export const changeAssignee = mutation({
export const changeQueue = mutation({
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
handler: async (ctx, { ticketId, queueId, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
if (!queue || queue.tenantId !== ticket.tenantId) {
throw new ConvexError("Fila inválida")
}
const now = Date.now();
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null;
const queueName = normalizeQueueName(queue);
await ctx.db.insert("ticketEvents", {
ticketId,
@ -554,6 +633,7 @@ export const updateCategories = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== ticket.tenantId) {
throw new ConvexError("Categoria inválida")
@ -595,10 +675,11 @@ export const updateCategories = mutation({
})
export const workSummary = query({
args: { ticketId: v.id("tickets") },
handler: async (ctx, { ticketId }) => {
args: { ticketId: v.id("tickets"), viewerId: v.id("users") },
handler: async (ctx, { ticketId, viewerId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) return null
await requireStaff(ctx, viewerId, ticket.tenantId)
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
return {
@ -618,6 +699,11 @@ export const workSummary = query({
export const updatePriority = mutation({
args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, priority, actorId }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
const now = Date.now();
await ctx.db.patch(ticketId, { priority, updatedAt: now });
const pt: Record<string, string> = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente" };
@ -637,6 +723,7 @@ export const startWork = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
if (ticket.activeSessionId) {
return { status: "already_started", sessionId: ticket.activeSessionId }
}
@ -673,6 +760,7 @@ export const pauseWork = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, ticket.tenantId)
if (!ticket.activeSessionId) {
return { status: "already_paused" }
}
@ -721,7 +809,10 @@ export const updateSubject = mutation({
handler: async (ctx, { ticketId, subject, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) return;
if (!t) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, t.tenantId)
const trimmed = subject.trim();
if (trimmed.length < 3) {
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
@ -742,7 +833,10 @@ export const updateSummary = mutation({
handler: async (ctx, { ticketId, summary, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) return;
if (!t) {
throw new ConvexError("Ticket não encontrado")
}
await requireStaff(ctx, actorId, t.tenantId)
await ctx.db.patch(ticketId, { summary, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
@ -761,6 +855,7 @@ export const playNext = mutation({
agentId: v.id("users"),
},
handler: async (ctx, { tenantId, queueId, agentId }) => {
const agent = await requireStaff(ctx, agentId, tenantId)
// Find eligible tickets: not resolved/closed and not assigned
let candidates: Doc<"tickets">[] = []
if (queueId) {
@ -793,7 +888,6 @@ export const playNext = mutation({
const chosen = candidates[0];
const now = Date.now();
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
const agent = (await ctx.db.get(agentId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", {
ticketId: chosen._id,
type: "ASSIGNEE_CHANGED",

View file

@ -1,18 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server"
import { getCookieCache } from "better-auth/cookies"
export function middleware(req: NextRequest) {
const url = req.nextUrl.clone();
const isPublic = url.pathname.startsWith("/login") || url.pathname.startsWith("/_next") || url.pathname.startsWith("/api") || url.pathname.startsWith("/favicon");
if (isPublic) return NextResponse.next();
const cookie = req.cookies.get("demoUser")?.value;
if (!cookie) {
const redirect = NextResponse.redirect(new URL("/login", req.url));
return redirect;
const PUBLIC_PATHS = [/^\/login$/, /^\/api\/auth/, /^\/_next\//, /^\/favicon/]
const CUSTOMER_ALLOWED_PATHS = [/^\/portal(?:$|\/)/, /^\/api\/auth/, /^\/_next\//, /^\/favicon/]
const ADMIN_ONLY_PATHS = [/^\/admin(?:$|\/)/]
const PORTAL_HOME = "/portal"
const APP_HOME = "/dashboard"
export async function middleware(request: NextRequest) {
const { pathname, search } = request.nextUrl
if (PUBLIC_PATHS.some((pattern) => pattern.test(pathname))) {
return NextResponse.next()
}
return NextResponse.next();
const session = await getCookieCache(request)
if (!session?.user) {
const redirectUrl = new URL("/login", request.url)
redirectUrl.searchParams.set("callbackUrl", pathname + search)
return NextResponse.redirect(redirectUrl)
}
const role = (session.user as { role?: string })?.role?.toLowerCase() ?? "agent"
if (role === "customer") {
const canAccess = CUSTOMER_ALLOWED_PATHS.some((pattern) => pattern.test(pathname))
if (!canAccess) {
const redirectUrl = new URL(PORTAL_HOME, request.url)
redirectUrl.searchParams.set("callbackUrl", pathname + search)
return NextResponse.redirect(redirectUrl)
}
} else {
if (pathname.startsWith(PORTAL_HOME)) {
return NextResponse.redirect(new URL(APP_HOME, request.url))
}
const isAdmin = role === "admin"
if (!isAdmin && ADMIN_ONLY_PATHS.some((pattern) => pattern.test(pathname))) {
return NextResponse.redirect(new URL(APP_HOME, request.url))
}
}
return NextResponse.next()
}
export const config = {
matcher: ["/:path*"],
};
runtime: "nodejs",
matcher: ["/(.*)"],
}

View file

@ -9,7 +9,8 @@
"lint": "eslint",
"prisma:generate": "prisma generate",
"convex:dev": "convex dev",
"test": "vitest"
"test": "vitest",
"auth:seed": "node --env-file=.env.local scripts/seed-auth.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -17,6 +18,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0",
"@paper-design/shaders-react": "^0.0.55",
"@prisma/client": "^6.16.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
@ -31,12 +33,14 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-three/fiber": "^9.3.0",
"@tabler/icons-react": "^3.35.0",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-link": "^3.6.5",
"@tiptap/extension-placeholder": "^3.6.5",
"@tiptap/react": "^3.6.5",
"@tiptap/starter-kit": "^3.6.5",
"better-auth": "^1.3.26",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.27.3",
@ -51,6 +55,8 @@
"sanitize-html": "^2.17.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"three": "^0.180.0",
"unicornstudio-react": "^1.4.31",
"vaul": "^1.1.2",
"zod": "^4.1.9"
},

548
web/pnpm-lock.yaml generated
View file

@ -23,6 +23,9 @@ importers:
'@hookform/resolvers':
specifier: ^3.10.0
version: 3.10.0(react-hook-form@7.64.0(react@19.1.0))
'@paper-design/shaders-react':
specifier: ^0.0.55
version: 0.0.55(@types/react@19.2.0)(react@19.1.0)
'@prisma/client':
specifier: ^6.16.2
version: 6.16.3(prisma@6.16.3(typescript@5.9.3))(typescript@5.9.3)
@ -65,6 +68,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@react-three/fiber':
specifier: ^9.3.0
version: 9.3.0(@types/react@19.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.180.0)
'@tabler/icons-react':
specifier: ^3.35.0
version: 3.35.0(react@19.1.0)
@ -83,6 +89,9 @@ importers:
'@tiptap/starter-kit':
specifier: ^3.6.5
version: 3.6.5
better-auth:
specifier: ^1.3.26
version: 1.3.26(next@15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -125,6 +134,12 @@ importers:
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
three:
specifier: ^0.180.0
version: 0.180.0
unicornstudio-react:
specifier: ^1.4.31
version: 1.4.31(next@15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -182,6 +197,15 @@ packages:
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@better-auth/core@1.3.26':
resolution: {integrity: sha512-S5ooXaOcn9eLV3/JayfbMsAB5PkfoTRaRrtpb5djwvI/UAJOgLyjqhd+rObsBycovQ/nPQvMKjzyM/G1oBKngA==}
'@better-auth/utils@0.3.0':
resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==}
'@better-fetch/fetch@1.1.18':
resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
@ -560,6 +584,9 @@ packages:
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@hookform/resolvers@3.10.0':
resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==}
peerDependencies:
@ -727,6 +754,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@levischuck/tiny-cbor@0.2.11':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@ -784,6 +814,14 @@ packages:
cpu: [x64]
os: [win32]
'@noble/ciphers@2.0.1':
resolution: {integrity: sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@2.0.1':
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -800,6 +838,54 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@paper-design/shaders-react@0.0.55':
resolution: {integrity: sha512-bIxdbjg+R9Hote+xrp1Po1dFEFUsHtBKBdnU57ioWSpNxTjXP0DXQPStQkS3qmknuw8n2DErarVkDLSyJ0HzwQ==}
peerDependencies:
'@types/react': ^18 || ^19
react: ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@paper-design/shaders@0.0.55':
resolution: {integrity: sha512-9Qrt54v4bOvPsfC2o8s4dBDZJfhIsX3lCfsu/CkySbvLSTqV3x+POO51x5sEd4AFUj8DwhkF/Ai+z4hl4HGtQw==}
'@peculiar/asn1-android@2.5.0':
resolution: {integrity: sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A==}
'@peculiar/asn1-cms@2.5.0':
resolution: {integrity: sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A==}
'@peculiar/asn1-csr@2.5.0':
resolution: {integrity: sha512-ioigvA6WSYN9h/YssMmmoIwgl3RvZlAYx4A/9jD2qaqXZwGcNlAxaw54eSx2QG1Yu7YyBC5Rku3nNoHrQ16YsQ==}
'@peculiar/asn1-ecc@2.5.0':
resolution: {integrity: sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg==}
'@peculiar/asn1-pfx@2.5.0':
resolution: {integrity: sha512-Vj0d0wxJZA+Ztqfb7W+/iu8Uasw6hhKtCdLKXLG/P3kEPIQpqGI4P4YXlROfl7gOCqFIbgsj1HzFIFwQ5s20ug==}
'@peculiar/asn1-pkcs8@2.5.0':
resolution: {integrity: sha512-L7599HTI2SLlitlpEP8oAPaJgYssByI4eCwQq2C9eC90otFpm8MRn66PpbKviweAlhinWQ3ZjDD2KIVtx7PaVw==}
'@peculiar/asn1-pkcs9@2.5.0':
resolution: {integrity: sha512-UgqSMBLNLR5TzEZ5ZzxR45Nk6VJrammxd60WMSkofyNzd3DQLSNycGWSK5Xg3UTYbXcDFyG8pA/7/y/ztVCa6A==}
'@peculiar/asn1-rsa@2.5.0':
resolution: {integrity: sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q==}
'@peculiar/asn1-schema@2.5.0':
resolution: {integrity: sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ==}
'@peculiar/asn1-x509-attr@2.5.0':
resolution: {integrity: sha512-9f0hPOxiJDoG/bfNLAFven+Bd4gwz/VzrCIIWc1025LEI4BXO0U5fOCTNDPbbp2ll+UzqKsZ3g61mpBp74gk9A==}
'@peculiar/asn1-x509@2.5.0':
resolution: {integrity: sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ==}
'@peculiar/x509@1.14.0':
resolution: {integrity: sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg==}
'@prisma/client@6.16.3':
resolution: {integrity: sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==}
engines: {node: '>=18.18'}
@ -1273,6 +1359,31 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@react-three/fiber@9.3.0':
resolution: {integrity: sha512-myPe3YL/C8+Eq939/4qIVEPBW/uxV0iiUbmjfwrs9sGKYDG8ib8Dz3Okq7BQt8P+0k4igedONbjXMQy84aDFmQ==}
peerDependencies:
expo: '>=43.0'
expo-asset: '>=8.4'
expo-file-system: '>=11.0'
expo-gl: '>=11.0'
react: ^19.0.0
react-dom: ^19.0.0
react-native: '>=0.78'
three: '>=0.156'
peerDependenciesMeta:
expo:
optional: true
expo-asset:
optional: true
expo-file-system:
optional: true
expo-gl:
optional: true
react-dom:
optional: true
react-native:
optional: true
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
@ -1392,6 +1503,13 @@ packages:
'@rushstack/eslint-patch@1.13.0':
resolution: {integrity: sha512-2ih5qGw5SZJ+2fLZxP6Lr6Na2NTIgPRL/7Kmyuw0uIyBQnuhQ8fi8fzUTd38eIQmqp+GYLC00cI6WgtqHxBwmw==}
'@simplewebauthn/browser@13.2.2':
resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==}
'@simplewebauthn/server@13.2.2':
resolution: {integrity: sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==}
engines: {node: '>=20.0.0'}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
@ -1715,6 +1833,16 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
'@types/react-reconciler@0.28.9':
resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
peerDependencies:
'@types/react': '*'
'@types/react-reconciler@0.32.1':
resolution: {integrity: sha512-RsqPttsBQ+6af0nATFXJJpemYQH7kL9+xLNm1z+0MjQFDKBZDM2R6SBrjdvRmHu9i9fM6povACj57Ft+pKRNOA==}
peerDependencies:
'@types/react': '*'
'@types/react@19.2.0':
resolution: {integrity: sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==}
@ -1724,6 +1852,9 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
'@typescript-eslint/eslint-plugin@8.45.0':
resolution: {integrity: sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -1967,6 +2098,10 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
asn1js@3.0.6:
resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==}
engines: {node: '>=12.0.0'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@ -1993,6 +2128,41 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-auth@1.3.26:
resolution: {integrity: sha512-umaOGmv29yF4sD6o2zlW6B0Oayko5yD/A8mKJOFDDEIoVP/pR7nJ/2KsqKy+xvBpnDsKdrZseqA8fmczPviUHw==}
peerDependencies:
'@lynx-js/react': '*'
'@sveltejs/kit': '*'
next: '*'
react: '*'
react-dom: '*'
solid-js: '*'
svelte: '*'
vue: '*'
peerDependenciesMeta:
'@lynx-js/react':
optional: true
'@sveltejs/kit':
optional: true
next:
optional: true
react:
optional: true
react-dom:
optional: true
solid-js:
optional: true
svelte:
optional: true
vue:
optional: true
better-call@1.0.19:
resolution: {integrity: sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw==}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@ -2003,6 +2173,9 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
c12@3.1.0:
resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==}
peerDependencies:
@ -2610,6 +2783,9 @@ packages:
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -2751,10 +2927,18 @@ packages:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
its-fine@2.0.0:
resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==}
peerDependencies:
react: ^19.0.0
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@6.1.0:
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -2786,6 +2970,10 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
kysely@0.28.7:
resolution: {integrity: sha512-u/cAuTL4DRIiO2/g4vNGRgklEKNIj5Q3CG7RoUB5DV5SfEC2hMvPxKi0GWPmnzwL2ryIeud2VTcEEmqzTzEPNw==}
engines: {node: '>=20.0.0'}
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@ -2937,6 +3125,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanostores@1.0.1:
resolution: {integrity: sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw==}
engines: {node: ^20.0.0 || >=22.0.0}
napi-postinstall@0.3.3:
resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@ -3182,6 +3374,13 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
pvutils@1.1.3:
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
engines: {node: '>=6.0.0'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@ -3205,6 +3404,12 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-reconciler@0.31.0:
resolution: {integrity: sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==}
engines: {node: '>=0.10.0'}
peerDependencies:
react: ^19.0.0
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@ -3247,6 +3452,15 @@ packages:
react: '>=16.6.0'
react-dom: '>=16.6.0'
react-use-measure@2.1.7:
resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
peerDependencies:
react: '>=16.13'
react-dom: '>=16.13'
peerDependenciesMeta:
react-dom:
optional: true
react@19.1.0:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'}
@ -3265,6 +3479,9 @@ packages:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@ -3301,6 +3518,9 @@ packages:
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
rou3@0.5.1:
resolution: {integrity: sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@ -3319,6 +3539,9 @@ packages:
sanitize-html@2.17.0:
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
scheduler@0.25.0:
resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==}
scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
@ -3331,6 +3554,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@2.7.1:
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@ -3449,6 +3675,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
suspend-react@0.1.3:
resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==}
peerDependencies:
react: '>=17.0'
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
@ -3463,6 +3694,9 @@ packages:
resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==}
engines: {node: '>=18'}
three@0.180.0:
resolution: {integrity: sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@ -3504,9 +3738,16 @@ packages:
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsyringe@4.10.0:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
@ -3542,9 +3783,23 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
unicornstudio-react@1.4.31:
resolution: {integrity: sha512-EYPeBPyOXiL6ltLMQRJFbBktnai+RQee4UZk5OcFWbVXii//E8pRF9p4++5ByEiBvDIX4jyj5Mgtxi76Kr12kQ==}
engines: {node: '>=16.0.0'}
peerDependencies:
next: '*'
react: '>=18.0.0'
react-dom: '>=18.0.0'
peerDependenciesMeta:
next:
optional: true
unrs-resolver@1.11.1:
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
@ -3690,12 +3945,39 @@ packages:
zod@4.1.11:
resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
zustand@5.0.8:
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@alloc/quick-lru@5.2.0': {}
'@babel/runtime@7.28.4': {}
'@better-auth/core@1.3.26':
dependencies:
better-call: 1.0.19
zod: 4.1.11
'@better-auth/utils@0.3.0': {}
'@better-fetch/fetch@1.1.18': {}
'@dnd-kit/accessibility@3.1.1(react@19.1.0)':
dependencies:
react: 19.1.0
@ -3951,6 +4233,8 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@hexagon/base64@1.1.28': {}
'@hookform/resolvers@3.10.0(react-hook-form@7.64.0(react@19.1.0))':
dependencies:
react-hook-form: 7.64.0(react@19.1.0)
@ -4078,6 +4362,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@levischuck/tiny-cbor@0.2.11': {}
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.5.0
@ -4115,6 +4401,10 @@ snapshots:
'@next/swc-win32-x64-msvc@15.5.3':
optional: true
'@noble/ciphers@2.0.1': {}
'@noble/hashes@2.0.1': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -4129,6 +4419,111 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@paper-design/shaders-react@0.0.55(@types/react@19.2.0)(react@19.1.0)':
dependencies:
'@paper-design/shaders': 0.0.55
react: 19.1.0
optionalDependencies:
'@types/react': 19.2.0
'@paper-design/shaders@0.0.55': {}
'@peculiar/asn1-android@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-cms@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
'@peculiar/asn1-x509-attr': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-csr@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-ecc@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-pfx@2.5.0':
dependencies:
'@peculiar/asn1-cms': 2.5.0
'@peculiar/asn1-pkcs8': 2.5.0
'@peculiar/asn1-rsa': 2.5.0
'@peculiar/asn1-schema': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-pkcs8@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-pkcs9@2.5.0':
dependencies:
'@peculiar/asn1-cms': 2.5.0
'@peculiar/asn1-pfx': 2.5.0
'@peculiar/asn1-pkcs8': 2.5.0
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
'@peculiar/asn1-x509-attr': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-rsa@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-schema@2.5.0':
dependencies:
asn1js: 3.0.6
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/asn1-x509-attr@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
asn1js: 3.0.6
tslib: 2.8.1
'@peculiar/asn1-x509@2.5.0':
dependencies:
'@peculiar/asn1-schema': 2.5.0
asn1js: 3.0.6
pvtsutils: 1.3.6
tslib: 2.8.1
'@peculiar/x509@1.14.0':
dependencies:
'@peculiar/asn1-cms': 2.5.0
'@peculiar/asn1-csr': 2.5.0
'@peculiar/asn1-ecc': 2.5.0
'@peculiar/asn1-pkcs9': 2.5.0
'@peculiar/asn1-rsa': 2.5.0
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
pvtsutils: 1.3.6
reflect-metadata: 0.2.2
tslib: 2.8.1
tsyringe: 4.10.0
'@prisma/client@6.16.3(prisma@6.16.3(typescript@5.9.3))(typescript@5.9.3)':
optionalDependencies:
prisma: 6.16.3(typescript@5.9.3)
@ -4611,6 +5006,28 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@react-three/fiber@9.3.0(@types/react@19.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.180.0)':
dependencies:
'@babel/runtime': 7.28.4
'@types/react-reconciler': 0.32.1(@types/react@19.2.0)
'@types/webxr': 0.5.24
base64-js: 1.5.1
buffer: 6.0.3
its-fine: 2.0.0(@types/react@19.2.0)(react@19.1.0)
react: 19.1.0
react-reconciler: 0.31.0(react@19.1.0)
react-use-measure: 2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
scheduler: 0.25.0
suspend-react: 0.1.3(react@19.1.0)
three: 0.180.0
use-sync-external-store: 1.6.0(react@19.1.0)
zustand: 5.0.8(@types/react@19.2.0)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0))
optionalDependencies:
react-dom: 19.1.0(react@19.1.0)
transitivePeerDependencies:
- '@types/react'
- immer
'@remirror/core-constants@3.0.0': {}
'@rollup/rollup-android-arm-eabi@4.52.4':
@ -4683,6 +5100,19 @@ snapshots:
'@rushstack/eslint-patch@1.13.0': {}
'@simplewebauthn/browser@13.2.2': {}
'@simplewebauthn/server@13.2.2':
dependencies:
'@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.11
'@peculiar/asn1-android': 2.5.0
'@peculiar/asn1-ecc': 2.5.0
'@peculiar/asn1-rsa': 2.5.0
'@peculiar/asn1-schema': 2.5.0
'@peculiar/asn1-x509': 2.5.0
'@peculiar/x509': 1.14.0
'@standard-schema/spec@1.0.0': {}
'@swc/helpers@0.5.15':
@ -5009,6 +5439,14 @@ snapshots:
dependencies:
'@types/react': 19.2.0
'@types/react-reconciler@0.28.9(@types/react@19.2.0)':
dependencies:
'@types/react': 19.2.0
'@types/react-reconciler@0.32.1(@types/react@19.2.0)':
dependencies:
'@types/react': 19.2.0
'@types/react@19.2.0':
dependencies:
csstype: 3.1.3
@ -5019,6 +5457,8 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
'@types/webxr@0.5.24': {}
'@typescript-eslint/eslint-plugin@8.45.0(@typescript-eslint/parser@8.45.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
@ -5303,6 +5743,12 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
asn1js@3.0.6:
dependencies:
pvtsutils: 1.3.6
pvutils: 1.1.3
tslib: 2.8.1
assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {}
@ -5319,6 +5765,36 @@ snapshots:
balanced-match@1.0.2: {}
base64-js@1.5.1: {}
better-auth@1.3.26(next@15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@better-auth/core': 1.3.26
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.18
'@noble/ciphers': 2.0.1
'@noble/hashes': 2.0.1
'@simplewebauthn/browser': 13.2.2
'@simplewebauthn/server': 13.2.2
better-call: 1.0.19
defu: 6.1.4
jose: 6.1.0
kysely: 0.28.7
nanostores: 1.0.1
zod: 4.1.11
optionalDependencies:
next: 15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
better-call@1.0.19:
dependencies:
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.18
rou3: 0.5.1
set-cookie-parser: 2.7.1
uncrypto: 0.1.3
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@ -5332,6 +5808,11 @@ snapshots:
dependencies:
fill-range: 7.1.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
c12@3.1.0:
dependencies:
chokidar: 4.0.3
@ -6115,6 +6596,8 @@ snapshots:
domutils: 3.2.2
entities: 4.5.0
ieee754@1.2.1: {}
ignore@5.3.2: {}
ignore@7.0.5: {}
@ -6261,8 +6744,17 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
its-fine@2.0.0(@types/react@19.2.0)(react@19.1.0):
dependencies:
'@types/react-reconciler': 0.28.9(@types/react@19.2.0)
react: 19.1.0
transitivePeerDependencies:
- '@types/react'
jiti@2.6.1: {}
jose@6.1.0: {}
js-tokens@4.0.0: {}
js-yaml@4.1.0:
@ -6292,6 +6784,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
kysely@0.28.7: {}
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@ -6416,6 +6910,8 @@ snapshots:
nanoid@3.3.11: {}
nanostores@1.0.1: {}
napi-postinstall@0.3.3: {}
natural-compare@1.4.0: {}
@ -6701,6 +7197,12 @@ snapshots:
pure-rand@6.1.0: {}
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
pvutils@1.1.3: {}
queue-microtask@1.2.3: {}
rc9@2.1.2:
@ -6721,6 +7223,11 @@ snapshots:
react-is@18.3.1: {}
react-reconciler@0.31.0(react@19.1.0):
dependencies:
react: 19.1.0
scheduler: 0.25.0
react-remove-scroll-bar@2.3.8(@types/react@19.2.0)(react@19.1.0):
dependencies:
react: 19.1.0
@ -6765,6 +7272,12 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-use-measure@2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
optionalDependencies:
react-dom: 19.1.0(react@19.1.0)
react@19.1.0: {}
readdirp@4.1.2: {}
@ -6786,6 +7299,8 @@ snapshots:
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
reflect-metadata@0.2.2: {}
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@ -6854,6 +7369,8 @@ snapshots:
rope-sequence@1.3.4: {}
rou3@0.5.1: {}
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@ -6886,12 +7403,16 @@ snapshots:
parse-srcset: 1.0.2
postcss: 8.5.6
scheduler@0.25.0: {}
scheduler@0.26.0: {}
semver@6.3.1: {}
semver@7.7.2: {}
set-cookie-parser@2.7.1: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@ -7063,6 +7584,10 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
suspend-react@0.1.3(react@19.1.0):
dependencies:
react: 19.1.0
tailwind-merge@3.3.1: {}
tailwindcss@4.1.14: {}
@ -7077,6 +7602,8 @@ snapshots:
minizlib: 3.1.0
yallist: 5.0.0
three@0.180.0: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
@ -7111,8 +7638,14 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tslib@1.14.1: {}
tslib@2.8.1: {}
tsyringe@4.10.0:
dependencies:
tslib: 1.14.1
tw-animate-css@1.4.0: {}
type-check@0.4.0:
@ -7163,8 +7696,17 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
uncrypto@0.1.3: {}
undici-types@6.21.0: {}
unicornstudio-react@1.4.31(next@15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
next: 15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
unrs-resolver@1.11.1:
dependencies:
napi-postinstall: 0.3.3
@ -7360,3 +7902,9 @@ snapshots:
yocto-queue@0.1.0: {}
zod@4.1.11: {}
zustand@5.0.8(@types/react@19.2.0)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)):
optionalDependencies:
'@types/react': 19.2.0
react: 19.1.0
use-sync-external-store: 1.6.0(react@19.1.0)

View file

@ -1,5 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
export default {
plugins: {
"@tailwindcss/postcss": {},
},
}

View file

@ -0,0 +1,215 @@
-- CreateTable
CREATE TABLE "Team" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "TeamMember" (
"teamId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"isLead" BOOLEAN NOT NULL DEFAULT false,
"assignedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("teamId", "userId"),
CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" TEXT NOT NULL,
"timezone" TEXT NOT NULL DEFAULT 'America/Sao_Paulo',
"avatarUrl" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Queue" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"teamId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Queue_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Ticket" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"reference" INTEGER NOT NULL DEFAULT 0,
"subject" TEXT NOT NULL,
"summary" TEXT,
"status" TEXT NOT NULL DEFAULT 'NEW',
"priority" TEXT NOT NULL DEFAULT 'MEDIUM',
"channel" TEXT NOT NULL DEFAULT 'EMAIL',
"queueId" TEXT,
"requesterId" TEXT NOT NULL,
"assigneeId" TEXT,
"slaPolicyId" TEXT,
"dueAt" DATETIME,
"firstResponseAt" DATETIME,
"resolvedAt" DATETIME,
"closedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Ticket_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Ticket_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Ticket_queueId_fkey" FOREIGN KEY ("queueId") REFERENCES "Queue" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Ticket_slaPolicyId_fkey" FOREIGN KEY ("slaPolicyId") REFERENCES "SlaPolicy" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TicketEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"ticketId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TicketEvent_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TicketComment" (
"id" TEXT NOT NULL PRIMARY KEY,
"ticketId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"visibility" TEXT NOT NULL DEFAULT 'INTERNAL',
"body" TEXT NOT NULL,
"attachments" JSONB,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "TicketComment_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "TicketComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SlaPolicy" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"timeToFirstResponse" INTEGER,
"timeToResolution" INTEGER,
"calendar" JSONB,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "AuthUser" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"role" TEXT NOT NULL DEFAULT 'agent',
"tenantId" TEXT,
"avatarUrl" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "AuthSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "AuthSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AuthUser" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuthAccount" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"accessTokenExpiresAt" DATETIME,
"refreshTokenExpiresAt" DATETIME,
"scope" TEXT,
"idToken" TEXT,
"password" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "AuthAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AuthUser" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuthVerification" (
"id" TEXT NOT NULL PRIMARY KEY,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE INDEX "Team_tenantId_name_idx" ON "Team"("tenantId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_tenantId_role_idx" ON "User"("tenantId", "role");
-- CreateIndex
CREATE UNIQUE INDEX "Queue_tenantId_slug_key" ON "Queue"("tenantId", "slug");
-- CreateIndex
CREATE INDEX "Ticket_tenantId_status_idx" ON "Ticket"("tenantId", "status");
-- CreateIndex
CREATE INDEX "Ticket_tenantId_queueId_idx" ON "Ticket"("tenantId", "queueId");
-- CreateIndex
CREATE INDEX "Ticket_tenantId_assigneeId_idx" ON "Ticket"("tenantId", "assigneeId");
-- CreateIndex
CREATE INDEX "TicketEvent_ticketId_createdAt_idx" ON "TicketEvent"("ticketId", "createdAt");
-- CreateIndex
CREATE INDEX "TicketComment_ticketId_visibility_idx" ON "TicketComment"("ticketId", "visibility");
-- CreateIndex
CREATE UNIQUE INDEX "SlaPolicy_tenantId_name_key" ON "SlaPolicy"("tenantId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "AuthUser_email_key" ON "AuthUser"("email");
-- CreateIndex
CREATE UNIQUE INDEX "AuthSession_token_key" ON "AuthSession"("token");
-- CreateIndex
CREATE INDEX "AuthSession_userId_idx" ON "AuthSession"("userId");
-- CreateIndex
CREATE INDEX "AuthSession_token_idx" ON "AuthSession"("token");
-- CreateIndex
CREATE INDEX "AuthAccount_userId_idx" ON "AuthAccount"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "AuthAccount_providerId_accountId_key" ON "AuthAccount"("providerId", "accountId");
-- CreateIndex
CREATE INDEX "AuthVerification_identifier_idx" ON "AuthVerification"("identifier");

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View file

@ -3,7 +3,7 @@ generator client {
}
datasource db {
provider = "postgresql"
provider = "sqlite"
url = env("DATABASE_URL")
}
@ -106,7 +106,7 @@ model Queue {
model Ticket {
id String @id @default(cuid())
tenantId String
reference Int @default(autoincrement())
reference Int @default(0)
subject String
summary String?
status TicketStatus @default(NEW)
@ -126,6 +126,7 @@ model Ticket {
requester User @relation("TicketRequester", fields: [requesterId], references: [id])
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
queue Queue? @relation(fields: [queueId], references: [id])
slaPolicy SlaPolicy? @relation(fields: [slaPolicyId], references: [id])
events TicketEvent[]
comments TicketComment[]
@ -177,3 +178,66 @@ model SlaPolicy {
@@unique([tenantId, name])
}
model AuthUser {
id String @id @default(cuid())
name String?
email String @unique
emailVerified Boolean @default(false)
image String?
role String @default("agent")
tenantId String?
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions AuthSession[]
accounts AuthAccount[]
}
model AuthSession {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([token])
}
model AuthAccount {
id String @id @default(cuid())
userId String
accountId String
providerId String
accessToken String?
refreshToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
idToken String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([providerId, accountId])
@@index([userId])
}
model AuthVerification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([identifier])
}

57
web/scripts/seed-auth.mjs Normal file
View file

@ -0,0 +1,57 @@
import { PrismaClient } from "@prisma/client"
import { hashPassword } from "better-auth/crypto"
const prisma = new PrismaClient()
const email = process.env.SEED_USER_EMAIL ?? "admin@sistema.dev"
const password = process.env.SEED_USER_PASSWORD ?? "admin123"
const name = process.env.SEED_USER_NAME ?? "Administrador"
const role = process.env.SEED_USER_ROLE ?? "admin"
const tenantId = process.env.SEED_USER_TENANT ?? "default"
async function main() {
const hashedPassword = await hashPassword(password)
const user = await prisma.authUser.upsert({
where: { email },
update: {
name,
role,
tenantId,
},
create: {
email,
name,
role,
tenantId,
accounts: {
create: {
providerId: "email",
accountId: email,
password: hashedPassword,
},
},
},
include: {
accounts: true,
},
})
const account = user.accounts[0]
console.log(`✅ Usuario seed criado/atualizado: ${user.email}`)
console.log(` ID: ${user.id}`)
console.log(` Role: ${user.role}`)
console.log(` Tenant: ${user.tenantId ?? "(nenhum)"}`)
console.log(` Provider: ${account?.providerId ?? "-"}`)
console.log(`Senha provisoria: ${password}`)
}
main()
.catch((error) => {
console.error("Erro ao criar usuario seed", error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})

View file

@ -0,0 +1,11 @@
import { ReactNode } from "react"
import { requireAdminSession } from "@/lib/auth-server"
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
export default async function AdminLayout({ children }: { children: ReactNode }) {
await requireAdminSession()
return <>{children}</>
}

View file

@ -0,0 +1,48 @@
import { AdminUsersManager } from "@/components/admin/admin-users-manager"
import { ROLE_OPTIONS, normalizeRole } from "@/lib/authz"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { prisma } from "@/lib/prisma"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
async function loadUsers() {
const users = await prisma.authUser.findMany({
orderBy: { createdAt: "desc" },
select: {
id: true,
email: true,
name: true,
role: true,
tenantId: true,
createdAt: true,
updatedAt: true,
},
})
return users.map((user) => ({
id: user.id,
email: user.email,
name: user.name ?? "",
role: normalizeRole(user.role) ?? "agent",
tenantId: user.tenantId ?? DEFAULT_TENANT_ID,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt?.toISOString() ?? null,
}))
}
export default async function AdminPage() {
const users = await loadUsers()
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
<div className="mb-8">
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Administração</h1>
<p className="mt-2 text-sm text-neutral-600">
Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento.
</p>
</div>
<AdminUsersManager initialUsers={users} roleOptions={ROLE_OPTIONS} defaultTenantId={DEFAULT_TENANT_ID} />
</main>
)
}

View file

@ -0,0 +1,122 @@
import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex generated API lacks type declarations in Next API routes
import { api } from "@/convex/_generated/api"
import { prisma } from "@/lib/prisma"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { assertAdminSession } from "@/lib/auth-server"
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
export const runtime = "nodejs"
function normalizeRole(input: string | null | undefined): RoleOption {
const role = (input ?? "agent").toLowerCase() as RoleOption
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
}
function generatePassword(length = 12) {
const bytes = randomBytes(length)
return Array.from(bytes)
.map((byte) => (byte % 36).toString(36))
.join("")
}
export async function GET() {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const users = await prisma.authUser.findMany({
orderBy: { createdAt: "desc" },
select: {
id: true,
email: true,
name: true,
role: true,
tenantId: true,
createdAt: true,
updatedAt: true,
},
})
return NextResponse.json({ users })
}
export async function POST(request: Request) {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const payload = await request.json().catch(() => null)
if (!payload || typeof payload !== "object") {
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
}
const emailInput = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : ""
const nameInput = typeof payload.name === "string" ? payload.name.trim() : ""
const roleInput = typeof payload.role === "string" ? payload.role : undefined
const tenantInput = typeof payload.tenantId === "string" ? payload.tenantId.trim() : undefined
if (!emailInput || !emailInput.includes("@")) {
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
}
const role = normalizeRole(roleInput)
const tenantId = tenantInput || session.user.tenantId || DEFAULT_TENANT_ID
const existing = await prisma.authUser.findUnique({ where: { email: emailInput } })
if (existing) {
return NextResponse.json({ error: "Já existe um usuário com este e-mail" }, { status: 409 })
}
const password = generatePassword()
const hashedPassword = await hashPassword(password)
const user = await prisma.authUser.create({
data: {
email: emailInput,
name: nameInput || emailInput,
role,
tenantId,
accounts: {
create: {
providerId: "email",
accountId: emailInput,
password: hashedPassword,
},
},
},
select: {
id: true,
email: true,
name: true,
role: true,
tenantId: true,
createdAt: true,
},
})
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (convexUrl) {
try {
const convex = new ConvexHttpClient(convexUrl)
await convex.mutation(api.users.ensureUser, {
tenantId,
email: emailInput,
name: nameInput || emailInput,
avatarUrl: undefined,
role: role.toUpperCase(),
})
} catch (error) {
console.warn("Falha ao sincronizar usuário no Convex", error)
}
}
return NextResponse.json({ user, temporaryPassword: password })
}

View file

@ -0,0 +1,5 @@
import { toNextJsHandler } from "better-auth/next-js"
import { auth } from "@/lib/auth"
export const { GET, POST } = toNextJsHandler(auth.handler)

View file

@ -41,6 +41,10 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-destructive-foreground: var(--destructive-foreground);
--font-geist-mono: var(----font-geist-mono);
--font-geist-sans: var(----font-geist-sans);
--radius: var(----radius);
}
:root {
@ -76,6 +80,9 @@
--sidebar-accent-foreground: #0f172a;
--sidebar-border: #cbd5e1;
--sidebar-ring: #00d6eb;
--destructive-foreground: oklch(1 0 0);
--font-geist-sans: "Geist Sans", sans-serif;
--font-geist-mono: "Geist Mono", monospace;
}
.dark {
@ -110,6 +117,7 @@
--sidebar-accent-foreground: #f8fafc;
--sidebar-border: #0f1b2a;
--sidebar-ring: #00e6ff;
--destructive-foreground: oklch(1 0 0);
}
@layer base {
@ -163,3 +171,12 @@
animation: recent-ticket-enter 0.45s ease-out;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -1,7 +1,6 @@
import type { Metadata } from "next"
import { Inter, JetBrains_Mono } from "next/font/google"
import "./globals.css"
import { cookies } from "next/headers"
import { ConvexClientProvider } from "./ConvexClientProvider"
import { AuthProvider } from "@/lib/auth-client"
import { Toaster } from "@/components/ui/sonner"
@ -28,20 +27,13 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode
}>) {
const cookieStore = await cookies()
const cookie = cookieStore.get("demoUser")?.value
let demoUser: { name: string; email: string } | null = null
try {
demoUser = cookie ? (JSON.parse(cookie) as { name: string; email: string }) : null
} catch {}
const tenantId = "tenant-atlas"
return (
<html lang="pt-BR" className="h-full">
<body
className={`${inter.variable} ${jetBrainsMono.variable} min-h-screen bg-background text-foreground antialiased`}
>
<ConvexClientProvider>
<AuthProvider demoUser={demoUser} tenantId={tenantId}>
<AuthProvider>
{children}
<Toaster position="bottom-center" richColors />
</AuthProvider>

View file

@ -1,36 +1,52 @@
"use client";
"use client"
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useEffect } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { GalleryVerticalEnd } from "lucide-react"
import { LoginForm } from "@/components/login-form"
import { useSession } from "@/lib/auth-client"
import dynamic from "next/dynamic"
const ShaderBackground = dynamic(
() => import("@/components/background-paper-shaders-wrapper"),
{ ssr: false }
)
export default function LoginPage() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const router = useRouter();
const router = useRouter()
const searchParams = useSearchParams()
const { data: session, isPending } = useSession()
const callbackUrl = searchParams?.get("callbackUrl") ?? undefined
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name || !email) return;
document.cookie = `demoUser=${JSON.stringify({ name, email })}; path=/; max-age=${60 * 60 * 24 * 365}`;
router.replace("/dashboard");
};
useEffect(() => {
if (!session?.user) return
const destination = callbackUrl ?? "/dashboard"
router.replace(destination)
}, [callbackUrl, router, session?.user])
return (
<div className="flex min-h-dvh items-center justify-center p-6">
<form onSubmit={submit} className="w-full max-w-sm space-y-4 rounded-xl border bg-card p-6 shadow-sm">
<h1 className="text-lg font-semibold">Entrar (placeholder)</h1>
<div className="space-y-2">
<label className="text-sm">Nome</label>
<input className="w-full rounded-md border bg-background px-3 py-2" placeholder="Ana Souza" value={name} onChange={(e) => setName(e.target.value)} />
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-6 p-6 md:p-10">
<div className="flex justify-center gap-2">
<Link href="/" className="flex items-center gap-2 font-medium">
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-4" />
</div>
<div className="space-y-2">
<label className="text-sm">E-mail</label>
<input className="w-full rounded-md border bg-background px-3 py-2" placeholder="ana@example.com" value={email} onChange={(e) => setEmail(e.target.value)} />
Sistema de Chamados
</Link>
</div>
<button type="submit" className="inline-flex w-full items-center justify-center rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground">Continuar</button>
<p className="text-center text-xs text-muted-foreground">Apenas para desenvolvimento. Em produção usar Auth.js/Clerk.</p>
</form>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<LoginForm callbackUrl={callbackUrl} disabled={isPending} />
</div>
);
</div>
</div>
<div className="relative hidden overflow-hidden lg:flex">
<ShaderBackground className="h-full w-full" />
</div>
</div>
)
}

View file

@ -0,0 +1,34 @@
import type { Metadata } from "next"
import Link from "next/link"
import { Button } from "@/components/ui/button"
export const metadata: Metadata = {
title: "Portal do cliente",
description: "Acompanhe seus chamados e atualizações como cliente.",
}
export default function PortalPage() {
return (
<main className="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-8 px-6 py-12 text-center lg:min-h-[calc(100vh-6rem)]">
<div className="max-w-md space-y-4">
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-muted-foreground">
Portal do cliente
</p>
<h1 className="text-3xl font-bold tracking-tight text-foreground">
Área do cliente em construção
</h1>
<p className="text-muted-foreground">
Em breve você poderá abrir novos chamados, acompanhar o status e conversar com a equipe de suporte por aqui.
Enquanto finalizamos os ajustes, utilize os canais combinados com sua equipe de atendimento.
</p>
</div>
<div className="flex flex-col items-center gap-3 text-sm text-muted-foreground">
<span>Precisa falar com a equipe agora?</span>
<Button asChild variant="outline">
<Link href="mailto:suporte@sistema.dev">Enviar e-mail para suporte</Link>
</Button>
</div>
</main>
)
}

View file

@ -29,7 +29,7 @@ import { CategorySelectFields } from "@/components/tickets/category-select"
export default function NewTicketPage() {
const router = useRouter()
const { userId } = useAuth()
const { convexUserId } = useAuth()
const queuesRaw = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
const create = useMutation(api.tickets.create)
@ -52,7 +52,7 @@ export default function NewTicketPage() {
async function submit(event: React.FormEvent) {
event.preventDefault()
if (!userId || loading) return
if (!convexUserId || loading) return
const trimmedSubject = subject.trim()
if (trimmedSubject.length < 3) {
@ -76,13 +76,14 @@ export default function NewTicketPage() {
const selQueue = queues.find((q) => q.name === queueName)
const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined
const id = await create({
actorId: convexUserId as Id<"users">,
tenantId: DEFAULT_TENANT_ID,
subject: trimmedSubject,
summary: summary.trim() || undefined,
priority,
channel,
queueId,
requesterId: userId as Id<"users">,
requesterId: convexUserId as Id<"users">,
categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
})
@ -90,7 +91,7 @@ export default function NewTicketPage() {
if (plainDescription.length > 0) {
await addComment({
ticketId: id as Id<"tickets">,
authorId: userId as Id<"users">,
authorId: convexUserId as Id<"users">,
visibility: "PUBLIC",
body: description,
attachments: [],

View file

@ -0,0 +1,240 @@
"use client"
import { useMemo, useState, useTransition } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
type AdminUser = {
id: string
email: string
name: string
role: RoleOption
tenantId: string
createdAt: string
updatedAt: string | null
}
type Props = {
initialUsers: AdminUser[]
roleOptions: readonly RoleOption[]
defaultTenantId: string
}
function formatDate(dateIso: string) {
const date = new Date(dateIso)
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "medium",
timeStyle: "short",
}).format(date)
}
export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }: Props) {
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
const [email, setEmail] = useState("")
const [name, setName] = useState("")
const [role, setRole] = useState<RoleOption>("agent")
const [tenantId, setTenantId] = useState(defaultTenantId)
const [lastInvite, setLastInvite] = useState<{ email: string; password: string } | null>(null)
const [isPending, startTransition] = useTransition()
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!email || !email.includes("@")) {
toast.error("Informe um e-mail válido")
return
}
const payload = { email, name, role, tenantId }
startTransition(async () => {
try {
const response = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível criar o usuário")
}
const data = (await response.json()) as {
user: AdminUser
temporaryPassword: string
}
setUsers((previous) => [data.user, ...previous])
setLastInvite({ email: data.user.email, password: data.temporaryPassword })
setEmail("")
setName("")
setRole("agent")
setTenantId(defaultTenantId)
toast.success("Usuário criado com sucesso")
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao criar usuário"
toast.error(message)
}
})
}
return (
<Tabs defaultValue="users" className="w-full">
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
</TabsList>
<TabsContent value="users" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Convidar novo usuário</CardTitle>
<CardDescription>Crie um acesso provisório e compartilhe a senha inicial com o colaborador.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_200px_200px_auto]">
<div className="grid gap-2">
<Label htmlFor="invite-email">E-mail corporativo</Label>
<Input
id="invite-email"
type="email"
inputMode="email"
placeholder="nome@suaempresa.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-name">Nome</Label>
<Input
id="invite-name"
placeholder="Nome completo"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label>Papel</Label>
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
<SelectTrigger id="invite-role">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{normalizedRoles.map((item) => (
<SelectItem key={item} value={item}>
{item === "customer" ? "Cliente" : item === "admin" ? "Administrador" : item}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-tenant">Tenant</Label>
<Input
id="invite-tenant"
value={tenantId}
onChange={(event) => setTenantId(event.target.value)}
/>
</div>
<div className="flex items-end">
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? "Criando..." : "Criar acesso"}
</Button>
</div>
</form>
{lastInvite ? (
<div className="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
<p className="font-medium">Acesso provisório gerado</p>
<p className="mt-1 text-neutral-600">
Envie para <span className="font-semibold">{lastInvite.email}</span> a senha inicial
<span className="font-mono text-neutral-900"> {lastInvite.password}</span>.
Solicite que altere após o primeiro login.
</p>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Equipe cadastrada</CardTitle>
<CardDescription>Lista completa de usuários autenticáveis pela Better Auth.</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="py-3 pr-4 font-medium">Nome</th>
<th className="py-3 pr-4 font-medium">E-mail</th>
<th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Tenant</th>
<th className="py-3 font-medium">Criado em</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 uppercase text-neutral-600">{user.role}</td>
<td className="py-3 pr-4 text-neutral-600">{user.tenantId}</td>
<td className="py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
</tr>
))}
{users.length === 0 ? (
<tr>
<td colSpan={5} className="py-6 text-center text-neutral-500">
Nenhum usuário cadastrado até o momento.
</td>
</tr>
) : null}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="queues" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Gestão de filas</CardTitle>
<CardDescription>
Em breve será possível criar e reordenar as filas utilizadas na triagem dos tickets.
</CardDescription>
</CardHeader>
</Card>
</TabsContent>
<TabsContent value="categories" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Gestão de categorias</CardTitle>
<CardDescription>
Estamos preparando o painel completo para organizar categorias e subcategorias do catálogo.
</CardDescription>
</CardHeader>
</Card>
</TabsContent>
</Tabs>
)
}

View file

@ -0,0 +1,20 @@
"use client"
import { MeshGradient } from "@paper-design/shaders-react"
export default function BackgroundPaperShadersWrapper() {
const speed = 1.0
return (
<div className="w-full h-full bg-black relative overflow-hidden">
<MeshGradient
className="w-full h-full absolute inset-0"
colors={["#000000", "#1a1a1a", "#333333", "#ffffff"]}
speed={speed * 0.5}
wireframe="true"
backgroundColor="#000000"
/>
</div>
)
}

View file

@ -0,0 +1,116 @@
"use client"
import { useRef, useMemo } from "react"
import { useFrame } from "@react-three/fiber"
import * as THREE from "three"
// Custom shader material for advanced effects
const vertexShader = `
uniform float time;
uniform float intensity;
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vUv = uv;
vPosition = position;
vec3 pos = position;
pos.y += sin(pos.x * 10.0 + time) * 0.1 * intensity;
pos.x += cos(pos.y * 8.0 + time * 1.5) * 0.05 * intensity;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`
const fragmentShader = `
uniform float time;
uniform float intensity;
uniform vec3 color1;
uniform vec3 color2;
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vec2 uv = vUv;
// Create animated noise pattern
float noise = sin(uv.x * 20.0 + time) * cos(uv.y * 15.0 + time * 0.8);
noise += sin(uv.x * 35.0 - time * 2.0) * cos(uv.y * 25.0 + time * 1.2) * 0.5;
// Mix colors based on noise and position
vec3 color = mix(color1, color2, noise * 0.5 + 0.5);
color = mix(color, vec3(1.0), pow(abs(noise), 2.0) * intensity);
// Add glow effect
float glow = 1.0 - length(uv - 0.5) * 2.0;
glow = pow(glow, 2.0);
gl_FragColor = vec4(color * glow, glow * 0.8);
}
`
export function ShaderPlane({
position,
color1 = "#ff5722",
color2 = "#ffffff",
}: {
position: [number, number, number]
color1?: string
color2?: string
}) {
const mesh = useRef<THREE.Mesh>(null)
const uniforms = useMemo(
() => ({
time: { value: 0 },
intensity: { value: 1.0 },
color1: { value: new THREE.Color(color1) },
color2: { value: new THREE.Color(color2) },
}),
[color1, color2],
)
useFrame((state) => {
if (mesh.current) {
uniforms.time.value = state.clock.elapsedTime
uniforms.intensity.value = 1.0 + Math.sin(state.clock.elapsedTime * 2) * 0.3
}
})
return (
<mesh ref={mesh} position={position}>
<planeGeometry args={[2, 2, 32, 32]} />
<shaderMaterial
uniforms={uniforms}
vertexShader={vertexShader}
fragmentShader={fragmentShader}
transparent
side={THREE.DoubleSide}
/>
</mesh>
)
}
export function EnergyRing({
radius = 1,
position = [0, 0, 0],
}: {
radius?: number
position?: [number, number, number]
}) {
const mesh = useRef<THREE.Mesh>(null)
useFrame((state) => {
if (mesh.current) {
mesh.current.rotation.z = state.clock.elapsedTime
mesh.current.material.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3
}
})
return (
<mesh ref={mesh} position={position}>
<ringGeometry args={[radius * 0.8, radius, 32]} />
<meshBasicMaterial color="#ff5722" transparent opacity={0.6} side={THREE.DoubleSide} />
</mesh>
)
}

View file

@ -0,0 +1,124 @@
"use client"
import { useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { Loader2 } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { signIn } from "@/lib/auth-client"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
type LoginFormProps = React.ComponentProps<"form"> & {
callbackUrl?: string
disabled?: boolean
}
export function LoginForm({ className, callbackUrl, disabled = false, ...props }: LoginFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const destination = callbackUrl ?? searchParams?.get("callbackUrl") ?? "/dashboard"
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (isSubmitting || disabled) return
if (!email || !password) {
toast.error("Informe e-mail e senha")
return
}
setIsSubmitting(true)
try {
const result = await signIn.email({ email, password, callbackURL: destination })
if (result?.error) {
toast.error("E-mail ou senha inválidos")
setIsSubmitting(false)
return
}
toast.success("Sessão iniciada com sucesso")
router.replace(destination)
} catch (error) {
console.error("Erro ao autenticar", error)
toast.error("Não foi possível entrar. Tente novamente")
setIsSubmitting(false)
}
}
return (
<form
onSubmit={handleSubmit}
className={cn("flex flex-col gap-6", disabled && "pointer-events-none opacity-70", className)}
{...props}
>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold">Acesse sua conta</h1>
<p className="text-muted-foreground text-sm text-balance">
Informe seu e-mail corporativo e senha para continuar atendendo os chamados.
</p>
</div>
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
placeholder="agente@sistema.dev"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
disabled={isSubmitting || disabled}
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Senha</FieldLabel>
<Link
href="/recuperar"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Esqueceu a senha?
</Link>
</div>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isSubmitting || disabled}
required
/>
</Field>
<Field>
<Button type="submit" disabled={isSubmitting || disabled} className="gap-2">
{(isSubmitting || disabled) && <Loader2 className="size-4 animate-spin" />}
Entrar
</Button>
</Field>
<FieldSeparator />
<Field>
<div className="space-y-1 text-left">
<p className="text-sm font-semibold">Primeiro acesso?</p>
<FieldDescription className="text-sm">
Fale com o nosso suporte por telefone ou e-mail para receber um convite e definir sua senha inicial.
</FieldDescription>
</div>
</Field>
</FieldGroup>
</form>
)
}

View file

@ -54,7 +54,7 @@ export function NewTicketDialog() {
},
mode: "onTouched",
})
const { userId } = useAuth()
const { convexUserId } = useAuth()
const queuesRaw = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
const create = useMutation(api.tickets.create)
@ -96,7 +96,7 @@ export function NewTicketDialog() {
}
async function submit(values: z.infer<typeof schema>) {
if (!userId) return
if (!convexUserId) return
const subjectTrimmed = (values.subject ?? "").trim()
if (subjectTrimmed.length < 3) {
form.setError("subject", { type: "min", message: "Informe um assunto" })
@ -107,13 +107,14 @@ export function NewTicketDialog() {
try {
const sel = queues.find((q) => q.name === values.queueName)
const id = await create({
actorId: convexUserId as Id<"users">,
tenantId: DEFAULT_TENANT_ID,
subject: subjectTrimmed,
summary: values.summary,
priority: values.priority,
channel: values.channel,
queueId: sel?.id as Id<"queues"> | undefined,
requesterId: userId as Id<"users">,
requesterId: convexUserId as Id<"users">,
categoryId: values.categoryId as Id<"ticketCategories">,
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
})
@ -126,7 +127,7 @@ export function NewTicketDialog() {
size: a.size,
type: a.type,
}))
await addComment({ ticketId: id as Id<"tickets">, authorId: userId as Id<"users">, visibility: "PUBLIC", body: bodyHtml, attachments: typedAttachments })
await addComment({ ticketId: id as Id<"tickets">, authorId: convexUserId as Id<"users">, visibility: "PUBLIC", body: bodyHtml, attachments: typedAttachments })
}
toast.success("Ticket criado!", { id: "new-ticket" })
setOpen(false)

View file

@ -31,19 +31,25 @@ const secondaryButtonClass = "inline-flex items-center gap-2 rounded-lg border b
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const router = useRouter()
const { userId } = useAuth()
const { convexUserId } = useAuth()
const queueSummary = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? []
const playNext = useMutation(api.tickets.playNext)
const [selectedQueueId, setSelectedQueueId] = useState<string | undefined>(undefined)
const nextTicketFromServer = useQuery(api.tickets.list, {
const nextTicketFromServer = useQuery(
api.tickets.list,
convexUserId
? {
tenantId: DEFAULT_TENANT_ID,
viewerId: convexUserId as Id<"users">,
status: undefined,
priority: undefined,
channel: undefined,
queueId: (selectedQueueId as Id<"queues">) || undefined,
limit: 1,
})?.[0]
}
: "skip"
)?.[0]
const nextTicketUi = nextTicketFromServer ? mapTicketFromServer(nextTicketFromServer as unknown) : null
const cardContext: TicketPlayContext | null =
@ -128,12 +134,12 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
<Button
className={startButtonClass}
onClick={async () => {
if (!userId) return
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: (selectedQueueId as Id<"queues">) || undefined, agentId: userId as Id<"users"> })
if (!convexUserId) return
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: (selectedQueueId as Id<"queues">) || undefined, agentId: convexUserId as Id<"users"> })
if (chosen?.id) router.push(`/tickets/${chosen.id}`)
}}
>
{userId ? (
{convexUserId ? (
<>
<IconPlayerPlayFilled className="size-4 text-black" /> Iniciar atendimento
</>

View file

@ -40,7 +40,7 @@ export function PriorityIcon({ value }: { value: TicketPriority }) {
export function PrioritySelect({ ticketId, value }: { ticketId: string; value: TicketPriority }) {
const updatePriority = useMutation(api.tickets.updatePriority)
const [priority, setPriority] = useState<TicketPriority>(value)
const { userId } = useAuth()
const { convexUserId } = useAuth()
return (
<Select
@ -51,8 +51,8 @@ export function PrioritySelect({ ticketId, value }: { ticketId: string; value: T
setPriority(next)
toast.loading("Atualizando prioridade...", { id: "priority" })
try {
if (!userId) throw new Error("missing user")
await updatePriority({ ticketId: ticketId as unknown as Id<"tickets">, priority: next, actorId: userId as Id<"users"> })
if (!convexUserId) throw new Error("missing user")
await updatePriority({ ticketId: ticketId as unknown as Id<"tickets">, priority: next, actorId: convexUserId as Id<"users"> })
toast.success("Prioridade atualizada!", { id: "priority" })
} catch {
setPriority(previous)

View file

@ -7,6 +7,7 @@ import { ptBR } from "date-fns/locale"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TS declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket } from "@/lib/schemas/ticket"
@ -16,6 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { useAuth } from "@/lib/auth-client"
const metaBadgeClass =
"inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-[11px] font-semibold text-neutral-700"
@ -80,7 +82,11 @@ function TicketRow({ ticket, entering }: { ticket: Ticket; entering: boolean })
}
export function RecentTicketsPanel() {
const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 6 })
const { convexUserId } = useAuth()
const ticketsRaw = useQuery(
api.tickets.list,
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users">, limit: 6 } : "skip"
)
const [enteringId, setEnteringId] = useState<string | null>(null)
const previousIdsRef = useRef<string[]>([])

View file

@ -31,7 +31,7 @@ const baseBadgeClass =
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
const updateStatus = useMutation(api.tickets.updateStatus)
const [status, setStatus] = useState<TicketStatus>(value)
const { userId } = useAuth()
const { convexUserId } = useAuth()
return (
<Select
@ -42,8 +42,8 @@ export function StatusSelect({ ticketId, value }: { ticketId: string; value: Tic
setStatus(next)
toast.loading("Atualizando status...", { id: "status" })
try {
if (!userId) throw new Error("missing user")
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: userId as Id<"users"> })
if (!convexUserId) throw new Error("missing user")
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: convexUserId as Id<"users"> })
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
} catch {
setStatus(previous)

View file

@ -33,7 +33,7 @@ const submitButtonClass =
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
export function TicketComments({ ticket }: TicketCommentsProps) {
const { userId } = useAuth()
const { convexUserId } = useAuth()
const addComment = useMutation(api.tickets.addComment)
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
const updateComment = useMutation(api.tickets.updateComment)
@ -59,7 +59,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const saveEditedComment = useCallback(
async (commentId: string, originalBody: string) => {
if (!editingComment || editingComment.id !== commentId) return
if (!userId) return
if (!convexUserId) return
if (commentId.startsWith("temp-")) return
const sanitized = sanitizeEditorHtml(editingComment.value)
@ -75,7 +75,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
await updateComment({
ticketId: ticket.id as Id<"tickets">,
commentId: commentId as unknown as Id<"ticketComments">,
actorId: userId as Id<"users">,
actorId: convexUserId as Id<"users">,
body: sanitized,
})
setLocalBodies((prev) => ({ ...prev, [commentId]: sanitized }))
@ -88,7 +88,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
setSavingCommentId(null)
}
},
[editingComment, ticket.id, updateComment, userId]
[editingComment, ticket.id, updateComment, convexUserId]
)
const commentsAll = useMemo(() => {
@ -97,7 +97,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!userId) return
if (!convexUserId) return
const now = new Date()
const attachments = attachmentsToSend.map((item) => ({ ...item }))
const previewsToRevoke = attachments
@ -132,7 +132,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
}))
await addComment({
ticketId: ticket.id as Id<"tickets">,
authorId: userId as Id<"users">,
authorId: convexUserId as Id<"users">,
visibility,
body: optimistic.body,
attachments: payload,
@ -153,7 +153,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
}
async function handleRemoveAttachment() {
if (!attachmentToRemove || !userId) return
if (!attachmentToRemove || !convexUserId) return
setRemovingAttachment(true)
toast.loading("Removendo anexo...", { id: "remove-attachment" })
try {
@ -161,7 +161,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
ticketId: ticket.id as unknown as Id<"tickets">,
commentId: attachmentToRemove.commentId as Id<"ticketComments">,
attachmentId: attachmentToRemove.attachmentId as Id<"_storage">,
actorId: userId as Id<"users">,
actorId: convexUserId as Id<"users">,
})
toast.success("Anexo removido.", { id: "remove-attachment" })
setAttachmentToRemove(null)
@ -203,7 +203,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const bodyPlain = storedBody.replace(/<[^>]*>/g, "").trim()
const isEditing = editingComment?.id === commentId
const isPending = commentId.startsWith("temp-")
const canEdit = Boolean(userId && String(comment.author.id) === userId && !isPending)
const canEdit = Boolean(convexUserId && String(comment.author.id) === convexUserId && !isPending)
const hasBody = bodyPlain.length > 0 || isEditing
return (

View file

@ -14,10 +14,22 @@ import { TicketComments } from "@/components/tickets/ticket-comments.rich";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
import { useAuth } from "@/lib/auth-client";
export function TicketDetailView({ id }: { id: string }) {
const isMockId = id.startsWith("ticket-");
const t = useQuery(api.tickets.getById, isMockId ? "skip" : ({ tenantId: DEFAULT_TENANT_ID, id: id as Id<"tickets"> }));
const { convexUserId } = useAuth();
const shouldSkip = isMockId || !convexUserId;
const t = useQuery(
api.tickets.getById,
shouldSkip
? "skip"
: {
tenantId: DEFAULT_TENANT_ID,
id: id as Id<"tickets">,
viewerId: convexUserId as Id<"users">,
}
);
let ticket: TicketWithDetails | null = null;
if (t) {
ticket = mapTicketWithDetailsFromServer(t as unknown);

View file

@ -57,7 +57,7 @@ function formatDuration(durationMs: number) {
}
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { userId } = useAuth()
const { convexUserId } = useAuth()
const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue)
const updateSubject = useMutation(api.tickets.updateSubject)
@ -69,7 +69,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
const [status] = useState<TicketStatus>(ticket.status)
const workSummaryRemote = useQuery(api.tickets.workSummary, { ticketId: ticket.id as Id<"tickets"> }) as
const workSummaryRemote = useQuery(
api.tickets.workSummary,
convexUserId
? { ticketId: ticket.id as Id<"tickets">, viewerId: convexUserId as Id<"users"> }
: "skip"
) as
| {
ticketId: Id<"tickets">
totalWorkedMs: number
@ -103,14 +108,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory])
async function handleSave() {
if (!userId) return
if (!convexUserId) return
toast.loading("Salvando alterações...", { id: "save-header" })
try {
if (subject !== ticket.subject) {
await updateSubject({ ticketId: ticket.id as Id<"tickets">, subject: subject.trim(), actorId: userId as Id<"users"> })
await updateSubject({ ticketId: ticket.id as Id<"tickets">, subject: subject.trim(), actorId: convexUserId as Id<"users"> })
}
if ((summary ?? "") !== (ticket.summary ?? "")) {
await updateSummary({ ticketId: ticket.id as Id<"tickets">, summary: (summary ?? "").trim(), actorId: userId as Id<"users"> })
await updateSummary({ ticketId: ticket.id as Id<"tickets">, summary: (summary ?? "").trim(), actorId: convexUserId as Id<"users"> })
}
toast.success("Cabeçalho atualizado!", { id: "save-header" })
setEditing(false)
@ -170,7 +175,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
useEffect(() => {
if (!editing) return
if (!userId) return
if (!convexUserId) return
const categoryId = selectedCategoryId
const subcategoryId = selectedSubcategoryId
if (!categoryId || !subcategoryId) return
@ -200,7 +205,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
ticketId: ticket.id as Id<"tickets">,
categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
actorId: userId as Id<"users">,
actorId: convexUserId as Id<"users">,
})
if (!cancelled) {
toast.success("Categoria atualizada!", { id: "ticket-category" })
@ -225,7 +230,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
return () => {
cancelled = true
}
}, [editing, selectedCategoryId, selectedSubcategoryId, ticket.category?.id, ticket.subcategory?.id, ticket.id, updateCategories, userId])
}, [editing, selectedCategoryId, selectedSubcategoryId, ticket.category?.id, ticket.subcategory?.id, ticket.id, updateCategories, convexUserId])
const workSummary = useMemo(() => {
if (workSummaryRemote !== undefined) return workSummaryRemote ?? null
@ -288,19 +293,19 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
size="sm"
className={isPlaying ? pauseButtonClass : startButtonClass}
onClick={async () => {
if (!userId) return
if (!convexUserId) return
toast.dismiss("work")
toast.loading(isPlaying ? "Pausando atendimento..." : "Iniciando atendimento...", { id: "work" })
try {
if (isPlaying) {
const result = await pauseWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
const result = await pauseWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
if (result?.status === "already_paused") {
toast.info("O atendimento já estava pausado", { id: "work" })
} else {
toast.success("Atendimento pausado", { id: "work" })
}
} else {
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
if (result?.status === "already_started") {
toast.info("O atendimento já estava em andamento", { id: "work" })
} else {
@ -418,12 +423,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<Select
value={ticket.queue ?? ""}
onValueChange={async (value) => {
if (!userId) return
if (!convexUserId) return
const queue = queues.find((item) => item.name === value)
if (!queue) return
toast.loading("Atualizando fila...", { id: "queue" })
try {
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: queue.id as Id<"queues">, actorId: userId as Id<"users"> })
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: queue.id as Id<"queues">, actorId: convexUserId as Id<"users"> })
toast.success("Fila atualizada!", { id: "queue" })
} catch {
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
@ -455,10 +460,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<Select
value={ticket.assignee?.id ?? ""}
onValueChange={async (value) => {
if (!userId) return
if (!convexUserId) return
toast.loading("Atribuindo responsável...", { id: "assignee" })
try {
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: convexUserId as Id<"users"> })
toast.success("Responsável atualizado!", { id: "assignee" })
} catch {
toast.error("Não foi possível atribuir.", { id: "assignee" })

View file

@ -4,24 +4,33 @@ import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table"
import { useAuth } from "@/lib/auth-client"
export function TicketsView() {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
const { convexUserId } = useAuth()
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined
const ticketsRaw = useQuery(api.tickets.list, {
const ticketsRaw = useQuery(
api.tickets.list,
convexUserId
? {
tenantId: DEFAULT_TENANT_ID,
viewerId: convexUserId as Id<"users">,
status: filters.status ?? undefined,
priority: filters.priority ?? undefined,
channel: filters.channel ?? undefined,
queueId: undefined, // simplified: filter by queue name on client
search: filters.search || undefined,
})
}
: "skip"
)
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])

View file

@ -0,0 +1,73 @@
"use client"
import dynamic from "next/dynamic"
import { cn } from "@/lib/utils"
const MeshGradient = dynamic(
() => import("@paper-design/shaders-react").then((mod) => mod.MeshGradient),
{ ssr: false }
)
const DotOrbit = dynamic(
() => import("@paper-design/shaders-react").then((mod) => mod.DotOrbit),
{ ssr: false }
)
function ShaderVisual() {
return (
<div className="absolute inset-0">
<MeshGradient
className="absolute inset-0"
colors={["#020202", "#04131f", "#062534", "#0b3947"]}
speed={0.8}
backgroundColor="#020202"
wireframe="true"
/>
<div className="absolute inset-0 opacity-70">
<DotOrbit
className="h-full w-full"
dotColor="#0f172a"
orbitColor="#155e75"
speed={1.4}
intensity={1.2}
/>
</div>
<div className="pointer-events-none absolute inset-0">
<div className="absolute left-1/4 top-1/3 h-24 w-24 rounded-full bg-cyan-300/10 blur-3xl animate-pulse" />
<div
className="absolute right-1/4 bottom-1/3 h-20 w-20 rounded-full bg-sky-500/15 blur-2xl animate-pulse"
style={{ animationDelay: "1s" }}
/>
<div
className="absolute right-1/3 top-1/2 h-16 w-16 rounded-full bg-white/10 blur-xl animate-pulse"
style={{ animationDelay: "0.5s" }}
/>
</div>
</div>
)
}
export function BackgroundPaperShaders({ className }: { className?: string }) {
return (
<div className={cn("shader-surface relative flex h-full w-full items-center justify-center bg-[#1f3d45]", className)}>
<div className="absolute h-[780px] w-[780px] -translate-y-6 rounded-full border border-white/10 opacity-30" />
<div className="absolute h-[640px] w-[640px] -translate-y-4 rounded-full border border-white/15 opacity-60" />
<div className="relative flex h-[520px] w-[520px] items-center justify-center rounded-full border border-white/20 bg-black/85 shadow-[0_0_160px_rgba(0,0,0,0.5)]">
<div className="absolute inset-6 rounded-full border border-white/15" />
<div className="relative h-[420px] w-[420px] overflow-hidden rounded-full">
<ShaderVisual />
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-10 text-center text-white">
<div className="text-sm uppercase tracking-[0.32em] text-white/50">Sistema de Chamados</div>
<h2 className="mt-4 text-xl font-semibold md:text-2xl">Atendimento moderno e colaborativo</h2>
<p className="mt-3 text-sm text-white/70">
Tenha visão unificada de todos os canais, monitore SLAs em tempo real e mantenha os clientes informados
com atualizações automáticas.
</p>
</div>
</div>
</div>
</div>
)
}
export default BackgroundPaperShaders

View file

@ -0,0 +1,45 @@
"use client"
import { useEffect, useState } from "react"
import UnicornScene from "unicornstudio-react"
import { cn } from "@/lib/utils"
export const useWindowSize = () => {
const [windowSize, setWindowSize] = useState({
width: typeof window !== "undefined" ? window.innerWidth : 0,
height: typeof window !== "undefined" ? window.innerHeight : 0,
})
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
})
}
window.addEventListener("resize", handleResize)
handleResize()
return () => window.removeEventListener("resize", handleResize)
}, [])
return windowSize
}
export const Component = ({ className }: { className?: string }) => {
const { width, height } = useWindowSize()
if (width === 0 || height === 0) {
return <div className={cn("flex flex-col items-center", className)} />
}
return (
<div className={cn("flex flex-col items-center", className)}>
<UnicornScene production projectId="erpu4mAlEe8kmhaGKYe9" width={width} height={height} />
</div>
)
}
export const RaycastAnimatedBlackBackground = Component

View file

@ -1,47 +1,118 @@
"use client";
"use client"
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { Doc } from "@/convex/_generated/dataModel";
import { useMutation } from "convex/react";
import { createContext, useContext, useEffect, useMemo, useState } from "react"
import { customSessionClient } from "better-auth/client/plugins"
import { createAuthClient } from "better-auth/react"
import { useMutation } from "convex/react"
// Lazy import to avoid build errors before convex is generated
// @ts-expect-error Convex generates runtime API without types until build
import { api } from "@/convex/_generated/api";
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { isAdmin, isCustomer, isStaff } from "@/lib/authz"
export type DemoUser = { name: string; email: string; avatarUrl?: string } | null;
export type AppSession = {
user: {
id: string
name?: string | null
email: string
role: string
tenantId: string | null
avatarUrl: string | null
}
session: {
id: string
expiresAt: number
}
}
const authClient = createAuthClient({
plugins: [customSessionClient<AppSession>()],
fetchOptions: {
credentials: "include",
},
})
type AuthContextValue = {
demoUser: DemoUser;
userId: string | null;
setDemoUser: (u: DemoUser) => void;
};
session: AppSession | null
isLoading: boolean
convexUserId: string | null
role: string | null
isAdmin: boolean
isStaff: boolean
isCustomer: boolean
}
const AuthContext = createContext<AuthContextValue>({ demoUser: null, userId: null, setDemoUser: () => {} });
const AuthContext = createContext<AuthContextValue>({
session: null,
isLoading: true,
convexUserId: null,
role: null,
isAdmin: false,
isStaff: false,
isCustomer: false,
})
export function useAuth() {
return useContext(AuthContext);
return useContext(AuthContext)
}
export function AuthProvider({ demoUser, tenantId, children }: { demoUser: DemoUser; tenantId: string; children: React.ReactNode }) {
const [localDemoUser, setLocalDemoUser] = useState<DemoUser>(demoUser);
const [userId, setUserId] = useState<string | null>(null);
const ensureUser = useMutation(api.users.ensureUser);
export const { signIn, signOut, useSession } = authClient
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, isPending } = useSession()
const ensureUser = useMutation(api.users.ensureUser)
const [convexUserId, setConvexUserId] = useState<string | null>(null)
useEffect(() => {
async function run() {
if (!process.env.NEXT_PUBLIC_CONVEX_URL) return; // allow dev without backend
if (!localDemoUser) return;
try {
const user = (await ensureUser({ tenantId, name: localDemoUser.name, email: localDemoUser.email, avatarUrl: localDemoUser.avatarUrl })) as Doc<"users"> | null;
setUserId(user?._id ?? null);
} catch (e) {
console.error("Failed to ensure user:", e);
if (!session?.user) {
setConvexUserId(null)
}
}
run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localDemoUser?.email, tenantId]);
}, [session?.user])
const value = useMemo(() => ({ demoUser: localDemoUser, setDemoUser: setLocalDemoUser, userId }), [localDemoUser, userId]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
useEffect(() => {
if (!session?.user || !process.env.NEXT_PUBLIC_CONVEX_URL || convexUserId) return
const controller = new AbortController()
;(async () => {
try {
const ensured = await ensureUser({
tenantId: session.user.tenantId ?? DEFAULT_TENANT_ID,
name: session.user.name ?? session.user.email,
email: session.user.email,
avatarUrl: session.user.avatarUrl ?? undefined,
role: session.user.role.toUpperCase(),
})
if (!controller.signal.aborted) {
setConvexUserId(ensured?._id ?? null)
}
} catch (error) {
if (!controller.signal.aborted) {
console.error("Failed to sync user with Convex", error)
}
}
})()
return () => {
controller.abort()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ensureUser, session?.user?.email, convexUserId])
const normalizedRole = session?.user?.role ? session.user.role.toLowerCase() : null
const value = useMemo<AuthContextValue>(
() => ({
session: session ?? null,
isLoading: isPending,
convexUserId,
role: normalizedRole,
isAdmin: isAdmin(normalizedRole),
isStaff: isStaff(normalizedRole),
isCustomer: isCustomer(normalizedRole),
}),
[session, isPending, convexUserId, normalizedRole]
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View file

@ -0,0 +1,92 @@
import { cookies, headers } from "next/headers"
import { redirect } from "next/navigation"
import { getCookieCache } from "better-auth/cookies"
import { env } from "@/lib/env"
import { isAdmin, isStaff } from "@/lib/authz"
type ServerSession = Awaited<ReturnType<typeof getCookieCache>>
function serializeCookies() {
const store = cookies()
return store.getAll().map((cookie) => `${cookie.name}=${cookie.value}`).join("; ")
}
function buildRequest() {
const cookieHeader = serializeCookies()
const headerList = headers()
const userAgent = headerList.get("user-agent") ?? ""
const ip =
headerList.get("x-forwarded-for") ||
headerList.get("x-real-ip") ||
headerList.get("cf-connecting-ip") ||
headerList.get("x-client-ip") ||
undefined
return new Request(env.BETTER_AUTH_URL ?? "http://localhost:3000", {
headers: {
cookie: cookieHeader,
"user-agent": userAgent,
...(ip ? { "x-forwarded-for": ip } : {}),
},
})
}
export async function getServerSession(): Promise<ServerSession | null> {
try {
const request = buildRequest()
const session = await getCookieCache(request)
return session ?? null
} catch (error) {
console.error("Failed to read Better Auth session", error)
return null
}
}
export async function assertAuthenticatedSession() {
const session = await getServerSession()
return session?.user ? session : null
}
export async function requireAuthenticatedSession() {
const session = await assertAuthenticatedSession()
if (!session) {
redirect("/login")
}
return session
}
export async function assertStaffSession() {
const session = await assertAuthenticatedSession()
if (!session) return null
if (!isStaff(session.user.role)) {
return null
}
return session
}
export async function requireStaffSession() {
const session = await assertStaffSession()
if (!session) {
redirect("/portal")
}
return session
}
export async function assertAdminSession() {
const session = await assertAuthenticatedSession()
if (!session) return null
if (!isAdmin(session.user.role)) {
return null
}
return session
}
export async function requireAdminSession() {
const session = await assertAdminSession()
if (!session) {
redirect("/dashboard")
}
return session
}

72
web/src/lib/auth.ts Normal file
View file

@ -0,0 +1,72 @@
import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { customSession } from "better-auth/plugins"
import { env } from "./env"
import { prisma } from "./prisma"
export const auth = betterAuth({
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
database: prismaAdapter(prisma, {
provider: "sqlite",
}),
user: {
modelName: "authUser",
additionalFields: {
role: {
type: "string",
required: false,
defaultValue: "agent",
input: false,
},
tenantId: {
type: "string",
required: false,
},
avatarUrl: {
type: "string",
required: false,
},
},
},
session: {
modelName: "authSession",
cookieCache: {
enabled: true,
maxAge: 60 * 5,
},
},
account: {
modelName: "authAccount",
},
verification: {
modelName: "authVerification",
},
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
plugins: [
customSession(async ({ user, session }) => {
const expiresAt = session.expiresAt instanceof Date
? session.expiresAt.getTime()
: new Date(session.expiresAt ?? Date.now()).getTime()
return {
session: {
id: session.id,
expiresAt,
},
user: {
id: user.id,
name: user.name,
email: user.email,
role: ((user as { role?: string }).role ?? "agent").toLowerCase(),
tenantId: (user as { tenantId?: string | null }).tenantId ?? null,
avatarUrl: (user as { avatarUrl?: string | null }).avatarUrl ?? null,
},
}
}),
],
})

23
web/src/lib/authz.ts Normal file
View file

@ -0,0 +1,23 @@
export const ROLE_OPTIONS = ["admin", "manager", "agent", "collaborator", "customer"] as const
const ADMIN_ROLE = "admin"
const CUSTOMER_ROLE = "customer"
const STAFF_ROLES = new Set(["admin", "manager", "agent", "collaborator"])
export type RoleOption = (typeof ROLE_OPTIONS)[number]
export function normalizeRole(role?: string | null) {
return role?.toLowerCase() ?? null
}
export function isAdmin(role?: string | null) {
return normalizeRole(role) === ADMIN_ROLE
}
export function isCustomer(role?: string | null) {
return normalizeRole(role) === CUSTOMER_ROLE
}
export function isStaff(role?: string | null) {
return STAFF_ROLES.has(normalizeRole(role) ?? "")
}

23
web/src/lib/env.ts Normal file
View file

@ -0,0 +1,23 @@
import { z } from "zod"
const envSchema = z.object({
BETTER_AUTH_SECRET: z.string().min(1, "Missing BETTER_AUTH_SECRET"),
BETTER_AUTH_URL: z.string().url().optional(),
NEXT_PUBLIC_CONVEX_URL: z.string().url().optional(),
DATABASE_URL: z.string().min(1).optional(),
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
})
const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
console.error("Failed to parse environment variables", parsed.error.flatten().fieldErrors)
throw new Error("Invalid environment configuration")
}
export const env = {
BETTER_AUTH_SECRET: parsed.data.BETTER_AUTH_SECRET,
BETTER_AUTH_URL: parsed.data.BETTER_AUTH_URL ?? parsed.data.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
NEXT_PUBLIC_CONVEX_URL: parsed.data.NEXT_PUBLIC_CONVEX_URL,
DATABASE_URL: parsed.data.DATABASE_URL,
}

11
web/src/lib/prisma.ts Normal file
View file

@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client"
declare global {
var prisma: PrismaClient | undefined
}
export const prisma = global.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== "production") {
global.prisma = prisma
}

View file

@ -1,10 +1,17 @@
import { defineConfig } from "vitest/config";
import { defineConfig } from "vitest/config"
import path from "path"
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@/convex": path.resolve(__dirname, "./convex"),
},
},
test: {
environment: "node",
globals: true,
include: ["src/**/*.test.ts"],
},
});
})