feat(devices): adiciona modais de confirmacao e deteccao em tempo real
All checks were successful
All checks were successful
- 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:
parent
cd3305f1e3
commit
0bfe4edc6c
4 changed files with 263 additions and 2 deletions
78
apps/desktop/src/components/MachineStateMonitor.tsx
Normal file
78
apps/desktop/src/components/MachineStateMonitor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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} />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue