Implement company provisioning codes and session tweaks
This commit is contained in:
parent
0fb9bf59b2
commit
2cba553efa
28 changed files with 1407 additions and 534 deletions
|
|
@ -55,13 +55,6 @@ type MachineRegisterResponse = {
|
|||
} | null
|
||||
}
|
||||
|
||||
type CompanyOption = {
|
||||
id: string
|
||||
tenantId: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
type AgentConfig = {
|
||||
machineId: string
|
||||
tenantId?: string | null
|
||||
|
|
@ -92,7 +85,6 @@ declare global {
|
|||
|
||||
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_TENANT_ID = "tenant-atlas"
|
||||
|
||||
function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) {
|
||||
const trimmed = (value ?? fallback).trim()
|
||||
|
|
@ -156,15 +148,13 @@ function App() {
|
|||
const [showSecret, setShowSecret] = useState(false)
|
||||
const [isLaunchingSystem, setIsLaunchingSystem] = useState(false)
|
||||
|
||||
const [provisioningSecret, setProvisioningSecret] = useState("")
|
||||
const [tenantId, setTenantId] = useState("")
|
||||
const [company, setCompany] = useState("")
|
||||
const [provisioningCode, setProvisioningCode] = useState("")
|
||||
const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null)
|
||||
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 [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 [updateInfo, setUpdateInfo] = useState<{ message: string; tone: "info" | "success" | "error" } | null>({
|
||||
message: "Atualizações automáticas são verificadas a cada inicialização.",
|
||||
|
|
@ -182,11 +172,9 @@ function App() {
|
|||
setToken(t)
|
||||
const cfg = await readConfig(s)
|
||||
setConfig(cfg)
|
||||
setAccessRole(cfg?.accessRole ?? "collaborator")
|
||||
if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail)
|
||||
if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName)
|
||||
if (cfg?.companyName) setCompany(cfg.companyName)
|
||||
if (cfg?.tenantId) setTenantId(cfg.tenantId)
|
||||
if (cfg?.companyName) setCompanyName(cfg.companyName)
|
||||
if (!t) {
|
||||
const p = await invoke<MachineProfile>("collect_machine_profile")
|
||||
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(() => {
|
||||
if (!store || !config) return
|
||||
const email = collabEmail.trim()
|
||||
|
|
@ -206,8 +220,7 @@ function App() {
|
|||
const normalizedName = name.length > 0 ? name : null
|
||||
if (
|
||||
config.collaboratorEmail === normalizedEmail &&
|
||||
config.collaboratorName === normalizedName &&
|
||||
config.accessRole === accessRole
|
||||
config.collaboratorName === normalizedName
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
|
@ -215,11 +228,10 @@ function App() {
|
|||
...config,
|
||||
collaboratorEmail: normalizedEmail,
|
||||
collaboratorName: normalizedName,
|
||||
accessRole,
|
||||
}
|
||||
setConfig(nextConfig)
|
||||
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(() => {
|
||||
if (!store || !config) return
|
||||
|
|
@ -235,6 +247,59 @@ function App() {
|
|||
}
|
||||
}, [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(() => {
|
||||
if (!config?.appUrl) return appUrl
|
||||
const normalized = normalizeUrl(config.appUrl, appUrl)
|
||||
|
|
@ -244,184 +309,97 @@ function App() {
|
|||
return normalized
|
||||
}, [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() {
|
||||
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()
|
||||
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
|
||||
}
|
||||
const normalizedName = collabName.trim()
|
||||
if (!normalizedName) {
|
||||
setError("Informe o nome completo do colaborador ou gestor.")
|
||||
setError("Informe o nome completo do colaborador.")
|
||||
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 = {
|
||||
email: normalizedEmail,
|
||||
name: normalizedName,
|
||||
}
|
||||
const collaboratorMetadata = collaboratorPayload
|
||||
? { ...collaboratorPayload, role: accessRole }
|
||||
: undefined
|
||||
const metadataPayload: Record<string, unknown> = {
|
||||
inventory: profile.inventory,
|
||||
metrics: profile.metrics,
|
||||
collaborator: { email: normalizedEmail, name: normalizedName, role: "collaborator" },
|
||||
}
|
||||
if (collaboratorMetadata) {
|
||||
metadataPayload.collaborator = collaboratorMetadata
|
||||
}
|
||||
|
||||
const payload = {
|
||||
provisioningSecret: provisioningSecret.trim(),
|
||||
tenantId: trimmedTenantId || undefined,
|
||||
companySlug: ensuredCompany?.slug,
|
||||
provisioningCode: trimmedCode,
|
||||
hostname: profile.hostname,
|
||||
os: profile.os,
|
||||
macAddresses: profile.macAddresses,
|
||||
serialNumbers: profile.serialNumbers,
|
||||
metadata: metadataPayload,
|
||||
accessRole,
|
||||
collaborator: collaboratorPayload,
|
||||
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) {
|
||||
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
|
||||
if (!store) throw new Error("Store ausente")
|
||||
|
||||
await writeToken(store, data.machineToken)
|
||||
|
||||
const cfg: AgentConfig = {
|
||||
machineId: data.machineId,
|
||||
tenantId: data.tenantId ?? null,
|
||||
companySlug: data.companySlug ?? ensuredCompany?.slug ?? null,
|
||||
companyName: ensuredCompany?.name ?? (trimmedCompanyName || null),
|
||||
tenantId: data.tenantId ?? validatedCompany.tenantId ?? null,
|
||||
companySlug: data.companySlug ?? validatedCompany.slug ?? null,
|
||||
companyName: validatedCompany.name,
|
||||
machineEmail: data.machineEmail ?? null,
|
||||
collaboratorEmail: collaboratorPayload?.email ?? null,
|
||||
collaboratorName: collaboratorPayload?.name ?? null,
|
||||
accessRole,
|
||||
collaboratorEmail: collaboratorPayload.email,
|
||||
collaboratorName: collaboratorPayload.name,
|
||||
accessRole: "collaborator",
|
||||
assignedUserId: data.assignedUserId ?? null,
|
||||
assignedUserEmail: data.collaborator?.email ?? collaboratorPayload?.email ?? null,
|
||||
assignedUserName: data.collaborator?.name ?? collaboratorPayload?.name ?? null,
|
||||
assignedUserEmail: data.collaborator?.email ?? collaboratorPayload.email,
|
||||
assignedUserName: data.collaborator?.name ?? collaboratorPayload.name,
|
||||
apiBaseUrl,
|
||||
appUrl,
|
||||
createdAt: Date.now(),
|
||||
lastSyncedAt: Date.now(),
|
||||
expiresAt: data.expiresAt ?? null,
|
||||
}
|
||||
|
||||
await writeConfig(store, cfg)
|
||||
setConfig(cfg); setToken(data.machineToken)
|
||||
if (ensuredCompany?.name) {
|
||||
setCompany(ensuredCompany.name)
|
||||
}
|
||||
await invoke("start_machine_agent", { baseUrl: apiBaseUrl, token: data.machineToken, status: "online", intervalSeconds: 300 })
|
||||
setConfig(cfg)
|
||||
setToken(data.machineToken)
|
||||
setCompanyName(validatedCompany.name)
|
||||
|
||||
await invoke("start_machine_agent", {
|
||||
baseUrl: apiBaseUrl,
|
||||
token: data.machineToken,
|
||||
status: "online",
|
||||
intervalSeconds: 300,
|
||||
})
|
||||
setStatus("online")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
|
|
@ -446,19 +424,22 @@ function App() {
|
|||
} catch {
|
||||
// ignoramos e seguimos para o handshake
|
||||
}
|
||||
const persona = (config?.accessRole ?? accessRole) === "manager" ? "manager" : "collaborator"
|
||||
// Envia para a página de diagnóstico para validar cookies/sessão
|
||||
const redirectTarget = persona === "manager" ? "/dashboard" : "/portal/debug"
|
||||
const persona = (config?.accessRole ?? "collaborator") === "manager" ? "manager" : "collaborator"
|
||||
// Envia para a página inicial apropriada após autenticar cookies/sessão
|
||||
const redirectTarget = persona === "manager" ? "/dashboard" : "/portal/tickets"
|
||||
const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}`
|
||||
window.location.href = url
|
||||
}, [token, config?.accessRole, accessRole, resolvedAppUrl, apiBaseUrl])
|
||||
}, [token, config?.accessRole, resolvedAppUrl])
|
||||
|
||||
async function reprovision() {
|
||||
if (!store) return
|
||||
await store.delete("token"); await store.delete("config"); await store.save()
|
||||
autoLaunchRef.current = false
|
||||
setToken(null); setConfig(null); setStatus(null); setAccessRole("collaborator")
|
||||
setCompany(""); setSelectedCompany(null); setCompanyOptions([])
|
||||
setToken(null); setConfig(null); setStatus(null)
|
||||
setProvisioningCode("")
|
||||
setValidatedCompany(null)
|
||||
setCodeStatus(null)
|
||||
setCompanyName("")
|
||||
setIsLaunchingSystem(false)
|
||||
const p = await invoke<MachineProfile>("collect_machine_profile")
|
||||
setProfile(p)
|
||||
|
|
@ -472,7 +453,7 @@ function App() {
|
|||
? { email: collabEmail.trim(), name: collabName.trim() || undefined }
|
||||
: undefined
|
||||
const collaboratorInventory = collaboratorPayload
|
||||
? { ...collaboratorPayload, role: accessRole }
|
||||
? { ...collaboratorPayload, role: "collaborator" as const }
|
||||
: undefined
|
||||
const inventoryPayload: Record<string, unknown> = { ...profile.inventory }
|
||||
if (collaboratorInventory) {
|
||||
|
|
@ -590,69 +571,69 @@ function App() {
|
|||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Código de provisionamento</label>
|
||||
<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)} />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Empresa (nome ou slug, opcional)</label>
|
||||
<input
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||
placeholder="ex.: Paulicon Contábil"
|
||||
value={company}
|
||||
onChange={(e) => handleCompanyInputChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 pr-9 text-sm"
|
||||
type={showSecret ? "text" : "password"}
|
||||
value={provisioningCode}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setProvisioningCode(value)
|
||||
setValidatedCompany(null)
|
||||
setCodeStatus(null)
|
||||
}}
|
||||
/>
|
||||
{companyLookupPending ? (
|
||||
<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)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-600"
|
||||
onClick={() => setShowSecret((v) => !v)}
|
||||
aria-label="Mostrar/ocultar"
|
||||
>
|
||||
<span className="font-medium text-slate-800">{option.name}</span>
|
||||
<span className="text-xs text-slate-500">{option.slug}</span>
|
||||
{showSecret ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : company.trim().length >= 2 ? (
|
||||
<p className="text-xs text-slate-500">Empresa não encontrada — criaremos automaticamente ao registrar.</p>
|
||||
{isValidatingCode ? (
|
||||
<p className="text-xs text-slate-500">Validando código...</p>
|
||||
) : codeStatus ? (
|
||||
<p
|
||||
className={`text-xs font-medium ${
|
||||
codeStatus.tone === "success" ? "text-emerald-600" : "text-rose-600"
|
||||
}`}
|
||||
>
|
||||
{codeStatus.message}
|
||||
</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 className="grid gap-2">
|
||||
<label className="text-sm font-medium">Perfil de acesso</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||
value={accessRole}
|
||||
onChange={(e) => setAccessRole((e.target.value as "collaborator" | "manager") ?? "collaborator")}
|
||||
>
|
||||
<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>
|
||||
{validatedCompany ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
|
||||
<div className="text-sm font-semibold text-emerald-800">{validatedCompany.name}</div>
|
||||
<div>Tenant: <span className="font-mono text-emerald-800">{validatedCompany.tenantId}</span></div>
|
||||
<div>Slug: <span className="font-mono text-emerald-800">{validatedCompany.slug}</span></div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">
|
||||
Colaborador (e-mail) <span className="text-rose-500">*</span>
|
||||
</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 className="grid gap-2">
|
||||
<label className="text-sm font-medium">Nome do colaborador <span className="text-rose-500">*</span></label>
|
||||
<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)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Tenant (opcional)</label>
|
||||
<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)} />
|
||||
<label className="text-sm font-medium">
|
||||
Nome do colaborador <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
{profile ? (
|
||||
<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>
|
||||
) : null}
|
||||
<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>
|
||||
) : (
|
||||
|
|
@ -684,10 +665,19 @@ function App() {
|
|||
<TabsList className="h-10">
|
||||
<TabsTrigger value="resumo" className="rounded-lg px-3">Resumo</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>
|
||||
</TabsList>
|
||||
<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="stat-card">
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
<div className="grid gap-2">
|
||||
<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 })()
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
|
|
|
|||
|
|
@ -33,8 +33,9 @@ export const ensureProvisioned = mutation({
|
|||
tenantId: v.string(),
|
||||
slug: 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)
|
||||
if (!normalizedSlug) {
|
||||
throw new ConvexError("Slug inválido")
|
||||
|
|
@ -50,6 +51,9 @@ export const ensureProvisioned = mutation({
|
|||
.unique()
|
||||
|
||||
if (existing) {
|
||||
if (existing.provisioningCode !== provisioningCode) {
|
||||
await ctx.db.patch(existing._id, { provisioningCode })
|
||||
}
|
||||
return {
|
||||
id: existing._id,
|
||||
slug: existing.slug,
|
||||
|
|
@ -62,6 +66,7 @@ export const ensureProvisioned = mutation({
|
|||
tenantId,
|
||||
name: trimmedName,
|
||||
slug: normalizedSlug,
|
||||
provisioningCode,
|
||||
isAvulso: false,
|
||||
contractedHoursPerMonth: undefined,
|
||||
cnpj: undefined,
|
||||
|
|
|
|||
|
|
@ -17,28 +17,6 @@ type NormalizedIdentifiers = {
|
|||
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 {
|
||||
const raw = process.env["MACHINE_TOKEN_TTL_MS"]
|
||||
if (!raw) return DEFAULT_TOKEN_TTL_MS
|
||||
|
|
@ -122,23 +100,6 @@ function hashToken(token: string) {
|
|||
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(
|
||||
ctx: MutationCtx,
|
||||
tokenValue: string
|
||||
|
|
@ -332,9 +293,7 @@ async function evaluatePostureAndMaybeRaise(
|
|||
|
||||
export const register = mutation({
|
||||
args: {
|
||||
provisioningSecret: v.string(),
|
||||
tenantId: v.optional(v.string()),
|
||||
companySlug: v.optional(v.string()),
|
||||
provisioningCode: v.string(),
|
||||
hostname: v.string(),
|
||||
os: v.object({
|
||||
name: v.string(),
|
||||
|
|
@ -347,16 +306,21 @@ export const register = mutation({
|
|||
registeredBy: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const secret = getProvisioningSecret()
|
||||
if (args.provisioningSecret !== secret) {
|
||||
const normalizedCode = args.provisioningCode.trim().toLowerCase()
|
||||
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")
|
||||
}
|
||||
|
||||
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
|
||||
const normalizedCompanySlug = normalizeCompanySlug(args.companySlug)
|
||||
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
|
||||
const companyId = companyRecord._id
|
||||
const companySlug = companyRecord.slug
|
||||
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
|
||||
const fingerprint = computeFingerprint(tenantId, normalizedCompanySlug, args.hostname, identifiers)
|
||||
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, normalizedCompanySlug)
|
||||
const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers)
|
||||
const now = Date.now()
|
||||
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({
|
||||
args: {
|
||||
provisioningSecret: v.string(),
|
||||
tenantId: v.optional(v.string()),
|
||||
companySlug: v.optional(v.string()),
|
||||
provisioningCode: v.string(),
|
||||
hostname: v.string(),
|
||||
os: v.object({
|
||||
name: v.string(),
|
||||
|
|
@ -485,16 +447,21 @@ export const upsertInventory = mutation({
|
|||
registeredBy: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const secret = getProvisioningSecret()
|
||||
if (args.provisioningSecret !== secret) {
|
||||
const normalizedCode = args.provisioningCode.trim().toLowerCase()
|
||||
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")
|
||||
}
|
||||
|
||||
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
|
||||
const normalizedCompanySlug = normalizeCompanySlug(args.companySlug)
|
||||
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
|
||||
const companyId = companyRecord._id
|
||||
const companySlug = companyRecord.slug
|
||||
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
|
||||
const fingerprint = computeFingerprint(tenantId, normalizedCompanySlug, args.hostname, identifiers)
|
||||
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, normalizedCompanySlug)
|
||||
const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers)
|
||||
const now = Date.now()
|
||||
|
||||
const metadataPatch: Record<string, unknown> = {}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { randomBytes } from "@noble/hashes/utils"
|
||||
import { ConvexError, v } from "convex/values"
|
||||
|
||||
import { mutation, query } from "./_generated/server"
|
||||
|
|
@ -13,6 +14,10 @@ function normalizeEmail(value: string) {
|
|||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function generateProvisioningCode() {
|
||||
return Array.from(randomBytes(32), (b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
type ImportedUser = {
|
||||
email: string
|
||||
name: string
|
||||
|
|
@ -30,6 +35,7 @@ type ImportedQueue = {
|
|||
type ImportedCompany = {
|
||||
slug: string
|
||||
name: string
|
||||
provisioningCode?: string | null
|
||||
isAvulso?: boolean | null
|
||||
cnpj?: string | null
|
||||
domain?: string | null
|
||||
|
|
@ -185,6 +191,7 @@ async function ensureCompany(
|
|||
tenantId,
|
||||
name: data.name,
|
||||
slug,
|
||||
provisioningCode: data.provisioningCode ?? existing?.provisioningCode ?? generateProvisioningCode(),
|
||||
isAvulso: data.isAvulso ?? undefined,
|
||||
cnpj: data.cnpj ?? undefined,
|
||||
domain: data.domain ?? undefined,
|
||||
|
|
@ -204,7 +211,8 @@ async function ensureCompany(
|
|||
existing.domain !== (payload.domain ?? undefined) ||
|
||||
existing.phone !== (payload.phone ?? undefined) ||
|
||||
existing.description !== (payload.description ?? undefined) ||
|
||||
existing.address !== (payload.address ?? undefined)
|
||||
existing.address !== (payload.address ?? undefined) ||
|
||||
existing.provisioningCode !== payload.provisioningCode
|
||||
if (needsPatch) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
name: payload.name,
|
||||
|
|
@ -214,6 +222,7 @@ async function ensureCompany(
|
|||
phone: payload.phone,
|
||||
description: payload.description,
|
||||
address: payload.address,
|
||||
provisioningCode: payload.provisioningCode,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export default defineSchema({
|
|||
tenantId: v.string(),
|
||||
name: v.string(),
|
||||
slug: v.string(),
|
||||
provisioningCode: v.string(),
|
||||
isAvulso: v.optional(v.boolean()),
|
||||
contractedHoursPerMonth: v.optional(v.number()),
|
||||
cnpj: v.optional(v.string()),
|
||||
|
|
@ -31,7 +32,8 @@ export default defineSchema({
|
|||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_tenant_slug", ["tenantId", "slug"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
.index("by_tenant", ["tenantId"])
|
||||
.index("by_provisioning_code", ["provisioningCode"]),
|
||||
|
||||
alerts: defineTable({
|
||||
tenantId: v.string(),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { randomBytes } from "@noble/hashes/utils"
|
||||
import type { Id } from "./_generated/dataModel"
|
||||
import { mutation } from "./_generated/server"
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ export const seedDemo = mutation({
|
|||
phone?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
provisioningCode?: string;
|
||||
}): Promise<Id<"companies">> {
|
||||
const slug = def.slug ?? slugify(def.name);
|
||||
const existing = await ctx.db
|
||||
|
|
@ -97,6 +99,7 @@ export const seedDemo = mutation({
|
|||
tenantId,
|
||||
name: def.name,
|
||||
slug,
|
||||
provisioningCode: def.provisioningCode ?? existing?.provisioningCode ?? generateCode(),
|
||||
cnpj: def.cnpj ?? undefined,
|
||||
domain: def.domain ?? undefined,
|
||||
phone: def.phone ?? undefined,
|
||||
|
|
@ -113,6 +116,7 @@ export const seedDemo = mutation({
|
|||
if (existing.phone !== payload.phone) updates.phone = payload.phone;
|
||||
if (existing.description !== payload.description) updates.description = payload.description;
|
||||
if (existing.address !== payload.address) updates.address = payload.address;
|
||||
if (existing.provisioningCode !== payload.provisioningCode) updates.provisioningCode = payload.provisioningCode;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates.updatedAt = now;
|
||||
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",
|
||||
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("")
|
||||
|
|
|
|||
|
|
@ -424,11 +424,61 @@ export const getById = query({
|
|||
const visibleComments = canViewInternalComments
|
||||
? comments
|
||||
: 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")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", id))
|
||||
.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(
|
||||
(t.customFields as NormalizedCustomField[] | undefined) ?? undefined
|
||||
);
|
||||
|
|
@ -529,7 +579,7 @@ export const getById = query({
|
|||
},
|
||||
description: undefined,
|
||||
customFields: customFieldsRecord,
|
||||
timeline: timeline.map((ev) => {
|
||||
timeline: timelineRecords.map((ev) => {
|
||||
let payload = ev.payload;
|
||||
if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) {
|
||||
const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null);
|
||||
|
|
@ -712,14 +762,19 @@ export const addComment = mutation({
|
|||
|
||||
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") {
|
||||
await ensureManagerTicketAccess(ctx, author, ticketDoc)
|
||||
if (args.visibility !== "PUBLIC") {
|
||||
if (requestedVisibility !== "PUBLIC") {
|
||||
throw new ConvexError("Gestores só podem registrar comentários públicos")
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
@ -731,13 +786,24 @@ export const addComment = mutation({
|
|||
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 id = await ctx.db.insert("ticketComments", {
|
||||
ticketId: args.ticketId,
|
||||
authorId: args.authorId,
|
||||
visibility: args.visibility,
|
||||
visibility: requestedVisibility,
|
||||
body: args.body,
|
||||
attachments: args.attachments ?? [],
|
||||
attachments,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,18 @@ export async function middleware(request: NextRequest) {
|
|||
const session = await getCookieCache(request)
|
||||
|
||||
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)
|
||||
redirectUrl.searchParams.set("callbackUrl", pathname + search)
|
||||
return NextResponse.redirect(redirectUrl)
|
||||
|
|
@ -42,3 +54,43 @@ export const config = {
|
|||
// Evita executar para assets e imagens estáticas
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -75,6 +75,7 @@ model Company {
|
|||
tenantId String
|
||||
name String
|
||||
slug String
|
||||
provisioningCode String @unique
|
||||
isAvulso Boolean @default(false)
|
||||
contractedHoursPerMonth Float?
|
||||
cnpj String?
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export default async function AdminCompaniesPage() {
|
|||
tenantId: c.tenantId,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
provisioningCode: c.provisioningCode,
|
||||
isAvulso: Boolean(extra.isAvulso ?? false),
|
||||
contractedHoursPerMonth: extra.contractedHoursPerMonth ?? null,
|
||||
cnpj: c.cnpj ?? null,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { assertStaffSession } from "@/lib/auth-server"
|
||||
|
|
@ -38,11 +39,13 @@ export async function POST(request: Request) {
|
|||
}
|
||||
|
||||
try {
|
||||
const provisioningCode = randomBytes(32).toString("hex")
|
||||
const company = await prisma.company.create({
|
||||
data: {
|
||||
tenantId: session.user.tenantId ?? "tenant-atlas",
|
||||
name: String(name),
|
||||
slug: String(slug),
|
||||
provisioningCode,
|
||||
// Campos opcionais (isAvulso, contractedHoursPerMonth) podem ser definidos via PATCH posteriormente.
|
||||
cnpj: cnpj ? String(cnpj) : null,
|
||||
domain: domain ? String(domain) : null,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { ConvexHttpClient } from "convex/browser"
|
|||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { env } from "@/lib/env"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
|
||||
const tokenModeSchema = z.object({
|
||||
|
|
@ -21,9 +20,7 @@ const tokenModeSchema = z.object({
|
|||
})
|
||||
|
||||
const provisioningModeSchema = z.object({
|
||||
provisioningSecret: z.string().min(1),
|
||||
tenantId: z.string().optional(),
|
||||
companySlug: z.string().optional(),
|
||||
provisioningCode: z.string().min(32),
|
||||
hostname: z.string().min(1),
|
||||
os: z.object({
|
||||
name: z.string().min(1),
|
||||
|
|
@ -87,9 +84,7 @@ export async function POST(request: Request) {
|
|||
if (provParsed.success) {
|
||||
try {
|
||||
const result = await client.mutation(api.machines.upsertInventory, {
|
||||
provisioningSecret: provParsed.data.provisioningSecret,
|
||||
tenantId: provParsed.data.tenantId ?? DEFAULT_TENANT_ID,
|
||||
companySlug: provParsed.data.companySlug ?? undefined,
|
||||
provisioningCode: provParsed.data.provisioningCode.trim().toLowerCase(),
|
||||
hostname: provParsed.data.hostname,
|
||||
os: provParsed.data.os,
|
||||
macAddresses: provParsed.data.macAddresses,
|
||||
|
|
@ -108,4 +103,3 @@ export async function POST(request: Request) {
|
|||
|
||||
return jsonWithCors({ error: "Formato de payload não suportado" }, 400, request.headers.get("origin"), CORS_METHODS)
|
||||
}
|
||||
|
||||
|
|
|
|||
93
src/app/api/machines/provisioning/route.ts
Normal file
93
src/app/api/machines/provisioning/route.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,15 +5,13 @@ import { api } from "@/convex/_generated/api"
|
|||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { env } from "@/lib/env"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { ensureMachineAccount } from "@/server/machines-auth"
|
||||
import { ensureCollaboratorAccount, ensureMachineAccount } from "@/server/machines-auth"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { normalizeSlug } from "@/lib/slug"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
provisioningSecret: z.string().min(1),
|
||||
tenantId: z.string().optional(),
|
||||
companySlug: z.string().optional(),
|
||||
provisioningCode: z.string().min(32),
|
||||
hostname: z.string().min(1),
|
||||
os: z.object({
|
||||
name: z.string().min(1),
|
||||
|
|
@ -67,13 +65,25 @@ export async function POST(request: Request) {
|
|||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
let normalizedCompanySlug: string | undefined
|
||||
|
||||
try {
|
||||
const tenantId = payload.tenantId ?? DEFAULT_TENANT_ID
|
||||
const provisioningCode = payload.provisioningCode.trim().toLowerCase()
|
||||
const companyRecord = await prisma.company.findFirst({
|
||||
where: { provisioningCode },
|
||||
select: { id: true, tenantId: true, name: true, slug: true, provisioningCode: true },
|
||||
})
|
||||
|
||||
if (!companyRecord) {
|
||||
return jsonWithCors(
|
||||
{ error: "Código de provisionamento inválido" },
|
||||
404,
|
||||
request.headers.get("origin"),
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
|
||||
const persona = payload.accessRole ?? undefined
|
||||
const collaborator = payload.collaborator ?? null
|
||||
normalizedCompanySlug = normalizeSlug(payload.companySlug)
|
||||
|
||||
if (persona && !collaborator) {
|
||||
return jsonWithCors(
|
||||
|
|
@ -99,10 +109,15 @@ export async function POST(request: Request) {
|
|||
}
|
||||
}
|
||||
|
||||
const registration = await client.mutation(api.machines.register, {
|
||||
provisioningSecret: payload.provisioningSecret,
|
||||
await client.mutation(api.companies.ensureProvisioned, {
|
||||
tenantId,
|
||||
companySlug: normalizedCompanySlug,
|
||||
slug: companyRecord.slug,
|
||||
name: companyRecord.name,
|
||||
provisioningCode: companyRecord.provisioningCode,
|
||||
})
|
||||
|
||||
const registration = await client.mutation(api.machines.register, {
|
||||
provisioningCode,
|
||||
hostname: payload.hostname,
|
||||
os: payload.os,
|
||||
macAddresses: payload.macAddresses,
|
||||
|
|
@ -126,18 +141,25 @@ export async function POST(request: Request) {
|
|||
})
|
||||
|
||||
let assignedUserId: Id<"users"> | undefined
|
||||
if (persona && collaborator) {
|
||||
if (collaborator) {
|
||||
const ensuredUser = (await client.mutation(api.users.ensureUser, {
|
||||
tenantId,
|
||||
email: collaborator.email,
|
||||
name: collaborator.name ?? collaborator.email,
|
||||
avatarUrl: undefined,
|
||||
role: persona.toUpperCase(),
|
||||
role: persona?.toUpperCase(),
|
||||
companyId: registration.companyId ? (registration.companyId as Id<"companies">) : undefined,
|
||||
})) as { _id?: Id<"users"> } | null
|
||||
|
||||
assignedUserId = ensuredUser?._id
|
||||
await ensureCollaboratorAccount({
|
||||
email: collaborator.email,
|
||||
name: collaborator.name ?? collaborator.email,
|
||||
tenantId,
|
||||
companyId: companyRecord.id,
|
||||
})
|
||||
|
||||
if (persona) {
|
||||
assignedUserId = ensuredUser?._id
|
||||
await client.mutation(api.machines.updatePersona, {
|
||||
machineId: registration.machineId as Id<"machines">,
|
||||
persona,
|
||||
|
|
@ -152,6 +174,12 @@ export async function POST(request: Request) {
|
|||
persona: "",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await client.mutation(api.machines.updatePersona, {
|
||||
machineId: registration.machineId as Id<"machines">,
|
||||
persona: "",
|
||||
})
|
||||
}
|
||||
|
||||
return jsonWithCors(
|
||||
{
|
||||
|
|
@ -174,18 +202,11 @@ export async function POST(request: Request) {
|
|||
console.error("[machines.register] Falha no provisionamento", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
const msg = details.toLowerCase()
|
||||
// Mapear alguns erros "esperados" para códigos adequados
|
||||
// - empresa inválida → 404
|
||||
// - segredo inválido → 401
|
||||
// - demais ConvexError → 400
|
||||
const isInvalidCode = msg.includes("código de provisionamento inválido")
|
||||
const isCompanyNotFound = msg.includes("empresa não encontrada")
|
||||
const isInvalidSecret = msg.includes("código de provisionamento inválido")
|
||||
const isConvexError = msg.includes("convexerror")
|
||||
const status = isCompanyNotFound ? 404 : isInvalidSecret ? 401 : isConvexError ? 400 : 500
|
||||
const payload = { error: "Falha ao provisionar máquina", details } as Record<string, unknown>
|
||||
if (isCompanyNotFound && normalizedCompanySlug) {
|
||||
payload["companySlug"] = normalizedCompanySlug
|
||||
}
|
||||
const status = isInvalidCode ? 401 : isCompanyNotFound ? 404 : isConvexError ? 400 : 500
|
||||
const payload = { error: "Falha ao provisionar máquina", details }
|
||||
return jsonWithCors(payload, status, request.headers.get("origin"), CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
169
src/app/api/portal/profile/route.ts
Normal file
169
src/app/api/portal/profile/route.ts
Normal 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 })
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
24
src/app/portal/profile/page.tsx
Normal file
24
src/app/portal/profile/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -123,7 +123,7 @@ export default function NewTicketPage() {
|
|||
await addComment({
|
||||
ticketId: id as Id<"tickets">,
|
||||
authorId: convexUserId as Id<"users">,
|
||||
visibility: "PUBLIC",
|
||||
visibility: "INTERNAL",
|
||||
body: sanitizedDescription,
|
||||
attachments: [],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type Company = {
|
|||
tenantId: string
|
||||
name: string
|
||||
slug: string
|
||||
provisioningCode: string
|
||||
isAvulso: boolean
|
||||
contractedHoursPerMonth?: number | null
|
||||
cnpj: string | null
|
||||
|
|
@ -58,6 +59,19 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
})
|
||||
}
|
||||
|
||||
const handleCopyProvisioningCode = useCallback(async (code: string) => {
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(code)
|
||||
toast.success("Código copiado para a área de transferência")
|
||||
} else {
|
||||
throw new Error("Clipboard indisponível")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Não foi possível copiar o código. Copie manualmente.")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadLastAlerts = useCallback(async (list: Company[] = companies) => {
|
||||
if (!list || list.length === 0) return
|
||||
const params = new URLSearchParams({ slugs: list.map((c) => c.slug).join(",") })
|
||||
|
|
@ -254,6 +268,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>Código</TableHead>
|
||||
<TableHead>Avulso</TableHead>
|
||||
<TableHead>Domínio</TableHead>
|
||||
<TableHead>Telefone</TableHead>
|
||||
|
|
@ -267,6 +282,23 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell>{c.slug}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-mono text-neutral-700"
|
||||
title={c.provisioningCode}
|
||||
>
|
||||
{c.provisioningCode.slice(0, 10)}…
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCopyProvisioningCode(c.provisioningCode)}
|
||||
>
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="outline" onClick={() => void toggleAvulso(c)}>
|
||||
{c.isAvulso ? "Sim" : "Não"}
|
||||
|
|
|
|||
125
src/components/portal/portal-profile-settings.tsx
Normal file
125
src/components/portal/portal-profile-settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ interface PortalShellProps {
|
|||
const navItems = [
|
||||
{ label: "Meus chamados", href: "/portal/tickets" },
|
||||
{ label: "Abrir chamado", href: "/portal/tickets/new", icon: PlusCircle },
|
||||
{ label: "Perfil", href: "/portal/profile" },
|
||||
]
|
||||
|
||||
export function PortalShell({ children }: PortalShellProps) {
|
||||
|
|
@ -155,38 +156,6 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
Recuperando dados do colaborador vinculado...
|
||||
</div>
|
||||
) : null}
|
||||
{!machineContextError && !machineContextLoading && machineContext && !machineContext.assignedUserId ? (
|
||||
<div className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 shadow-sm">
|
||||
<p className="font-semibold">Debug: m<EFBFBD>quina autenticada sem colaborador vinculado.</p>
|
||||
<p className="mt-1 text-xs text-amber-700">
|
||||
Copie os dados abaixo e compartilhe com o suporte para investigar.
|
||||
</p>
|
||||
<pre className="mt-2 max-h-64 overflow-y-auto rounded-lg bg-white/70 px-3 py-2 text-[11px] leading-tight text-amber-900">
|
||||
{JSON.stringify(machineContext, null, 2)}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const payload = {
|
||||
machineContext,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
try {
|
||||
navigator.clipboard
|
||||
.writeText(JSON.stringify(payload, null, 2))
|
||||
.catch(() => {
|
||||
console.warn("N<>o foi poss<73>vel copiar automaticamente. Selecione o texto manualmente.", payload)
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Clipboard n<>o suportado. Selecione manualmente.", error)
|
||||
}
|
||||
}}
|
||||
className="mt-2 inline-flex items-center rounded-md border border-amber-300 px-3 py-1 text-xs font-semibold text-amber-800 transition hover:bg-amber-100"
|
||||
>
|
||||
Copiar detalhes
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</main>
|
||||
<footer className="border-t border-slate-200 bg-white/70">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery, useMutation } from "convex/react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useAction, useMutation, useQuery } from "convex/react"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { MessageCircle } from "lucide-react"
|
||||
import { Download, FileIcon, MessageCircle, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
|
@ -14,19 +14,15 @@ import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
|||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dropzone } from "@/components/ui/dropzone"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Dropzone } from "@/components/ui/dropzone"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
// removed wrong import; RichTextEditor comes from rich-text-editor
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { sanitizeEditorHtml, RichTextEditor } from "@/components/ui/rich-text-editor"
|
||||
|
||||
const statusLabel: Record<TicketWithDetails["status"], string> = {
|
||||
PENDING: "Pendente",
|
||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
}
|
||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
|
||||
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
|
||||
LOW: "Baixa",
|
||||
|
|
@ -42,16 +38,6 @@ const priorityTone: Record<TicketWithDetails["priority"], string> = {
|
|||
URGENT: "bg-rose-100 text-rose-700",
|
||||
}
|
||||
|
||||
const timelineLabels: Record<string, string> = {
|
||||
CREATED: "Chamado criado",
|
||||
STATUS_CHANGED: "Status atualizado",
|
||||
ASSIGNEE_CHANGED: "Responsável alterado",
|
||||
COMMENT_ADDED: "Novo comentário",
|
||||
COMMENT_EDITED: "Comentário editado",
|
||||
ATTACHMENT_REMOVED: "Anexo removido",
|
||||
QUEUE_CHANGED: "Fila atualizada",
|
||||
}
|
||||
|
||||
function toHtmlFromText(text: string) {
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
|
|
@ -66,10 +52,21 @@ interface PortalTicketDetailProps {
|
|||
ticketId: string
|
||||
}
|
||||
|
||||
type ClientTimelineEntry = {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
when: Date
|
||||
}
|
||||
|
||||
export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||
const { convexUserId, session, isCustomer } = useAuth()
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
const [comment, setComment] = useState(""); const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
|
||||
const getFileUrl = useAction(api.files.getUrl)
|
||||
const [comment, setComment] = useState("")
|
||||
const [attachments, setAttachments] = useState<
|
||||
Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>
|
||||
>([])
|
||||
|
||||
const ticketRaw = useQuery(
|
||||
api.tickets.getById,
|
||||
|
|
@ -87,6 +84,112 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
return mapTicketWithDetailsFromServer(ticketRaw)
|
||||
}, [ticketRaw])
|
||||
|
||||
const clientTimeline = useMemo(() => {
|
||||
if (!ticket) return []
|
||||
return ticket.timeline
|
||||
.map<ClientTimelineEntry | null>((event) => {
|
||||
const payload = (event.payload ?? {}) as Record<string, unknown>
|
||||
const actorName = typeof payload.actorName === "string" && payload.actorName.trim().length > 0 ? String(payload.actorName).trim() : null
|
||||
|
||||
if (event.type === "CREATED") {
|
||||
const requesterName = typeof payload.requesterName === "string" && payload.requesterName.trim().length > 0
|
||||
? String(payload.requesterName).trim()
|
||||
: null
|
||||
return {
|
||||
id: event.id,
|
||||
title: "Chamado criado",
|
||||
description: requesterName ? `Aberto por ${requesterName}` : "Chamado registrado",
|
||||
when: event.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "QUEUE_CHANGED") {
|
||||
const queueNameRaw =
|
||||
(typeof payload.queueName === "string" && payload.queueName.trim()) ||
|
||||
(typeof payload.toLabel === "string" && payload.toLabel.trim()) ||
|
||||
(typeof payload.to === "string" && payload.to.trim()) ||
|
||||
null
|
||||
if (!queueNameRaw) return null
|
||||
const queueName = queueNameRaw.trim()
|
||||
const description = actorName ? `Fila ${queueName} • por ${actorName}` : `Fila ${queueName}`
|
||||
return {
|
||||
id: event.id,
|
||||
title: "Fila atualizada",
|
||||
description,
|
||||
when: event.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "ASSIGNEE_CHANGED") {
|
||||
const assigneeName = typeof payload.assigneeName === "string" && payload.assigneeName.trim().length > 0 ? String(payload.assigneeName).trim() : null
|
||||
const title = assigneeName ? "Responsável atribuído" : "Responsável atualizado"
|
||||
const description = assigneeName ? `Agora com ${assigneeName}` : "Chamado sem responsável no momento"
|
||||
return {
|
||||
id: event.id,
|
||||
title,
|
||||
description,
|
||||
when: event.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "CATEGORY_CHANGED") {
|
||||
const categoryName = typeof payload.categoryName === "string" ? payload.categoryName.trim() : ""
|
||||
const subcategoryName = typeof payload.subcategoryName === "string" ? payload.subcategoryName.trim() : ""
|
||||
const hasCategory = categoryName.length > 0
|
||||
const hasSubcategory = subcategoryName.length > 0
|
||||
const description = hasCategory
|
||||
? hasSubcategory
|
||||
? `${categoryName} • ${subcategoryName}`
|
||||
: categoryName
|
||||
: "Categoria removida"
|
||||
return {
|
||||
id: event.id,
|
||||
title: "Categoria atualizada",
|
||||
description,
|
||||
when: event.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "COMMENT_ADDED") {
|
||||
const matchingComment = ticket.comments.find((comment) => comment.createdAt.getTime() === event.createdAt.getTime())
|
||||
if (!matchingComment) {
|
||||
return null
|
||||
}
|
||||
const rawBody = matchingComment.body ?? ""
|
||||
const plainBody = rawBody.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()
|
||||
const summary = plainBody.length > 0 ? (plainBody.length > 140 ? `${plainBody.slice(0, 140)}…` : plainBody) : null
|
||||
const author = matchingComment.author.name || actorName || "Equipe"
|
||||
const description = summary ?? `Comentário registrado por ${author}`
|
||||
return {
|
||||
id: event.id,
|
||||
title: "Novo comentário",
|
||||
description,
|
||||
when: event.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "STATUS_CHANGED") {
|
||||
const toLabel = typeof payload.toLabel === "string" && payload.toLabel.trim().length > 0 ? String(payload.toLabel).trim() : null
|
||||
const toRaw = typeof payload.to === "string" && payload.to.trim().length > 0 ? String(payload.to).trim() : null
|
||||
const normalized = (toLabel ?? toRaw ?? "").toUpperCase()
|
||||
if (!normalized) return null
|
||||
const isFinal = normalized === "RESOLVED" || normalized === "RESOLVIDO" || normalized === "CLOSED" || normalized === "FINALIZADO" || normalized === "FINALIZED"
|
||||
if (!isFinal) return null
|
||||
const description = `Status alterado para ${toLabel ?? toRaw ?? "Resolvido"}`
|
||||
return {
|
||||
id: event.id,
|
||||
title: normalized === "RESOLVED" || normalized === "RESOLVIDO" ? "Chamado resolvido" : "Chamado finalizado",
|
||||
description,
|
||||
when: event.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
.filter((entry): entry is ClientTimelineEntry => entry !== null)
|
||||
.sort((a, b) => b.when.getTime() - a.when.getTime())
|
||||
}, [ticket])
|
||||
|
||||
if (ticketRaw === undefined) {
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
|
|
@ -120,7 +223,6 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
|
||||
const createdAt = format(ticket.createdAt, "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })
|
||||
const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!convexUserId || !comment.trim() || !ticket) return
|
||||
|
|
@ -133,9 +235,24 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
authorId: convexUserId as Id<"users">,
|
||||
visibility: "PUBLIC",
|
||||
body: htmlBody,
|
||||
attachments: attachments.map((f) => ({ storageId: f.storageId as Id<"_storage">, name: f.name, size: f.size, type: f.type, })),
|
||||
attachments: attachments.map((f) => ({
|
||||
storageId: f.storageId as Id<"_storage">,
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type,
|
||||
})),
|
||||
})
|
||||
setComment("")
|
||||
attachments.forEach((file) => {
|
||||
if (file.previewUrl?.startsWith("blob:")) {
|
||||
try {
|
||||
URL.revokeObjectURL(file.previewUrl)
|
||||
} catch {
|
||||
// ignore revoke issues
|
||||
}
|
||||
}
|
||||
})
|
||||
setAttachments([])
|
||||
toast.success("Comentário enviado!", { id: toastId })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
|
@ -156,9 +273,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 text-sm">
|
||||
<Badge className="rounded-full bg-neutral-900 px-3 py-1 text-xs font-semibold uppercase text-white">
|
||||
{statusLabel[ticket.status]}
|
||||
</Badge>
|
||||
<TicketStatusBadge status={ticket.status} className="px-3 py-1 text-xs font-semibold uppercase" />
|
||||
{!isCustomer ? (
|
||||
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold uppercase ${priorityTone[ticket.priority]}`}>
|
||||
{priorityLabel[ticket.priority]}
|
||||
|
|
@ -169,7 +284,8 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
</CardHeader>
|
||||
<CardContent className="grid gap-4 border-t border-slate-100 px-5 py-5 text-sm text-neutral-700 sm:grid-cols-2">
|
||||
{isCustomer ? null : <DetailItem label="Fila" value={ticket.queue ?? "Sem fila"} />}
|
||||
<DetailItem label="Categoria" value={ticket.category?.name ?? "Não classificada"} />`n <DetailItem label="Subcategoria" value={ticket.subcategory?.name ?? "—"} />
|
||||
<DetailItem label="Categoria" value={ticket.category?.name ?? "Não classificada"} />
|
||||
<DetailItem label="Subcategoria" value={ticket.subcategory?.name ?? "—"} />
|
||||
<DetailItem label="Solicitante" value={ticket.requester.name} subtitle={ticket.requester.email} />
|
||||
{ticket.assignee ? (
|
||||
<DetailItem label="Responsável" value={ticket.assignee.name} />
|
||||
|
|
@ -187,7 +303,8 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 px-5 pb-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="comment" className="text-sm font-medium text-neutral-800">
|
||||
Enviar uma mensagem para a equipe
|
||||
</label>
|
||||
|
|
@ -197,12 +314,64 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
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
|
||||
onUploaded={(files) => setAttachments((prev) => [...prev, ...files])}
|
||||
className="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-3 py-4 text-sm text-neutral-600 shadow-inner"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500">Máximo 10MB • Até 5 arquivos</p>
|
||||
{attachments.length > 0 ? (
|
||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||
{attachments.map((attachment, index) => {
|
||||
const isImage =
|
||||
(attachment.type ?? "").startsWith("image/") ||
|
||||
/\.(png|jpe?g|gif|webp|svg)$/i.test(attachment.name)
|
||||
return (
|
||||
<div
|
||||
key={`${attachment.storageId}-${index}`}
|
||||
className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5"
|
||||
>
|
||||
{isImage && attachment.previewUrl ? (
|
||||
<div className="block w-full overflow-hidden rounded-md">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={attachment.previewUrl}
|
||||
alt={attachment.name}
|
||||
className="h-24 w-full rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-600">
|
||||
<FileIcon className="size-4" />
|
||||
<span className="line-clamp-2 px-2 text-center">{attachment.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setAttachments((prev) => {
|
||||
const next = [...prev]
|
||||
const removed = next.splice(index, 1)[0]
|
||||
if (removed?.previewUrl?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(removed.previewUrl)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
|
||||
aria-label={`Remover ${attachment.name}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">
|
||||
{attachment.name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-xs text-neutral-500">Máximo 5MB • Até 5 arquivos</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90">
|
||||
|
|
@ -248,14 +417,22 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
<span className="text-xs text-neutral-500">{createdAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="rounded-full border-dashed px-3 py-1 text-[11px] uppercase text-neutral-600">
|
||||
{commentItem.visibility === "PUBLIC" ? "Público" : "Interno"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm mt-3 max-w-none text-neutral-800"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(commentItem.body ?? "") }}
|
||||
/>
|
||||
{commentItem.attachments && commentItem.attachments.length > 0 ? (
|
||||
<div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
|
||||
{commentItem.attachments.map((attachment) => (
|
||||
<PortalCommentAttachmentCard
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
getFileUrl={getFileUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
@ -269,18 +446,17 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
<CardTitle className="text-lg font-semibold text-neutral-900">Linha do tempo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 px-5 pb-6 text-sm text-neutral-700">
|
||||
{ticket.timeline.length === 0 ? (
|
||||
{clientTimeline.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">Nenhum evento registrado ainda.</p>
|
||||
) : (
|
||||
ticket.timeline
|
||||
.slice()
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
.map((event) => {
|
||||
const label = timelineLabels[event.type] ?? event.type
|
||||
const when = formatDistanceToNow(event.createdAt, { addSuffix: true, locale: ptBR })
|
||||
clientTimeline.map((event) => {
|
||||
const when = formatDistanceToNow(event.when, { addSuffix: true, locale: ptBR })
|
||||
return (
|
||||
<div key={event.id} className="flex flex-col gap-1 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
|
||||
<span className="text-sm font-semibold text-neutral-900">{label}</span>
|
||||
<span className="text-sm font-semibold text-neutral-900">{event.title}</span>
|
||||
{event.description ? (
|
||||
<span className="text-xs text-neutral-600">{event.description}</span>
|
||||
) : null}
|
||||
<span className="text-xs text-neutral-500">{when}</span>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export function NewTicketDialog() {
|
|||
size: a.size,
|
||||
type: a.type,
|
||||
}))
|
||||
await addComment({ ticketId: id as Id<"tickets">, authorId: convexUserId as Id<"users">, visibility: "PUBLIC", body: bodyHtml, attachments: typedAttachments })
|
||||
await addComment({ ticketId: id as Id<"tickets">, authorId: convexUserId as Id<"users">, visibility: "INTERNAL", body: bodyHtml, attachments: typedAttachments })
|
||||
}
|
||||
toast.success("Ticket criado!", { id: "new-ticket" })
|
||||
setOpen(false)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
|
||||
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
|
||||
import { Download, FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
|
||||
import { useAction, useMutation, useQuery } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
@ -43,7 +43,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
|
||||
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
|
||||
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("INTERNAL")
|
||||
const [attachmentToRemove, setAttachmentToRemove] = useState<{ commentId: string; attachmentId: string; name: string } | null>(null)
|
||||
const [removingAttachment, setRemovingAttachment] = useState(false)
|
||||
const [editingComment, setEditingComment] = useState<{ id: string; value: string } | null>(null)
|
||||
|
|
@ -228,16 +228,17 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const canEdit = Boolean(convexUserId && String(comment.author.id) === convexUserId && !isPending)
|
||||
const hasBody = bodyPlain.length > 0 || isEditing
|
||||
const isInternal = comment.visibility === "INTERNAL" && canSeeInternalComments
|
||||
const containerClass = isInternal
|
||||
const isPublic = comment.visibility === "PUBLIC"
|
||||
const containerClass = isPublic
|
||||
? "group/comment flex gap-3 rounded-2xl border border-amber-200/80 bg-amber-50/80 px-3 py-3 shadow-[0_0_0_1px_rgba(217,119,6,0.15)]"
|
||||
: "group/comment flex gap-3"
|
||||
const bodyClass = isInternal
|
||||
: "group/comment flex gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3"
|
||||
const bodyClass = isPublic
|
||||
? "relative break-words rounded-xl border border-amber-200/80 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]"
|
||||
: "relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700"
|
||||
const bodyEditButtonClass = isInternal
|
||||
const bodyEditButtonClass = isPublic
|
||||
? "absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-700 opacity-0 transition hover:border-amber-500 hover:text-amber-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30 group-hover/comment:opacity-100"
|
||||
: "absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
|
||||
const addContentButtonClass = isInternal
|
||||
const addContentButtonClass = isPublic
|
||||
? "inline-flex items-center gap-2 text-sm font-medium text-amber-700 transition hover:text-amber-900"
|
||||
: "inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"
|
||||
|
||||
|
|
@ -263,6 +264,10 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
<span className="text-xs font-semibold tracking-wide text-amber-700/80">
|
||||
Comentário interno — visível apenas para administradores e agentes
|
||||
</span>
|
||||
) : comment.visibility === "PUBLIC" ? (
|
||||
<span className="text-xs font-semibold tracking-wide text-amber-700/80">
|
||||
Comentário visível para o cliente
|
||||
</span>
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<div
|
||||
|
|
@ -596,10 +601,20 @@ function CommentAttachmentCard({
|
|||
|
||||
const handleDownload = useCallback(async () => {
|
||||
const target = url ?? (await ensureUrl())
|
||||
if (target) {
|
||||
if (!target) return
|
||||
try {
|
||||
const link = document.createElement("a")
|
||||
link.href = target
|
||||
link.download = attachment.name ?? "anexo"
|
||||
link.rel = "noopener noreferrer"
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} catch (error) {
|
||||
console.error("Failed to download attachment", error)
|
||||
window.open(target, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
}, [ensureUrl, url])
|
||||
}, [attachment.name, ensureUrl, url])
|
||||
|
||||
const name = attachment.name ?? ""
|
||||
const urlLooksImage = url ? /\.(png|jpe?g|gif|webp|svg)$/i.test(url) : false
|
||||
|
|
@ -642,6 +657,14 @@ function CommentAttachmentCard({
|
|||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
aria-label={`Baixar ${name}`}
|
||||
className="absolute left-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 focus-visible:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRequestRemoval}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ type Uploaded = { storageId: string; name: string; size?: number; type?: string;
|
|||
export function Dropzone({
|
||||
onUploaded,
|
||||
maxFiles = 5,
|
||||
maxSize = 10 * 1024 * 1024,
|
||||
maxSize = 5 * 1024 * 1024,
|
||||
multiple = true,
|
||||
className,
|
||||
}: {
|
||||
|
|
@ -32,7 +32,9 @@ export function Dropzone({
|
|||
const list = Array.from(files).slice(0, maxFiles);
|
||||
const uploaded: Uploaded[] = [];
|
||||
for (const file of list) {
|
||||
if (file.size > maxSize) continue;
|
||||
if (file.size > maxSize) {
|
||||
continue;
|
||||
}
|
||||
const url = await generateUrl({});
|
||||
const id = `${file.name}-${file.size}-${Date.now()}`;
|
||||
const localPreview = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined;
|
||||
|
|
@ -54,16 +56,28 @@ export function Dropzone({
|
|||
if (res?.storageId) {
|
||||
uploaded.push({ storageId: res.storageId, name: file.name, size: file.size, type: file.type, previewUrl: localPreview });
|
||||
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, progress: 100, status: "done" } : it)));
|
||||
setTimeout(() => {
|
||||
setItems((prev) => prev.filter((it) => it.id !== id));
|
||||
}, 600);
|
||||
} else {
|
||||
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
|
||||
setTimeout(() => {
|
||||
setItems((prev) => prev.filter((it) => it.id !== id));
|
||||
}, 1200);
|
||||
}
|
||||
} catch {
|
||||
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
|
||||
setTimeout(() => {
|
||||
setItems((prev) => prev.filter((it) => it.id !== id));
|
||||
}, 1200);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
|
||||
setTimeout(() => {
|
||||
setItems((prev) => prev.filter((it) => it.id !== id));
|
||||
}, 1200);
|
||||
resolve();
|
||||
};
|
||||
xhr.send(file);
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
} catch {
|
||||
payload = null
|
||||
}
|
||||
const fallbackMessage = "Falha ao carregar o contexto da m<EFBFBD>quina."
|
||||
const fallbackMessage = "Falha ao carregar o contexto da maquina."
|
||||
const message =
|
||||
(payload && typeof payload.error === "string" && payload.error.trim()) || fallbackMessage
|
||||
if (!cancelled) {
|
||||
|
|
@ -193,7 +193,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
setMachineContext(null)
|
||||
setMachineContextError({
|
||||
status: 0,
|
||||
message: "Erro ao carregar o contexto da m<EFBFBD>quina.",
|
||||
message: "Erro ao carregar o contexto da maquina.",
|
||||
details: error instanceof Error ? { message: error.message } : null,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,3 +62,72 @@ export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
|
|||
authEmail: machineEmail,
|
||||
}
|
||||
}
|
||||
|
||||
type EnsureCollaboratorAccountParams = {
|
||||
email: string
|
||||
name: string
|
||||
tenantId: string
|
||||
companyId?: string | null
|
||||
}
|
||||
|
||||
export async function ensureCollaboratorAccount(params: EnsureCollaboratorAccountParams) {
|
||||
const normalizedEmail = params.email.trim().toLowerCase()
|
||||
const name = params.name.trim() || normalizedEmail
|
||||
const tenantId = params.tenantId
|
||||
|
||||
const existingAuth = await prisma.authUser.findUnique({ where: { email: normalizedEmail } })
|
||||
const authUser = existingAuth
|
||||
? await prisma.authUser.update({
|
||||
where: { id: existingAuth.id },
|
||||
data: {
|
||||
name,
|
||||
tenantId,
|
||||
role: "collaborator",
|
||||
},
|
||||
})
|
||||
: await prisma.authUser.create({
|
||||
data: {
|
||||
email: normalizedEmail,
|
||||
name,
|
||||
tenantId,
|
||||
role: "collaborator",
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.authAccount.upsert({
|
||||
where: {
|
||||
providerId_accountId: {
|
||||
providerId: "credential",
|
||||
accountId: normalizedEmail,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
userId: authUser.id,
|
||||
},
|
||||
create: {
|
||||
providerId: "credential",
|
||||
accountId: normalizedEmail,
|
||||
userId: authUser.id,
|
||||
password: null,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { email: normalizedEmail },
|
||||
update: {
|
||||
name,
|
||||
tenantId,
|
||||
role: "COLLABORATOR",
|
||||
companyId: params.companyId ?? undefined,
|
||||
},
|
||||
create: {
|
||||
email: normalizedEmail,
|
||||
name,
|
||||
tenantId,
|
||||
role: "COLLABORATOR",
|
||||
companyId: params.companyId ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
return { authUserId: authUser.id }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue