diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 7d17b28..c7dd08b 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -7,6 +7,7 @@ import { appLocalDataDir, executableDir, join } from "@tauri-apps/api/path" import { ExternalLink, Eye, EyeOff, GalleryVerticalEnd, Loader2, RefreshCw } from "lucide-react" import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs" import { cn } from "./lib/utils" +import { DeactivationScreen } from "./components/DeactivationScreen" type MachineOs = { name: string @@ -137,14 +138,54 @@ function bytes(n?: number) { function pct(p?: number) { return !p && p !== 0 ? "—" : `${p.toFixed(0)}%` } +type MachineStatePayload = { + isActive?: boolean | null + metadata?: Record | null +} + +function extractActiveFromMetadata(metadata: unknown): boolean { + if (!metadata || typeof metadata !== "object") return true + const record = metadata as Record + const direct = record["isActive"] + if (typeof direct === "boolean") return direct + const state = record["state"] + if (state && typeof state === "object") { + const nested = state as Record + const active = nested["isActive"] ?? nested["active"] ?? nested["enabled"] + if (typeof active === "boolean") return active + } + const flags = record["flags"] + if (flags && typeof flags === "object") { + const nested = flags as Record + const active = nested["isActive"] ?? nested["active"] + if (typeof active === "boolean") return active + } + const status = record["status"] + if (typeof status === "string") { + const normalized = status.trim().toLowerCase() + if (["deactivated", "desativada", "desativado", "inactive", "inativo", "disabled"].includes(normalized)) { + return false + } + } + return true +} + +function resolveMachineActive(machine?: MachineStatePayload | null): boolean { + if (!machine) return true + if (typeof machine.isActive === "boolean") return machine.isActive + return extractActiveFromMetadata(machine.metadata) +} + function App() { const [store, setStore] = useState(null) const [token, setToken] = useState(null) const [config, setConfig] = useState(null) const [profile, setProfile] = useState(null) + const [logoSrc, setLogoSrc] = useState(() => `${appUrl}/logo-raven.png`) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const [status, setStatus] = useState(null) + const [isMachineActive, setIsMachineActive] = useState(true) const [showSecret, setShowSecret] = useState(false) const [isLaunchingSystem, setIsLaunchingSystem] = useState(false) const [, setIsValidatingToken] = useState(false) @@ -164,6 +205,7 @@ function App() { }) const autoLaunchRef = useRef(false) const autoUpdateRef = useRef(false) + const logoFallbackRef = useRef(false) useEffect(() => { (async () => { @@ -214,6 +256,14 @@ function App() { } catch (err) { console.error("Falha ao iniciar heartbeat em segundo plano", err) } + const payload = await res.clone().json().catch(() => null) + if (payload && typeof payload === "object" && "machine" in payload) { + const machineData = (payload as { machine?: MachineStatePayload }).machine + if (machineData) { + const currentActive = resolveMachineActive(machineData) + setIsMachineActive(currentActive) + } + } return } const text = await res.text() @@ -231,6 +281,7 @@ function App() { setToken(null) setConfig(null) setStatus(null) + setIsMachineActive(true) setError("Este dispositivo precisa ser reprovisionado. Informe o código de provisionamento.") try { const p = await invoke("collect_machine_profile") @@ -493,13 +544,41 @@ function App() { setIsLaunchingSystem(true) try { // Tenta criar a sessão via API (evita dependência de redirecionamento + cookies em 3xx) - const res = await fetch(`${apiBaseUrl}/api/machines/sessions`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ machineToken: token, rememberMe: true }), - }) - if (!res.ok) { + const res = await fetch(`${apiBaseUrl}/api/machines/sessions`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ machineToken: token, rememberMe: true }), + }) + if (res.ok) { + const payload = await res.clone().json().catch(() => null) + if (payload && typeof payload === "object" && "machine" in payload) { + const machineData = (payload as { machine?: MachineStatePayload }).machine + if (machineData) { + const currentActive = resolveMachineActive(machineData) + setIsMachineActive(currentActive) + if (currentActive) { + setError(null) + } + if (!currentActive) { + setError("Esta máquina está desativada. Entre em contato com o suporte da Rever para reativar o acesso.") + setIsLaunchingSystem(false) + return + } + } + } + } else { + if (res.status === 423) { + const payload = await res.clone().json().catch(() => null) + const message = + payload && typeof payload === "object" && typeof (payload as { error?: unknown }).error === "string" + ? ((payload as { error?: string }).error ?? "").trim() + : "" + setIsMachineActive(false) + setIsLaunchingSystem(false) + setError(message.length > 0 ? message : "Esta máquina está desativada. Entre em contato com o suporte da Rever.") + return + } // Se sessão falhar, tenta identificar token inválido/expirado try { const hb = await fetch(`${apiBaseUrl}/api/machines/heartbeat`, { @@ -522,6 +601,7 @@ function App() { setToken(null) setConfig(null) setStatus(null) + setIsMachineActive(true) setError("Sessão expirada. Reprovisione a máquina para continuar.") setIsLaunchingSystem(false) const p = await invoke("collect_machine_profile") @@ -667,7 +747,24 @@ function App() { return (
+ {token && !isMachineActive ? ( + + ) : (
+
+ Logotipo Raven { + if (logoFallbackRef.current) return + logoFallbackRef.current = true + setLogoSrc(`${appUrl}/raven.png`) + }} + /> +
@@ -869,9 +966,7 @@ function App() {
)}
-
- Logotipo Raven -
+ )}
) } diff --git a/src/app/api/machines/sessions/route.ts b/src/app/api/machines/sessions/route.ts index c0a4ba2..207b780 100644 --- a/src/app/api/machines/sessions/route.ts +++ b/src/app/api/machines/sessions/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" import { z } from "zod" -import { createMachineSession } from "@/server/machines-session" +import { createMachineSession, MachineInactiveError } from "@/server/machines-session" import { applyCorsHeaders, createCorsPreflight, jsonWithCors } from "@/server/cors" import { MACHINE_CTX_COOKIE, @@ -20,8 +20,9 @@ export async function OPTIONS(request: Request) { } export async function POST(request: Request) { + const origin = request.headers.get("origin") if (request.method !== "POST") { - return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS) + return jsonWithCors({ error: "Método não permitido" }, 405, origin, CORS_METHODS) } let payload @@ -124,7 +125,15 @@ export async function POST(request: Request) { return response } catch (error) { + if (error instanceof MachineInactiveError) { + return jsonWithCors( + { error: "Máquina desativada. Entre em contato com o suporte da Rever para reativar o acesso." }, + 423, + origin, + CORS_METHODS + ) + } console.error("[machines.sessions] Falha ao criar sessão", error) - return jsonWithCors({ error: "Falha ao autenticar máquina" }, 500, request.headers.get("origin"), CORS_METHODS) + return jsonWithCors({ error: "Falha ao autenticar máquina" }, 500, origin, CORS_METHODS) } } diff --git a/src/app/machines/handshake/route.ts b/src/app/machines/handshake/route.ts index cb779d9..31f4feb 100644 --- a/src/app/machines/handshake/route.ts +++ b/src/app/machines/handshake/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" -import { createMachineSession } from "@/server/machines-session" +import { createMachineSession, MachineInactiveError } from "@/server/machines-session" import { env } from "@/lib/env" const ERROR_TEMPLATE = ` @@ -30,6 +30,36 @@ const ERROR_TEMPLATE = ` ` +const INACTIVE_TEMPLATE = ` + + + + + + Máquina desativada + + + +
+
Acesso bloqueado
+

Esta máquina está desativada

+

O acesso ao portal foi suspenso pelos administradores da Rever. Enquanto isso, você não poderá abrir chamados ou enviar atualizações.

+

Entre em contato com a equipe da Rever para solicitar a reativação desta máquina.

+ Falar com o suporte +
+ + +` + export async function GET(request: NextRequest) { const token = request.nextUrl.searchParams.get("token") if (!token) { @@ -124,6 +154,14 @@ export async function GET(request: NextRequest) { return response } catch (error) { + if (error instanceof MachineInactiveError) { + return new NextResponse(INACTIVE_TEMPLATE, { + status: 423, + headers: { + "Content-Type": "text/html; charset=utf-8", + }, + }) + } console.error("[machines.handshake] Falha ao autenticar máquina", error) return new NextResponse(ERROR_TEMPLATE, { status: 500, diff --git a/src/components/portal/portal-ticket-form.tsx b/src/components/portal/portal-ticket-form.tsx index f99601b..f966e9d 100644 --- a/src/components/portal/portal-ticket-form.tsx +++ b/src/components/portal/portal-ticket-form.tsx @@ -44,10 +44,15 @@ export function PortalTicketForm() { const [categoryId, setCategoryId] = useState(null) const [subcategoryId, setSubcategoryId] = useState(null) const [attachments, setAttachments] = useState>([]) + const attachmentsTotalBytes = useMemo( + () => attachments.reduce((acc, item) => acc + (item.size ?? 0), 0), + [attachments] + ) const [isSubmitting, setIsSubmitting] = useState(false) + const machineInactive = machineContext?.isActive === false const isFormValid = useMemo(() => { - return Boolean(subject.trim() && description.trim() && categoryId && subcategoryId) - }, [subject, description, categoryId, subcategoryId]) + return Boolean(subject.trim() && description.trim() && categoryId && subcategoryId && !machineInactive) + }, [subject, description, categoryId, subcategoryId, machineInactive]) const isViewerReady = Boolean(viewerId) const viewerErrorMessage = useMemo(() => { if (!machineContextError) return null @@ -58,10 +63,14 @@ export function PortalTicketForm() { async function handleSubmit(event: React.FormEvent) { event.preventDefault() if (isSubmitting || !isFormValid) return + if (machineInactive) { + toast.error("Esta máquina está desativada no momento. Reative-a para abrir novos chamados.", { id: "portal-new-ticket" }) + return + } if (!viewerId) { const detail = viewerErrorMessage ? ` Detalhes: ${viewerErrorMessage}` : "" toast.error( - `N�o foi poss�vel identificar o colaborador vinculado a esta m�quina. Tente abrir novamente o portal ou contate o suporte.${detail}`, + `Não foi possível identificar o colaborador vinculado a esta máquina. Tente abrir novamente o portal ou contate o suporte.${detail}`, { id: "portal-new-ticket" } ) return @@ -127,6 +136,11 @@ export function PortalTicketForm() { Abrir novo chamado + {machineInactive ? ( +
+ Esta máquina foi desativada pelos administradores e não pode abrir novos chamados até ser reativada. +
+ ) : null} {!isViewerReady ? (
Vincule esta máquina a um colaborador na aplicação desktop para enviar chamados em nome dele. @@ -151,6 +165,7 @@ export function PortalTicketForm() { value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Ex.: Problema de acesso ao sistema" + disabled={machineInactive || isSubmitting} required />
@@ -163,6 +178,7 @@ export function PortalTicketForm() { value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="Descreva rapidamente o que está acontecendo" + disabled={machineInactive || isSubmitting} />
@@ -174,6 +190,7 @@ export function PortalTicketForm() { onChange={(html) => setDescription(html)} placeholder="Compartilhe passos para reproduzir, mensagens de erro ou informações adicionais." className="rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20" + disabled={machineInactive || isSubmitting} />
@@ -195,6 +212,9 @@ export function PortalTicketForm() { 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" + currentFileCount={attachments.length} + currentTotalBytes={attachmentsTotalBytes} + disabled={!isViewerReady || machineInactive || isSubmitting} />

Formatos comuns de imagens e documentos são aceitos. @@ -208,12 +228,13 @@ export function PortalTicketForm() { variant="outline" onClick={() => router.push("/portal/tickets")} className="rounded-full border-slate-300 px-6 text-sm font-semibold text-neutral-700 hover:bg-neutral-100" + disabled={isSubmitting} > Cancelar