diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index e1a8708..4a3b00c 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -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([]) - const [selectedCompany, setSelectedCompany] = useState(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("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 = { 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("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 = { ...profile.inventory } if (collaboratorInventory) { @@ -590,69 +571,69 @@ function App() {
- setProvisioningSecret(e.target.value)} /> -
-
-
- - handleCompanyInputChange(e.target.value)} - /> - {companyLookupPending ? ( -

Buscando empresas...

- ) : companyOptions.length > 0 ? ( -
- {companyOptions.map((option) => ( - - ))} -
- ) : company.trim().length >= 2 ? ( -

Empresa não encontrada — criaremos automaticamente ao registrar.

+ {isValidatingCode ? ( +

Validando código...

+ ) : codeStatus ? ( +

+ {codeStatus.message} +

) : ( -

Pode informar o nome completo que transformamos no slug registrado.

+

+ Informe o código único fornecido pela equipe para vincular esta máquina a uma empresa. +

)}
-
- - -

- Colaboradores veem apenas seus chamados. Gestores acompanham todos os tickets da empresa. -

-
+ {validatedCompany ? ( +
+
{validatedCompany.name}
+
Tenant: {validatedCompany.tenantId}
+
Slug: {validatedCompany.slug}
+
+ ) : null}
- setCollabEmail(e.target.value)} /> + setCollabEmail(e.target.value)} + />
- - setCollabName(e.target.value)} /> -
-
- - setTenantId(e.target.value)} /> + + setCollabName(e.target.value)} + />
{profile ? (
@@ -675,7 +656,7 @@ function App() {
) : null}
- +
) : ( @@ -684,10 +665,19 @@ function App() { Resumo Inventário - Diagnóstico Configurações + {companyName ? ( +
+
{companyName}
+ {config?.collaboratorEmail ? ( +
+ Vinculado a {config.collaboratorEmail} +
+ ) : null} +
+ ) : null}
CPU
@@ -722,12 +712,6 @@ function App() {
- -
-

Token armazenado: {token?.slice(0, 6)}…{token?.slice(-6)}

-

Base URL: {apiBaseUrl}

-
-
+ + + + ) +} diff --git a/src/components/portal/portal-shell.tsx b/src/components/portal/portal-shell.tsx index 0471b2d..076fda0 100644 --- a/src/components/portal/portal-shell.tsx +++ b/src/components/portal/portal-shell.tsx @@ -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...
) : null} - {!machineContextError && !machineContextLoading && machineContext && !machineContext.assignedUserId ? ( -
-

Debug: m�quina autenticada sem colaborador vinculado.

-

- Copie os dados abaixo e compartilhe com o suporte para investigar. -

-
-              {JSON.stringify(machineContext, null, 2)}
-            
- -
- ) : null} {children}