feat: overhaul admin user management and desktop UX

This commit is contained in:
Esdras Renan 2025-10-13 10:36:38 -03:00
parent 7d6f3bea01
commit ecad81b0ea
16 changed files with 1546 additions and 395 deletions

View file

@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { createRoot } from "react-dom/client"
import { invoke } from "@tauri-apps/api/core"
import { Store } from "@tauri-apps/plugin-store"
import { ExternalLink, Eye, EyeOff, GalleryVerticalEnd, RefreshCw } from "lucide-react"
import { ExternalLink, Eye, EyeOff, GalleryVerticalEnd, Loader2, RefreshCw } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
import { cn } from "./lib/utils"
@ -54,10 +54,18 @@ type MachineRegisterResponse = {
} | null
}
type CompanyOption = {
id: string
tenantId: string
name: string
slug: string
}
type AgentConfig = {
machineId: string
tenantId?: string | null
companySlug?: string | null
companyName?: string | null
machineEmail?: string | null
collaboratorEmail?: string | null
collaboratorName?: string | null
@ -83,6 +91,7 @@ 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()
@ -134,6 +143,7 @@ function App() {
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState<string | null>(null)
const [showSecret, setShowSecret] = useState(false)
const [isLaunchingSystem, setIsLaunchingSystem] = useState(false)
const [provisioningSecret, setProvisioningSecret] = useState("")
const [tenantId, setTenantId] = useState("")
@ -141,6 +151,9 @@ function App() {
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.",
@ -161,6 +174,8 @@ function App() {
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 (!t) {
const p = await invoke<MachineProfile>("collect_machine_profile")
setProfile(p)
@ -218,6 +233,71 @@ 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 }
@ -226,11 +306,55 @@ function App() {
setError("Informe o e-mail do colaborador ou gestor para vincular esta máquina.")
return
}
const normalizedName = collabName.trim()
if (!normalizedName) {
setError("Informe o nome completo do colaborador ou gestor.")
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)
}
}
}
const collaboratorPayload = {
email: normalizedEmail,
name: collabName.trim() || undefined,
name: normalizedName,
}
const collaboratorMetadata = collaboratorPayload
? { ...collaboratorPayload, role: accessRole }
@ -244,8 +368,8 @@ function App() {
}
const payload = {
provisioningSecret: provisioningSecret.trim(),
tenantId: tenantId.trim() || undefined,
companySlug: company.trim() || undefined,
tenantId: trimmedTenantId || undefined,
companySlug: ensuredCompany?.slug,
hostname: profile.hostname,
os: profile.os,
macAddresses: profile.macAddresses,
@ -266,7 +390,8 @@ function App() {
const cfg: AgentConfig = {
machineId: data.machineId,
tenantId: data.tenantId ?? null,
companySlug: data.companySlug ?? null,
companySlug: data.companySlug ?? ensuredCompany?.slug ?? null,
companyName: ensuredCompany?.name ?? (trimmedCompanyName || null),
machineEmail: data.machineEmail ?? null,
collaboratorEmail: collaboratorPayload?.email ?? null,
collaboratorName: collaboratorPayload?.name ?? null,
@ -282,6 +407,9 @@ function App() {
}
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 })
setStatus("online")
} catch (err) {
@ -293,6 +421,7 @@ function App() {
const openSystem = useCallback(() => {
if (!token) return
setIsLaunchingSystem(true)
const persona = (config?.accessRole ?? accessRole) === "manager" ? "manager" : "collaborator"
const redirectTarget = persona === "manager" ? "/dashboard" : "/portal"
const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}`
@ -304,6 +433,8 @@ function App() {
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([])
setIsLaunchingSystem(false)
const p = await invoke<MachineProfile>("collect_machine_profile")
setProfile(p)
}
@ -397,9 +528,22 @@ function App() {
if (!token) return
if (autoLaunchRef.current) return
autoLaunchRef.current = true
setIsLaunchingSystem(true)
openSystem()
}, [token, config?.accessRole, openSystem])
if (isLaunchingSystem && token) {
return (
<div className="min-h-screen grid place-items-center bg-slate-50 p-6">
<div className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm">
<Loader2 className="size-6 animate-spin text-neutral-700" />
<p className="text-sm font-medium text-neutral-800">Abrindo plataforma da Rever</p>
<p className="text-xs text-neutral-500">Aguarde um instante.</p>
</div>
</div>
)
}
return (
<div className="min-h-screen grid place-items-center p-6">
<div className="w-full max-w-[720px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
@ -425,8 +569,34 @@ function App() {
</div>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Empresa (slug opcional)</label>
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="ex.: atlas-engenharia" value={company} onChange={(e)=>setCompany(e.target.value)} />
<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)}
/>
{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)}
>
<span className="font-medium text-slate-800">{option.name}</span>
<span className="text-xs text-slate-500">{option.slug}</span>
</button>
))}
</div>
) : company.trim().length >= 2 ? (
<p className="text-xs text-slate-500">Empresa não encontrada criaremos automaticamente ao registrar.</p>
) : (
<p className="text-xs text-slate-500">Pode informar o nome completo que transformamos no slug registrado.</p>
)}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Perfil de acesso</label>
@ -449,7 +619,7 @@ function App() {
<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 (opcional)</label>
<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">