From 7946b8d0177012909cdc758b668c3b845c08dd00 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 5 Oct 2025 17:25:57 -0300 Subject: [PATCH] feat: migrate auth stack and admin portal Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- agents.md | 66 ++- globals.css | 75 +++ web/.gitignore | 1 + web/auth.ts | 1 + web/convex/_generated/api.d.ts | 2 + web/convex/rbac.ts | 53 ++ web/convex/tickets.ts | 114 +++- web/middleware.ts | 57 +- web/package.json | 8 +- web/pnpm-lock.yaml | 548 ++++++++++++++++++ web/postcss.config.mjs | 10 +- .../20251005183834_init/migration.sql | 215 +++++++ web/prisma/migrations/migration_lock.toml | 3 + web/prisma/schema.prisma | 68 ++- web/scripts/seed-auth.mjs | 57 ++ web/src/app/admin/layout.tsx | 11 + web/src/app/admin/page.tsx | 48 ++ web/src/app/api/admin/users/route.ts | 122 ++++ web/src/app/api/auth/[...all]/route.ts | 5 + web/src/app/globals.css | 25 +- web/src/app/layout.tsx | 10 +- web/src/app/login/page.tsx | 66 ++- web/src/app/portal/page.tsx | 34 ++ web/src/app/tickets/new/page.tsx | 9 +- .../components/admin/admin-users-manager.tsx | 240 ++++++++ .../background-paper-shaders-wrapper.tsx | 20 + .../components/background-paper-shaders.tsx | 116 ++++ web/src/components/login-form.tsx | 124 ++++ .../components/tickets/new-ticket-dialog.tsx | 9 +- .../tickets/play-next-ticket-card.tsx | 30 +- .../components/tickets/priority-select.tsx | 6 +- .../tickets/recent-tickets-panel.tsx | 8 +- web/src/components/tickets/status-select.tsx | 6 +- .../tickets/ticket-comments.rich.tsx | 18 +- .../components/tickets/ticket-detail-view.tsx | 14 +- .../tickets/ticket-summary-header.tsx | 35 +- web/src/components/tickets/tickets-view.tsx | 25 +- .../ui/background-paper-shaders.tsx | 73 +++ .../ui/raycast-animated-black-background.tsx | 45 ++ web/src/lib/auth-client.tsx | 133 ++++- web/src/lib/auth-server.ts | 92 +++ web/src/lib/auth.ts | 72 +++ web/src/lib/authz.ts | 23 + web/src/lib/env.ts | 23 + web/src/lib/prisma.ts | 11 + web/vitest.config.ts | 11 +- 46 files changed, 2564 insertions(+), 178 deletions(-) create mode 100644 web/auth.ts create mode 100644 web/convex/rbac.ts create mode 100644 web/prisma/migrations/20251005183834_init/migration.sql create mode 100644 web/prisma/migrations/migration_lock.toml create mode 100644 web/scripts/seed-auth.mjs create mode 100644 web/src/app/admin/layout.tsx create mode 100644 web/src/app/admin/page.tsx create mode 100644 web/src/app/api/admin/users/route.ts create mode 100644 web/src/app/api/auth/[...all]/route.ts create mode 100644 web/src/app/portal/page.tsx create mode 100644 web/src/components/admin/admin-users-manager.tsx create mode 100644 web/src/components/background-paper-shaders-wrapper.tsx create mode 100644 web/src/components/background-paper-shaders.tsx create mode 100644 web/src/components/login-form.tsx create mode 100644 web/src/components/ui/background-paper-shaders.tsx create mode 100644 web/src/components/ui/raycast-animated-black-background.tsx create mode 100644 web/src/lib/auth-server.ts create mode 100644 web/src/lib/auth.ts create mode 100644 web/src/lib/authz.ts create mode 100644 web/src/lib/env.ts create mode 100644 web/src/lib/prisma.ts diff --git a/agents.md b/agents.md index cd5b3e3..7bcc55c 100644 --- a/agents.md +++ b/agents.md @@ -41,20 +41,25 @@ Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garanti ## Fase C - Servicos complementares (posterior) - 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. +## Backlog imediato +- [ ] 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. +## Proximas entregas sugeridas +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. diff --git a/globals.css b/globals.css index 7dd6176..c9d164f 100644 --- a/globals.css +++ b/globals.css @@ -605,4 +605,79 @@ .ProseMirror > * + ul, .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); + } } \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore index 517252d..8ae0116 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -23,6 +23,7 @@ # misc .DS_Store *.pem +*.sqlite # debug npm-debug.log* diff --git a/web/auth.ts b/web/auth.ts new file mode 100644 index 0000000..51ec0cb --- /dev/null +++ b/web/auth.ts @@ -0,0 +1 @@ +export { auth } from "./src/lib/auth" diff --git a/web/convex/_generated/api.d.ts b/web/convex/_generated/api.d.ts index 55d1ba2..b974ffc 100644 --- a/web/convex/_generated/api.d.ts +++ b/web/convex/_generated/api.d.ts @@ -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; diff --git a/web/convex/rbac.ts b/web/convex/rbac.ts new file mode 100644 index 0000000..d4af701 --- /dev/null +++ b/web/convex/rbac.ts @@ -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 +} diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index 96cedeb..60e1983 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -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 = { "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) { @@ -65,6 +73,10 @@ export const list = query({ .collect(); } 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); @@ -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 = { @@ -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 = { 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", diff --git a/web/middleware.ts b/web/middleware.ts index ab34937..f6e3708 100644 --- a/web/middleware.ts +++ b/web/middleware.ts @@ -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: ["/(.*)"], +} diff --git a/web/package.json b/web/package.json index 33efd98..7b9b8a2 100644 --- a/web/package.json +++ b/web/package.json @@ -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" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 02ea927..46f2c62 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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) diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs index 8c21800..fb05b56 100644 --- a/web/postcss.config.mjs +++ b/web/postcss.config.mjs @@ -1,5 +1,5 @@ -const config = { - plugins: ["@tailwindcss/postcss"], -}; - -export default config; +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +} diff --git a/web/prisma/migrations/20251005183834_init/migration.sql b/web/prisma/migrations/20251005183834_init/migration.sql new file mode 100644 index 0000000..d00f7ad --- /dev/null +++ b/web/prisma/migrations/20251005183834_init/migration.sql @@ -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"); diff --git a/web/prisma/migrations/migration_lock.toml b/web/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/web/prisma/migrations/migration_lock.toml @@ -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" diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index cfefd95..4db4ccb 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -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]) +} diff --git a/web/scripts/seed-auth.mjs b/web/scripts/seed-auth.mjs new file mode 100644 index 0000000..4f9f607 --- /dev/null +++ b/web/scripts/seed-auth.mjs @@ -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() + }) diff --git a/web/src/app/admin/layout.tsx b/web/src/app/admin/layout.tsx new file mode 100644 index 0000000..f1659c3 --- /dev/null +++ b/web/src/app/admin/layout.tsx @@ -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} +} diff --git a/web/src/app/admin/page.tsx b/web/src/app/admin/page.tsx new file mode 100644 index 0000000..1f1aa11 --- /dev/null +++ b/web/src/app/admin/page.tsx @@ -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 ( +
+
+

Administração

+

+ Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento. +

+
+ +
+ ) +} diff --git a/web/src/app/api/admin/users/route.ts b/web/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..e9ec538 --- /dev/null +++ b/web/src/app/api/admin/users/route.ts @@ -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 }) +} diff --git a/web/src/app/api/auth/[...all]/route.ts b/web/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..440620d --- /dev/null +++ b/web/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,5 @@ +import { toNextJsHandler } from "better-auth/next-js" + +import { auth } from "@/lib/auth" + +export const { GET, POST } = toNextJsHandler(auth.handler) diff --git a/web/src/app/globals.css b/web/src/app/globals.css index fcd5555..9c2ce90 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,8 +1,8 @@ @import "tailwindcss"; @import "tw-animate-css"; - + @custom-variant dark (&:is(.dark *)); - + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -41,8 +41,12 @@ --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 { --radius: 0.75rem; --background: #f7f8fb; @@ -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,8 +117,9 @@ --sidebar-accent-foreground: #f8fafc; --sidebar-border: #0f1b2a; --sidebar-ring: #00e6ff; + --destructive-foreground: oklch(1 0 0); } - + @layer base { * { @apply border-border outline-ring/50; @@ -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; + } +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index d8aafff..f8a79ae 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -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 ( - + {children} diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index c8f7169..a86f4d6 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -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 ( -
-
-

Entrar (placeholder)

-
- - setName(e.target.value)} /> +
+
+
+ +
+ +
+ Sistema de Chamados +
-
- - setEmail(e.target.value)} /> +
+
+ +
- -

Apenas para desenvolvimento. Em produção usar Auth.js/Clerk.

- +
+
+ +
- ); + ) } diff --git a/web/src/app/portal/page.tsx b/web/src/app/portal/page.tsx new file mode 100644 index 0000000..867aa81 --- /dev/null +++ b/web/src/app/portal/page.tsx @@ -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 ( +
+
+

+ Portal do cliente +

+

+ Área do cliente em construção +

+

+ 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. +

+
+
+ Precisa falar com a equipe agora? + +
+
+ ) +} diff --git a/web/src/app/tickets/new/page.tsx b/web/src/app/tickets/new/page.tsx index 3217d6c..08b5cec 100644 --- a/web/src/app/tickets/new/page.tsx +++ b/web/src/app/tickets/new/page.tsx @@ -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: [], diff --git a/web/src/components/admin/admin-users-manager.tsx b/web/src/components/admin/admin-users-manager.tsx new file mode 100644 index 0000000..778125f --- /dev/null +++ b/web/src/components/admin/admin-users-manager.tsx @@ -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(initialUsers) + const [email, setEmail] = useState("") + const [name, setName] = useState("") + const [role, setRole] = useState("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) { + 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 ( + + + Usuários + Filas + Categorias + + + + + + Convidar novo usuário + Crie um acesso provisório e compartilhe a senha inicial com o colaborador. + + +
+
+ + setEmail(event.target.value)} + required + autoComplete="off" + /> +
+
+ + setName(event.target.value)} + autoComplete="off" + /> +
+
+ + +
+
+ + setTenantId(event.target.value)} + /> +
+
+ +
+
+ {lastInvite ? ( +
+

Acesso provisório gerado

+

+ Envie para {lastInvite.email} a senha inicial + {lastInvite.password}. + Solicite que altere após o primeiro login. +

+
+ ) : null} +
+
+ + + + Equipe cadastrada + Lista completa de usuários autenticáveis pela Better Auth. + + + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + {users.length === 0 ? ( + + + + ) : null} + +
NomeE-mailPapelTenantCriado em
{user.name || "—"}{user.email}{user.role}{user.tenantId}{formatDate(user.createdAt)}
+ Nenhum usuário cadastrado até o momento. +
+
+
+
+ + + + + Gestão de filas + + Em breve será possível criar e reordenar as filas utilizadas na triagem dos tickets. + + + + + + + + + Gestão de categorias + + Estamos preparando o painel completo para organizar categorias e subcategorias do catálogo. + + + + +
+ ) +} diff --git a/web/src/components/background-paper-shaders-wrapper.tsx b/web/src/components/background-paper-shaders-wrapper.tsx new file mode 100644 index 0000000..26c1d57 --- /dev/null +++ b/web/src/components/background-paper-shaders-wrapper.tsx @@ -0,0 +1,20 @@ +"use client" + +import { MeshGradient } from "@paper-design/shaders-react" + +export default function BackgroundPaperShadersWrapper() { + const speed = 1.0 + + return ( +
+ + +
+ ) +} diff --git a/web/src/components/background-paper-shaders.tsx b/web/src/components/background-paper-shaders.tsx new file mode 100644 index 0000000..56fa1d3 --- /dev/null +++ b/web/src/components/background-paper-shaders.tsx @@ -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(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 ( + + + + + ) +} + +export function EnergyRing({ + radius = 1, + position = [0, 0, 0], +}: { + radius?: number + position?: [number, number, number] +}) { + const mesh = useRef(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 ( + + + + + ) +} diff --git a/web/src/components/login-form.tsx b/web/src/components/login-form.tsx new file mode 100644 index 0000000..3156eb0 --- /dev/null +++ b/web/src/components/login-form.tsx @@ -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) { + 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 ( +
+ +
+

Acesse sua conta

+

+ Informe seu e-mail corporativo e senha para continuar atendendo os chamados. +

+
+ + E-mail + setEmail(event.target.value)} + disabled={isSubmitting || disabled} + required + /> + + +
+ Senha + + Esqueceu a senha? + +
+ setPassword(event.target.value)} + disabled={isSubmitting || disabled} + required + /> +
+ + + + + +
+

Primeiro acesso?

+ + Fale com o nosso suporte por telefone ou e-mail para receber um convite e definir sua senha inicial. + +
+
+
+
+ ) +} diff --git a/web/src/components/tickets/new-ticket-dialog.tsx b/web/src/components/tickets/new-ticket-dialog.tsx index d3b993e..347fc18 100644 --- a/web/src/components/tickets/new-ticket-dialog.tsx +++ b/web/src/components/tickets/new-ticket-dialog.tsx @@ -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) { - 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) diff --git a/web/src/components/tickets/play-next-ticket-card.tsx b/web/src/components/tickets/play-next-ticket-card.tsx index faa0113..d89472f 100644 --- a/web/src/components/tickets/play-next-ticket-card.tsx +++ b/web/src/components/tickets/play-next-ticket-card.tsx @@ -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(undefined) - const nextTicketFromServer = useQuery(api.tickets.list, { - tenantId: DEFAULT_TENANT_ID, - status: undefined, - priority: undefined, - channel: undefined, - queueId: (selectedQueueId as Id<"queues">) || undefined, - limit: 1, - })?.[0] + 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, + } + : "skip" + )?.[0] const nextTicketUi = nextTicketFromServer ? mapTicketFromServer(nextTicketFromServer as unknown) : null const cardContext: TicketPlayContext | null = @@ -128,12 +134,12 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {