Implement company provisioning codes and session tweaks

This commit is contained in:
Esdras Renan 2025-10-15 20:45:25 -03:00
parent 0fb9bf59b2
commit 2cba553efa
28 changed files with 1407 additions and 534 deletions

View file

@ -11,18 +11,19 @@ export default async function AdminCompaniesPage() {
const companies = companiesRaw.map((c) => {
const extra = c as unknown as { isAvulso?: boolean; contractedHoursPerMonth?: number | null }
return {
id: c.id,
tenantId: c.tenantId,
name: c.name,
slug: c.slug,
isAvulso: Boolean(extra.isAvulso ?? false),
contractedHoursPerMonth: extra.contractedHoursPerMonth ?? null,
cnpj: c.cnpj ?? null,
domain: c.domain ?? null,
phone: c.phone ?? null,
description: c.description ?? null,
address: c.address ?? null,
}
id: c.id,
tenantId: c.tenantId,
name: c.name,
slug: c.slug,
provisioningCode: c.provisioningCode,
isAvulso: Boolean(extra.isAvulso ?? false),
contractedHoursPerMonth: extra.contractedHoursPerMonth ?? null,
cnpj: c.cnpj ?? null,
domain: c.domain ?? null,
phone: c.phone ?? null,
description: c.description ?? null,
address: c.address ?? null,
}
})
return (
<AppShell

View file

@ -1,4 +1,5 @@
import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { prisma } from "@/lib/prisma"
import { assertStaffSession } from "@/lib/auth-server"
@ -38,11 +39,13 @@ export async function POST(request: Request) {
}
try {
const provisioningCode = randomBytes(32).toString("hex")
const company = await prisma.company.create({
data: {
tenantId: session.user.tenantId ?? "tenant-atlas",
name: String(name),
slug: String(slug),
provisioningCode,
// Campos opcionais (isAvulso, contractedHoursPerMonth) podem ser definidos via PATCH posteriormente.
cnpj: cnpj ? String(cnpj) : null,
domain: domain ? String(domain) : null,

View file

@ -3,7 +3,6 @@ import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { env } from "@/lib/env"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
const tokenModeSchema = z.object({
@ -21,9 +20,7 @@ const tokenModeSchema = z.object({
})
const provisioningModeSchema = z.object({
provisioningSecret: z.string().min(1),
tenantId: z.string().optional(),
companySlug: z.string().optional(),
provisioningCode: z.string().min(32),
hostname: z.string().min(1),
os: z.object({
name: z.string().min(1),
@ -87,9 +84,7 @@ export async function POST(request: Request) {
if (provParsed.success) {
try {
const result = await client.mutation(api.machines.upsertInventory, {
provisioningSecret: provParsed.data.provisioningSecret,
tenantId: provParsed.data.tenantId ?? DEFAULT_TENANT_ID,
companySlug: provParsed.data.companySlug ?? undefined,
provisioningCode: provParsed.data.provisioningCode.trim().toLowerCase(),
hostname: provParsed.data.hostname,
os: provParsed.data.os,
macAddresses: provParsed.data.macAddresses,
@ -108,4 +103,3 @@ export async function POST(request: Request) {
return jsonWithCors({ error: "Formato de payload não suportado" }, 400, request.headers.get("origin"), CORS_METHODS)
}

View file

@ -0,0 +1,93 @@
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { prisma } from "@/lib/prisma"
import { env } from "@/lib/env"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
export const runtime = "nodejs"
const CORS_METHODS = "POST, OPTIONS"
export async function OPTIONS(request: Request) {
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
}
export async function POST(request: Request) {
const origin = request.headers.get("origin")
let payload: unknown
try {
payload = await request.json()
} catch (error) {
return jsonWithCors(
{ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
400,
origin,
CORS_METHODS
)
}
const provisioningCodeRaw =
payload && typeof payload === "object" && "provisioningCode" in payload
? (payload as { provisioningCode?: unknown }).provisioningCode
: null
const provisioningCode =
typeof provisioningCodeRaw === "string" ? provisioningCodeRaw.trim().toLowerCase() : ""
if (!provisioningCode) {
return jsonWithCors({ error: "Informe o código de provisionamento" }, 400, origin, CORS_METHODS)
}
try {
const company = await prisma.company.findFirst({
where: { provisioningCode },
select: {
id: true,
tenantId: true,
name: true,
slug: true,
provisioningCode: true,
},
})
if (!company) {
return jsonWithCors({ error: "Código de provisionamento inválido" }, 404, origin, CORS_METHODS)
}
if (env.NEXT_PUBLIC_CONVEX_URL) {
try {
const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL)
await client.mutation(api.companies.ensureProvisioned, {
tenantId: company.tenantId,
slug: company.slug,
name: company.name,
provisioningCode: company.provisioningCode,
})
} catch (error) {
console.error("[machines.provisioning] Falha ao sincronizar empresa no Convex", error)
}
}
return jsonWithCors(
{
company: {
id: company.id,
tenantId: company.tenantId,
name: company.name,
slug: company.slug,
},
},
200,
origin,
CORS_METHODS
)
} catch (error) {
console.error("[machines.provisioning] Falha ao validar código", error)
return jsonWithCors(
{ error: "Falha ao validar código de provisionamento" },
500,
origin,
CORS_METHODS
)
}
}

View file

@ -5,15 +5,13 @@ import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { ensureMachineAccount } from "@/server/machines-auth"
import { ensureCollaboratorAccount, ensureMachineAccount } from "@/server/machines-auth"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { normalizeSlug } from "@/lib/slug"
import { prisma } from "@/lib/prisma"
const registerSchema = z
.object({
provisioningSecret: z.string().min(1),
tenantId: z.string().optional(),
companySlug: z.string().optional(),
provisioningCode: z.string().min(32),
hostname: z.string().min(1),
os: z.object({
name: z.string().min(1),
@ -67,13 +65,25 @@ export async function POST(request: Request) {
}
const client = new ConvexHttpClient(convexUrl)
let normalizedCompanySlug: string | undefined
try {
const tenantId = payload.tenantId ?? DEFAULT_TENANT_ID
const provisioningCode = payload.provisioningCode.trim().toLowerCase()
const companyRecord = await prisma.company.findFirst({
where: { provisioningCode },
select: { id: true, tenantId: true, name: true, slug: true, provisioningCode: true },
})
if (!companyRecord) {
return jsonWithCors(
{ error: "Código de provisionamento inválido" },
404,
request.headers.get("origin"),
CORS_METHODS
)
}
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
const persona = payload.accessRole ?? undefined
const collaborator = payload.collaborator ?? null
normalizedCompanySlug = normalizeSlug(payload.companySlug)
if (persona && !collaborator) {
return jsonWithCors(
@ -99,10 +109,15 @@ export async function POST(request: Request) {
}
}
const registration = await client.mutation(api.machines.register, {
provisioningSecret: payload.provisioningSecret,
await client.mutation(api.companies.ensureProvisioned, {
tenantId,
companySlug: normalizedCompanySlug,
slug: companyRecord.slug,
name: companyRecord.name,
provisioningCode: companyRecord.provisioningCode,
})
const registration = await client.mutation(api.machines.register, {
provisioningCode,
hostname: payload.hostname,
os: payload.os,
macAddresses: payload.macAddresses,
@ -126,26 +141,39 @@ export async function POST(request: Request) {
})
let assignedUserId: Id<"users"> | undefined
if (persona && collaborator) {
if (collaborator) {
const ensuredUser = (await client.mutation(api.users.ensureUser, {
tenantId,
email: collaborator.email,
name: collaborator.name ?? collaborator.email,
avatarUrl: undefined,
role: persona.toUpperCase(),
role: persona?.toUpperCase(),
companyId: registration.companyId ? (registration.companyId as Id<"companies">) : undefined,
})) as { _id?: Id<"users"> } | null
assignedUserId = ensuredUser?._id
await client.mutation(api.machines.updatePersona, {
machineId: registration.machineId as Id<"machines">,
persona,
...(assignedUserId ? { assignedUserId } : {}),
assignedUserEmail: collaborator.email,
assignedUserName: collaborator.name ?? undefined,
assignedUserRole: persona === "manager" ? "MANAGER" : "COLLABORATOR",
await ensureCollaboratorAccount({
email: collaborator.email,
name: collaborator.name ?? collaborator.email,
tenantId,
companyId: companyRecord.id,
})
if (persona) {
assignedUserId = ensuredUser?._id
await client.mutation(api.machines.updatePersona, {
machineId: registration.machineId as Id<"machines">,
persona,
...(assignedUserId ? { assignedUserId } : {}),
assignedUserEmail: collaborator.email,
assignedUserName: collaborator.name ?? undefined,
assignedUserRole: persona === "manager" ? "MANAGER" : "COLLABORATOR",
})
} else {
await client.mutation(api.machines.updatePersona, {
machineId: registration.machineId as Id<"machines">,
persona: "",
})
}
} else {
await client.mutation(api.machines.updatePersona, {
machineId: registration.machineId as Id<"machines">,
@ -174,18 +202,11 @@ export async function POST(request: Request) {
console.error("[machines.register] Falha no provisionamento", error)
const details = error instanceof Error ? error.message : String(error)
const msg = details.toLowerCase()
// Mapear alguns erros "esperados" para códigos adequados
// - empresa inválida → 404
// - segredo inválido → 401
// - demais ConvexError → 400
const isInvalidCode = msg.includes("código de provisionamento inválido")
const isCompanyNotFound = msg.includes("empresa não encontrada")
const isInvalidSecret = msg.includes("código de provisionamento inválido")
const isConvexError = msg.includes("convexerror")
const status = isCompanyNotFound ? 404 : isInvalidSecret ? 401 : isConvexError ? 400 : 500
const payload = { error: "Falha ao provisionar máquina", details } as Record<string, unknown>
if (isCompanyNotFound && normalizedCompanySlug) {
payload["companySlug"] = normalizedCompanySlug
}
const status = isInvalidCode ? 401 : isCompanyNotFound ? 404 : isConvexError ? 400 : 500
const payload = { error: "Falha ao provisionar máquina", details }
return jsonWithCors(payload, status, request.headers.get("origin"), CORS_METHODS)
}
}

View file

@ -0,0 +1,169 @@
import { NextResponse } from "next/server"
import { z } from "zod"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
import { prisma } from "@/lib/prisma"
import { requireAuthenticatedSession } from "@/lib/auth-server"
import { env } from "@/lib/env"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { api } from "@/convex/_generated/api"
import { ensureCollaboratorAccount } from "@/server/machines-auth"
const updateSchema = z.object({
email: z.string().email().optional(),
password: z
.object({
newPassword: z.string().min(8, "A nova senha deve ter pelo menos 8 caracteres."),
confirmPassword: z.string().min(8),
})
.optional(),
})
export async function PATCH(request: Request) {
const session = await requireAuthenticatedSession()
const role = (session.user.role ?? "").toLowerCase()
if (role !== "collaborator" && role !== "manager") {
return NextResponse.json({ error: "Acesso não autorizado" }, { status: 403 })
}
let payload: unknown
try {
payload = await request.json()
} catch {
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
}
const parsed = updateSchema.safeParse(payload)
if (!parsed.success) {
return NextResponse.json({ error: "Dados inválidos", details: parsed.error.flatten() }, { status: 400 })
}
const { email: emailInput, password } = parsed.data
const currentEmail = session.user.email.trim().toLowerCase()
const authUserId = session.user.id
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
let newEmail = emailInput?.trim().toLowerCase()
if (newEmail && newEmail === currentEmail) {
newEmail = undefined
}
if (password && password.newPassword !== password.confirmPassword) {
return NextResponse.json({ error: "As senhas informadas não conferem." }, { status: 400 })
}
if (newEmail) {
const existingEmail = await prisma.authUser.findUnique({ where: { email: newEmail } })
if (existingEmail && existingEmail.id !== authUserId) {
return NextResponse.json({ error: "Já existe um usuário com este e-mail." }, { status: 409 })
}
}
const domainUser = await prisma.user.findUnique({ where: { email: currentEmail } })
const companyId = domainUser?.companyId ?? null
const name = session.user.name ?? currentEmail
await prisma.$transaction(async (tx) => {
if (newEmail) {
await tx.authUser.update({
where: { id: authUserId },
data: { email: newEmail },
})
const existingAccount = await tx.authAccount.findUnique({
where: {
providerId_accountId: {
providerId: "credential",
accountId: currentEmail,
},
},
})
if (existingAccount) {
await tx.authAccount.update({
where: { id: existingAccount.id },
data: { accountId: newEmail },
})
} else {
await tx.authAccount.create({
data: {
providerId: "credential",
accountId: newEmail,
userId: authUserId,
password: null,
},
})
}
await tx.user.updateMany({
where: { email: currentEmail },
data: { email: newEmail },
})
}
if (password) {
const hashed = await hashPassword(password.newPassword)
await tx.authAccount.upsert({
where: {
providerId_accountId: {
providerId: "credential",
accountId: newEmail ?? currentEmail,
},
},
update: {
password: hashed,
userId: authUserId,
},
create: {
providerId: "credential",
accountId: newEmail ?? currentEmail,
userId: authUserId,
password: hashed,
},
})
}
})
const effectiveEmail = newEmail ?? currentEmail
await prisma.user.upsert({
where: { email: effectiveEmail },
update: {
name,
tenantId,
role: role === "manager" ? "MANAGER" : "COLLABORATOR",
companyId: companyId ?? undefined,
},
create: {
email: effectiveEmail,
name,
tenantId,
role: role === "manager" ? "MANAGER" : "COLLABORATOR",
companyId: companyId ?? undefined,
},
})
await ensureCollaboratorAccount({
email: effectiveEmail,
name,
tenantId,
companyId,
})
if (env.NEXT_PUBLIC_CONVEX_URL) {
try {
const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL)
await client.mutation(api.users.ensureUser, {
tenantId,
email: effectiveEmail,
name,
role: role === "manager" ? "MANAGER" : "COLLABORATOR",
})
} catch (error) {
console.warn("[portal.profile] Falha ao sincronizar usuário no Convex", error)
}
}
return NextResponse.json({ ok: true, email: effectiveEmail })
}

View file

@ -1,65 +0,0 @@
"use client"
import { useEffect, useState } from "react"
type Json = Record<string, unknown> | null
export default function PortalDebugPage() {
const [authSession, setAuthSession] = useState<Json>(null)
const [authStatus, setAuthStatus] = useState<number | null>(null)
const [machine, setMachine] = useState<Json>(null)
const [machineStatus, setMachineStatus] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const a = await fetch("/api/auth/get-session", { credentials: "include" })
const aBody = await a.json().catch(() => null)
if (!cancelled) {
setAuthStatus(a.status)
setAuthSession(aBody as Json)
}
const m = await fetch("/api/machines/session", { credentials: "include" })
const mBody = await m.json().catch(() => null)
if (!cancelled) {
setMachineStatus(m.status)
setMachine(mBody as Json)
}
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
}
})()
return () => { cancelled = true }
}, [])
return (
<div className="mx-auto max-w-3xl space-y-4 p-6">
<h1 className="text-lg font-semibold">Diagnóstico de sessão</h1>
<p className="text-sm text-neutral-600">Esta página consulta a API com os mesmos cookies desta aba.</p>
{error ? (
<div className="rounded-md border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{error}</div>
) : null}
<section className="rounded-md border border-slate-200 bg-white p-4">
<h2 className="mb-2 text-sm font-semibold">/api/auth/get-session</h2>
<div className="mb-2 text-xs text-neutral-500">status: {authStatus ?? "—"}</div>
<pre className="overflow-x-auto rounded-md bg-slate-50 p-3 text-xs leading-tight">{JSON.stringify(authSession, null, 2)}</pre>
</section>
<section className="rounded-md border border-slate-200 bg-white p-4">
<h2 className="mb-2 text-sm font-semibold">/api/machines/session</h2>
<div className="mb-2 text-xs text-neutral-500">status: {machineStatus ?? "—"}</div>
<pre className="overflow-x-auto rounded-md bg-slate-50 p-3 text-xs leading-tight">{JSON.stringify(machine, null, 2)}</pre>
</section>
<div className="text-xs text-neutral-500">
Se algum status for 401/403, os cookies de sessão não estão válidos. Reabra o agente e tente novamente.
</div>
</div>
)
}

View file

@ -0,0 +1,24 @@
import type { Metadata } from "next"
import { redirect } from "next/navigation"
import { requireAuthenticatedSession } from "@/lib/auth-server"
import { PortalProfileSettings } from "@/components/portal/portal-profile-settings"
export const metadata: Metadata = {
title: "Meu perfil",
description: "Atualize seu e-mail e defina uma senha de acesso ao portal.",
}
export default async function PortalProfilePage() {
const session = await requireAuthenticatedSession()
const role = (session.user.role ?? "").toLowerCase()
if (role !== "collaborator" && role !== "manager") {
redirect("/portal")
}
return (
<div className="space-y-6">
<PortalProfileSettings initialEmail={session.user.email} />
</div>
)
}

View file

@ -123,7 +123,7 @@ export default function NewTicketPage() {
await addComment({
ticketId: id as Id<"tickets">,
authorId: convexUserId as Id<"users">,
visibility: "PUBLIC",
visibility: "INTERNAL",
body: sanitizedDescription,
attachments: [],
})

View file

@ -23,6 +23,7 @@ type Company = {
tenantId: string
name: string
slug: string
provisioningCode: string
isAvulso: boolean
contractedHoursPerMonth?: number | null
cnpj: string | null
@ -58,6 +59,19 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
})
}
const handleCopyProvisioningCode = useCallback(async (code: string) => {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(code)
toast.success("Código copiado para a área de transferência")
} else {
throw new Error("Clipboard indisponível")
}
} catch {
toast.error("Não foi possível copiar o código. Copie manualmente.")
}
}, [])
const loadLastAlerts = useCallback(async (list: Company[] = companies) => {
if (!list || list.length === 0) return
const params = new URLSearchParams({ slugs: list.map((c) => c.slug).join(",") })
@ -254,6 +268,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Slug</TableHead>
<TableHead>Código</TableHead>
<TableHead>Avulso</TableHead>
<TableHead>Domínio</TableHead>
<TableHead>Telefone</TableHead>
@ -267,6 +282,23 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell>{c.slug}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-mono text-neutral-700"
title={c.provisioningCode}
>
{c.provisioningCode.slice(0, 10)}
</code>
<Button
size="sm"
variant="outline"
onClick={() => handleCopyProvisioningCode(c.provisioningCode)}
>
Copiar
</Button>
</div>
</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => void toggleAvulso(c)}>
{c.isAvulso ? "Sim" : "Não"}

View file

@ -0,0 +1,125 @@
"use client"
import { FormEvent, useMemo, useState } from "react"
import { toast } from "sonner"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
interface PortalProfileSettingsProps {
initialEmail: string
}
export function PortalProfileSettings({ initialEmail }: PortalProfileSettingsProps) {
const [email, setEmail] = useState(initialEmail)
const [referenceEmail, setReferenceEmail] = useState(initialEmail)
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const hasChanges = useMemo(() => {
const normalizedEmail = email.trim().toLowerCase()
const emailChanged = normalizedEmail !== referenceEmail.trim().toLowerCase()
const passwordChanged = newPassword.length > 0 || confirmPassword.length > 0
return emailChanged || passwordChanged
}, [email, referenceEmail, newPassword, confirmPassword])
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!hasChanges) {
toast.info("Nenhuma alteração a salvar.")
return
}
const payload: Record<string, unknown> = {}
const trimmedEmail = email.trim()
if (trimmedEmail && trimmedEmail.toLowerCase() !== referenceEmail.trim().toLowerCase()) {
payload.email = trimmedEmail
}
if (newPassword || confirmPassword) {
payload.password = { newPassword, confirmPassword }
}
setIsSubmitting(true)
try {
const res = await fetch("/api/portal/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) {
const data = await res.json().catch(() => ({ error: "Falha ao atualizar perfil" }))
const message = typeof data.error === "string" ? data.error : "Falha ao atualizar perfil"
toast.error(message)
return
}
const data = (await res.json().catch(() => null)) as { email?: string } | null
if (data?.email) {
setEmail(data.email)
setReferenceEmail(data.email)
}
setNewPassword("")
setConfirmPassword("")
toast.success("Dados atualizados com sucesso!")
} catch (error) {
console.error("Falha ao atualizar perfil", error)
toast.error("Não foi possível atualizar o perfil agora.")
} finally {
setIsSubmitting(false)
}
}
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Meu perfil</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Ajuste seu e-mail de acesso e defina uma senha para entrar pelo navegador.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-2">
<label className="text-sm font-medium text-neutral-800" htmlFor="profile-email">
E-mail
</label>
<Input
id="profile-email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="seuemail@empresa.com"
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium text-neutral-800" htmlFor="profile-password">
Nova senha
</label>
<Input
id="profile-password"
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
placeholder="Digite a nova senha"
/>
<Input
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder="Confirme a nova senha"
/>
<p className="text-xs text-neutral-500">
Utilize pelo menos 8 caracteres. Deixe os campos em branco caso não queira alterar a senha.
</p>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting || !hasChanges}>
{isSubmitting ? "Salvando..." : "Salvar alterações"}
</Button>
</div>
</form>
</CardContent>
</Card>
)
}

View file

@ -18,6 +18,7 @@ interface PortalShellProps {
const navItems = [
{ label: "Meus chamados", href: "/portal/tickets" },
{ label: "Abrir chamado", href: "/portal/tickets/new", icon: PlusCircle },
{ label: "Perfil", href: "/portal/profile" },
]
export function PortalShell({ children }: PortalShellProps) {
@ -155,38 +156,6 @@ export function PortalShell({ children }: PortalShellProps) {
Recuperando dados do colaborador vinculado...
</div>
) : null}
{!machineContextError && !machineContextLoading && machineContext && !machineContext.assignedUserId ? (
<div className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 shadow-sm">
<p className="font-semibold">Debug: m<EFBFBD>quina autenticada sem colaborador vinculado.</p>
<p className="mt-1 text-xs text-amber-700">
Copie os dados abaixo e compartilhe com o suporte para investigar.
</p>
<pre className="mt-2 max-h-64 overflow-y-auto rounded-lg bg-white/70 px-3 py-2 text-[11px] leading-tight text-amber-900">
{JSON.stringify(machineContext, null, 2)}
</pre>
<button
type="button"
onClick={() => {
const payload = {
machineContext,
timestamp: new Date().toISOString(),
}
try {
navigator.clipboard
.writeText(JSON.stringify(payload, null, 2))
.catch(() => {
console.warn("N<>o foi poss<73>vel copiar automaticamente. Selecione o texto manualmente.", payload)
})
} catch (error) {
console.warn("Clipboard n<>o suportado. Selecione manualmente.", error)
}
}}
className="mt-2 inline-flex items-center rounded-md border border-amber-300 px-3 py-1 text-xs font-semibold text-amber-800 transition hover:bg-amber-100"
>
Copiar detalhes
</button>
</div>
) : null}
{children}
</main>
<footer className="border-t border-slate-200 bg-white/70">

View file

@ -1,10 +1,10 @@
"use client"
import { useMemo, useState } from "react"
import { useQuery, useMutation } from "convex/react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useAction, useMutation, useQuery } from "convex/react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { MessageCircle } from "lucide-react"
import { Download, FileIcon, MessageCircle, X } from "lucide-react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
@ -14,19 +14,15 @@ import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dropzone } from "@/components/ui/dropzone"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Dropzone } from "@/components/ui/dropzone"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Skeleton } from "@/components/ui/skeleton"
// removed wrong import; RichTextEditor comes from rich-text-editor
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { sanitizeEditorHtml, RichTextEditor } from "@/components/ui/rich-text-editor"
const statusLabel: Record<TicketWithDetails["status"], string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
}
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Spinner } from "@/components/ui/spinner"
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
LOW: "Baixa",
@ -42,16 +38,6 @@ const priorityTone: Record<TicketWithDetails["priority"], string> = {
URGENT: "bg-rose-100 text-rose-700",
}
const timelineLabels: Record<string, string> = {
CREATED: "Chamado criado",
STATUS_CHANGED: "Status atualizado",
ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Novo comentário",
COMMENT_EDITED: "Comentário editado",
ATTACHMENT_REMOVED: "Anexo removido",
QUEUE_CHANGED: "Fila atualizada",
}
function toHtmlFromText(text: string) {
const escaped = text
.replace(/&/g, "&amp;")
@ -66,10 +52,21 @@ interface PortalTicketDetailProps {
ticketId: string
}
type ClientTimelineEntry = {
id: string
title: string
description: string | null
when: Date
}
export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
const { convexUserId, session, isCustomer } = useAuth()
const addComment = useMutation(api.tickets.addComment)
const [comment, setComment] = useState(""); const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
const getFileUrl = useAction(api.files.getUrl)
const [comment, setComment] = useState("")
const [attachments, setAttachments] = useState<
Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>
>([])
const ticketRaw = useQuery(
api.tickets.getById,
@ -87,6 +84,112 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
return mapTicketWithDetailsFromServer(ticketRaw)
}, [ticketRaw])
const clientTimeline = useMemo(() => {
if (!ticket) return []
return ticket.timeline
.map<ClientTimelineEntry | null>((event) => {
const payload = (event.payload ?? {}) as Record<string, unknown>
const actorName = typeof payload.actorName === "string" && payload.actorName.trim().length > 0 ? String(payload.actorName).trim() : null
if (event.type === "CREATED") {
const requesterName = typeof payload.requesterName === "string" && payload.requesterName.trim().length > 0
? String(payload.requesterName).trim()
: null
return {
id: event.id,
title: "Chamado criado",
description: requesterName ? `Aberto por ${requesterName}` : "Chamado registrado",
when: event.createdAt,
}
}
if (event.type === "QUEUE_CHANGED") {
const queueNameRaw =
(typeof payload.queueName === "string" && payload.queueName.trim()) ||
(typeof payload.toLabel === "string" && payload.toLabel.trim()) ||
(typeof payload.to === "string" && payload.to.trim()) ||
null
if (!queueNameRaw) return null
const queueName = queueNameRaw.trim()
const description = actorName ? `Fila ${queueName} • por ${actorName}` : `Fila ${queueName}`
return {
id: event.id,
title: "Fila atualizada",
description,
when: event.createdAt,
}
}
if (event.type === "ASSIGNEE_CHANGED") {
const assigneeName = typeof payload.assigneeName === "string" && payload.assigneeName.trim().length > 0 ? String(payload.assigneeName).trim() : null
const title = assigneeName ? "Responsável atribuído" : "Responsável atualizado"
const description = assigneeName ? `Agora com ${assigneeName}` : "Chamado sem responsável no momento"
return {
id: event.id,
title,
description,
when: event.createdAt,
}
}
if (event.type === "CATEGORY_CHANGED") {
const categoryName = typeof payload.categoryName === "string" ? payload.categoryName.trim() : ""
const subcategoryName = typeof payload.subcategoryName === "string" ? payload.subcategoryName.trim() : ""
const hasCategory = categoryName.length > 0
const hasSubcategory = subcategoryName.length > 0
const description = hasCategory
? hasSubcategory
? `${categoryName}${subcategoryName}`
: categoryName
: "Categoria removida"
return {
id: event.id,
title: "Categoria atualizada",
description,
when: event.createdAt,
}
}
if (event.type === "COMMENT_ADDED") {
const matchingComment = ticket.comments.find((comment) => comment.createdAt.getTime() === event.createdAt.getTime())
if (!matchingComment) {
return null
}
const rawBody = matchingComment.body ?? ""
const plainBody = rawBody.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()
const summary = plainBody.length > 0 ? (plainBody.length > 140 ? `${plainBody.slice(0, 140)}` : plainBody) : null
const author = matchingComment.author.name || actorName || "Equipe"
const description = summary ?? `Comentário registrado por ${author}`
return {
id: event.id,
title: "Novo comentário",
description,
when: event.createdAt,
}
}
if (event.type === "STATUS_CHANGED") {
const toLabel = typeof payload.toLabel === "string" && payload.toLabel.trim().length > 0 ? String(payload.toLabel).trim() : null
const toRaw = typeof payload.to === "string" && payload.to.trim().length > 0 ? String(payload.to).trim() : null
const normalized = (toLabel ?? toRaw ?? "").toUpperCase()
if (!normalized) return null
const isFinal = normalized === "RESOLVED" || normalized === "RESOLVIDO" || normalized === "CLOSED" || normalized === "FINALIZADO" || normalized === "FINALIZED"
if (!isFinal) return null
const description = `Status alterado para ${toLabel ?? toRaw ?? "Resolvido"}`
return {
id: event.id,
title: normalized === "RESOLVED" || normalized === "RESOLVIDO" ? "Chamado resolvido" : "Chamado finalizado",
description,
when: event.createdAt,
}
}
return null
})
.filter((entry): entry is ClientTimelineEntry => entry !== null)
.sort((a, b) => b.when.getTime() - a.when.getTime())
}, [ticket])
if (ticketRaw === undefined) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
@ -120,7 +223,6 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
const createdAt = format(ticket.createdAt, "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })
const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!convexUserId || !comment.trim() || !ticket) return
@ -133,9 +235,24 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
authorId: convexUserId as Id<"users">,
visibility: "PUBLIC",
body: htmlBody,
attachments: attachments.map((f) => ({ storageId: f.storageId as Id<"_storage">, name: f.name, size: f.size, type: f.type, })),
attachments: attachments.map((f) => ({
storageId: f.storageId as Id<"_storage">,
name: f.name,
size: f.size,
type: f.type,
})),
})
setComment("")
attachments.forEach((file) => {
if (file.previewUrl?.startsWith("blob:")) {
try {
URL.revokeObjectURL(file.previewUrl)
} catch {
// ignore revoke issues
}
}
})
setAttachments([])
toast.success("Comentário enviado!", { id: toastId })
} catch (error) {
console.error(error)
@ -156,9 +273,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
) : null}
</div>
<div className="flex flex-col items-end gap-2 text-sm">
<Badge className="rounded-full bg-neutral-900 px-3 py-1 text-xs font-semibold uppercase text-white">
{statusLabel[ticket.status]}
</Badge>
<TicketStatusBadge status={ticket.status} className="px-3 py-1 text-xs font-semibold uppercase" />
{!isCustomer ? (
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold uppercase ${priorityTone[ticket.priority]}`}>
{priorityLabel[ticket.priority]}
@ -169,7 +284,8 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
</CardHeader>
<CardContent className="grid gap-4 border-t border-slate-100 px-5 py-5 text-sm text-neutral-700 sm:grid-cols-2">
{isCustomer ? null : <DetailItem label="Fila" value={ticket.queue ?? "Sem fila"} />}
<DetailItem label="Categoria" value={ticket.category?.name ?? "Não classificada"} />`n <DetailItem label="Subcategoria" value={ticket.subcategory?.name ?? "—"} />
<DetailItem label="Categoria" value={ticket.category?.name ?? "Não classificada"} />
<DetailItem label="Subcategoria" value={ticket.subcategory?.name ?? "—"} />
<DetailItem label="Solicitante" value={ticket.requester.name} subtitle={ticket.requester.email} />
{ticket.assignee ? (
<DetailItem label="Responsável" value={ticket.assignee.name} />
@ -187,22 +303,75 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-5 pb-6">
<form onSubmit={handleSubmit} className="space-y-3">
<label htmlFor="comment" className="text-sm font-medium text-neutral-800">
Enviar uma mensagem para a equipe
</label>
<RichTextEditor
value={comment}
onChange={(html) => setComment(html)}
placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações."
className="rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20"
/>
<div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="comment" className="text-sm font-medium text-neutral-800">
Enviar uma mensagem para a equipe
</label>
<RichTextEditor
value={comment}
onChange={(html) => setComment(html)}
placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações."
className="rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20"
/>
</div>
<div className="space-y-2">
<Dropzone
onUploaded={(files) => setAttachments((prev) => [...prev, ...files])}
className="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-3 py-4 text-sm text-neutral-600 shadow-inner"
/>
<p className="mt-1 text-xs text-neutral-500">Máximo 10MB Até 5 arquivos</p>
{attachments.length > 0 ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{attachments.map((attachment, index) => {
const isImage =
(attachment.type ?? "").startsWith("image/") ||
/\.(png|jpe?g|gif|webp|svg)$/i.test(attachment.name)
return (
<div
key={`${attachment.storageId}-${index}`}
className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5"
>
{isImage && attachment.previewUrl ? (
<div className="block w-full overflow-hidden rounded-md">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={attachment.previewUrl}
alt={attachment.name}
className="h-24 w-full rounded-md object-cover"
/>
</div>
) : (
<div className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-600">
<FileIcon className="size-4" />
<span className="line-clamp-2 px-2 text-center">{attachment.name}</span>
</div>
)}
<button
type="button"
onClick={() =>
setAttachments((prev) => {
const next = [...prev]
const removed = next.splice(index, 1)[0]
if (removed?.previewUrl?.startsWith("blob:")) {
URL.revokeObjectURL(removed.previewUrl)
}
return next
})
}
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
aria-label={`Remover ${attachment.name}`}
>
<X className="size-3.5" />
</button>
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">
{attachment.name}
</div>
</div>
)
})}
</div>
) : null}
<p className="text-xs text-neutral-500">Máximo 5MB Até 5 arquivos</p>
</div>
<div className="flex justify-end">
<Button type="submit" className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90">
@ -248,14 +417,22 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
<span className="text-xs text-neutral-500">{createdAgo}</span>
</div>
</div>
<Badge variant="outline" className="rounded-full border-dashed px-3 py-1 text-[11px] uppercase text-neutral-600">
{commentItem.visibility === "PUBLIC" ? "Público" : "Interno"}
</Badge>
</div>
<div
className="prose prose-sm mt-3 max-w-none text-neutral-800"
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(commentItem.body ?? "") }}
/>
{commentItem.attachments && commentItem.attachments.length > 0 ? (
<div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
{commentItem.attachments.map((attachment) => (
<PortalCommentAttachmentCard
key={attachment.id}
attachment={attachment}
getFileUrl={getFileUrl}
/>
))}
</div>
) : null}
</div>
)
})
@ -269,22 +446,21 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
<CardTitle className="text-lg font-semibold text-neutral-900">Linha do tempo</CardTitle>
</CardHeader>
<CardContent className="space-y-5 px-5 pb-6 text-sm text-neutral-700">
{ticket.timeline.length === 0 ? (
{clientTimeline.length === 0 ? (
<p className="text-sm text-neutral-500">Nenhum evento registrado ainda.</p>
) : (
ticket.timeline
.slice()
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.map((event) => {
const label = timelineLabels[event.type] ?? event.type
const when = formatDistanceToNow(event.createdAt, { addSuffix: true, locale: ptBR })
return (
<div key={event.id} className="flex flex-col gap-1 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<span className="text-sm font-semibold text-neutral-900">{label}</span>
<span className="text-xs text-neutral-500">{when}</span>
</div>
)
})
clientTimeline.map((event) => {
const when = formatDistanceToNow(event.when, { addSuffix: true, locale: ptBR })
return (
<div key={event.id} className="flex flex-col gap-1 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<span className="text-sm font-semibold text-neutral-900">{event.title}</span>
{event.description ? (
<span className="text-xs text-neutral-600">{event.description}</span>
) : null}
<span className="text-xs text-neutral-500">{when}</span>
</div>
)
})
)}
</CardContent>
</Card>
@ -308,6 +484,121 @@ function DetailItem({ label, value, subtitle }: DetailItemProps) {
</div>
)
}
type CommentAttachment = TicketWithDetails["comments"][number]["attachments"][number]
type GetFileUrlAction = (args: { storageId: Id<"_storage"> }) => Promise<string | null>
function PortalCommentAttachmentCard({ attachment, getFileUrl }: { attachment: CommentAttachment; getFileUrl: GetFileUrlAction }) {
const [url, setUrl] = useState<string | null>(attachment.url ?? null)
const [loading, setLoading] = useState(false)
const [errored, setErrored] = useState(false)
const isImageType = useMemo(() => {
const name = attachment.name ?? ""
const type = attachment.type ?? ""
return type.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/i.test(name)
}, [attachment.name, attachment.type])
const ensureUrl = useCallback(async () => {
if (url) return url
try {
setLoading(true)
const fresh = await getFileUrl({ storageId: attachment.id as Id<"_storage"> })
if (fresh) {
setUrl(fresh)
setErrored(false)
return fresh
}
setErrored(true)
} catch (error) {
console.error("Falha ao obter URL do anexo", error)
setErrored(true)
} finally {
setLoading(false)
}
return null
}, [attachment.id, getFileUrl, url])
useEffect(() => {
if (attachment.url) {
setUrl(attachment.url)
setErrored(false)
return
}
if (isImageType) {
void ensureUrl()
}
}, [attachment.url, ensureUrl, isImageType])
const handlePreview = useCallback(async () => {
const target = await ensureUrl()
if (target) {
window.open(target, "_blank", "noopener,noreferrer")
}
}, [ensureUrl])
const handleDownload = useCallback(async () => {
const target = await ensureUrl()
if (!target) return
try {
const link = document.createElement("a")
link.href = target
link.download = attachment.name ?? "anexo"
link.rel = "noopener noreferrer"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (error) {
console.error("Falha ao iniciar download do anexo", error)
window.open(target, "_blank", "noopener,noreferrer")
}
}, [attachment.name, ensureUrl])
const resolvedUrl = url
return (
<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
{isImageType && resolvedUrl ? (
<button
type="button"
onClick={handlePreview}
className="relative block w-full overflow-hidden rounded-md"
>
{loading ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/70">
<Spinner className="size-5 text-neutral-600" />
</div>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={resolvedUrl} alt={attachment.name ?? "Anexo"} className="h-24 w-full rounded-md object-cover" />
</button>
) : (
<button
type="button"
onClick={handleDownload}
className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-700 transition hover:bg-slate-100"
disabled={loading}
>
{loading ? <Spinner className="size-5 text-neutral-600" /> : <FileIcon className="size-5 text-neutral-600" />}
<span className="font-medium">
{errored ? "Gerar link novamente" : "Baixar"}
</span>
</button>
)}
<button
type="button"
onClick={handleDownload}
className="absolute left-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
aria-label={`Baixar ${attachment.name ?? "anexo"}`}
>
<Download className="size-3.5" />
</button>
<div className="mt-1 line-clamp-2 w-full text-ellipsis text-center text-[11px] text-neutral-500">
{attachment.name}
</div>
</div>
)
}

View file

@ -168,7 +168,7 @@ export function NewTicketDialog() {
size: a.size,
type: a.type,
}))
await addComment({ ticketId: id as Id<"tickets">, authorId: convexUserId as Id<"users">, visibility: "PUBLIC", body: bodyHtml, attachments: typedAttachments })
await addComment({ ticketId: id as Id<"tickets">, authorId: convexUserId as Id<"users">, visibility: "INTERNAL", body: bodyHtml, attachments: typedAttachments })
}
toast.success("Ticket criado!", { id: "new-ticket" })
setOpen(false)

View file

@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
import { Download, FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
import { useAction, useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
@ -43,7 +43,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
const [preview, setPreview] = useState<string | null>(null)
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("INTERNAL")
const [attachmentToRemove, setAttachmentToRemove] = useState<{ commentId: string; attachmentId: string; name: string } | null>(null)
const [removingAttachment, setRemovingAttachment] = useState(false)
const [editingComment, setEditingComment] = useState<{ id: string; value: string } | null>(null)
@ -228,16 +228,17 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const canEdit = Boolean(convexUserId && String(comment.author.id) === convexUserId && !isPending)
const hasBody = bodyPlain.length > 0 || isEditing
const isInternal = comment.visibility === "INTERNAL" && canSeeInternalComments
const containerClass = isInternal
const isPublic = comment.visibility === "PUBLIC"
const containerClass = isPublic
? "group/comment flex gap-3 rounded-2xl border border-amber-200/80 bg-amber-50/80 px-3 py-3 shadow-[0_0_0_1px_rgba(217,119,6,0.15)]"
: "group/comment flex gap-3"
const bodyClass = isInternal
: "group/comment flex gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3"
const bodyClass = isPublic
? "relative break-words rounded-xl border border-amber-200/80 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]"
: "relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700"
const bodyEditButtonClass = isInternal
const bodyEditButtonClass = isPublic
? "absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-700 opacity-0 transition hover:border-amber-500 hover:text-amber-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30 group-hover/comment:opacity-100"
: "absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
const addContentButtonClass = isInternal
const addContentButtonClass = isPublic
? "inline-flex items-center gap-2 text-sm font-medium text-amber-700 transition hover:text-amber-900"
: "inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"
@ -263,6 +264,10 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<span className="text-xs font-semibold tracking-wide text-amber-700/80">
Comentário interno visível apenas para administradores e agentes
</span>
) : comment.visibility === "PUBLIC" ? (
<span className="text-xs font-semibold tracking-wide text-amber-700/80">
Comentário visível para o cliente
</span>
) : null}
{isEditing ? (
<div
@ -596,10 +601,20 @@ function CommentAttachmentCard({
const handleDownload = useCallback(async () => {
const target = url ?? (await ensureUrl())
if (target) {
if (!target) return
try {
const link = document.createElement("a")
link.href = target
link.download = attachment.name ?? "anexo"
link.rel = "noopener noreferrer"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (error) {
console.error("Failed to download attachment", error)
window.open(target, "_blank", "noopener,noreferrer")
}
}, [ensureUrl, url])
}, [attachment.name, ensureUrl, url])
const name = attachment.name ?? ""
const urlLooksImage = url ? /\.(png|jpe?g|gif|webp|svg)$/i.test(url) : false
@ -642,6 +657,14 @@ function CommentAttachmentCard({
</span>
</button>
)}
<button
type="button"
onClick={handleDownload}
aria-label={`Baixar ${name}`}
className="absolute left-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 focus-visible:opacity-100 group-hover:opacity-100"
>
<Download className="size-3.5" />
</button>
<button
type="button"
onClick={onRequestRemoval}

View file

@ -13,7 +13,7 @@ type Uploaded = { storageId: string; name: string; size?: number; type?: string;
export function Dropzone({
onUploaded,
maxFiles = 5,
maxSize = 10 * 1024 * 1024,
maxSize = 5 * 1024 * 1024,
multiple = true,
className,
}: {
@ -32,7 +32,9 @@ export function Dropzone({
const list = Array.from(files).slice(0, maxFiles);
const uploaded: Uploaded[] = [];
for (const file of list) {
if (file.size > maxSize) continue;
if (file.size > maxSize) {
continue;
}
const url = await generateUrl({});
const id = `${file.name}-${file.size}-${Date.now()}`;
const localPreview = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined;
@ -54,16 +56,28 @@ export function Dropzone({
if (res?.storageId) {
uploaded.push({ storageId: res.storageId, name: file.name, size: file.size, type: file.type, previewUrl: localPreview });
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, progress: 100, status: "done" } : it)));
setTimeout(() => {
setItems((prev) => prev.filter((it) => it.id !== id));
}, 600);
} else {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
setTimeout(() => {
setItems((prev) => prev.filter((it) => it.id !== id));
}, 1200);
}
} catch {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
setTimeout(() => {
setItems((prev) => prev.filter((it) => it.id !== id));
}, 1200);
}
resolve();
};
xhr.onerror = () => {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
setTimeout(() => {
setItems((prev) => prev.filter((it) => it.id !== id));
}, 1200);
resolve();
};
xhr.send(file);

View file

@ -132,7 +132,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} catch {
payload = null
}
const fallbackMessage = "Falha ao carregar o contexto da m<EFBFBD>quina."
const fallbackMessage = "Falha ao carregar o contexto da maquina."
const message =
(payload && typeof payload.error === "string" && payload.error.trim()) || fallbackMessage
if (!cancelled) {
@ -193,7 +193,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setMachineContext(null)
setMachineContextError({
status: 0,
message: "Erro ao carregar o contexto da m<EFBFBD>quina.",
message: "Erro ao carregar o contexto da maquina.",
details: error instanceof Error ? { message: error.message } : null,
})
}

View file

@ -62,3 +62,72 @@ export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
authEmail: machineEmail,
}
}
type EnsureCollaboratorAccountParams = {
email: string
name: string
tenantId: string
companyId?: string | null
}
export async function ensureCollaboratorAccount(params: EnsureCollaboratorAccountParams) {
const normalizedEmail = params.email.trim().toLowerCase()
const name = params.name.trim() || normalizedEmail
const tenantId = params.tenantId
const existingAuth = await prisma.authUser.findUnique({ where: { email: normalizedEmail } })
const authUser = existingAuth
? await prisma.authUser.update({
where: { id: existingAuth.id },
data: {
name,
tenantId,
role: "collaborator",
},
})
: await prisma.authUser.create({
data: {
email: normalizedEmail,
name,
tenantId,
role: "collaborator",
},
})
await prisma.authAccount.upsert({
where: {
providerId_accountId: {
providerId: "credential",
accountId: normalizedEmail,
},
},
update: {
userId: authUser.id,
},
create: {
providerId: "credential",
accountId: normalizedEmail,
userId: authUser.id,
password: null,
},
})
await prisma.user.upsert({
where: { email: normalizedEmail },
update: {
name,
tenantId,
role: "COLLABORATOR",
companyId: params.companyId ?? undefined,
},
create: {
email: normalizedEmail,
name,
tenantId,
role: "COLLABORATOR",
companyId: params.companyId ?? undefined,
},
})
return { authUserId: authUser.id }
}