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:
parent
ff674d5bb5
commit
7946b8d017
46 changed files with 2564 additions and 178 deletions
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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
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, {
|
||||
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
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
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])
|
||||
|
||||
|
|
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue