feat(devices): adiciona modais de confirmacao e deteccao em tempo real
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 10s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m31s
Quality Checks / Lint, Test and Build (push) Successful in 4m46s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 1m39s

- Adiciona modais de confirmacao para resetar e desativar dispositivos
- Cria query getMachineState no Convex para monitoramento em tempo real
- Implementa MachineStateMonitor no desktop para detectar mudancas
- Desktop redireciona para tela de registro apos reset
- Desktop mostra tela de desativacao imediatamente apos bloqueio

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rever-tecnologia 2025-12-17 17:13:37 -03:00
parent cd3305f1e3
commit 0bfe4edc6c
4 changed files with 263 additions and 2 deletions

View file

@ -0,0 +1,78 @@
/**
* MachineStateMonitor - Componente para monitorar o estado da máquina em tempo real
*
* Este componente usa uma subscription Convex para detectar mudanças no estado da máquina:
* - Quando isActive muda para false: máquina foi desativada
* - Quando hasValidToken muda para false: máquina foi resetada (tokens revogados)
*
* O componente não renderiza nada, apenas monitora e chama callbacks quando detecta mudanças.
*/
import { useEffect, useRef } from "react"
import { useQuery, ConvexProvider } from "convex/react"
import type { ConvexReactClient } from "convex/react"
import { api } from "../convex/_generated/api"
import type { Id } from "../convex/_generated/dataModel"
type MachineStateMonitorProps = {
machineId: string
onDeactivated?: () => void
onTokenRevoked?: () => void
}
function MachineStateMonitorInner({ machineId, onDeactivated, onTokenRevoked }: MachineStateMonitorProps) {
const machineState = useQuery(api.machines.getMachineState, {
machineId: machineId as Id<"machines">,
})
// Refs para rastrear o estado anterior e evitar chamadas duplicadas
const previousIsActive = useRef<boolean | null>(null)
const previousHasValidToken = useRef<boolean | null>(null)
const initialLoadDone = useRef(false)
useEffect(() => {
if (!machineState) return
// Na primeira carga, apenas armazena os valores iniciais
if (!initialLoadDone.current) {
previousIsActive.current = machineState.isActive
previousHasValidToken.current = machineState.hasValidToken
initialLoadDone.current = true
return
}
// Detecta mudança de ativo para inativo
if (previousIsActive.current === true && machineState.isActive === false) {
console.log("[MachineStateMonitor] Máquina foi desativada")
onDeactivated?.()
}
// Detecta mudança de token válido para inválido
if (previousHasValidToken.current === true && machineState.hasValidToken === false) {
console.log("[MachineStateMonitor] Token foi revogado (reset)")
onTokenRevoked?.()
}
// Atualiza refs
previousIsActive.current = machineState.isActive
previousHasValidToken.current = machineState.hasValidToken
}, [machineState, onDeactivated, onTokenRevoked])
// Este componente nao renderiza nada
return null
}
type MachineStateMonitorWithClientProps = MachineStateMonitorProps & {
client: ConvexReactClient
}
/**
* Wrapper que recebe o cliente Convex e envolve o monitor com o provider
*/
export function MachineStateMonitor({ client, ...props }: MachineStateMonitorWithClientProps) {
return (
<ConvexProvider client={client}>
<MachineStateMonitorInner {...props} />
</ConvexProvider>
)
}

View file

@ -6,12 +6,19 @@ import { listen } from "@tauri-apps/api/event"
import { Store } from "@tauri-apps/plugin-store" import { Store } from "@tauri-apps/plugin-store"
import { appLocalDataDir, join } from "@tauri-apps/api/path" import { appLocalDataDir, join } from "@tauri-apps/api/path"
import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react" import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react"
import { ConvexReactClient } from "convex/react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
import { cn } from "./lib/utils" import { cn } from "./lib/utils"
import { ChatApp } from "./chat" import { ChatApp } from "./chat"
import { DeactivationScreen } from "./components/DeactivationScreen" import { DeactivationScreen } from "./components/DeactivationScreen"
import { MachineStateMonitor } from "./components/MachineStateMonitor"
import type { SessionStartedEvent, UnreadUpdateEvent, NewMessageEvent, SessionEndedEvent } from "./chat/types" import type { SessionStartedEvent, UnreadUpdateEvent, NewMessageEvent, SessionEndedEvent } from "./chat/types"
// URL do Convex para subscription em tempo real
const CONVEX_URL = import.meta.env.MODE === "production"
? "https://convex.esdrasrenan.com.br"
: (import.meta.env.VITE_CONVEX_URL ?? "https://convex.esdrasrenan.com.br")
type MachineOs = { type MachineOs = {
name: string name: string
version?: string | null version?: string | null
@ -321,6 +328,9 @@ function App() {
const selfHealPromiseRef = useRef<Promise<boolean> | null>(null) const selfHealPromiseRef = useRef<Promise<boolean> | null>(null)
const lastHealAtRef = useRef(0) const lastHealAtRef = useRef(0)
// Cliente Convex para monitoramento em tempo real do estado da maquina
const [convexClient, setConvexClient] = useState<ConvexReactClient | null>(null)
const [provisioningCode, setProvisioningCode] = useState("") const [provisioningCode, setProvisioningCode] = useState("")
const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null) const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null)
const [companyName, setCompanyName] = useState("") const [companyName, setCompanyName] = useState("")
@ -693,6 +703,56 @@ useEffect(() => {
rustdeskInfoRef.current = rustdeskInfo rustdeskInfoRef.current = rustdeskInfo
}, [rustdeskInfo]) }, [rustdeskInfo])
// Cria/destrói cliente Convex quando o token muda
useEffect(() => {
if (!token) {
if (convexClient) {
convexClient.close()
setConvexClient(null)
}
return
}
// Cria novo cliente Convex para monitoramento em tempo real
const client = new ConvexReactClient(CONVEX_URL, {
unsavedChangesWarning: false,
})
setConvexClient(client)
return () => {
client.close()
}
}, [token]) // eslint-disable-line react-hooks/exhaustive-deps
// Callbacks para quando a máquina for desativada ou resetada
const handleMachineDeactivated = useCallback(() => {
console.log("[App] Máquina foi desativada - mostrando tela de bloqueio")
setIsMachineActive(false)
}, [])
const handleTokenRevoked = useCallback(async () => {
console.log("[App] Token foi revogado - voltando para tela de registro")
if (store) {
try {
await store.delete("token")
await store.delete("config")
await store.save()
} catch (err) {
console.error("Falha ao limpar store", err)
}
}
tokenVerifiedRef.current = false
autoLaunchRef.current = false
setToken(null)
setConfig(null)
setStatus(null)
setIsMachineActive(true)
setError("Este dispositivo foi resetado. Informe o código de provisionamento para reconectar.")
try {
const p = await invoke<MachineProfile>("collect_machine_profile")
setProfile(p)
} catch {}
}, [store])
useEffect(() => { useEffect(() => {
if (!store || !config) return if (!store || !config) return
@ -1514,6 +1574,15 @@ const resolvedAppUrl = useMemo(() => {
return ( return (
<div className="min-h-screen grid place-items-center bg-slate-50 p-6"> <div className="min-h-screen grid place-items-center bg-slate-50 p-6">
{/* Monitor de estado da maquina em tempo real via Convex */}
{token && config?.machineId && convexClient && (
<MachineStateMonitor
client={convexClient}
machineId={config.machineId}
onDeactivated={handleMachineDeactivated}
onTokenRevoked={handleTokenRevoked}
/>
)}
{token && !isMachineActive ? ( {token && !isMachineActive ? (
<DeactivationScreen companyName={companyName} /> <DeactivationScreen companyName={companyName} />
) : ( ) : (

View file

@ -2317,6 +2317,44 @@ export const resetAgent = mutation({
}, },
}) })
/**
* Query para o desktop monitorar o estado da máquina em tempo real.
* O desktop faz subscribe nessa query e reage imediatamente quando:
* - isActive muda para false (desativação)
* - hasValidToken muda para false (reset/revogação de tokens)
*/
export const getMachineState = query({
args: {
machineId: v.id("machines"),
},
handler: async (ctx, { machineId }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
return { found: false, isActive: false, hasValidToken: false, status: "unknown" as const }
}
// Verifica se existe algum token válido (não revogado e não expirado)
const now = Date.now()
const tokens = await ctx.db
.query("machineTokens")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.take(10)
const hasValidToken = tokens.some((token) => {
if (token.revoked) return false
if (token.expiresAt && token.expiresAt < now) return false
return true
})
return {
found: true,
isActive: machine.isActive ?? true,
hasValidToken,
status: machine.status ?? "unknown",
}
},
})
type RemoteAccessEntry = { type RemoteAccessEntry = {
id: string id: string
provider: string provider: string

View file

@ -3275,6 +3275,8 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
) )
const [togglingActive, setTogglingActive] = useState(false) const [togglingActive, setTogglingActive] = useState(false)
const [isResettingAgent, setIsResettingAgent] = useState(false) const [isResettingAgent, setIsResettingAgent] = useState(false)
const [resetConfirmOpen, setResetConfirmOpen] = useState(false)
const [deactivateConfirmOpen, setDeactivateConfirmOpen] = useState(false)
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false) const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
const [isUsbModalOpen, setIsUsbModalOpen] = useState(false) const [isUsbModalOpen, setIsUsbModalOpen] = useState(false)
const jsonText = useMemo(() => { const jsonText = useMemo(() => {
@ -4001,7 +4003,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
size="sm" size="sm"
variant="outline" variant="outline"
className="gap-2 border-dashed" className="gap-2 border-dashed"
onClick={handleResetAgent} onClick={() => setResetConfirmOpen(true)}
disabled={isResettingAgent} disabled={isResettingAgent}
> >
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} /> <RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
@ -4014,7 +4016,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
"gap-2 border-dashed", "gap-2 border-dashed",
!isActiveLocal && "bg-emerald-600 text-white hover:bg-emerald-600/90" !isActiveLocal && "bg-emerald-600 text-white hover:bg-emerald-600/90"
)} )}
onClick={handleToggleActive} onClick={() => isActiveLocal ? setDeactivateConfirmOpen(true) : handleToggleActive()}
disabled={togglingActive} disabled={togglingActive}
> >
{isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />} {isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
@ -5970,6 +5972,80 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Modal de confirmação - Resetar */}
<Dialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-amber-700">
<RefreshCcw className="size-5" />
Resetar dispositivo
</DialogTitle>
<DialogDescription>
Esta ação revogará todos os tokens de acesso do dispositivo.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p className="text-sm text-amber-700">
O dispositivo <span className="font-semibold text-amber-800">{device?.displayName ?? device?.hostname}</span> será desconectado imediatamente e precisará ser reprovisionado para voltar a funcionar.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setResetConfirmOpen(false)} disabled={isResettingAgent}>
Cancelar
</Button>
<Button
variant="destructive"
disabled={isResettingAgent}
className="gap-2"
onClick={async () => {
setResetConfirmOpen(false)
await handleResetAgent()
}}
>
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
{isResettingAgent ? "Resetando..." : "Resetar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal de confirmação - Desativar */}
<Dialog open={deactivateConfirmOpen} onOpenChange={setDeactivateConfirmOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-rose-700">
<Power className="size-5" />
Desativar dispositivo
</DialogTitle>
<DialogDescription>
O dispositivo será bloqueado e não poderá mais acessar o sistema.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-rose-200 bg-rose-50 p-4">
<p className="text-sm text-rose-700">
O dispositivo <span className="font-semibold text-rose-800">{device?.displayName ?? device?.hostname}</span> será bloqueado imediatamente. O usuário verá uma tela de desativação e não poderá mais abrir chamados ou usar o chat.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeactivateConfirmOpen(false)} disabled={togglingActive}>
Cancelar
</Button>
<Button
variant="destructive"
disabled={togglingActive}
className="gap-2"
onClick={async () => {
setDeactivateConfirmOpen(false)
await handleToggleActive()
}}
>
<Power className="size-4" />
{togglingActive ? "Desativando..." : "Desativar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
)} )}
</CardContent> </CardContent>