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

@ -55,13 +55,6 @@ type MachineRegisterResponse = {
} | null } | null
} }
type CompanyOption = {
id: string
tenantId: string
name: string
slug: string
}
type AgentConfig = { type AgentConfig = {
machineId: string machineId: string
tenantId?: string | null tenantId?: string | null
@ -92,7 +85,6 @@ declare global {
const STORE_FILENAME = "machine-agent.json" const STORE_FILENAME = "machine-agent.json"
const DEFAULT_APP_URL = import.meta.env.MODE === "production" ? "https://tickets.esdrasrenan.com.br" : "http://localhost:3000" const DEFAULT_APP_URL = import.meta.env.MODE === "production" ? "https://tickets.esdrasrenan.com.br" : "http://localhost:3000"
const DEFAULT_TENANT_ID = "tenant-atlas"
function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) { function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) {
const trimmed = (value ?? fallback).trim() const trimmed = (value ?? fallback).trim()
@ -156,15 +148,13 @@ function App() {
const [showSecret, setShowSecret] = useState(false) const [showSecret, setShowSecret] = useState(false)
const [isLaunchingSystem, setIsLaunchingSystem] = useState(false) const [isLaunchingSystem, setIsLaunchingSystem] = useState(false)
const [provisioningSecret, setProvisioningSecret] = useState("") const [provisioningCode, setProvisioningCode] = useState("")
const [tenantId, setTenantId] = useState("") const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null)
const [company, setCompany] = useState("") const [companyName, setCompanyName] = useState("")
const [isValidatingCode, setIsValidatingCode] = useState(false)
const [codeStatus, setCodeStatus] = useState<{ tone: "success" | "error"; message: string } | null>(null)
const [collabEmail, setCollabEmail] = useState("") const [collabEmail, setCollabEmail] = useState("")
const [collabName, setCollabName] = useState("") const [collabName, setCollabName] = useState("")
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">("collaborator")
const [companyOptions, setCompanyOptions] = useState<CompanyOption[]>([])
const [selectedCompany, setSelectedCompany] = useState<CompanyOption | null>(null)
const [companyLookupPending, setCompanyLookupPending] = useState(false)
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const [updateInfo, setUpdateInfo] = useState<{ message: string; tone: "info" | "success" | "error" } | null>({ const [updateInfo, setUpdateInfo] = useState<{ message: string; tone: "info" | "success" | "error" } | null>({
message: "Atualizações automáticas são verificadas a cada inicialização.", message: "Atualizações automáticas são verificadas a cada inicialização.",
@ -182,11 +172,9 @@ function App() {
setToken(t) setToken(t)
const cfg = await readConfig(s) const cfg = await readConfig(s)
setConfig(cfg) setConfig(cfg)
setAccessRole(cfg?.accessRole ?? "collaborator")
if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail) if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail)
if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName) if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName)
if (cfg?.companyName) setCompany(cfg.companyName) if (cfg?.companyName) setCompanyName(cfg.companyName)
if (cfg?.tenantId) setTenantId(cfg.tenantId)
if (!t) { if (!t) {
const p = await invoke<MachineProfile>("collect_machine_profile") const p = await invoke<MachineProfile>("collect_machine_profile")
setProfile(p) setProfile(p)
@ -198,6 +186,32 @@ function App() {
})() })()
}, []) }, [])
useEffect(() => {
if (!import.meta.env.DEV) return
function onKeyDown(event: KeyboardEvent) {
const key = (event.key || "").toLowerCase()
if (key === "f12" || (event.ctrlKey && event.shiftKey && key === "i")) {
invoke("open_devtools").catch(() => {})
event.preventDefault()
}
}
function onContextMenu(event: MouseEvent) {
if (event.ctrlKey || event.shiftKey) {
invoke("open_devtools").catch(() => {})
event.preventDefault()
}
}
window.addEventListener("keydown", onKeyDown)
window.addEventListener("contextmenu", onContextMenu)
return () => {
window.removeEventListener("keydown", onKeyDown)
window.removeEventListener("contextmenu", onContextMenu)
}
}, [])
useEffect(() => { useEffect(() => {
if (!store || !config) return if (!store || !config) return
const email = collabEmail.trim() const email = collabEmail.trim()
@ -206,8 +220,7 @@ function App() {
const normalizedName = name.length > 0 ? name : null const normalizedName = name.length > 0 ? name : null
if ( if (
config.collaboratorEmail === normalizedEmail && config.collaboratorEmail === normalizedEmail &&
config.collaboratorName === normalizedName && config.collaboratorName === normalizedName
config.accessRole === accessRole
) { ) {
return return
} }
@ -215,11 +228,10 @@ function App() {
...config, ...config,
collaboratorEmail: normalizedEmail, collaboratorEmail: normalizedEmail,
collaboratorName: normalizedName, collaboratorName: normalizedName,
accessRole,
} }
setConfig(nextConfig) setConfig(nextConfig)
writeConfig(store, nextConfig).catch((err) => console.error("Falha ao atualizar colaborador", err)) writeConfig(store, nextConfig).catch((err) => console.error("Falha ao atualizar colaborador", err))
}, [store, config, config?.collaboratorEmail, config?.collaboratorName, config?.accessRole, collabEmail, collabName, accessRole]) }, [store, config, config?.collaboratorEmail, config?.collaboratorName, collabEmail, collabName])
useEffect(() => { useEffect(() => {
if (!store || !config) return if (!store || !config) return
@ -235,6 +247,59 @@ function App() {
} }
}, [store, config]) }, [store, config])
useEffect(() => {
const trimmed = provisioningCode.trim()
if (trimmed.length < 32) {
setValidatedCompany(null)
setCodeStatus(null)
setCompanyName("")
return
}
let cancelled = false
const controller = new AbortController()
const timeout = setTimeout(async () => {
setIsValidatingCode(true)
try {
const res = await fetch(`${apiBaseUrl}/api/machines/provisioning`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provisioningCode: trimmed }),
signal: controller.signal,
})
if (!res.ok) {
const message = res.status === 404 ? "Código não encontrado" : "Falha ao validar código"
if (!cancelled) {
setValidatedCompany(null)
setCompanyName("")
setCodeStatus({ tone: "error", message })
}
return
}
const data = (await res.json()) as { company: { id: string; name: string; slug: string; tenantId: string } }
if (!cancelled) {
setValidatedCompany(data.company)
setCompanyName(data.company.name)
setCodeStatus({ tone: "success", message: `Empresa encontrada: ${data.company.name}` })
}
} catch (error) {
if (!cancelled) {
console.error("Falha ao validar código de provisionamento", error)
setValidatedCompany(null)
setCompanyName("")
setCodeStatus({ tone: "error", message: "Não foi possível validar o código agora" })
}
} finally {
if (!cancelled) setIsValidatingCode(false)
}
}, 400)
return () => {
cancelled = true
clearTimeout(timeout)
controller.abort()
setIsValidatingCode(false)
}
}, [provisioningCode])
const resolvedAppUrl = useMemo(() => { const resolvedAppUrl = useMemo(() => {
if (!config?.appUrl) return appUrl if (!config?.appUrl) return appUrl
const normalized = normalizeUrl(config.appUrl, appUrl) const normalized = normalizeUrl(config.appUrl, appUrl)
@ -244,184 +309,97 @@ function App() {
return normalized return normalized
}, [config?.appUrl]) }, [config?.appUrl])
useEffect(() => {
const trimmedSecret = provisioningSecret.trim()
const query = company.trim()
if (!trimmedSecret || query.length < 2) {
setCompanyOptions([])
setCompanyLookupPending(false)
return
}
if (selectedCompany && selectedCompany.name.toLowerCase() === query.toLowerCase()) {
setCompanyOptions([])
setCompanyLookupPending(false)
return
}
const controller = new AbortController()
const timeout = setTimeout(async () => {
setCompanyLookupPending(true)
try {
const params = new URLSearchParams()
params.set("search", query)
const tenant = tenantId.trim()
if (tenant) params.set("tenantId", tenant)
const res = await fetch(`${apiBaseUrl}/api/machines/companies?${params.toString()}`, {
headers: {
"X-Machine-Secret": trimmedSecret,
},
signal: controller.signal,
})
if (!res.ok) {
setCompanyOptions([])
return
}
const data = (await res.json()) as { companies?: CompanyOption[] }
setCompanyOptions(data.companies ?? [])
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return
if (typeof error === "object" && error && "name" in error && (error as { name?: string }).name === "AbortError") return
console.error("Falha ao buscar empresas", error)
} finally {
setCompanyLookupPending(false)
}
}, 200)
return () => {
clearTimeout(timeout)
controller.abort()
setCompanyLookupPending(false)
}
}, [company, tenantId, provisioningSecret, selectedCompany])
function handleSelectCompany(option: CompanyOption) {
setCompany(option.name)
setSelectedCompany(option)
setCompanyOptions([])
if (!tenantId.trim()) {
setTenantId(option.tenantId)
}
}
function handleCompanyInputChange(value: string) {
setCompany(value)
setSelectedCompany((prev) => {
if (!prev) return null
return prev.name.toLowerCase() === value.trim().toLowerCase() ? prev : null
})
}
async function register() { async function register() {
if (!profile) return if (!profile) return
if (!provisioningSecret.trim()) { setError("Informe o código de provisionamento."); return } const trimmedCode = provisioningCode.trim().toLowerCase()
if (trimmedCode.length < 32) {
setError("Informe o código de provisionamento fornecido pela equipe.")
return
}
if (!validatedCompany) {
setError("Valide o código de provisionamento antes de registrar a máquina.")
return
}
const normalizedEmail = collabEmail.trim().toLowerCase() const normalizedEmail = collabEmail.trim().toLowerCase()
if (!normalizedEmail) { if (!normalizedEmail) {
setError("Informe o e-mail do colaborador ou gestor para vincular esta máquina.") setError("Informe o e-mail do colaborador vinculado a esta máquina.")
return return
} }
const normalizedName = collabName.trim() const normalizedName = collabName.trim()
if (!normalizedName) { if (!normalizedName) {
setError("Informe o nome completo do colaborador ou gestor.") setError("Informe o nome completo do colaborador.")
return return
} }
setBusy(true); setError(null)
try {
const trimmedTenantId = tenantId.trim()
const trimmedCompanyName = company.trim()
let ensuredCompany: { name: string; slug: string } | null = null
if (trimmedCompanyName) {
if (selectedCompany && selectedCompany.name.toLowerCase() === trimmedCompanyName.toLowerCase()) {
ensuredCompany = { name: selectedCompany.name, slug: selectedCompany.slug }
} else {
const ensureRes = await fetch(`${apiBaseUrl}/api/machines/companies`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Machine-Secret": provisioningSecret.trim(),
},
body: JSON.stringify({
tenantId: trimmedTenantId || undefined,
name: trimmedCompanyName,
}),
})
if (!ensureRes.ok) {
const text = await ensureRes.text()
throw new Error(`Falha ao preparar empresa (${ensureRes.status}): ${text.slice(0, 300)}`)
}
const ensureData = (await ensureRes.json()) as { company?: { name: string; slug: string; tenantId?: string } }
if (!ensureData.company?.slug) {
throw new Error("Resposta inválida ao preparar empresa.")
}
ensuredCompany = { name: ensureData.company.name, slug: ensureData.company.slug }
setSelectedCompany({
id: ensureData.company.slug,
name: ensureData.company.name,
slug: ensureData.company.slug,
tenantId: ensureData.company.tenantId ?? (trimmedTenantId || DEFAULT_TENANT_ID),
})
if (!trimmedTenantId && ensureData.company.tenantId) {
setTenantId(ensureData.company.tenantId)
}
}
}
setBusy(true)
setError(null)
try {
const collaboratorPayload = { const collaboratorPayload = {
email: normalizedEmail, email: normalizedEmail,
name: normalizedName, name: normalizedName,
} }
const collaboratorMetadata = collaboratorPayload
? { ...collaboratorPayload, role: accessRole }
: undefined
const metadataPayload: Record<string, unknown> = { const metadataPayload: Record<string, unknown> = {
inventory: profile.inventory, inventory: profile.inventory,
metrics: profile.metrics, metrics: profile.metrics,
collaborator: { email: normalizedEmail, name: normalizedName, role: "collaborator" },
} }
if (collaboratorMetadata) {
metadataPayload.collaborator = collaboratorMetadata
}
const payload = { const payload = {
provisioningSecret: provisioningSecret.trim(), provisioningCode: trimmedCode,
tenantId: trimmedTenantId || undefined,
companySlug: ensuredCompany?.slug,
hostname: profile.hostname, hostname: profile.hostname,
os: profile.os, os: profile.os,
macAddresses: profile.macAddresses, macAddresses: profile.macAddresses,
serialNumbers: profile.serialNumbers, serialNumbers: profile.serialNumbers,
metadata: metadataPayload, metadata: metadataPayload,
accessRole,
collaborator: collaboratorPayload, collaborator: collaboratorPayload,
registeredBy: "desktop-agent", registeredBy: "desktop-agent",
} }
const res = await fetch(`${apiBaseUrl}/api/machines/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) })
const res = await fetch(`${apiBaseUrl}/api/machines/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) { if (!res.ok) {
const text = await res.text() const text = await res.text()
throw new Error(`Falha no registro (${res.status}): ${text.slice(0,300)}`) throw new Error(`Falha no registro (${res.status}): ${text.slice(0, 300)}`)
} }
const data = (await res.json()) as MachineRegisterResponse const data = (await res.json()) as MachineRegisterResponse
if (!store) throw new Error("Store ausente") if (!store) throw new Error("Store ausente")
await writeToken(store, data.machineToken) await writeToken(store, data.machineToken)
const cfg: AgentConfig = { const cfg: AgentConfig = {
machineId: data.machineId, machineId: data.machineId,
tenantId: data.tenantId ?? null, tenantId: data.tenantId ?? validatedCompany.tenantId ?? null,
companySlug: data.companySlug ?? ensuredCompany?.slug ?? null, companySlug: data.companySlug ?? validatedCompany.slug ?? null,
companyName: ensuredCompany?.name ?? (trimmedCompanyName || null), companyName: validatedCompany.name,
machineEmail: data.machineEmail ?? null, machineEmail: data.machineEmail ?? null,
collaboratorEmail: collaboratorPayload?.email ?? null, collaboratorEmail: collaboratorPayload.email,
collaboratorName: collaboratorPayload?.name ?? null, collaboratorName: collaboratorPayload.name,
accessRole, accessRole: "collaborator",
assignedUserId: data.assignedUserId ?? null, assignedUserId: data.assignedUserId ?? null,
assignedUserEmail: data.collaborator?.email ?? collaboratorPayload?.email ?? null, assignedUserEmail: data.collaborator?.email ?? collaboratorPayload.email,
assignedUserName: data.collaborator?.name ?? collaboratorPayload?.name ?? null, assignedUserName: data.collaborator?.name ?? collaboratorPayload.name,
apiBaseUrl, apiBaseUrl,
appUrl, appUrl,
createdAt: Date.now(), createdAt: Date.now(),
lastSyncedAt: Date.now(), lastSyncedAt: Date.now(),
expiresAt: data.expiresAt ?? null, expiresAt: data.expiresAt ?? null,
} }
await writeConfig(store, cfg) await writeConfig(store, cfg)
setConfig(cfg); setToken(data.machineToken) setConfig(cfg)
if (ensuredCompany?.name) { setToken(data.machineToken)
setCompany(ensuredCompany.name) setCompanyName(validatedCompany.name)
}
await invoke("start_machine_agent", { baseUrl: apiBaseUrl, token: data.machineToken, status: "online", intervalSeconds: 300 }) await invoke("start_machine_agent", {
baseUrl: apiBaseUrl,
token: data.machineToken,
status: "online",
intervalSeconds: 300,
})
setStatus("online") setStatus("online")
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)) setError(err instanceof Error ? err.message : String(err))
@ -446,19 +424,22 @@ function App() {
} catch { } catch {
// ignoramos e seguimos para o handshake // ignoramos e seguimos para o handshake
} }
const persona = (config?.accessRole ?? accessRole) === "manager" ? "manager" : "collaborator" const persona = (config?.accessRole ?? "collaborator") === "manager" ? "manager" : "collaborator"
// Envia para a página de diagnóstico para validar cookies/sessão // Envia para a página inicial apropriada após autenticar cookies/sessão
const redirectTarget = persona === "manager" ? "/dashboard" : "/portal/debug" const redirectTarget = persona === "manager" ? "/dashboard" : "/portal/tickets"
const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}` const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}`
window.location.href = url window.location.href = url
}, [token, config?.accessRole, accessRole, resolvedAppUrl, apiBaseUrl]) }, [token, config?.accessRole, resolvedAppUrl])
async function reprovision() { async function reprovision() {
if (!store) return if (!store) return
await store.delete("token"); await store.delete("config"); await store.save() await store.delete("token"); await store.delete("config"); await store.save()
autoLaunchRef.current = false autoLaunchRef.current = false
setToken(null); setConfig(null); setStatus(null); setAccessRole("collaborator") setToken(null); setConfig(null); setStatus(null)
setCompany(""); setSelectedCompany(null); setCompanyOptions([]) setProvisioningCode("")
setValidatedCompany(null)
setCodeStatus(null)
setCompanyName("")
setIsLaunchingSystem(false) setIsLaunchingSystem(false)
const p = await invoke<MachineProfile>("collect_machine_profile") const p = await invoke<MachineProfile>("collect_machine_profile")
setProfile(p) setProfile(p)
@ -472,7 +453,7 @@ function App() {
? { email: collabEmail.trim(), name: collabName.trim() || undefined } ? { email: collabEmail.trim(), name: collabName.trim() || undefined }
: undefined : undefined
const collaboratorInventory = collaboratorPayload const collaboratorInventory = collaboratorPayload
? { ...collaboratorPayload, role: accessRole } ? { ...collaboratorPayload, role: "collaborator" as const }
: undefined : undefined
const inventoryPayload: Record<string, unknown> = { ...profile.inventory } const inventoryPayload: Record<string, unknown> = { ...profile.inventory }
if (collaboratorInventory) { if (collaboratorInventory) {
@ -590,69 +571,69 @@ function App() {
<div className="grid gap-2"> <div className="grid gap-2">
<label className="text-sm font-medium">Código de provisionamento</label> <label className="text-sm font-medium">Código de provisionamento</label>
<div className="relative"> <div className="relative">
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 pr-9 text-sm" type={showSecret?"text":"password"} value={provisioningSecret} onChange={(e)=>setProvisioningSecret(e.target.value)} /> <input
<button className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-600" onClick={()=>setShowSecret((v)=>!v)} aria-label="Mostrar/ocultar"> className="w-full rounded-lg border border-slate-300 px-3 py-2 pr-9 text-sm"
{showSecret ? <EyeOff className="size-4"/> : <Eye className="size-4"/>} type={showSecret ? "text" : "password"}
value={provisioningCode}
onChange={(e) => {
const value = e.target.value
setProvisioningCode(value)
setValidatedCompany(null)
setCodeStatus(null)
}}
/>
<button
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-600"
onClick={() => setShowSecret((v) => !v)}
aria-label="Mostrar/ocultar"
>
{showSecret ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button> </button>
</div> </div>
</div> {isValidatingCode ? (
<div className="grid gap-2"> <p className="text-xs text-slate-500">Validando código...</p>
<label className="text-sm font-medium">Empresa (nome ou slug, opcional)</label> ) : codeStatus ? (
<input <p
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" className={`text-xs font-medium ${
placeholder="ex.: Paulicon Contábil" codeStatus.tone === "success" ? "text-emerald-600" : "text-rose-600"
value={company} }`}
onChange={(e) => handleCompanyInputChange(e.target.value)} >
/> {codeStatus.message}
{companyLookupPending ? ( </p>
<p className="text-xs text-slate-500">Buscando empresas...</p>
) : companyOptions.length > 0 ? (
<div className="max-h-48 w-full overflow-y-auto rounded-md border border-slate-200 bg-white text-sm shadow-sm">
{companyOptions.map((option) => (
<button
key={option.id}
type="button"
className="flex w-full flex-col gap-0.5 px-3 py-2 text-left hover:bg-slate-100"
onClick={() => handleSelectCompany(option)}
>
<span className="font-medium text-slate-800">{option.name}</span>
<span className="text-xs text-slate-500">{option.slug}</span>
</button>
))}
</div>
) : company.trim().length >= 2 ? (
<p className="text-xs text-slate-500">Empresa não encontrada criaremos automaticamente ao registrar.</p>
) : ( ) : (
<p className="text-xs text-slate-500">Pode informar o nome completo que transformamos no slug registrado.</p> <p className="text-xs text-slate-500">
Informe o código único fornecido pela equipe para vincular esta máquina a uma empresa.
</p>
)} )}
</div> </div>
<div className="grid gap-2"> {validatedCompany ? (
<label className="text-sm font-medium">Perfil de acesso</label> <div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
<select <div className="text-sm font-semibold text-emerald-800">{validatedCompany.name}</div>
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" <div>Tenant:&nbsp;<span className="font-mono text-emerald-800">{validatedCompany.tenantId}</span></div>
value={accessRole} <div>Slug:&nbsp;<span className="font-mono text-emerald-800">{validatedCompany.slug}</span></div>
onChange={(e) => setAccessRole((e.target.value as "collaborator" | "manager") ?? "collaborator")} </div>
> ) : null}
<option value="collaborator">Colaborador (portal)</option>
<option value="manager">Gestor (painel completo)</option>
</select>
<p className="text-xs text-slate-500">
Colaboradores veem apenas seus chamados. Gestores acompanham todos os tickets da empresa.
</p>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<label className="text-sm font-medium"> <label className="text-sm font-medium">
Colaborador (e-mail) <span className="text-rose-500">*</span> Colaborador (e-mail) <span className="text-rose-500">*</span>
</label> </label>
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="colaborador@empresa.com" value={collabEmail} onChange={(e)=>setCollabEmail(e.target.value)} /> <input
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
placeholder="colaborador@empresa.com"
value={collabEmail}
onChange={(e) => setCollabEmail(e.target.value)}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<label className="text-sm font-medium">Nome do colaborador <span className="text-rose-500">*</span></label> <label className="text-sm font-medium">
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="Nome completo" value={collabName} onChange={(e)=>setCollabName(e.target.value)} /> Nome do colaborador <span className="text-rose-500">*</span>
</div> </label>
<div className="grid gap-2"> <input
<label className="text-sm font-medium">Tenant (opcional)</label> className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="tenant-atlas" value={tenantId} onChange={(e)=>setTenantId(e.target.value)} /> placeholder="Nome completo"
value={collabName}
onChange={(e) => setCollabName(e.target.value)}
/>
</div> </div>
{profile ? ( {profile ? (
<div className="mt-2 grid grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs"> <div className="mt-2 grid grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs">
@ -675,7 +656,7 @@ function App() {
</div> </div>
) : null} ) : null}
<div className="mt-2 flex gap-2"> <div className="mt-2 flex gap-2">
<button disabled={busy} onClick={register} className="rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white hover:bg-black/90 disabled:opacity-60">Registrar máquina</button> <button disabled={busy || !validatedCompany} onClick={register} className="rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white hover:bg-black/90 disabled:opacity-60">Registrar máquina</button>
</div> </div>
</div> </div>
) : ( ) : (
@ -684,10 +665,19 @@ function App() {
<TabsList className="h-10"> <TabsList className="h-10">
<TabsTrigger value="resumo" className="rounded-lg px-3">Resumo</TabsTrigger> <TabsTrigger value="resumo" className="rounded-lg px-3">Resumo</TabsTrigger>
<TabsTrigger value="inventario" className="rounded-lg px-3">Inventário</TabsTrigger> <TabsTrigger value="inventario" className="rounded-lg px-3">Inventário</TabsTrigger>
<TabsTrigger value="diagnostico" className="rounded-lg px-3">Diagnóstico</TabsTrigger>
<TabsTrigger value="config" className="rounded-lg px-3">Configurações</TabsTrigger> <TabsTrigger value="config" className="rounded-lg px-3">Configurações</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="resumo" className="mt-4"> <TabsContent value="resumo" className="mt-4">
{companyName ? (
<div className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-700 shadow-sm">
<div className="text-sm font-semibold text-neutral-900">{companyName}</div>
{config?.collaboratorEmail ? (
<div className="text-xs text-neutral-500">
Vinculado a {config.collaboratorEmail}
</div>
) : null}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="stat-card"> <div className="stat-card">
<div className="text-xs text-slate-500">CPU</div> <div className="text-xs text-slate-500">CPU</div>
@ -722,12 +712,6 @@ function App() {
<button onClick={openSystem} className="btn btn-outline">Ver no sistema</button> <button onClick={openSystem} className="btn btn-outline">Ver no sistema</button>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="diagnostico" className="mt-4 space-y-2">
<div className="card">
<p className="text-sm text-slate-700">Token armazenado: <span className="font-mono break-all text-xs">{token?.slice(0, 6)}{token?.slice(-6)}</span></p>
<p className="text-sm text-slate-700">Base URL: <span className="font-mono text-xs">{apiBaseUrl}</span></p>
</div>
</TabsContent>
<TabsContent value="config" className="mt-4 space-y-3"> <TabsContent value="config" className="mt-4 space-y-3">
<div className="grid gap-2"> <div className="grid gap-2">
<label className="label"> <label className="label">
@ -792,26 +776,3 @@ function StatusBadge({ status }: { status: string | null }) {
const root = document.getElementById("root") || (() => { const el = document.createElement("div"); el.id = "root"; document.body.appendChild(el); return el })() const root = document.getElementById("root") || (() => { const el = document.createElement("div"); el.id = "root"; document.body.appendChild(el); return el })()
createRoot(root).render(<App />) createRoot(root).render(<App />)
// DevTools shortcut (F12 / Ctrl+Shift+I) and context menu with modifier
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const key = (e.key || '').toLowerCase()
if (key === 'f12' || (e.ctrlKey && e.shiftKey && key === 'i')) {
invoke('open_devtools').catch(() => {})
e.preventDefault()
}
}
function onContextMenu(e: MouseEvent) {
// Evita abrir sempre: use Ctrl ou Shift + botão direito para abrir DevTools
if (e.ctrlKey || e.shiftKey) {
invoke('open_devtools').catch(() => {})
e.preventDefault()
}
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('contextmenu', onContextMenu)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('contextmenu', onContextMenu)
}
}, [])

View file

@ -33,8 +33,9 @@ export const ensureProvisioned = mutation({
tenantId: v.string(), tenantId: v.string(),
slug: v.string(), slug: v.string(),
name: v.string(), name: v.string(),
provisioningCode: v.string(),
}, },
handler: async (ctx, { tenantId, slug, name }) => { handler: async (ctx, { tenantId, slug, name, provisioningCode }) => {
const normalizedSlug = normalizeSlug(slug) const normalizedSlug = normalizeSlug(slug)
if (!normalizedSlug) { if (!normalizedSlug) {
throw new ConvexError("Slug inválido") throw new ConvexError("Slug inválido")
@ -50,6 +51,9 @@ export const ensureProvisioned = mutation({
.unique() .unique()
if (existing) { if (existing) {
if (existing.provisioningCode !== provisioningCode) {
await ctx.db.patch(existing._id, { provisioningCode })
}
return { return {
id: existing._id, id: existing._id,
slug: existing.slug, slug: existing.slug,
@ -62,6 +66,7 @@ export const ensureProvisioned = mutation({
tenantId, tenantId,
name: trimmedName, name: trimmedName,
slug: normalizedSlug, slug: normalizedSlug,
provisioningCode,
isAvulso: false, isAvulso: false,
contractedHoursPerMonth: undefined, contractedHoursPerMonth: undefined,
cnpj: undefined, cnpj: undefined,

View file

@ -17,28 +17,6 @@ type NormalizedIdentifiers = {
serials: string[] serials: string[]
} }
function normalizeCompanySlug(input?: string | null): string | undefined {
if (!input) return undefined
const trimmed = input.trim()
if (!trimmed) return undefined
const ascii = trimmed
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[\u2013\u2014]/g, "-")
const sanitized = ascii.replace(/[^\w\s-]/g, "").replace(/[_\s]+/g, "-")
const collapsed = sanitized.replace(/-+/g, "-").toLowerCase()
const normalized = collapsed.replace(/^-+|-+$/g, "")
return normalized || undefined
}
function getProvisioningSecret() {
const secret = process.env["MACHINE_PROVISIONING_SECRET"]
if (!secret) {
throw new ConvexError("Provisionamento de máquinas não configurado")
}
return secret
}
function getTokenTtlMs(): number { function getTokenTtlMs(): number {
const raw = process.env["MACHINE_TOKEN_TTL_MS"] const raw = process.env["MACHINE_TOKEN_TTL_MS"]
if (!raw) return DEFAULT_TOKEN_TTL_MS if (!raw) return DEFAULT_TOKEN_TTL_MS
@ -122,23 +100,6 @@ function hashToken(token: string) {
return toHex(sha256(token)) return toHex(sha256(token))
} }
async function ensureCompany(
ctx: MutationCtx,
tenantId: string,
companySlug?: string
): Promise<{ companyId?: Id<"companies">; companySlug?: string }> {
const normalized = normalizeCompanySlug(companySlug)
if (!normalized) return {}
const company = await ctx.db
.query("companies")
.withIndex("by_tenant_slug", (q: any) => q.eq("tenantId", tenantId).eq("slug", normalized))
.unique()
if (!company) {
throw new ConvexError("Empresa não encontrada para o tenant informado")
}
return { companyId: company._id, companySlug: company.slug }
}
async function getActiveToken( async function getActiveToken(
ctx: MutationCtx, ctx: MutationCtx,
tokenValue: string tokenValue: string
@ -332,9 +293,7 @@ async function evaluatePostureAndMaybeRaise(
export const register = mutation({ export const register = mutation({
args: { args: {
provisioningSecret: v.string(), provisioningCode: v.string(),
tenantId: v.optional(v.string()),
companySlug: v.optional(v.string()),
hostname: v.string(), hostname: v.string(),
os: v.object({ os: v.object({
name: v.string(), name: v.string(),
@ -347,16 +306,21 @@ export const register = mutation({
registeredBy: v.optional(v.string()), registeredBy: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const secret = getProvisioningSecret() const normalizedCode = args.provisioningCode.trim().toLowerCase()
if (args.provisioningSecret !== secret) { const companyRecord = await ctx.db
.query("companies")
.withIndex("by_provisioning_code", (q) => q.eq("provisioningCode", normalizedCode))
.unique()
if (!companyRecord) {
throw new ConvexError("Código de provisionamento inválido") throw new ConvexError("Código de provisionamento inválido")
} }
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
const normalizedCompanySlug = normalizeCompanySlug(args.companySlug) const companyId = companyRecord._id
const companySlug = companyRecord.slug
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers) const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
const fingerprint = computeFingerprint(tenantId, normalizedCompanySlug, args.hostname, identifiers) const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers)
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, normalizedCompanySlug)
const now = Date.now() const now = Date.now()
const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record<string, unknown>) : undefined const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record<string, unknown>) : undefined
@ -469,9 +433,7 @@ export const register = mutation({
export const upsertInventory = mutation({ export const upsertInventory = mutation({
args: { args: {
provisioningSecret: v.string(), provisioningCode: v.string(),
tenantId: v.optional(v.string()),
companySlug: v.optional(v.string()),
hostname: v.string(), hostname: v.string(),
os: v.object({ os: v.object({
name: v.string(), name: v.string(),
@ -485,16 +447,21 @@ export const upsertInventory = mutation({
registeredBy: v.optional(v.string()), registeredBy: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const secret = getProvisioningSecret() const normalizedCode = args.provisioningCode.trim().toLowerCase()
if (args.provisioningSecret !== secret) { const companyRecord = await ctx.db
.query("companies")
.withIndex("by_provisioning_code", (q) => q.eq("provisioningCode", normalizedCode))
.unique()
if (!companyRecord) {
throw new ConvexError("Código de provisionamento inválido") throw new ConvexError("Código de provisionamento inválido")
} }
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
const normalizedCompanySlug = normalizeCompanySlug(args.companySlug) const companyId = companyRecord._id
const companySlug = companyRecord.slug
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers) const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
const fingerprint = computeFingerprint(tenantId, normalizedCompanySlug, args.hostname, identifiers) const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers)
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, normalizedCompanySlug)
const now = Date.now() const now = Date.now()
const metadataPatch: Record<string, unknown> = {} const metadataPatch: Record<string, unknown> = {}

View file

@ -1,3 +1,4 @@
import { randomBytes } from "@noble/hashes/utils"
import { ConvexError, v } from "convex/values" import { ConvexError, v } from "convex/values"
import { mutation, query } from "./_generated/server" import { mutation, query } from "./_generated/server"
@ -13,6 +14,10 @@ function normalizeEmail(value: string) {
return value.trim().toLowerCase() return value.trim().toLowerCase()
} }
function generateProvisioningCode() {
return Array.from(randomBytes(32), (b) => b.toString(16).padStart(2, "0")).join("")
}
type ImportedUser = { type ImportedUser = {
email: string email: string
name: string name: string
@ -30,6 +35,7 @@ type ImportedQueue = {
type ImportedCompany = { type ImportedCompany = {
slug: string slug: string
name: string name: string
provisioningCode?: string | null
isAvulso?: boolean | null isAvulso?: boolean | null
cnpj?: string | null cnpj?: string | null
domain?: string | null domain?: string | null
@ -185,6 +191,7 @@ async function ensureCompany(
tenantId, tenantId,
name: data.name, name: data.name,
slug, slug,
provisioningCode: data.provisioningCode ?? existing?.provisioningCode ?? generateProvisioningCode(),
isAvulso: data.isAvulso ?? undefined, isAvulso: data.isAvulso ?? undefined,
cnpj: data.cnpj ?? undefined, cnpj: data.cnpj ?? undefined,
domain: data.domain ?? undefined, domain: data.domain ?? undefined,
@ -204,7 +211,8 @@ async function ensureCompany(
existing.domain !== (payload.domain ?? undefined) || existing.domain !== (payload.domain ?? undefined) ||
existing.phone !== (payload.phone ?? undefined) || existing.phone !== (payload.phone ?? undefined) ||
existing.description !== (payload.description ?? undefined) || existing.description !== (payload.description ?? undefined) ||
existing.address !== (payload.address ?? undefined) existing.address !== (payload.address ?? undefined) ||
existing.provisioningCode !== payload.provisioningCode
if (needsPatch) { if (needsPatch) {
await ctx.db.patch(existing._id, { await ctx.db.patch(existing._id, {
name: payload.name, name: payload.name,
@ -214,6 +222,7 @@ async function ensureCompany(
phone: payload.phone, phone: payload.phone,
description: payload.description, description: payload.description,
address: payload.address, address: payload.address,
provisioningCode: payload.provisioningCode,
updatedAt: Date.now(), updatedAt: Date.now(),
}) })
} }

View file

@ -20,6 +20,7 @@ export default defineSchema({
tenantId: v.string(), tenantId: v.string(),
name: v.string(), name: v.string(),
slug: v.string(), slug: v.string(),
provisioningCode: v.string(),
isAvulso: v.optional(v.boolean()), isAvulso: v.optional(v.boolean()),
contractedHoursPerMonth: v.optional(v.number()), contractedHoursPerMonth: v.optional(v.number()),
cnpj: v.optional(v.string()), cnpj: v.optional(v.string()),
@ -31,7 +32,8 @@ export default defineSchema({
updatedAt: v.number(), updatedAt: v.number(),
}) })
.index("by_tenant_slug", ["tenantId", "slug"]) .index("by_tenant_slug", ["tenantId", "slug"])
.index("by_tenant", ["tenantId"]), .index("by_tenant", ["tenantId"])
.index("by_provisioning_code", ["provisioningCode"]),
alerts: defineTable({ alerts: defineTable({
tenantId: v.string(), tenantId: v.string(),

View file

@ -1,3 +1,4 @@
import { randomBytes } from "@noble/hashes/utils"
import type { Id } from "./_generated/dataModel" import type { Id } from "./_generated/dataModel"
import { mutation } from "./_generated/server" import { mutation } from "./_generated/server"
@ -86,6 +87,7 @@ export const seedDemo = mutation({
phone?: string; phone?: string;
description?: string; description?: string;
address?: string; address?: string;
provisioningCode?: string;
}): Promise<Id<"companies">> { }): Promise<Id<"companies">> {
const slug = def.slug ?? slugify(def.name); const slug = def.slug ?? slugify(def.name);
const existing = await ctx.db const existing = await ctx.db
@ -97,6 +99,7 @@ export const seedDemo = mutation({
tenantId, tenantId,
name: def.name, name: def.name,
slug, slug,
provisioningCode: def.provisioningCode ?? existing?.provisioningCode ?? generateCode(),
cnpj: def.cnpj ?? undefined, cnpj: def.cnpj ?? undefined,
domain: def.domain ?? undefined, domain: def.domain ?? undefined,
phone: def.phone ?? undefined, phone: def.phone ?? undefined,
@ -113,6 +116,7 @@ export const seedDemo = mutation({
if (existing.phone !== payload.phone) updates.phone = payload.phone; if (existing.phone !== payload.phone) updates.phone = payload.phone;
if (existing.description !== payload.description) updates.description = payload.description; if (existing.description !== payload.description) updates.description = payload.description;
if (existing.address !== payload.address) updates.address = payload.address; if (existing.address !== payload.address) updates.address = payload.address;
if (existing.provisioningCode !== payload.provisioningCode) updates.provisioningCode = payload.provisioningCode;
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
updates.updatedAt = now; updates.updatedAt = now;
await ctx.db.patch(existing._id, updates); await ctx.db.patch(existing._id, updates);
@ -157,7 +161,16 @@ export const seedDemo = mutation({
}); });
} }
const companiesSeed = [ const companiesSeed: Array<{
name: string;
slug: string;
cnpj?: string;
domain?: string;
phone?: string;
description?: string;
address?: string;
provisioningCode?: string;
}> = [
{ {
name: "Atlas Engenharia Digital", name: "Atlas Engenharia Digital",
slug: "atlas-engenharia", slug: "atlas-engenharia",
@ -387,3 +400,4 @@ export const seedDemo = mutation({
}); });
}, },
}); });
const generateCode = () => Array.from(randomBytes(32), (b) => b.toString(16).padStart(2, "0")).join("")

View file

@ -424,11 +424,61 @@ export const getById = query({
const visibleComments = canViewInternalComments const visibleComments = canViewInternalComments
? comments ? comments
: comments.filter((comment) => comment.visibility !== "INTERNAL"); : comments.filter((comment) => comment.visibility !== "INTERNAL");
const timeline = await ctx.db const visibleCommentKeys = new Set(
visibleComments.map((comment) => `${comment.createdAt}:${comment.authorId}`)
)
const visibleCommentTimestamps = new Set(visibleComments.map((comment) => comment.createdAt))
let timelineRecords = await ctx.db
.query("ticketEvents") .query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", id)) .withIndex("by_ticket", (q) => q.eq("ticketId", id))
.collect(); .collect();
if (!(role === "ADMIN" || role === "AGENT")) {
timelineRecords = timelineRecords.filter((event) => {
const payload = (event.payload ?? {}) as Record<string, unknown>
switch (event.type) {
case "CREATED":
return true
case "QUEUE_CHANGED":
return true
case "ASSIGNEE_CHANGED":
return true
case "CATEGORY_CHANGED":
return true
case "COMMENT_ADDED": {
const authorIdRaw = (payload as { authorId?: string }).authorId
if (typeof authorIdRaw === "string" && authorIdRaw.trim().length > 0) {
const key = `${event.createdAt}:${authorIdRaw}`
if (visibleCommentKeys.has(key)) {
return true
}
}
return visibleCommentTimestamps.has(event.createdAt)
}
case "STATUS_CHANGED": {
const toLabelRaw = (payload as { toLabel?: string }).toLabel
const toRaw = (payload as { to?: string }).to
const normalized = (typeof toLabelRaw === "string" && toLabelRaw.trim().length > 0
? toLabelRaw.trim()
: typeof toRaw === "string"
? toRaw.trim()
: "").toUpperCase()
if (!normalized) return false
return (
normalized === "RESOLVED" ||
normalized === "RESOLVIDO" ||
normalized === "CLOSED" ||
normalized === "FINALIZADO" ||
normalized === "FINALIZED"
)
}
default:
return false
}
})
}
const customFieldsRecord = mapCustomFieldsToRecord( const customFieldsRecord = mapCustomFieldsToRecord(
(t.customFields as NormalizedCustomField[] | undefined) ?? undefined (t.customFields as NormalizedCustomField[] | undefined) ?? undefined
); );
@ -529,7 +579,7 @@ export const getById = query({
}, },
description: undefined, description: undefined,
customFields: customFieldsRecord, customFields: customFieldsRecord,
timeline: timeline.map((ev) => { timeline: timelineRecords.map((ev) => {
let payload = ev.payload; let payload = ev.payload;
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) { if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null); const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null);
@ -712,14 +762,19 @@ export const addComment = mutation({
const normalizedRole = (author.role ?? "AGENT").toUpperCase() const normalizedRole = (author.role ?? "AGENT").toUpperCase()
const requestedVisibility = (args.visibility ?? "").toUpperCase()
if (requestedVisibility !== "PUBLIC" && requestedVisibility !== "INTERNAL") {
throw new ConvexError("Visibilidade inválida")
}
if (normalizedRole === "MANAGER") { if (normalizedRole === "MANAGER") {
await ensureManagerTicketAccess(ctx, author, ticketDoc) await ensureManagerTicketAccess(ctx, author, ticketDoc)
if (args.visibility !== "PUBLIC") { if (requestedVisibility !== "PUBLIC") {
throw new ConvexError("Gestores só podem registrar comentários públicos") throw new ConvexError("Gestores só podem registrar comentários públicos")
} }
} }
const canUseInternalComments = normalizedRole === "ADMIN" || normalizedRole === "AGENT" const canUseInternalComments = normalizedRole === "ADMIN" || normalizedRole === "AGENT"
if (args.visibility === "INTERNAL" && !canUseInternalComments) { if (requestedVisibility === "INTERNAL" && !canUseInternalComments) {
throw new ConvexError("Apenas administradores e agentes podem registrar comentários internos") throw new ConvexError("Apenas administradores e agentes podem registrar comentários internos")
} }
@ -731,13 +786,24 @@ export const addComment = mutation({
await requireTicketStaff(ctx, args.authorId, ticketDoc) await requireTicketStaff(ctx, args.authorId, ticketDoc)
} }
const attachments = args.attachments ?? []
if (attachments.length > 5) {
throw new ConvexError("É permitido anexar no máximo 5 arquivos por comentário")
}
const maxAttachmentSize = 5 * 1024 * 1024
for (const attachment of attachments) {
if (typeof attachment.size === "number" && attachment.size > maxAttachmentSize) {
throw new ConvexError("Cada anexo pode ter até 5MB")
}
}
const now = Date.now(); const now = Date.now();
const id = await ctx.db.insert("ticketComments", { const id = await ctx.db.insert("ticketComments", {
ticketId: args.ticketId, ticketId: args.ticketId,
authorId: args.authorId, authorId: args.authorId,
visibility: args.visibility, visibility: requestedVisibility,
body: args.body, body: args.body,
attachments: args.attachments ?? [], attachments,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });

View file

@ -16,6 +16,18 @@ export async function middleware(request: NextRequest) {
const session = await getCookieCache(request) const session = await getCookieCache(request)
if (!session?.user) { if (!session?.user) {
const hasSessionCookie = Boolean(request.cookies.get("better-auth.session-token"))
const hasRefreshCookie =
Boolean(request.cookies.get("better-auth.refresh-token")) ||
Boolean(request.cookies.get("better-auth.refresh-token-v2"))
if (hasSessionCookie || hasRefreshCookie) {
const refreshed = await attemptSessionRefresh(request)
if (refreshed) {
return refreshed
}
}
const redirectUrl = new URL("/login", request.url) const redirectUrl = new URL("/login", request.url)
redirectUrl.searchParams.set("callbackUrl", pathname + search) redirectUrl.searchParams.set("callbackUrl", pathname + search)
return NextResponse.redirect(redirectUrl) return NextResponse.redirect(redirectUrl)
@ -42,3 +54,43 @@ export const config = {
// Evita executar para assets e imagens estáticas // Evita executar para assets e imagens estáticas
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|icon.png).*)"], matcher: ["/((?!api|_next/static|_next/image|favicon.ico|icon.png).*)"],
} }
async function attemptSessionRefresh(request: NextRequest): Promise<NextResponse | null> {
try {
const refreshUrl = new URL("/api/auth/get-session", request.url)
const response = await fetch(refreshUrl, {
method: "GET",
headers: {
cookie: request.headers.get("cookie") ?? "",
},
})
if (!response.ok) {
return null
}
const data = await response.json().catch(() => null)
if (!data?.user) {
return null
}
const redirect = NextResponse.redirect(request.nextUrl)
const setCookieHeaders =
typeof response.headers.raw === "function"
? response.headers.raw()["set-cookie"] ?? []
: []
if (setCookieHeaders.length === 0) {
const single = response.headers.get("set-cookie")
if (single) setCookieHeaders.push(single)
}
for (const cookie of setCookieHeaders) {
redirect.headers.append("set-cookie", cookie)
}
return redirect
} catch {
return null
}
}

View file

@ -0,0 +1,33 @@
/*
Warnings:
- Added the required column `provisioningCode` to the `Company` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Company" (
"id" TEXT NOT NULL PRIMARY KEY,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"provisioningCode" TEXT NOT NULL,
"isAvulso" BOOLEAN NOT NULL DEFAULT false,
"contractedHoursPerMonth" REAL,
"cnpj" TEXT,
"domain" TEXT,
"phone" TEXT,
"description" TEXT,
"address" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Company" ("address", "cnpj", "contractedHoursPerMonth", "createdAt", "description", "domain", "id", "isAvulso", "name", "phone", "slug", "tenantId", "updatedAt") SELECT "address", "cnpj", "contractedHoursPerMonth", "createdAt", "description", "domain", "id", "isAvulso", "name", "phone", "slug", "tenantId", "updatedAt" FROM "Company";
DROP TABLE "Company";
ALTER TABLE "new_Company" RENAME TO "Company";
CREATE UNIQUE INDEX "Company_provisioningCode_key" ON "Company"("provisioningCode");
CREATE INDEX "Company_tenantId_name_idx" ON "Company"("tenantId", "name");
CREATE UNIQUE INDEX "Company_tenantId_slug_key" ON "Company"("tenantId", "slug");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -75,6 +75,7 @@ model Company {
tenantId String tenantId String
name String name String
slug String slug String
provisioningCode String @unique
isAvulso Boolean @default(false) isAvulso Boolean @default(false)
contractedHoursPerMonth Float? contractedHoursPerMonth Float?
cnpj String? cnpj String?

View file

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

View file

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

View file

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

View file

@ -23,6 +23,7 @@ type Company = {
tenantId: string tenantId: string
name: string name: string
slug: string slug: string
provisioningCode: string
isAvulso: boolean isAvulso: boolean
contractedHoursPerMonth?: number | null contractedHoursPerMonth?: number | null
cnpj: string | 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) => { const loadLastAlerts = useCallback(async (list: Company[] = companies) => {
if (!list || list.length === 0) return if (!list || list.length === 0) return
const params = new URLSearchParams({ slugs: list.map((c) => c.slug).join(",") }) const params = new URLSearchParams({ slugs: list.map((c) => c.slug).join(",") })
@ -254,6 +268,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
<TableRow> <TableRow>
<TableHead>Nome</TableHead> <TableHead>Nome</TableHead>
<TableHead>Slug</TableHead> <TableHead>Slug</TableHead>
<TableHead>Código</TableHead>
<TableHead>Avulso</TableHead> <TableHead>Avulso</TableHead>
<TableHead>Domínio</TableHead> <TableHead>Domínio</TableHead>
<TableHead>Telefone</TableHead> <TableHead>Telefone</TableHead>
@ -267,6 +282,23 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
<TableRow key={c.id}> <TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell> <TableCell className="font-medium">{c.name}</TableCell>
<TableCell>{c.slug}</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> <TableCell>
<Button size="sm" variant="outline" onClick={() => void toggleAvulso(c)}> <Button size="sm" variant="outline" onClick={() => void toggleAvulso(c)}>
{c.isAvulso ? "Sim" : "Não"} {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 = [ const navItems = [
{ label: "Meus chamados", href: "/portal/tickets" }, { label: "Meus chamados", href: "/portal/tickets" },
{ label: "Abrir chamado", href: "/portal/tickets/new", icon: PlusCircle }, { label: "Abrir chamado", href: "/portal/tickets/new", icon: PlusCircle },
{ label: "Perfil", href: "/portal/profile" },
] ]
export function PortalShell({ children }: PortalShellProps) { export function PortalShell({ children }: PortalShellProps) {
@ -155,38 +156,6 @@ export function PortalShell({ children }: PortalShellProps) {
Recuperando dados do colaborador vinculado... Recuperando dados do colaborador vinculado...
</div> </div>
) : null} ) : 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} {children}
</main> </main>
<footer className="border-t border-slate-200 bg-white/70"> <footer className="border-t border-slate-200 bg-white/70">

View file

@ -1,10 +1,10 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { useQuery, useMutation } from "convex/react" import { useAction, useMutation, useQuery } from "convex/react"
import { format, formatDistanceToNow } from "date-fns" import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { MessageCircle } from "lucide-react" import { Download, FileIcon, MessageCircle, X } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" 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 { useAuth } from "@/lib/auth-client"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" 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 { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
// removed wrong import; RichTextEditor comes from rich-text-editor // removed wrong import; RichTextEditor comes from rich-text-editor
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { sanitizeEditorHtml, RichTextEditor } from "@/components/ui/rich-text-editor" import { sanitizeEditorHtml, RichTextEditor } from "@/components/ui/rich-text-editor"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
const statusLabel: Record<TicketWithDetails["status"], string> = { import { Spinner } from "@/components/ui/spinner"
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
}
const priorityLabel: Record<TicketWithDetails["priority"], string> = { const priorityLabel: Record<TicketWithDetails["priority"], string> = {
LOW: "Baixa", LOW: "Baixa",
@ -42,16 +38,6 @@ const priorityTone: Record<TicketWithDetails["priority"], string> = {
URGENT: "bg-rose-100 text-rose-700", 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) { function toHtmlFromText(text: string) {
const escaped = text const escaped = text
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@ -66,10 +52,21 @@ interface PortalTicketDetailProps {
ticketId: string ticketId: string
} }
type ClientTimelineEntry = {
id: string
title: string
description: string | null
when: Date
}
export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
const { convexUserId, session, isCustomer } = useAuth() const { convexUserId, session, isCustomer } = useAuth()
const addComment = useMutation(api.tickets.addComment) 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( const ticketRaw = useQuery(
api.tickets.getById, api.tickets.getById,
@ -87,6 +84,112 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
return mapTicketWithDetailsFromServer(ticketRaw) return mapTicketWithDetailsFromServer(ticketRaw)
}, [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) { if (ticketRaw === undefined) {
return ( return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm"> <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 createdAt = format(ticket.createdAt, "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })
const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR }) const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
async function handleSubmit(event: React.FormEvent) { async function handleSubmit(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
if (!convexUserId || !comment.trim() || !ticket) return if (!convexUserId || !comment.trim() || !ticket) return
@ -133,9 +235,24 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
authorId: convexUserId as Id<"users">, authorId: convexUserId as Id<"users">,
visibility: "PUBLIC", visibility: "PUBLIC",
body: htmlBody, 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("") 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 }) toast.success("Comentário enviado!", { id: toastId })
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -156,9 +273,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
) : null} ) : null}
</div> </div>
<div className="flex flex-col items-end gap-2 text-sm"> <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"> <TicketStatusBadge status={ticket.status} className="px-3 py-1 text-xs font-semibold uppercase" />
{statusLabel[ticket.status]}
</Badge>
{!isCustomer ? ( {!isCustomer ? (
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold uppercase ${priorityTone[ticket.priority]}`}> <Badge className={`rounded-full px-3 py-1 text-xs font-semibold uppercase ${priorityTone[ticket.priority]}`}>
{priorityLabel[ticket.priority]} {priorityLabel[ticket.priority]}
@ -169,7 +284,8 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 border-t border-slate-100 px-5 py-5 text-sm text-neutral-700 sm:grid-cols-2"> <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"} />} {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} /> <DetailItem label="Solicitante" value={ticket.requester.name} subtitle={ticket.requester.email} />
{ticket.assignee ? ( {ticket.assignee ? (
<DetailItem label="Responsável" value={ticket.assignee.name} /> <DetailItem label="Responsável" value={ticket.assignee.name} />
@ -187,22 +303,75 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6 px-5 pb-6"> <CardContent className="space-y-6 px-5 pb-6">
<form onSubmit={handleSubmit} className="space-y-3"> <form onSubmit={handleSubmit} className="space-y-4">
<label htmlFor="comment" className="text-sm font-medium text-neutral-800"> <div className="space-y-2">
Enviar uma mensagem para a equipe <label htmlFor="comment" className="text-sm font-medium text-neutral-800">
</label> Enviar uma mensagem para a equipe
<RichTextEditor </label>
value={comment} <RichTextEditor
onChange={(html) => setComment(html)} value={comment}
placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações." onChange={(html) => setComment(html)}
className="rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20" 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>
<div className="space-y-2">
<Dropzone <Dropzone
onUploaded={(files) => setAttachments((prev) => [...prev, ...files])} 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" 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>
<div className="flex justify-end"> <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"> <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> <span className="text-xs text-neutral-500">{createdAgo}</span>
</div> </div>
</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>
<div <div
className="prose prose-sm mt-3 max-w-none text-neutral-800" className="prose prose-sm mt-3 max-w-none text-neutral-800"
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(commentItem.body ?? "") }} 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> </div>
) )
}) })
@ -269,22 +446,21 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
<CardTitle className="text-lg font-semibold text-neutral-900">Linha do tempo</CardTitle> <CardTitle className="text-lg font-semibold text-neutral-900">Linha do tempo</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-5 px-5 pb-6 text-sm text-neutral-700"> <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> <p className="text-sm text-neutral-500">Nenhum evento registrado ainda.</p>
) : ( ) : (
ticket.timeline clientTimeline.map((event) => {
.slice() const when = formatDistanceToNow(event.when, { addSuffix: true, locale: ptBR })
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) return (
.map((event) => { <div key={event.id} className="flex flex-col gap-1 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
const label = timelineLabels[event.type] ?? event.type <span className="text-sm font-semibold text-neutral-900">{event.title}</span>
const when = formatDistanceToNow(event.createdAt, { addSuffix: true, locale: ptBR }) {event.description ? (
return ( <span className="text-xs text-neutral-600">{event.description}</span>
<div key={event.id} className="flex flex-col gap-1 rounded-xl border border-slate-100 bg-slate-50/50 p-3"> ) : null}
<span className="text-sm font-semibold text-neutral-900">{label}</span> <span className="text-xs text-neutral-500">{when}</span>
<span className="text-xs text-neutral-500">{when}</span> </div>
</div> )
) })
})
)} )}
</CardContent> </CardContent>
</Card> </Card>
@ -309,6 +485,121 @@ function DetailItem({ label, value, subtitle }: DetailItemProps) {
) )
} }
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, size: a.size,
type: a.type, 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" }) toast.success("Ticket criado!", { id: "new-ticket" })
setOpen(false) setOpen(false)

View file

@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react" 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 { useAction, useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client" 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 [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
const [preview, setPreview] = useState<string | null>(null) const [preview, setPreview] = useState<string | null>(null)
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([]) 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 [attachmentToRemove, setAttachmentToRemove] = useState<{ commentId: string; attachmentId: string; name: string } | null>(null)
const [removingAttachment, setRemovingAttachment] = useState(false) const [removingAttachment, setRemovingAttachment] = useState(false)
const [editingComment, setEditingComment] = useState<{ id: string; value: string } | null>(null) 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 canEdit = Boolean(convexUserId && String(comment.author.id) === convexUserId && !isPending)
const hasBody = bodyPlain.length > 0 || isEditing const hasBody = bodyPlain.length > 0 || isEditing
const isInternal = comment.visibility === "INTERNAL" && canSeeInternalComments 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 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" : "group/comment flex gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3"
const bodyClass = isInternal 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-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" : "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-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" : "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-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" : "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"> <span className="text-xs font-semibold tracking-wide text-amber-700/80">
Comentário interno visível apenas para administradores e agentes Comentário interno visível apenas para administradores e agentes
</span> </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} ) : null}
{isEditing ? ( {isEditing ? (
<div <div
@ -596,10 +601,20 @@ function CommentAttachmentCard({
const handleDownload = useCallback(async () => { const handleDownload = useCallback(async () => {
const target = url ?? (await ensureUrl()) 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") window.open(target, "_blank", "noopener,noreferrer")
} }
}, [ensureUrl, url]) }, [attachment.name, ensureUrl, url])
const name = attachment.name ?? "" const name = attachment.name ?? ""
const urlLooksImage = url ? /\.(png|jpe?g|gif|webp|svg)$/i.test(url) : false const urlLooksImage = url ? /\.(png|jpe?g|gif|webp|svg)$/i.test(url) : false
@ -642,6 +657,14 @@ function CommentAttachmentCard({
</span> </span>
</button> </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 <button
type="button" type="button"
onClick={onRequestRemoval} onClick={onRequestRemoval}

View file

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

View file

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

View file

@ -62,3 +62,72 @@ export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
authEmail: machineEmail, 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 }
}