feat: overhaul admin user management and desktop UX
This commit is contained in:
parent
7d6f3bea01
commit
ecad81b0ea
16 changed files with 1546 additions and 395 deletions
|
|
@ -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 só 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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue