Merge pull request #13 from esdrasrenan/feat/convex-tickets-core
feat: migrate auth stack and admin portal
This commit is contained in:
commit
14f0049cf3
46 changed files with 2564 additions and 178 deletions
62
agents.md
62
agents.md
|
|
@ -42,19 +42,24 @@ Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garanti
|
|||
- SLAs (BullMQ + Redis), notificacoes, ingest de e-mail, portal cliente, etc.
|
||||
|
||||
## Backlog imediato
|
||||
- [x] Scaffold Next.js + Tailwind + shadcn/ui.
|
||||
- [x] Ajustar layout shell (dashboard + sidebar) com tema solicitado.
|
||||
- [x] Criar modulos base de dominio (schemas Prisma/Zod) ainda com dados mockados.
|
||||
- [x] Preparar estrutura de paginas: `/tickets`, `/tickets/[id]`, `/play`.
|
||||
- [x] Implementar Auth placeholder (cookie + middleware).
|
||||
- [x] Conectar APIs/mutations reais (Convex) e sincronizar tipos no frontend.
|
||||
- [ ] Expor portal do cliente com listagem de tickets filtrada por `viewerId` (Convex + UI)
|
||||
- [ ] Completar painel administrativo (gestão de agentes, filas, categorias) com RBAC server/client
|
||||
- [ ] Consolidar sincronização Better Auth ↔ Convex para fluxo de convites e resets de senha
|
||||
- [ ] Expandir suite de testes (UI + Convex) cobrindo guardas e mapeadores críticos
|
||||
|
||||
### Iniciativa atual — Autenticação real e personas
|
||||
- [x] Migrar placeholder para Better Auth + Prisma (handlers Next, cliente React e sync Convex).
|
||||
- [x] Expor roles (`admin`, `agent`, `customer`) e aplicar guardas (`requireUser/Staff/Admin/Customer`) no Convex.
|
||||
- [x] Ajustar middleware e componentes para usar `viewerId`/`actorId`, evitando vazamento de dados entre tenants.
|
||||
- [ ] Criar portal do cliente para abertura/consulta de chamados e comentários públicos.
|
||||
- [ ] Consolidar painel administrativo (agentes, filas, categorias) com fluxos completos de convite.
|
||||
|
||||
## Proximas entregas sugeridas
|
||||
1. Finalizar Auth placeholder e guardas de rota (Auth.js + middleware).
|
||||
2. Implementar camada de dados real (Prisma Client + server actions) para tickets.
|
||||
3. Adicionar formularios de criacao/edicao de ticket com validacao (React Hook Form + Zod).
|
||||
4. Conectar timeline/comentarios a mutations otimizadas (UI otimista + websockets futuro).
|
||||
5. Preparar testes basicos (unit + e2e mockados) e pipeline de CI inicial.
|
||||
1. Entregar portal do cliente (listagem, detalhes e criação de ticket) consumindo RBAC e mapeadores atualizados.
|
||||
2. Evoluir painel administrativo com gerenciamento de filas/categorias e convites com Better Auth.
|
||||
3. Introduzir relatórios e métricas (workSummary, SLA) protegidos por `requireStaff/requireAdmin`.
|
||||
4. Automatizar pipeline CI (lint + vitest) integrando checagens obrigatórias.
|
||||
5. Revisar UX dos fluxos de atendimento (play next, comentários) com feedback otimista e trilha de auditoria.
|
||||
|
||||
## Acompanhamento
|
||||
Atualizar este arquivo a cada marco relevante (setup concluido, nucleo funcional, etc.).
|
||||
|
|
@ -72,10 +77,10 @@ Este repositório foi atualizado para usar Convex como backend em tempo real par
|
|||
- Upload de arquivos: `web/convex/files.ts` (Convex Storage).
|
||||
- Filas: `web/convex/queues.ts` (resumo por fila).
|
||||
- Seed/bootstrap: `web/convex/seed.ts`, `web/convex/bootstrap.ts`.
|
||||
- Auth placeholder: cookie + middleware
|
||||
- Login: `web/src/app/login/page.tsx`
|
||||
- Middleware: `web/middleware.ts`
|
||||
- Provider: `web/src/lib/auth-client.tsx` (garante usuário no Convex)
|
||||
- Autenticação: Better Auth + Prisma (SQLite) com roles (`admin`, `agent`, `customer`) sincronizadas com Convex
|
||||
- Login: `web/src/app/login/page.tsx` + `web/src/components/login/login-form.tsx`
|
||||
- Middleware e guards: `web/middleware.ts`, helpers em `web/src/lib/auth{,z, -server}.ts`
|
||||
- Cliente React: `web/src/lib/auth-client.tsx` (sincroniza sessão Better Auth ↔ Convex, expõe helpers de role)
|
||||
- Frontend (Next.js + shadcn/ui)
|
||||
- Páginas principais: `/tickets`, `/tickets/[id]`, `/tickets/new`, `/play`.
|
||||
- UI ligada ao Convex com `convex/react`.
|
||||
|
|
@ -210,6 +215,12 @@ Observações:
|
|||
|
||||
---
|
||||
|
||||
## Atualizações recentes (dez/2025)
|
||||
|
||||
- RBAC do Convex reforçado: `tickets.list`, `tickets.getById`, `workSummary` e mutações sensíveis (`changeQueue`, `updateCategories`, `startWork/pauseWork`, `updatePriority`) agora exigem `viewerId/actorId` e validam `requireStaff` com `tenantId`.
|
||||
- Componentes de tickets (tabela, painel de recentes, play next, cabeçalho/detalhe) passam a usar o contexto Better Auth para prover `viewerId`, com `useQuery` protegido por `"skip"` enquanto não há sessão.
|
||||
- Testes (`pnpm vitest run`) executados após as alterações para garantir regressão zero.
|
||||
|
||||
## Progresso recente (mar/2025)
|
||||
|
||||
Resumo do que foi implementado desde o último marco:
|
||||
|
|
@ -313,6 +324,16 @@ Observações de codificação
|
|||
- Linhas da tabela de tickets agora são totalmente clicáveis (mouse e teclado), reforçando acessibilidade e atalho de navegação.
|
||||
- Toasts e layouts refinados para manter consistência entre criação, listagem e detalhe dos tickets.
|
||||
|
||||
## Atualizações recentes (out/2025)
|
||||
- Tabela de tickets refinada com ícones de canal, prioridade ajustável inline e indicadores suavizados (fila/status/categoria) para reduzir ruído visual.
|
||||
- Definido plano de migração para Better Auth com RBAC (admin/agent/customer), portal do cliente e painel administrativo para filas/categorias/agentes.
|
||||
- Próximo passo: iniciar fase de implementação da autenticação real, substituindo middleware placeholder e alinhando Convex aos novos papéis.
|
||||
- Better Auth agora usa banco SQLite local (`db.sqlite`) e o schema Prisma foi migrado com sucesso via `pnpm exec prisma migrate dev --name init`.
|
||||
- Configuração do `postcss.config.mjs` corrigida para usar `@tailwindcss/postcss` como plugin executável, liberando a suíte do Vitest (`pnpm exec vitest run`).
|
||||
- Script `pnpm auth:seed` cria/atualiza o usuário inicial (`admin@sistema.dev` / `admin123`) usando `better-auth/crypto` para hash de senha.
|
||||
- Página de login refeita com layout em duas colunas (header + imagem lateral) e formulário integrado ao Better Auth (`LoginForm`).
|
||||
- Middleware atualizado aplica RBAC inicial (clientes direcionados ao portal, rotas `/admin` reservadas a administradores) e helpers de role expostos em `src/lib/authz.ts`; página `/portal` criada como placeholder do futuro autosserviço.
|
||||
|
||||
## Próximos passos estratégicos
|
||||
|
||||
### Produto / Experiência
|
||||
|
|
@ -335,3 +356,14 @@ Observações de codificação
|
|||
- [ ] Documentar fluxo de suporte interno (quem revisa PRs, janelas de deploy, rollback).
|
||||
|
||||
Manter este arquivo atualizado ao concluir cada item estratégico ou quando surgirem novas dependências administrativas.
|
||||
|
||||
## Atualizações recentes (mai/2026)
|
||||
|
||||
- Login corporativo refinado com instruções revisadas para primeiro acesso e mensagens de erro totalmente em PT-BR.
|
||||
- Script `pnpm auth:seed` executado para garantir o usuário administrador padrão (`admin@sistema.dev` / `admin123`).
|
||||
- Toast de autenticação inválida agora informa "E-mail ou senha inválidos", alinhando o feedback com o restante da interface.
|
||||
|
||||
### Próximos passos imediatos
|
||||
- [ ] Consolidar o painel administrativo com fluxo completo de convites (criação, exibição e revogação) utilizando Better Auth.
|
||||
- [ ] Iniciar o portal do cliente com listagem de tickets filtrada por `viewerId` e detalhamento básico.
|
||||
- [ ] Cobrir o fluxo de autenticação (login/convite/reset) com testes Vitest focados em regressões críticas.
|
||||
|
|
|
|||
75
globals.css
75
globals.css
|
|
@ -606,3 +606,78 @@
|
|||
.ProseMirror > * + ol {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
.shader-surface {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--background: oklch(0 0 0);
|
||||
--foreground: oklch(1 0 0);
|
||||
--card: oklch(0.1 0 0 / 0.1);
|
||||
--card-foreground: oklch(1 0 0);
|
||||
--popover: oklch(0 0 0 / 0.8);
|
||||
--popover-foreground: oklch(1 0 0);
|
||||
--primary: oklch(1 0 0);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
--secondary: oklch(0.65 0.25 25);
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(1 0 0 / 0.5);
|
||||
--muted-foreground: oklch(0.7 0 0);
|
||||
--accent: oklch(0.65 0.25 25);
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
--destructive: oklch(0.6 0.25 15);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(1 0 0 / 0.2);
|
||||
--input: oklch(1 0 0 / 0.1);
|
||||
--ring: oklch(1 0 0 / 0.3);
|
||||
--chart-1: oklch(0.65 0.25 25);
|
||||
--chart-2: oklch(0.8 0.15 85);
|
||||
--chart-3: oklch(0.7 0.2 140);
|
||||
--chart-4: oklch(0.7 0.2 240);
|
||||
--chart-5: oklch(0.6 0.25 300);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0 0 0 / 0.9);
|
||||
--sidebar-foreground: oklch(1 0 0);
|
||||
--sidebar-primary: oklch(0.65 0.25 25);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.65 0.25 25);
|
||||
--sidebar-accent-foreground: oklch(1 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 0.2);
|
||||
--sidebar-ring: oklch(1 0 0 / 0.3);
|
||||
}
|
||||
|
||||
.shader-surface.dark {
|
||||
--background: oklch(0 0 0);
|
||||
--foreground: oklch(1 0 0);
|
||||
--card: oklch(0.1 0 0 / 0.1);
|
||||
--card-foreground: oklch(1 0 0);
|
||||
--popover: oklch(0 0 0 / 0.8);
|
||||
--popover-foreground: oklch(1 0 0);
|
||||
--primary: oklch(1 0 0);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
--secondary: oklch(0.65 0.25 25);
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(1 0 0 / 0.5);
|
||||
--muted-foreground: oklch(0.7 0 0);
|
||||
--accent: oklch(0.65 0.25 25);
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
--destructive: oklch(0.6 0.25 15);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(1 0 0 / 0.2);
|
||||
--input: oklch(1 0 0 / 0.1);
|
||||
--ring: oklch(1 0 0 / 0.3);
|
||||
--chart-1: oklch(0.65 0.25 25);
|
||||
--chart-2: oklch(0.8 0.15 85);
|
||||
--chart-3: oklch(0.7 0.2 140);
|
||||
--chart-4: oklch(0.7 0.2 240);
|
||||
--chart-5: oklch(0.6 0.25 300);
|
||||
--sidebar: oklch(0 0 0 / 0.9);
|
||||
--sidebar-foreground: oklch(1 0 0);
|
||||
--sidebar-primary: oklch(0.65 0.25 25);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.65 0.25 25);
|
||||
--sidebar-accent-foreground: oklch(1 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 0.2);
|
||||
--sidebar-ring: oklch(1 0 0 / 0.3);
|
||||
}
|
||||
}
|
||||
1
web/.gitignore
vendored
1
web/.gitignore
vendored
|
|
@ -23,6 +23,7 @@
|
|||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.sqlite
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
|
|
|||
1
web/auth.ts
Normal file
1
web/auth.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { auth } from "./src/lib/auth"
|
||||
2
web/convex/_generated/api.d.ts
vendored
2
web/convex/_generated/api.d.ts
vendored
|
|
@ -12,6 +12,7 @@ import type * as bootstrap from "../bootstrap.js";
|
|||
import type * as categories from "../categories.js";
|
||||
import type * as files from "../files.js";
|
||||
import type * as queues from "../queues.js";
|
||||
import type * as rbac from "../rbac.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as tickets from "../tickets.js";
|
||||
import type * as users from "../users.js";
|
||||
|
|
@ -35,6 +36,7 @@ declare const fullApi: ApiFromModules<{
|
|||
categories: typeof categories;
|
||||
files: typeof files;
|
||||
queues: typeof queues;
|
||||
rbac: typeof rbac;
|
||||
seed: typeof seed;
|
||||
tickets: typeof tickets;
|
||||
users: typeof users;
|
||||
|
|
|
|||
53
web/convex/rbac.ts
Normal file
53
web/convex/rbac.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { ConvexError } from "convex/values"
|
||||
|
||||
import type { Id } from "./_generated/dataModel"
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||
|
||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
||||
const CUSTOMER_ROLE = "CUSTOMER"
|
||||
|
||||
type Ctx = QueryCtx | MutationCtx
|
||||
|
||||
function normalizeRole(role?: string | null) {
|
||||
return role?.toUpperCase() ?? null
|
||||
}
|
||||
|
||||
async function getUser(ctx: Ctx, userId: Id<"users">) {
|
||||
const user = await ctx.db.get(userId)
|
||||
if (!user) {
|
||||
throw new ConvexError("Usuário não encontrado")
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
export async function requireUser(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||
const user = await getUser(ctx, userId)
|
||||
if (tenantId && user.tenantId !== tenantId) {
|
||||
throw new ConvexError("Usuário não pertence a este tenant")
|
||||
}
|
||||
return { user, role: normalizeRole(user.role) }
|
||||
}
|
||||
|
||||
export async function requireStaff(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||
const result = await requireUser(ctx, userId, tenantId)
|
||||
if (!result.role || !STAFF_ROLES.has(result.role)) {
|
||||
throw new ConvexError("Acesso restrito à equipe interna")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function requireAdmin(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||
const result = await requireStaff(ctx, userId, tenantId)
|
||||
if (result.role !== "ADMIN") {
|
||||
throw new ConvexError("Apenas administradores podem executar esta ação")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function requireCustomer(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||
const result = await requireUser(ctx, userId, tenantId)
|
||||
if (result.role !== CUSTOMER_ROLE) {
|
||||
throw new ConvexError("Acesso restrito ao portal do cliente")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import { mutation, query } from "./_generated/server";
|
|||
import { ConvexError, v } from "convex/values";
|
||||
import { Id, type Doc } from "./_generated/dataModel";
|
||||
|
||||
import { requireAdmin, requireCustomer, requireStaff, requireUser } from "./rbac";
|
||||
|
||||
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
||||
"Suporte N1": "Chamados",
|
||||
"suporte-n1": "Chamados",
|
||||
|
|
@ -37,6 +39,7 @@ function normalizeTeams(teams?: string[] | null): string[] {
|
|||
|
||||
export const list = query({
|
||||
args: {
|
||||
viewerId: v.optional(v.id("users")),
|
||||
tenantId: v.string(),
|
||||
status: v.optional(v.string()),
|
||||
priority: v.optional(v.string()),
|
||||
|
|
@ -46,6 +49,11 @@ export const list = query({
|
|||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
if (!args.viewerId) {
|
||||
return []
|
||||
}
|
||||
const { user, role } = await requireUser(ctx, args.viewerId, args.tenantId)
|
||||
|
||||
// Choose best index based on provided args for efficiency
|
||||
let base: Doc<"tickets">[] = [];
|
||||
if (args.status) {
|
||||
|
|
@ -66,6 +74,10 @@ export const list = query({
|
|||
}
|
||||
let filtered = base;
|
||||
|
||||
if (role === "CUSTOMER") {
|
||||
filtered = filtered.filter((t) => t.requesterId === args.viewerId);
|
||||
}
|
||||
|
||||
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
|
||||
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
|
||||
if (args.search) {
|
||||
|
|
@ -164,10 +176,14 @@ export const list = query({
|
|||
});
|
||||
|
||||
export const getById = query({
|
||||
args: { tenantId: v.string(), id: v.id("tickets") },
|
||||
handler: async (ctx, { tenantId, id }) => {
|
||||
args: { tenantId: v.string(), id: v.id("tickets"), viewerId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, id, viewerId }) => {
|
||||
const { role } = await requireUser(ctx, viewerId, tenantId)
|
||||
const t = await ctx.db.get(id);
|
||||
if (!t || t.tenantId !== tenantId) return null;
|
||||
if (role === "CUSTOMER" && t.requesterId !== viewerId) {
|
||||
throw new ConvexError("Acesso restrito ao solicitante")
|
||||
}
|
||||
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
|
||||
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
|
||||
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
|
||||
|
|
@ -297,6 +313,7 @@ export const getById = query({
|
|||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
actorId: v.id("users"),
|
||||
tenantId: v.string(),
|
||||
subject: v.string(),
|
||||
summary: v.optional(v.string()),
|
||||
|
|
@ -308,6 +325,11 @@ export const create = mutation({
|
|||
subcategoryId: v.id("ticketSubcategories"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { role } = await requireUser(ctx, args.actorId, args.tenantId)
|
||||
if (role === "CUSTOMER" && args.requesterId !== args.actorId) {
|
||||
throw new ConvexError("Clientes só podem abrir chamados para si mesmos")
|
||||
}
|
||||
|
||||
const subject = args.subject.trim();
|
||||
if (subject.length < 3) {
|
||||
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
||||
|
|
@ -382,6 +404,20 @@ export const addComment = mutation({
|
|||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ticket = await ctx.db.get(args.ticketId);
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
|
||||
if (ticket.requesterId === args.authorId) {
|
||||
await requireCustomer(ctx, args.authorId, ticket.tenantId)
|
||||
if (args.visibility !== "PUBLIC") {
|
||||
throw new ConvexError("Clientes só podem registrar comentários públicos")
|
||||
}
|
||||
} else {
|
||||
await requireStaff(ctx, args.authorId, ticket.tenantId)
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const id = await ctx.db.insert("ticketComments", {
|
||||
ticketId: args.ticketId,
|
||||
|
|
@ -413,6 +449,10 @@ export const updateComment = mutation({
|
|||
body: v.string(),
|
||||
},
|
||||
handler: async (ctx, { ticketId, commentId, actorId, body }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
const comment = await ctx.db.get(commentId);
|
||||
if (!comment || comment.ticketId !== ticketId) {
|
||||
throw new ConvexError("Comentário não encontrado");
|
||||
|
|
@ -420,6 +460,11 @@ export const updateComment = mutation({
|
|||
if (comment.authorId !== actorId) {
|
||||
throw new ConvexError("Você não tem permissão para editar este comentário");
|
||||
}
|
||||
if (ticket.requesterId === actorId) {
|
||||
await requireCustomer(ctx, actorId, ticket.tenantId)
|
||||
} else {
|
||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(commentId, {
|
||||
|
|
@ -452,10 +497,23 @@ export const removeCommentAttachment = mutation({
|
|||
actorId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { ticketId, commentId, attachmentId, actorId }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
const comment = await ctx.db.get(commentId);
|
||||
if (!comment || comment.ticketId !== ticketId) {
|
||||
throw new ConvexError("Comentário não encontrado");
|
||||
}
|
||||
if (comment.authorId !== actorId) {
|
||||
throw new ConvexError("Você não pode alterar anexos de outro usuário")
|
||||
}
|
||||
|
||||
if (ticket.requesterId === actorId) {
|
||||
await requireCustomer(ctx, actorId, ticket.tenantId)
|
||||
} else {
|
||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
||||
}
|
||||
|
||||
const attachments = comment.attachments ?? [];
|
||||
const target = attachments.find((att) => att.storageId === attachmentId);
|
||||
|
|
@ -492,6 +550,11 @@ export const removeCommentAttachment = mutation({
|
|||
export const updateStatus = mutation({
|
||||
args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") },
|
||||
handler: async (ctx, { ticketId, status, actorId }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(ticketId, { status, updatedAt: now });
|
||||
const statusPt: Record<string, string> = {
|
||||
|
|
@ -514,13 +577,21 @@ export const updateStatus = mutation({
|
|||
export const changeAssignee = mutation({
|
||||
args: { ticketId: v.id("tickets"), assigneeId: v.id("users"), actorId: v.id("users") },
|
||||
handler: async (ctx, { ticketId, assigneeId, actorId }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
||||
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
|
||||
if (!assignee || assignee.tenantId !== ticket.tenantId) {
|
||||
throw new ConvexError("Responsável inválido")
|
||||
}
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
|
||||
const user = (await ctx.db.get(assigneeId)) as Doc<"users"> | null;
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "ASSIGNEE_CHANGED",
|
||||
payload: { assigneeId, assigneeName: user?.name, actorId },
|
||||
payload: { assigneeId, assigneeName: assignee.name, actorId },
|
||||
createdAt: now,
|
||||
});
|
||||
},
|
||||
|
|
@ -529,9 +600,17 @@ export const changeAssignee = mutation({
|
|||
export const changeQueue = mutation({
|
||||
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
|
||||
handler: async (ctx, { ticketId, queueId, actorId }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
||||
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null
|
||||
if (!queue || queue.tenantId !== ticket.tenantId) {
|
||||
throw new ConvexError("Fila inválida")
|
||||
}
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
|
||||
const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null;
|
||||
const queueName = normalizeQueueName(queue);
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
|
|
@ -554,6 +633,7 @@ export const updateCategories = mutation({
|
|||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
||||
const category = await ctx.db.get(categoryId)
|
||||
if (!category || category.tenantId !== ticket.tenantId) {
|
||||
throw new ConvexError("Categoria inválida")
|
||||
|
|
@ -595,10 +675,11 @@ export const updateCategories = mutation({
|
|||
})
|
||||
|
||||
export const workSummary = query({
|
||||
args: { ticketId: v.id("tickets") },
|
||||
handler: async (ctx, { ticketId }) => {
|
||||
args: { ticketId: v.id("tickets"), viewerId: v.id("users") },
|
||||
handler: async (ctx, { ticketId, viewerId }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) return null
|
||||
await requireStaff(ctx, viewerId, ticket.tenantId)
|
||||
|
||||
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
|
||||
return {
|
||||
|
|
@ -618,6 +699,11 @@ export const workSummary = query({
|
|||
export const updatePriority = mutation({
|
||||
args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") },
|
||||
handler: async (ctx, { ticketId, priority, actorId }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(ticketId, { priority, updatedAt: now });
|
||||
const pt: Record<string, string> = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente" };
|
||||
|
|
@ -637,6 +723,7 @@ export const startWork = mutation({
|
|||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
||||
if (ticket.activeSessionId) {
|
||||
return { status: "already_started", sessionId: ticket.activeSessionId }
|
||||
}
|
||||
|
|
@ -673,6 +760,7 @@ export const pauseWork = mutation({
|
|||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
await requireStaff(ctx, actorId, ticket.tenantId)
|
||||
if (!ticket.activeSessionId) {
|
||||
return { status: "already_paused" }
|
||||
}
|
||||
|
|
@ -721,7 +809,10 @@ export const updateSubject = mutation({
|
|||
handler: async (ctx, { ticketId, subject, actorId }) => {
|
||||
const now = Date.now();
|
||||
const t = await ctx.db.get(ticketId);
|
||||
if (!t) return;
|
||||
if (!t) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
await requireStaff(ctx, actorId, t.tenantId)
|
||||
const trimmed = subject.trim();
|
||||
if (trimmed.length < 3) {
|
||||
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
||||
|
|
@ -742,7 +833,10 @@ export const updateSummary = mutation({
|
|||
handler: async (ctx, { ticketId, summary, actorId }) => {
|
||||
const now = Date.now();
|
||||
const t = await ctx.db.get(ticketId);
|
||||
if (!t) return;
|
||||
if (!t) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
await requireStaff(ctx, actorId, t.tenantId)
|
||||
await ctx.db.patch(ticketId, { summary, updatedAt: now });
|
||||
const actor = await ctx.db.get(actorId);
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
|
|
@ -761,6 +855,7 @@ export const playNext = mutation({
|
|||
agentId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { tenantId, queueId, agentId }) => {
|
||||
const agent = await requireStaff(ctx, agentId, tenantId)
|
||||
// Find eligible tickets: not resolved/closed and not assigned
|
||||
let candidates: Doc<"tickets">[] = []
|
||||
if (queueId) {
|
||||
|
|
@ -793,7 +888,6 @@ export const playNext = mutation({
|
|||
const chosen = candidates[0];
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
|
||||
const agent = (await ctx.db.get(agentId)) as Doc<"users"> | null;
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: chosen._id,
|
||||
type: "ASSIGNEE_CHANGED",
|
||||
|
|
|
|||
|
|
@ -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: ["/(.*)"],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
"lint": "eslint",
|
||||
"prisma:generate": "prisma generate",
|
||||
"convex:dev": "convex dev",
|
||||
"test": "vitest"
|
||||
"test": "vitest",
|
||||
"auth:seed": "node --env-file=.env.local scripts/seed-auth.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@paper-design/shaders-react": "^0.0.55",
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
|
|
@ -31,12 +33,14 @@
|
|||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-three/fiber": "^9.3.0",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/extension-link": "^3.6.5",
|
||||
"@tiptap/extension-placeholder": "^3.6.5",
|
||||
"@tiptap/react": "^3.6.5",
|
||||
"@tiptap/starter-kit": "^3.6.5",
|
||||
"better-auth": "^1.3.26",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.27.3",
|
||||
|
|
@ -51,6 +55,8 @@
|
|||
"sanitize-html": "^2.17.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"three": "^0.180.0",
|
||||
"unicornstudio-react": "^1.4.31",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.9"
|
||||
},
|
||||
|
|
|
|||
548
web/pnpm-lock.yaml
generated
548
web/pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
215
web/prisma/migrations/20251005183834_init/migration.sql
Normal file
215
web/prisma/migrations/20251005183834_init/migration.sql
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "Team" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamMember" (
|
||||
"teamId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"isLead" BOOLEAN NOT NULL DEFAULT false,
|
||||
"assignedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY ("teamId", "userId"),
|
||||
CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"timezone" TEXT NOT NULL DEFAULT 'America/Sao_Paulo',
|
||||
"avatarUrl" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Queue" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"teamId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Queue_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Ticket" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"reference" INTEGER NOT NULL DEFAULT 0,
|
||||
"subject" TEXT NOT NULL,
|
||||
"summary" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'NEW',
|
||||
"priority" TEXT NOT NULL DEFAULT 'MEDIUM',
|
||||
"channel" TEXT NOT NULL DEFAULT 'EMAIL',
|
||||
"queueId" TEXT,
|
||||
"requesterId" TEXT NOT NULL,
|
||||
"assigneeId" TEXT,
|
||||
"slaPolicyId" TEXT,
|
||||
"dueAt" DATETIME,
|
||||
"firstResponseAt" DATETIME,
|
||||
"resolvedAt" DATETIME,
|
||||
"closedAt" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Ticket_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Ticket_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Ticket_queueId_fkey" FOREIGN KEY ("queueId") REFERENCES "Queue" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Ticket_slaPolicyId_fkey" FOREIGN KEY ("slaPolicyId") REFERENCES "SlaPolicy" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TicketEvent" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "TicketEvent_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TicketComment" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"visibility" TEXT NOT NULL DEFAULT 'INTERNAL',
|
||||
"body" TEXT NOT NULL,
|
||||
"attachments" JSONB,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "TicketComment_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "TicketComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SlaPolicy" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"timeToFirstResponse" INTEGER,
|
||||
"timeToResolution" INTEGER,
|
||||
"calendar" JSONB,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuthUser" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"role" TEXT NOT NULL DEFAULT 'agent',
|
||||
"tenantId" TEXT,
|
||||
"avatarUrl" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuthSession" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "AuthSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AuthUser" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuthAccount" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"accessToken" TEXT,
|
||||
"refreshToken" TEXT,
|
||||
"accessTokenExpiresAt" DATETIME,
|
||||
"refreshTokenExpiresAt" DATETIME,
|
||||
"scope" TEXT,
|
||||
"idToken" TEXT,
|
||||
"password" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "AuthAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AuthUser" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuthVerification" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Team_tenantId_name_idx" ON "Team"("tenantId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_tenantId_role_idx" ON "User"("tenantId", "role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Queue_tenantId_slug_key" ON "Queue"("tenantId", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_tenantId_status_idx" ON "Ticket"("tenantId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_tenantId_queueId_idx" ON "Ticket"("tenantId", "queueId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_tenantId_assigneeId_idx" ON "Ticket"("tenantId", "assigneeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketEvent_ticketId_createdAt_idx" ON "TicketEvent"("ticketId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketComment_ticketId_visibility_idx" ON "TicketComment"("ticketId", "visibility");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SlaPolicy_tenantId_name_key" ON "SlaPolicy"("tenantId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AuthUser_email_key" ON "AuthUser"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AuthSession_token_key" ON "AuthSession"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuthSession_userId_idx" ON "AuthSession"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuthSession_token_idx" ON "AuthSession"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuthAccount_userId_idx" ON "AuthAccount"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AuthAccount_providerId_accountId_key" ON "AuthAccount"("providerId", "accountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuthVerification_identifier_idx" ON "AuthVerification"("identifier");
|
||||
3
web/prisma/migrations/migration_lock.toml
Normal file
3
web/prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
|
|
@ -3,7 +3,7 @@ generator client {
|
|||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ model Queue {
|
|||
model Ticket {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
reference Int @default(autoincrement())
|
||||
reference Int @default(0)
|
||||
subject String
|
||||
summary String?
|
||||
status TicketStatus @default(NEW)
|
||||
|
|
@ -126,6 +126,7 @@ model Ticket {
|
|||
requester User @relation("TicketRequester", fields: [requesterId], references: [id])
|
||||
assignee User? @relation("TicketAssignee", fields: [assigneeId], references: [id])
|
||||
queue Queue? @relation(fields: [queueId], references: [id])
|
||||
slaPolicy SlaPolicy? @relation(fields: [slaPolicyId], references: [id])
|
||||
events TicketEvent[]
|
||||
comments TicketComment[]
|
||||
|
||||
|
|
@ -177,3 +178,66 @@ model SlaPolicy {
|
|||
|
||||
@@unique([tenantId, name])
|
||||
}
|
||||
|
||||
model AuthUser {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
role String @default("agent")
|
||||
tenantId String?
|
||||
avatarUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sessions AuthSession[]
|
||||
accounts AuthAccount[]
|
||||
}
|
||||
|
||||
model AuthSession {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([token])
|
||||
}
|
||||
|
||||
model AuthAccount {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
accountId String
|
||||
providerId String
|
||||
accessToken String?
|
||||
refreshToken String?
|
||||
accessTokenExpiresAt DateTime?
|
||||
refreshTokenExpiresAt DateTime?
|
||||
scope String?
|
||||
idToken String?
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([providerId, accountId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model AuthVerification {
|
||||
id String @id @default(cuid())
|
||||
identifier String
|
||||
value String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([identifier])
|
||||
}
|
||||
|
|
|
|||
57
web/scripts/seed-auth.mjs
Normal file
57
web/scripts/seed-auth.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { PrismaClient } from "@prisma/client"
|
||||
import { hashPassword } from "better-auth/crypto"
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const email = process.env.SEED_USER_EMAIL ?? "admin@sistema.dev"
|
||||
const password = process.env.SEED_USER_PASSWORD ?? "admin123"
|
||||
const name = process.env.SEED_USER_NAME ?? "Administrador"
|
||||
const role = process.env.SEED_USER_ROLE ?? "admin"
|
||||
const tenantId = process.env.SEED_USER_TENANT ?? "default"
|
||||
|
||||
async function main() {
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
const user = await prisma.authUser.upsert({
|
||||
where: { email },
|
||||
update: {
|
||||
name,
|
||||
role,
|
||||
tenantId,
|
||||
},
|
||||
create: {
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
tenantId,
|
||||
accounts: {
|
||||
create: {
|
||||
providerId: "email",
|
||||
accountId: email,
|
||||
password: hashedPassword,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
accounts: true,
|
||||
},
|
||||
})
|
||||
|
||||
const account = user.accounts[0]
|
||||
|
||||
console.log(`✅ Usuario seed criado/atualizado: ${user.email}`)
|
||||
console.log(` ID: ${user.id}`)
|
||||
console.log(` Role: ${user.role}`)
|
||||
console.log(` Tenant: ${user.tenantId ?? "(nenhum)"}`)
|
||||
console.log(` Provider: ${account?.providerId ?? "-"}`)
|
||||
console.log(`Senha provisoria: ${password}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error("Erro ao criar usuario seed", error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
11
web/src/app/admin/layout.tsx
Normal file
11
web/src/app/admin/layout.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { ReactNode } from "react"
|
||||
|
||||
import { requireAdminSession } from "@/lib/auth-server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export default async function AdminLayout({ children }: { children: ReactNode }) {
|
||||
await requireAdminSession()
|
||||
return <>{children}</>
|
||||
}
|
||||
48
web/src/app/admin/page.tsx
Normal file
48
web/src/app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { AdminUsersManager } from "@/components/admin/admin-users-manager"
|
||||
import { ROLE_OPTIONS, normalizeRole } from "@/lib/authz"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
async function loadUsers() {
|
||||
const users = await prisma.authUser.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return users.map((user) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? "",
|
||||
role: normalizeRole(user.role) ?? "agent",
|
||||
tenantId: user.tenantId ?? DEFAULT_TENANT_ID,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt?.toISOString() ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const users = await loadUsers()
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Administração</h1>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento.
|
||||
</p>
|
||||
</div>
|
||||
<AdminUsersManager initialUsers={users} roleOptions={ROLE_OPTIONS} defaultTenantId={DEFAULT_TENANT_ID} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
122
web/src/app/api/admin/users/route.ts
Normal file
122
web/src/app/api/admin/users/route.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
import { hashPassword } from "better-auth/crypto"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
// @ts-expect-error Convex generated API lacks type declarations in Next API routes
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { assertAdminSession } from "@/lib/auth-server"
|
||||
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
function normalizeRole(input: string | null | undefined): RoleOption {
|
||||
const role = (input ?? "agent").toLowerCase() as RoleOption
|
||||
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
|
||||
}
|
||||
|
||||
function generatePassword(length = 12) {
|
||||
const bytes = randomBytes(length)
|
||||
return Array.from(bytes)
|
||||
.map((byte) => (byte % 36).toString(36))
|
||||
.join("")
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await assertAdminSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const users = await prisma.authUser.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ users })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await assertAdminSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const payload = await request.json().catch(() => null)
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
|
||||
}
|
||||
|
||||
const emailInput = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : ""
|
||||
const nameInput = typeof payload.name === "string" ? payload.name.trim() : ""
|
||||
const roleInput = typeof payload.role === "string" ? payload.role : undefined
|
||||
const tenantInput = typeof payload.tenantId === "string" ? payload.tenantId.trim() : undefined
|
||||
|
||||
if (!emailInput || !emailInput.includes("@")) {
|
||||
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
|
||||
}
|
||||
|
||||
const role = normalizeRole(roleInput)
|
||||
const tenantId = tenantInput || session.user.tenantId || DEFAULT_TENANT_ID
|
||||
|
||||
const existing = await prisma.authUser.findUnique({ where: { email: emailInput } })
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Já existe um usuário com este e-mail" }, { status: 409 })
|
||||
}
|
||||
|
||||
const password = generatePassword()
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
const user = await prisma.authUser.create({
|
||||
data: {
|
||||
email: emailInput,
|
||||
name: nameInput || emailInput,
|
||||
role,
|
||||
tenantId,
|
||||
accounts: {
|
||||
create: {
|
||||
providerId: "email",
|
||||
accountId: emailInput,
|
||||
password: hashedPassword,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (convexUrl) {
|
||||
try {
|
||||
const convex = new ConvexHttpClient(convexUrl)
|
||||
await convex.mutation(api.users.ensureUser, {
|
||||
tenantId,
|
||||
email: emailInput,
|
||||
name: nameInput || emailInput,
|
||||
avatarUrl: undefined,
|
||||
role: role.toUpperCase(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Falha ao sincronizar usuário no Convex", error)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ user, temporaryPassword: password })
|
||||
}
|
||||
5
web/src/app/api/auth/[...all]/route.ts
Normal file
5
web/src/app/api/auth/[...all]/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { toNextJsHandler } from "better-auth/next-js"
|
||||
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler)
|
||||
|
|
@ -41,6 +41,10 @@
|
|||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--font-geist-mono: var(----font-geist-mono);
|
||||
--font-geist-sans: var(----font-geist-sans);
|
||||
--radius: var(----radius);
|
||||
}
|
||||
|
||||
:root {
|
||||
|
|
@ -76,6 +80,9 @@
|
|||
--sidebar-accent-foreground: #0f172a;
|
||||
--sidebar-border: #cbd5e1;
|
||||
--sidebar-ring: #00d6eb;
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--font-geist-sans: "Geist Sans", sans-serif;
|
||||
--font-geist-mono: "Geist Mono", monospace;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -110,6 +117,7 @@
|
|||
--sidebar-accent-foreground: #f8fafc;
|
||||
--sidebar-border: #0f1b2a;
|
||||
--sidebar-ring: #00e6ff;
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
@ -163,3 +171,12 @@
|
|||
animation: recent-ticket-enter 0.45s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { Metadata } from "next"
|
||||
import { Inter, JetBrains_Mono } from "next/font/google"
|
||||
import "./globals.css"
|
||||
import { cookies } from "next/headers"
|
||||
import { ConvexClientProvider } from "./ConvexClientProvider"
|
||||
import { AuthProvider } from "@/lib/auth-client"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
|
|
@ -28,20 +27,13 @@ export default async function RootLayout({
|
|||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
const cookieStore = await cookies()
|
||||
const cookie = cookieStore.get("demoUser")?.value
|
||||
let demoUser: { name: string; email: string } | null = null
|
||||
try {
|
||||
demoUser = cookie ? (JSON.parse(cookie) as { name: string; email: string }) : null
|
||||
} catch {}
|
||||
const tenantId = "tenant-atlas"
|
||||
return (
|
||||
<html lang="pt-BR" className="h-full">
|
||||
<body
|
||||
className={`${inter.variable} ${jetBrainsMono.variable} min-h-screen bg-background text-foreground antialiased`}
|
||||
>
|
||||
<ConvexClientProvider>
|
||||
<AuthProvider demoUser={demoUser} tenantId={tenantId}>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<Toaster position="bottom-center" richColors />
|
||||
</AuthProvider>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,52 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { GalleryVerticalEnd } from "lucide-react"
|
||||
|
||||
import { LoginForm } from "@/components/login-form"
|
||||
import { useSession } from "@/lib/auth-client"
|
||||
import dynamic from "next/dynamic"
|
||||
|
||||
const ShaderBackground = dynamic(
|
||||
() => import("@/components/background-paper-shaders-wrapper"),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
export default function LoginPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const router = useRouter();
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session, isPending } = useSession()
|
||||
const callbackUrl = searchParams?.get("callbackUrl") ?? undefined
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name || !email) return;
|
||||
document.cookie = `demoUser=${JSON.stringify({ name, email })}; path=/; max-age=${60 * 60 * 24 * 365}`;
|
||||
router.replace("/dashboard");
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!session?.user) return
|
||||
const destination = callbackUrl ?? "/dashboard"
|
||||
router.replace(destination)
|
||||
}, [callbackUrl, router, session?.user])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center p-6">
|
||||
<form onSubmit={submit} className="w-full max-w-sm space-y-4 rounded-xl border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-lg font-semibold">Entrar (placeholder)</h1>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">Nome</label>
|
||||
<input className="w-full rounded-md border bg-background px-3 py-2" placeholder="Ana Souza" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-6 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Link href="/" className="flex items-center gap-2 font-medium">
|
||||
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
|
||||
<GalleryVerticalEnd className="size-4" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm">E-mail</label>
|
||||
<input className="w-full rounded-md border bg-background px-3 py-2" placeholder="ana@example.com" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
Sistema de Chamados
|
||||
</Link>
|
||||
</div>
|
||||
<button type="submit" className="inline-flex w-full items-center justify-center rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground">Continuar</button>
|
||||
<p className="text-center text-xs text-muted-foreground">Apenas para desenvolvimento. Em produção usar Auth.js/Clerk.</p>
|
||||
</form>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<LoginForm callbackUrl={callbackUrl} disabled={isPending} />
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden overflow-hidden lg:flex">
|
||||
<ShaderBackground className="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
34
web/src/app/portal/page.tsx
Normal file
34
web/src/app/portal/page.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Portal do cliente",
|
||||
description: "Acompanhe seus chamados e atualizações como cliente.",
|
||||
}
|
||||
|
||||
export default function PortalPage() {
|
||||
return (
|
||||
<main className="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-8 px-6 py-12 text-center lg:min-h-[calc(100vh-6rem)]">
|
||||
<div className="max-w-md space-y-4">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-muted-foreground">
|
||||
Portal do cliente
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||
Área do cliente em construção
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Em breve você poderá abrir novos chamados, acompanhar o status e conversar com a equipe de suporte por aqui.
|
||||
Enquanto finalizamos os ajustes, utilize os canais combinados com sua equipe de atendimento.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>Precisa falar com a equipe agora?</span>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="mailto:suporte@sistema.dev">Enviar e-mail para suporte</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
240
web/src/components/admin/admin-users-manager.tsx
Normal file
240
web/src/components/admin/admin-users-manager.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState, useTransition } from "react"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
||||
|
||||
type AdminUser = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: RoleOption
|
||||
tenantId: string
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
initialUsers: AdminUser[]
|
||||
roleOptions: readonly RoleOption[]
|
||||
defaultTenantId: string
|
||||
}
|
||||
|
||||
function formatDate(dateIso: string) {
|
||||
const date = new Date(dateIso)
|
||||
return new Intl.DateTimeFormat("pt-BR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }: Props) {
|
||||
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
|
||||
const [email, setEmail] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [role, setRole] = useState<RoleOption>("agent")
|
||||
const [tenantId, setTenantId] = useState(defaultTenantId)
|
||||
const [lastInvite, setLastInvite] = useState<{ email: string; password: string } | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!email || !email.includes("@")) {
|
||||
toast.error("Informe um e-mail válido")
|
||||
return
|
||||
}
|
||||
|
||||
const payload = { email, name, role, tenantId }
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error ?? "Não foi possível criar o usuário")
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
user: AdminUser
|
||||
temporaryPassword: string
|
||||
}
|
||||
|
||||
setUsers((previous) => [data.user, ...previous])
|
||||
setLastInvite({ email: data.user.email, password: data.temporaryPassword })
|
||||
setEmail("")
|
||||
setName("")
|
||||
setRole("agent")
|
||||
setTenantId(defaultTenantId)
|
||||
toast.success("Usuário criado com sucesso")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Falha ao criar usuário"
|
||||
toast.error(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="users" className="w-full">
|
||||
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
|
||||
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
|
||||
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
|
||||
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Convidar novo usuário</CardTitle>
|
||||
<CardDescription>Crie um acesso provisório e compartilhe a senha inicial com o colaborador.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_200px_200px_auto]">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-email">E-mail corporativo</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
placeholder="nome@suaempresa.com"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-name">Nome</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
placeholder="Nome completo"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Papel</Label>
|
||||
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
|
||||
<SelectTrigger id="invite-role">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{normalizedRoles.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item === "customer" ? "Cliente" : item === "admin" ? "Administrador" : item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-tenant">Tenant</Label>
|
||||
<Input
|
||||
id="invite-tenant"
|
||||
value={tenantId}
|
||||
onChange={(event) => setTenantId(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? "Criando..." : "Criar acesso"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{lastInvite ? (
|
||||
<div className="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
|
||||
<p className="font-medium">Acesso provisório gerado</p>
|
||||
<p className="mt-1 text-neutral-600">
|
||||
Envie para <span className="font-semibold">{lastInvite.email}</span> a senha inicial
|
||||
<span className="font-mono text-neutral-900"> {lastInvite.password}</span>.
|
||||
Solicite que altere após o primeiro login.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Equipe cadastrada</CardTitle>
|
||||
<CardDescription>Lista completa de usuários autenticáveis pela Better Auth.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Nome</th>
|
||||
<th className="py-3 pr-4 font-medium">E-mail</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Tenant</th>
|
||||
<th className="py-3 font-medium">Criado em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 uppercase text-neutral-600">{user.role}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.tenantId}</td>
|
||||
<td className="py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-6 text-center text-neutral-500">
|
||||
Nenhum usuário cadastrado até o momento.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="queues" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestão de filas</CardTitle>
|
||||
<CardDescription>
|
||||
Em breve será possível criar e reordenar as filas utilizadas na triagem dos tickets.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="categories" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gestão de categorias</CardTitle>
|
||||
<CardDescription>
|
||||
Estamos preparando o painel completo para organizar categorias e subcategorias do catálogo.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
20
web/src/components/background-paper-shaders-wrapper.tsx
Normal file
20
web/src/components/background-paper-shaders-wrapper.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"use client"
|
||||
|
||||
import { MeshGradient } from "@paper-design/shaders-react"
|
||||
|
||||
export default function BackgroundPaperShadersWrapper() {
|
||||
const speed = 1.0
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-black relative overflow-hidden">
|
||||
<MeshGradient
|
||||
className="w-full h-full absolute inset-0"
|
||||
colors={["#000000", "#1a1a1a", "#333333", "#ffffff"]}
|
||||
speed={speed * 0.5}
|
||||
wireframe="true"
|
||||
backgroundColor="#000000"
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
web/src/components/background-paper-shaders.tsx
Normal file
116
web/src/components/background-paper-shaders.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"use client"
|
||||
|
||||
import { useRef, useMemo } from "react"
|
||||
import { useFrame } from "@react-three/fiber"
|
||||
import * as THREE from "three"
|
||||
// Custom shader material for advanced effects
|
||||
const vertexShader = `
|
||||
uniform float time;
|
||||
uniform float intensity;
|
||||
varying vec2 vUv;
|
||||
varying vec3 vPosition;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vPosition = position;
|
||||
|
||||
vec3 pos = position;
|
||||
pos.y += sin(pos.x * 10.0 + time) * 0.1 * intensity;
|
||||
pos.x += cos(pos.y * 8.0 + time * 1.5) * 0.05 * intensity;
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const fragmentShader = `
|
||||
uniform float time;
|
||||
uniform float intensity;
|
||||
uniform vec3 color1;
|
||||
uniform vec3 color2;
|
||||
varying vec2 vUv;
|
||||
varying vec3 vPosition;
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
|
||||
// Create animated noise pattern
|
||||
float noise = sin(uv.x * 20.0 + time) * cos(uv.y * 15.0 + time * 0.8);
|
||||
noise += sin(uv.x * 35.0 - time * 2.0) * cos(uv.y * 25.0 + time * 1.2) * 0.5;
|
||||
|
||||
// Mix colors based on noise and position
|
||||
vec3 color = mix(color1, color2, noise * 0.5 + 0.5);
|
||||
color = mix(color, vec3(1.0), pow(abs(noise), 2.0) * intensity);
|
||||
|
||||
// Add glow effect
|
||||
float glow = 1.0 - length(uv - 0.5) * 2.0;
|
||||
glow = pow(glow, 2.0);
|
||||
|
||||
gl_FragColor = vec4(color * glow, glow * 0.8);
|
||||
}
|
||||
`
|
||||
|
||||
export function ShaderPlane({
|
||||
position,
|
||||
color1 = "#ff5722",
|
||||
color2 = "#ffffff",
|
||||
}: {
|
||||
position: [number, number, number]
|
||||
color1?: string
|
||||
color2?: string
|
||||
}) {
|
||||
const mesh = useRef<THREE.Mesh>(null)
|
||||
|
||||
const uniforms = useMemo(
|
||||
() => ({
|
||||
time: { value: 0 },
|
||||
intensity: { value: 1.0 },
|
||||
color1: { value: new THREE.Color(color1) },
|
||||
color2: { value: new THREE.Color(color2) },
|
||||
}),
|
||||
[color1, color2],
|
||||
)
|
||||
|
||||
useFrame((state) => {
|
||||
if (mesh.current) {
|
||||
uniforms.time.value = state.clock.elapsedTime
|
||||
uniforms.intensity.value = 1.0 + Math.sin(state.clock.elapsedTime * 2) * 0.3
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<mesh ref={mesh} position={position}>
|
||||
<planeGeometry args={[2, 2, 32, 32]} />
|
||||
<shaderMaterial
|
||||
uniforms={uniforms}
|
||||
vertexShader={vertexShader}
|
||||
fragmentShader={fragmentShader}
|
||||
transparent
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnergyRing({
|
||||
radius = 1,
|
||||
position = [0, 0, 0],
|
||||
}: {
|
||||
radius?: number
|
||||
position?: [number, number, number]
|
||||
}) {
|
||||
const mesh = useRef<THREE.Mesh>(null)
|
||||
|
||||
useFrame((state) => {
|
||||
if (mesh.current) {
|
||||
mesh.current.rotation.z = state.clock.elapsedTime
|
||||
mesh.current.material.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<mesh ref={mesh} position={position}>
|
||||
<ringGeometry args={[radius * 0.8, radius, 32]} />
|
||||
<meshBasicMaterial color="#ff5722" transparent opacity={0.6} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
124
web/src/components/login-form.tsx
Normal file
124
web/src/components/login-form.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { signIn } from "@/lib/auth-client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
type LoginFormProps = React.ComponentProps<"form"> & {
|
||||
callbackUrl?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function LoginForm({ className, callbackUrl, disabled = false, ...props }: LoginFormProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const destination = callbackUrl ?? searchParams?.get("callbackUrl") ?? "/dashboard"
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (isSubmitting || disabled) return
|
||||
if (!email || !password) {
|
||||
toast.error("Informe e-mail e senha")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const result = await signIn.email({ email, password, callbackURL: destination })
|
||||
if (result?.error) {
|
||||
toast.error("E-mail ou senha inválidos")
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
toast.success("Sessão iniciada com sucesso")
|
||||
router.replace(destination)
|
||||
} catch (error) {
|
||||
console.error("Erro ao autenticar", error)
|
||||
toast.error("Não foi possível entrar. Tente novamente")
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={cn("flex flex-col gap-6", disabled && "pointer-events-none opacity-70", className)}
|
||||
{...props}
|
||||
>
|
||||
<FieldGroup>
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<h1 className="text-2xl font-bold">Acesse sua conta</h1>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Informe seu e-mail corporativo e senha para continuar atendendo os chamados.
|
||||
</p>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">E-mail</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="agente@sistema.dev"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
disabled={isSubmitting || disabled}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-center">
|
||||
<FieldLabel htmlFor="password">Senha</FieldLabel>
|
||||
<Link
|
||||
href="/recuperar"
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Esqueceu a senha?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
disabled={isSubmitting || disabled}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit" disabled={isSubmitting || disabled} className="gap-2">
|
||||
{(isSubmitting || disabled) && <Loader2 className="size-4 animate-spin" />}
|
||||
Entrar
|
||||
</Button>
|
||||
</Field>
|
||||
<FieldSeparator />
|
||||
<Field>
|
||||
<div className="space-y-1 text-left">
|
||||
<p className="text-sm font-semibold">Primeiro acesso?</p>
|
||||
<FieldDescription className="text-sm">
|
||||
Fale com o nosso suporte por telefone ou e-mail para receber um convite e definir sua senha inicial.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ export function NewTicketDialog() {
|
|||
},
|
||||
mode: "onTouched",
|
||||
})
|
||||
const { userId } = useAuth()
|
||||
const { convexUserId } = useAuth()
|
||||
const queuesRaw = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined
|
||||
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
|
||||
const create = useMutation(api.tickets.create)
|
||||
|
|
@ -96,7 +96,7 @@ export function NewTicketDialog() {
|
|||
}
|
||||
|
||||
async function submit(values: z.infer<typeof schema>) {
|
||||
if (!userId) return
|
||||
if (!convexUserId) return
|
||||
const subjectTrimmed = (values.subject ?? "").trim()
|
||||
if (subjectTrimmed.length < 3) {
|
||||
form.setError("subject", { type: "min", message: "Informe um assunto" })
|
||||
|
|
@ -107,13 +107,14 @@ export function NewTicketDialog() {
|
|||
try {
|
||||
const sel = queues.find((q) => q.name === values.queueName)
|
||||
const id = await create({
|
||||
actorId: convexUserId as Id<"users">,
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
subject: subjectTrimmed,
|
||||
summary: values.summary,
|
||||
priority: values.priority,
|
||||
channel: values.channel,
|
||||
queueId: sel?.id as Id<"queues"> | undefined,
|
||||
requesterId: userId as Id<"users">,
|
||||
requesterId: convexUserId as Id<"users">,
|
||||
categoryId: values.categoryId as Id<"ticketCategories">,
|
||||
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
|
||||
})
|
||||
|
|
@ -126,7 +127,7 @@ export function NewTicketDialog() {
|
|||
size: a.size,
|
||||
type: a.type,
|
||||
}))
|
||||
await addComment({ ticketId: id as Id<"tickets">, authorId: userId as Id<"users">, visibility: "PUBLIC", body: bodyHtml, attachments: typedAttachments })
|
||||
await addComment({ ticketId: id as Id<"tickets">, authorId: convexUserId as Id<"users">, visibility: "PUBLIC", body: bodyHtml, attachments: typedAttachments })
|
||||
}
|
||||
toast.success("Ticket criado!", { id: "new-ticket" })
|
||||
setOpen(false)
|
||||
|
|
|
|||
|
|
@ -31,19 +31,25 @@ const secondaryButtonClass = "inline-flex items-center gap-2 rounded-lg border b
|
|||
|
||||
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
||||
const router = useRouter()
|
||||
const { userId } = useAuth()
|
||||
const { convexUserId } = useAuth()
|
||||
const queueSummary = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? []
|
||||
const playNext = useMutation(api.tickets.playNext)
|
||||
const [selectedQueueId, setSelectedQueueId] = useState<string | undefined>(undefined)
|
||||
|
||||
const nextTicketFromServer = useQuery(api.tickets.list, {
|
||||
const nextTicketFromServer = useQuery(
|
||||
api.tickets.list,
|
||||
convexUserId
|
||||
? {
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
viewerId: convexUserId as Id<"users">,
|
||||
status: undefined,
|
||||
priority: undefined,
|
||||
channel: undefined,
|
||||
queueId: (selectedQueueId as Id<"queues">) || undefined,
|
||||
limit: 1,
|
||||
})?.[0]
|
||||
}
|
||||
: "skip"
|
||||
)?.[0]
|
||||
const nextTicketUi = nextTicketFromServer ? mapTicketFromServer(nextTicketFromServer as unknown) : null
|
||||
|
||||
const cardContext: TicketPlayContext | null =
|
||||
|
|
@ -128,12 +134,12 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
|||
<Button
|
||||
className={startButtonClass}
|
||||
onClick={async () => {
|
||||
if (!userId) return
|
||||
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: (selectedQueueId as Id<"queues">) || undefined, agentId: userId as Id<"users"> })
|
||||
if (!convexUserId) return
|
||||
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: (selectedQueueId as Id<"queues">) || undefined, agentId: convexUserId as Id<"users"> })
|
||||
if (chosen?.id) router.push(`/tickets/${chosen.id}`)
|
||||
}}
|
||||
>
|
||||
{userId ? (
|
||||
{convexUserId ? (
|
||||
<>
|
||||
<IconPlayerPlayFilled className="size-4 text-black" /> Iniciar atendimento
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function PriorityIcon({ value }: { value: TicketPriority }) {
|
|||
export function PrioritySelect({ ticketId, value }: { ticketId: string; value: TicketPriority }) {
|
||||
const updatePriority = useMutation(api.tickets.updatePriority)
|
||||
const [priority, setPriority] = useState<TicketPriority>(value)
|
||||
const { userId } = useAuth()
|
||||
const { convexUserId } = useAuth()
|
||||
|
||||
return (
|
||||
<Select
|
||||
|
|
@ -51,8 +51,8 @@ export function PrioritySelect({ ticketId, value }: { ticketId: string; value: T
|
|||
setPriority(next)
|
||||
toast.loading("Atualizando prioridade...", { id: "priority" })
|
||||
try {
|
||||
if (!userId) throw new Error("missing user")
|
||||
await updatePriority({ ticketId: ticketId as unknown as Id<"tickets">, priority: next, actorId: userId as Id<"users"> })
|
||||
if (!convexUserId) throw new Error("missing user")
|
||||
await updatePriority({ ticketId: ticketId as unknown as Id<"tickets">, priority: next, actorId: convexUserId as Id<"users"> })
|
||||
toast.success("Prioridade atualizada!", { id: "priority" })
|
||||
} catch {
|
||||
setPriority(previous)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ptBR } from "date-fns/locale"
|
|||
import { useQuery } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TS declarations
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
|
||||
import type { Ticket } from "@/lib/schemas/ticket"
|
||||
|
|
@ -16,6 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
|
||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
||||
const metaBadgeClass =
|
||||
"inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-[11px] font-semibold text-neutral-700"
|
||||
|
|
@ -80,7 +82,11 @@ function TicketRow({ ticket, entering }: { ticket: Ticket; entering: boolean })
|
|||
}
|
||||
|
||||
export function RecentTicketsPanel() {
|
||||
const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 6 })
|
||||
const { convexUserId } = useAuth()
|
||||
const ticketsRaw = useQuery(
|
||||
api.tickets.list,
|
||||
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users">, limit: 6 } : "skip"
|
||||
)
|
||||
const [enteringId, setEnteringId] = useState<string | null>(null)
|
||||
const previousIdsRef = useRef<string[]>([])
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const baseBadgeClass =
|
|||
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
|
||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
||||
const [status, setStatus] = useState<TicketStatus>(value)
|
||||
const { userId } = useAuth()
|
||||
const { convexUserId } = useAuth()
|
||||
|
||||
return (
|
||||
<Select
|
||||
|
|
@ -42,8 +42,8 @@ export function StatusSelect({ ticketId, value }: { ticketId: string; value: Tic
|
|||
setStatus(next)
|
||||
toast.loading("Atualizando status...", { id: "status" })
|
||||
try {
|
||||
if (!userId) throw new Error("missing user")
|
||||
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: userId as Id<"users"> })
|
||||
if (!convexUserId) throw new Error("missing user")
|
||||
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: convexUserId as Id<"users"> })
|
||||
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
|
||||
} catch {
|
||||
setStatus(previous)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const submitButtonClass =
|
|||
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||
|
||||
export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||
const { userId } = useAuth()
|
||||
const { convexUserId } = useAuth()
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
|
||||
const updateComment = useMutation(api.tickets.updateComment)
|
||||
|
|
@ -59,7 +59,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const saveEditedComment = useCallback(
|
||||
async (commentId: string, originalBody: string) => {
|
||||
if (!editingComment || editingComment.id !== commentId) return
|
||||
if (!userId) return
|
||||
if (!convexUserId) return
|
||||
if (commentId.startsWith("temp-")) return
|
||||
|
||||
const sanitized = sanitizeEditorHtml(editingComment.value)
|
||||
|
|
@ -75,7 +75,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
await updateComment({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
commentId: commentId as unknown as Id<"ticketComments">,
|
||||
actorId: userId as Id<"users">,
|
||||
actorId: convexUserId as Id<"users">,
|
||||
body: sanitized,
|
||||
})
|
||||
setLocalBodies((prev) => ({ ...prev, [commentId]: sanitized }))
|
||||
|
|
@ -88,7 +88,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
setSavingCommentId(null)
|
||||
}
|
||||
},
|
||||
[editingComment, ticket.id, updateComment, userId]
|
||||
[editingComment, ticket.id, updateComment, convexUserId]
|
||||
)
|
||||
|
||||
const commentsAll = useMemo(() => {
|
||||
|
|
@ -97,7 +97,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!userId) return
|
||||
if (!convexUserId) return
|
||||
const now = new Date()
|
||||
const attachments = attachmentsToSend.map((item) => ({ ...item }))
|
||||
const previewsToRevoke = attachments
|
||||
|
|
@ -132,7 +132,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
}))
|
||||
await addComment({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
authorId: userId as Id<"users">,
|
||||
authorId: convexUserId as Id<"users">,
|
||||
visibility,
|
||||
body: optimistic.body,
|
||||
attachments: payload,
|
||||
|
|
@ -153,7 +153,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
}
|
||||
|
||||
async function handleRemoveAttachment() {
|
||||
if (!attachmentToRemove || !userId) return
|
||||
if (!attachmentToRemove || !convexUserId) return
|
||||
setRemovingAttachment(true)
|
||||
toast.loading("Removendo anexo...", { id: "remove-attachment" })
|
||||
try {
|
||||
|
|
@ -161,7 +161,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
ticketId: ticket.id as unknown as Id<"tickets">,
|
||||
commentId: attachmentToRemove.commentId as Id<"ticketComments">,
|
||||
attachmentId: attachmentToRemove.attachmentId as Id<"_storage">,
|
||||
actorId: userId as Id<"users">,
|
||||
actorId: convexUserId as Id<"users">,
|
||||
})
|
||||
toast.success("Anexo removido.", { id: "remove-attachment" })
|
||||
setAttachmentToRemove(null)
|
||||
|
|
@ -203,7 +203,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const bodyPlain = storedBody.replace(/<[^>]*>/g, "").trim()
|
||||
const isEditing = editingComment?.id === commentId
|
||||
const isPending = commentId.startsWith("temp-")
|
||||
const canEdit = Boolean(userId && String(comment.author.id) === userId && !isPending)
|
||||
const canEdit = Boolean(convexUserId && String(comment.author.id) === convexUserId && !isPending)
|
||||
const hasBody = bodyPlain.length > 0 || isEditing
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -14,10 +14,22 @@ import { TicketComments } from "@/components/tickets/ticket-comments.rich";
|
|||
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
||||
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
||||
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
||||
import { useAuth } from "@/lib/auth-client";
|
||||
|
||||
export function TicketDetailView({ id }: { id: string }) {
|
||||
const isMockId = id.startsWith("ticket-");
|
||||
const t = useQuery(api.tickets.getById, isMockId ? "skip" : ({ tenantId: DEFAULT_TENANT_ID, id: id as Id<"tickets"> }));
|
||||
const { convexUserId } = useAuth();
|
||||
const shouldSkip = isMockId || !convexUserId;
|
||||
const t = useQuery(
|
||||
api.tickets.getById,
|
||||
shouldSkip
|
||||
? "skip"
|
||||
: {
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
id: id as Id<"tickets">,
|
||||
viewerId: convexUserId as Id<"users">,
|
||||
}
|
||||
);
|
||||
let ticket: TicketWithDetails | null = null;
|
||||
if (t) {
|
||||
ticket = mapTicketWithDetailsFromServer(t as unknown);
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ function formatDuration(durationMs: number) {
|
|||
}
|
||||
|
||||
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||
const { userId } = useAuth()
|
||||
const { convexUserId } = useAuth()
|
||||
const changeAssignee = useMutation(api.tickets.changeAssignee)
|
||||
const changeQueue = useMutation(api.tickets.changeQueue)
|
||||
const updateSubject = useMutation(api.tickets.updateSubject)
|
||||
|
|
@ -69,7 +69,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
|
||||
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
|
||||
const [status] = useState<TicketStatus>(ticket.status)
|
||||
const workSummaryRemote = useQuery(api.tickets.workSummary, { ticketId: ticket.id as Id<"tickets"> }) as
|
||||
const workSummaryRemote = useQuery(
|
||||
api.tickets.workSummary,
|
||||
convexUserId
|
||||
? { ticketId: ticket.id as Id<"tickets">, viewerId: convexUserId as Id<"users"> }
|
||||
: "skip"
|
||||
) as
|
||||
| {
|
||||
ticketId: Id<"tickets">
|
||||
totalWorkedMs: number
|
||||
|
|
@ -103,14 +108,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory])
|
||||
|
||||
async function handleSave() {
|
||||
if (!userId) return
|
||||
if (!convexUserId) return
|
||||
toast.loading("Salvando alterações...", { id: "save-header" })
|
||||
try {
|
||||
if (subject !== ticket.subject) {
|
||||
await updateSubject({ ticketId: ticket.id as Id<"tickets">, subject: subject.trim(), actorId: userId as Id<"users"> })
|
||||
await updateSubject({ ticketId: ticket.id as Id<"tickets">, subject: subject.trim(), actorId: convexUserId as Id<"users"> })
|
||||
}
|
||||
if ((summary ?? "") !== (ticket.summary ?? "")) {
|
||||
await updateSummary({ ticketId: ticket.id as Id<"tickets">, summary: (summary ?? "").trim(), actorId: userId as Id<"users"> })
|
||||
await updateSummary({ ticketId: ticket.id as Id<"tickets">, summary: (summary ?? "").trim(), actorId: convexUserId as Id<"users"> })
|
||||
}
|
||||
toast.success("Cabeçalho atualizado!", { id: "save-header" })
|
||||
setEditing(false)
|
||||
|
|
@ -170,7 +175,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
|
||||
useEffect(() => {
|
||||
if (!editing) return
|
||||
if (!userId) return
|
||||
if (!convexUserId) return
|
||||
const categoryId = selectedCategoryId
|
||||
const subcategoryId = selectedSubcategoryId
|
||||
if (!categoryId || !subcategoryId) return
|
||||
|
|
@ -200,7 +205,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
ticketId: ticket.id as Id<"tickets">,
|
||||
categoryId: categoryId as Id<"ticketCategories">,
|
||||
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
|
||||
actorId: userId as Id<"users">,
|
||||
actorId: convexUserId as Id<"users">,
|
||||
})
|
||||
if (!cancelled) {
|
||||
toast.success("Categoria atualizada!", { id: "ticket-category" })
|
||||
|
|
@ -225,7 +230,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [editing, selectedCategoryId, selectedSubcategoryId, ticket.category?.id, ticket.subcategory?.id, ticket.id, updateCategories, userId])
|
||||
}, [editing, selectedCategoryId, selectedSubcategoryId, ticket.category?.id, ticket.subcategory?.id, ticket.id, updateCategories, convexUserId])
|
||||
|
||||
const workSummary = useMemo(() => {
|
||||
if (workSummaryRemote !== undefined) return workSummaryRemote ?? null
|
||||
|
|
@ -288,19 +293,19 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
size="sm"
|
||||
className={isPlaying ? pauseButtonClass : startButtonClass}
|
||||
onClick={async () => {
|
||||
if (!userId) return
|
||||
if (!convexUserId) return
|
||||
toast.dismiss("work")
|
||||
toast.loading(isPlaying ? "Pausando atendimento..." : "Iniciando atendimento...", { id: "work" })
|
||||
try {
|
||||
if (isPlaying) {
|
||||
const result = await pauseWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
|
||||
const result = await pauseWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
|
||||
if (result?.status === "already_paused") {
|
||||
toast.info("O atendimento já estava pausado", { id: "work" })
|
||||
} else {
|
||||
toast.success("Atendimento pausado", { id: "work" })
|
||||
}
|
||||
} else {
|
||||
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
|
||||
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
|
||||
if (result?.status === "already_started") {
|
||||
toast.info("O atendimento já estava em andamento", { id: "work" })
|
||||
} else {
|
||||
|
|
@ -418,12 +423,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<Select
|
||||
value={ticket.queue ?? ""}
|
||||
onValueChange={async (value) => {
|
||||
if (!userId) return
|
||||
if (!convexUserId) return
|
||||
const queue = queues.find((item) => item.name === value)
|
||||
if (!queue) return
|
||||
toast.loading("Atualizando fila...", { id: "queue" })
|
||||
try {
|
||||
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: queue.id as Id<"queues">, actorId: userId as Id<"users"> })
|
||||
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: queue.id as Id<"queues">, actorId: convexUserId as Id<"users"> })
|
||||
toast.success("Fila atualizada!", { id: "queue" })
|
||||
} catch {
|
||||
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
|
||||
|
|
@ -455,10 +460,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<Select
|
||||
value={ticket.assignee?.id ?? ""}
|
||||
onValueChange={async (value) => {
|
||||
if (!userId) return
|
||||
if (!convexUserId) return
|
||||
toast.loading("Atribuindo responsável...", { id: "assignee" })
|
||||
try {
|
||||
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
|
||||
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: convexUserId as Id<"users"> })
|
||||
toast.success("Responsável atualizado!", { id: "assignee" })
|
||||
} catch {
|
||||
toast.error("Não foi possível atribuir.", { id: "assignee" })
|
||||
|
|
|
|||
|
|
@ -4,24 +4,33 @@ import { useMemo, useState } from "react"
|
|||
import { useQuery } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
|
||||
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
|
||||
import { TicketsTable } from "@/components/tickets/tickets-table"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
||||
export function TicketsView() {
|
||||
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
|
||||
const { convexUserId } = useAuth()
|
||||
|
||||
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined
|
||||
const ticketsRaw = useQuery(api.tickets.list, {
|
||||
const ticketsRaw = useQuery(
|
||||
api.tickets.list,
|
||||
convexUserId
|
||||
? {
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
viewerId: convexUserId as Id<"users">,
|
||||
status: filters.status ?? undefined,
|
||||
priority: filters.priority ?? undefined,
|
||||
channel: filters.channel ?? undefined,
|
||||
queueId: undefined, // simplified: filter by queue name on client
|
||||
search: filters.search || undefined,
|
||||
})
|
||||
}
|
||||
: "skip"
|
||||
)
|
||||
|
||||
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
|
||||
|
||||
|
|
|
|||
73
web/src/components/ui/background-paper-shaders.tsx
Normal file
73
web/src/components/ui/background-paper-shaders.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const MeshGradient = dynamic(
|
||||
() => import("@paper-design/shaders-react").then((mod) => mod.MeshGradient),
|
||||
{ ssr: false }
|
||||
)
|
||||
const DotOrbit = dynamic(
|
||||
() => import("@paper-design/shaders-react").then((mod) => mod.DotOrbit),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
function ShaderVisual() {
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
<MeshGradient
|
||||
className="absolute inset-0"
|
||||
colors={["#020202", "#04131f", "#062534", "#0b3947"]}
|
||||
speed={0.8}
|
||||
backgroundColor="#020202"
|
||||
wireframe="true"
|
||||
/>
|
||||
<div className="absolute inset-0 opacity-70">
|
||||
<DotOrbit
|
||||
className="h-full w-full"
|
||||
dotColor="#0f172a"
|
||||
orbitColor="#155e75"
|
||||
speed={1.4}
|
||||
intensity={1.2}
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute left-1/4 top-1/3 h-24 w-24 rounded-full bg-cyan-300/10 blur-3xl animate-pulse" />
|
||||
<div
|
||||
className="absolute right-1/4 bottom-1/3 h-20 w-20 rounded-full bg-sky-500/15 blur-2xl animate-pulse"
|
||||
style={{ animationDelay: "1s" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-1/3 top-1/2 h-16 w-16 rounded-full bg-white/10 blur-xl animate-pulse"
|
||||
style={{ animationDelay: "0.5s" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BackgroundPaperShaders({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("shader-surface relative flex h-full w-full items-center justify-center bg-[#1f3d45]", className)}>
|
||||
<div className="absolute h-[780px] w-[780px] -translate-y-6 rounded-full border border-white/10 opacity-30" />
|
||||
<div className="absolute h-[640px] w-[640px] -translate-y-4 rounded-full border border-white/15 opacity-60" />
|
||||
<div className="relative flex h-[520px] w-[520px] items-center justify-center rounded-full border border-white/20 bg-black/85 shadow-[0_0_160px_rgba(0,0,0,0.5)]">
|
||||
<div className="absolute inset-6 rounded-full border border-white/15" />
|
||||
<div className="relative h-[420px] w-[420px] overflow-hidden rounded-full">
|
||||
<ShaderVisual />
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-10 text-center text-white">
|
||||
<div className="text-sm uppercase tracking-[0.32em] text-white/50">Sistema de Chamados</div>
|
||||
<h2 className="mt-4 text-xl font-semibold md:text-2xl">Atendimento moderno e colaborativo</h2>
|
||||
<p className="mt-3 text-sm text-white/70">
|
||||
Tenha visão unificada de todos os canais, monitore SLAs em tempo real e mantenha os clientes informados
|
||||
com atualizações automáticas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackgroundPaperShaders
|
||||
45
web/src/components/ui/raycast-animated-black-background.tsx
Normal file
45
web/src/components/ui/raycast-animated-black-background.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import UnicornScene from "unicornstudio-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export const useWindowSize = () => {
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
||||
height: typeof window !== "undefined" ? window.innerHeight : 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleResize)
|
||||
handleResize()
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize)
|
||||
}, [])
|
||||
|
||||
return windowSize
|
||||
}
|
||||
|
||||
export const Component = ({ className }: { className?: string }) => {
|
||||
const { width, height } = useWindowSize()
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
return <div className={cn("flex flex-col items-center", className)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center", className)}>
|
||||
<UnicornScene production projectId="erpu4mAlEe8kmhaGKYe9" width={width} height={height} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RaycastAnimatedBlackBackground = Component
|
||||
|
|
@ -1,47 +1,118 @@
|
|||
"use client";
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import type { Doc } from "@/convex/_generated/dataModel";
|
||||
import { useMutation } from "convex/react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react"
|
||||
import { customSessionClient } from "better-auth/client/plugins"
|
||||
import { createAuthClient } from "better-auth/react"
|
||||
import { useMutation } from "convex/react"
|
||||
|
||||
// Lazy import to avoid build errors before convex is generated
|
||||
// @ts-expect-error Convex generates runtime API without types until build
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { isAdmin, isCustomer, isStaff } from "@/lib/authz"
|
||||
|
||||
export type DemoUser = { name: string; email: string; avatarUrl?: string } | null;
|
||||
export type AppSession = {
|
||||
user: {
|
||||
id: string
|
||||
name?: string | null
|
||||
email: string
|
||||
role: string
|
||||
tenantId: string | null
|
||||
avatarUrl: string | null
|
||||
}
|
||||
session: {
|
||||
id: string
|
||||
expiresAt: number
|
||||
}
|
||||
}
|
||||
|
||||
const authClient = createAuthClient({
|
||||
plugins: [customSessionClient<AppSession>()],
|
||||
fetchOptions: {
|
||||
credentials: "include",
|
||||
},
|
||||
})
|
||||
|
||||
type AuthContextValue = {
|
||||
demoUser: DemoUser;
|
||||
userId: string | null;
|
||||
setDemoUser: (u: DemoUser) => void;
|
||||
};
|
||||
session: AppSession | null
|
||||
isLoading: boolean
|
||||
convexUserId: string | null
|
||||
role: string | null
|
||||
isAdmin: boolean
|
||||
isStaff: boolean
|
||||
isCustomer: boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({ demoUser: null, userId: null, setDemoUser: () => {} });
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
session: null,
|
||||
isLoading: true,
|
||||
convexUserId: null,
|
||||
role: null,
|
||||
isAdmin: false,
|
||||
isStaff: false,
|
||||
isCustomer: false,
|
||||
})
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
|
||||
export function AuthProvider({ demoUser, tenantId, children }: { demoUser: DemoUser; tenantId: string; children: React.ReactNode }) {
|
||||
const [localDemoUser, setLocalDemoUser] = useState<DemoUser>(demoUser);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const ensureUser = useMutation(api.users.ensureUser);
|
||||
export const { signIn, signOut, useSession } = authClient
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, isPending } = useSession()
|
||||
const ensureUser = useMutation(api.users.ensureUser)
|
||||
const [convexUserId, setConvexUserId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
if (!process.env.NEXT_PUBLIC_CONVEX_URL) return; // allow dev without backend
|
||||
if (!localDemoUser) return;
|
||||
try {
|
||||
const user = (await ensureUser({ tenantId, name: localDemoUser.name, email: localDemoUser.email, avatarUrl: localDemoUser.avatarUrl })) as Doc<"users"> | null;
|
||||
setUserId(user?._id ?? null);
|
||||
} catch (e) {
|
||||
console.error("Failed to ensure user:", e);
|
||||
if (!session?.user) {
|
||||
setConvexUserId(null)
|
||||
}
|
||||
}
|
||||
run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localDemoUser?.email, tenantId]);
|
||||
}, [session?.user])
|
||||
|
||||
const value = useMemo(() => ({ demoUser: localDemoUser, setDemoUser: setLocalDemoUser, userId }), [localDemoUser, userId]);
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
useEffect(() => {
|
||||
if (!session?.user || !process.env.NEXT_PUBLIC_CONVEX_URL || convexUserId) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const ensured = await ensureUser({
|
||||
tenantId: session.user.tenantId ?? DEFAULT_TENANT_ID,
|
||||
name: session.user.name ?? session.user.email,
|
||||
email: session.user.email,
|
||||
avatarUrl: session.user.avatarUrl ?? undefined,
|
||||
role: session.user.role.toUpperCase(),
|
||||
})
|
||||
if (!controller.signal.aborted) {
|
||||
setConvexUserId(ensured?._id ?? null)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.error("Failed to sync user with Convex", error)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ensureUser, session?.user?.email, convexUserId])
|
||||
|
||||
const normalizedRole = session?.user?.role ? session.user.role.toLowerCase() : null
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
session: session ?? null,
|
||||
isLoading: isPending,
|
||||
convexUserId,
|
||||
role: normalizedRole,
|
||||
isAdmin: isAdmin(normalizedRole),
|
||||
isStaff: isStaff(normalizedRole),
|
||||
isCustomer: isCustomer(normalizedRole),
|
||||
}),
|
||||
[session, isPending, convexUserId, normalizedRole]
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
|
|
|||
92
web/src/lib/auth-server.ts
Normal file
92
web/src/lib/auth-server.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { cookies, headers } from "next/headers"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { getCookieCache } from "better-auth/cookies"
|
||||
|
||||
import { env } from "@/lib/env"
|
||||
import { isAdmin, isStaff } from "@/lib/authz"
|
||||
|
||||
type ServerSession = Awaited<ReturnType<typeof getCookieCache>>
|
||||
|
||||
function serializeCookies() {
|
||||
const store = cookies()
|
||||
return store.getAll().map((cookie) => `${cookie.name}=${cookie.value}`).join("; ")
|
||||
}
|
||||
|
||||
function buildRequest() {
|
||||
const cookieHeader = serializeCookies()
|
||||
const headerList = headers()
|
||||
const userAgent = headerList.get("user-agent") ?? ""
|
||||
const ip =
|
||||
headerList.get("x-forwarded-for") ||
|
||||
headerList.get("x-real-ip") ||
|
||||
headerList.get("cf-connecting-ip") ||
|
||||
headerList.get("x-client-ip") ||
|
||||
undefined
|
||||
|
||||
return new Request(env.BETTER_AUTH_URL ?? "http://localhost:3000", {
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
"user-agent": userAgent,
|
||||
...(ip ? { "x-forwarded-for": ip } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function getServerSession(): Promise<ServerSession | null> {
|
||||
try {
|
||||
const request = buildRequest()
|
||||
const session = await getCookieCache(request)
|
||||
return session ?? null
|
||||
} catch (error) {
|
||||
console.error("Failed to read Better Auth session", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertAuthenticatedSession() {
|
||||
const session = await getServerSession()
|
||||
return session?.user ? session : null
|
||||
}
|
||||
|
||||
export async function requireAuthenticatedSession() {
|
||||
const session = await assertAuthenticatedSession()
|
||||
if (!session) {
|
||||
redirect("/login")
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
export async function assertStaffSession() {
|
||||
const session = await assertAuthenticatedSession()
|
||||
if (!session) return null
|
||||
if (!isStaff(session.user.role)) {
|
||||
return null
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
export async function requireStaffSession() {
|
||||
const session = await assertStaffSession()
|
||||
if (!session) {
|
||||
redirect("/portal")
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
export async function assertAdminSession() {
|
||||
const session = await assertAuthenticatedSession()
|
||||
if (!session) return null
|
||||
if (!isAdmin(session.user.role)) {
|
||||
return null
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
export async function requireAdminSession() {
|
||||
const session = await assertAdminSession()
|
||||
if (!session) {
|
||||
redirect("/dashboard")
|
||||
}
|
||||
return session
|
||||
}
|
||||
72
web/src/lib/auth.ts
Normal file
72
web/src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { betterAuth } from "better-auth"
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma"
|
||||
import { customSession } from "better-auth/plugins"
|
||||
|
||||
import { env } from "./env"
|
||||
import { prisma } from "./prisma"
|
||||
|
||||
export const auth = betterAuth({
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "sqlite",
|
||||
}),
|
||||
user: {
|
||||
modelName: "authUser",
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
required: false,
|
||||
defaultValue: "agent",
|
||||
input: false,
|
||||
},
|
||||
tenantId: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
avatarUrl: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
modelName: "authSession",
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 60 * 5,
|
||||
},
|
||||
},
|
||||
account: {
|
||||
modelName: "authAccount",
|
||||
},
|
||||
verification: {
|
||||
modelName: "authVerification",
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false,
|
||||
},
|
||||
plugins: [
|
||||
customSession(async ({ user, session }) => {
|
||||
const expiresAt = session.expiresAt instanceof Date
|
||||
? session.expiresAt.getTime()
|
||||
: new Date(session.expiresAt ?? Date.now()).getTime()
|
||||
|
||||
return {
|
||||
session: {
|
||||
id: session.id,
|
||||
expiresAt,
|
||||
},
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: ((user as { role?: string }).role ?? "agent").toLowerCase(),
|
||||
tenantId: (user as { tenantId?: string | null }).tenantId ?? null,
|
||||
avatarUrl: (user as { avatarUrl?: string | null }).avatarUrl ?? null,
|
||||
},
|
||||
}
|
||||
}),
|
||||
],
|
||||
})
|
||||
23
web/src/lib/authz.ts
Normal file
23
web/src/lib/authz.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export const ROLE_OPTIONS = ["admin", "manager", "agent", "collaborator", "customer"] as const
|
||||
|
||||
const ADMIN_ROLE = "admin"
|
||||
const CUSTOMER_ROLE = "customer"
|
||||
const STAFF_ROLES = new Set(["admin", "manager", "agent", "collaborator"])
|
||||
|
||||
export type RoleOption = (typeof ROLE_OPTIONS)[number]
|
||||
|
||||
export function normalizeRole(role?: string | null) {
|
||||
return role?.toLowerCase() ?? null
|
||||
}
|
||||
|
||||
export function isAdmin(role?: string | null) {
|
||||
return normalizeRole(role) === ADMIN_ROLE
|
||||
}
|
||||
|
||||
export function isCustomer(role?: string | null) {
|
||||
return normalizeRole(role) === CUSTOMER_ROLE
|
||||
}
|
||||
|
||||
export function isStaff(role?: string | null) {
|
||||
return STAFF_ROLES.has(normalizeRole(role) ?? "")
|
||||
}
|
||||
23
web/src/lib/env.ts
Normal file
23
web/src/lib/env.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { z } from "zod"
|
||||
|
||||
const envSchema = z.object({
|
||||
BETTER_AUTH_SECRET: z.string().min(1, "Missing BETTER_AUTH_SECRET"),
|
||||
BETTER_AUTH_URL: z.string().url().optional(),
|
||||
NEXT_PUBLIC_CONVEX_URL: z.string().url().optional(),
|
||||
DATABASE_URL: z.string().min(1).optional(),
|
||||
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
|
||||
})
|
||||
|
||||
const parsed = envSchema.safeParse(process.env)
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error("Failed to parse environment variables", parsed.error.flatten().fieldErrors)
|
||||
throw new Error("Invalid environment configuration")
|
||||
}
|
||||
|
||||
export const env = {
|
||||
BETTER_AUTH_SECRET: parsed.data.BETTER_AUTH_SECRET,
|
||||
BETTER_AUTH_URL: parsed.data.BETTER_AUTH_URL ?? parsed.data.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
||||
NEXT_PUBLIC_CONVEX_URL: parsed.data.NEXT_PUBLIC_CONVEX_URL,
|
||||
DATABASE_URL: parsed.data.DATABASE_URL,
|
||||
}
|
||||
11
web/src/lib/prisma.ts
Normal file
11
web/src/lib/prisma.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { PrismaClient } from "@prisma/client"
|
||||
|
||||
declare global {
|
||||
var prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = global.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
global.prisma = prisma
|
||||
}
|
||||
|
|
@ -1,10 +1,17 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import { defineConfig } from "vitest/config"
|
||||
import path from "path"
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@/convex": path.resolve(__dirname, "./convex"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue