feat: migrate auth stack and admin portal

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
esdrasrenan 2025-10-05 17:25:57 -03:00
parent ff674d5bb5
commit 7946b8d017
46 changed files with 2564 additions and 178 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

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

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

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