feat(portal): adiciona monitoramento em tempo real de desativação de máquina
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Has been cancelled
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
Some checks failed
CI/CD Web + Desktop / Deploy Convex functions (push) Blocked by required conditions
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
Quality Checks / Lint, Test and Build (push) Has been cancelled
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Has been cancelled
- Cria hook useMachineStateMonitor com subscription Convex - Cria MachineDeactivationOverlay para bloquear acesso - Integra no PortalShell para exibir overlay quando máquina é desativada - Adiciona refreshMachineContext ao AuthProvider para retry manual - Funciona tanto via Raven quanto via navegador web 🤖 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
413749d999
commit
ae4fd7f890
4 changed files with 274 additions and 3 deletions
73
src/components/portal/machine-deactivation-overlay.tsx
Normal file
73
src/components/portal/machine-deactivation-overlay.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ShieldAlert, Mail, RefreshCw } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
type MachineDeactivationOverlayProps = {
|
||||||
|
companyName?: string | null
|
||||||
|
onRetry?: () => void
|
||||||
|
isRetrying?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay de bloqueio exibido quando a máquina é desativada.
|
||||||
|
* Cobre toda a tela impedindo qualquer interação até que a máquina seja reativada.
|
||||||
|
*/
|
||||||
|
export function MachineDeactivationOverlay({
|
||||||
|
companyName,
|
||||||
|
onRetry,
|
||||||
|
isRetrying,
|
||||||
|
}: MachineDeactivationOverlayProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] grid place-items-center bg-neutral-950/95 p-6 backdrop-blur-sm">
|
||||||
|
<div className="flex w-full max-w-[520px] flex-col items-center gap-6 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-xl animate-in fade-in zoom-in-95 duration-300">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold text-rose-700">
|
||||||
|
<ShieldAlert className="size-4" /> Acesso bloqueado
|
||||||
|
</span>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Dispositivo desativado</h1>
|
||||||
|
<p className="max-w-md text-sm text-neutral-600">
|
||||||
|
Este dispositivo foi desativado temporariamente pelos administradores. O acesso ao portal
|
||||||
|
e o envio de informações ficam indisponíveis até a reativação.
|
||||||
|
</p>
|
||||||
|
{companyName ? (
|
||||||
|
<span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700">
|
||||||
|
{companyName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
|
||||||
|
<p className="font-medium text-neutral-800">Como regularizar</p>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5 text-neutral-600">
|
||||||
|
<li>Entre em contato com o suporte e solicite a reativação.</li>
|
||||||
|
<li>Informe o nome do computador e seus dados de contato.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
|
||||||
|
<a
|
||||||
|
href="mailto:suporte@rever.com.br"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-black/90"
|
||||||
|
>
|
||||||
|
<Mail className="size-4" /> Falar com o suporte
|
||||||
|
</a>
|
||||||
|
{onRetry ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
className="gap-2 rounded-full"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`size-4 ${isRetrying ? "animate-spin" : ""}`} />
|
||||||
|
{isRetrying ? "Verificando..." : "Verificar novamente"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { type ReactNode, useMemo, useState } from "react"
|
import { type ReactNode, useMemo, useState, useCallback } from "react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
|
|
@ -12,6 +12,8 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useAuth, signOut } from "@/lib/auth-client"
|
import { useAuth, signOut } from "@/lib/auth-client"
|
||||||
|
import { useMachineStateMonitor } from "@/hooks/use-machine-state-monitor"
|
||||||
|
import { MachineDeactivationOverlay } from "./machine-deactivation-overlay"
|
||||||
|
|
||||||
interface PortalShellProps {
|
interface PortalShellProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
|
@ -25,10 +27,50 @@ const navItems = [
|
||||||
export function PortalShell({ children }: PortalShellProps) {
|
export function PortalShell({ children }: PortalShellProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { session, machineContext, machineContextError, machineContextLoading } = useAuth()
|
const { session, machineContext, machineContextError, machineContextLoading, refreshMachineContext } = useAuth()
|
||||||
const [isSigningOut, setIsSigningOut] = useState(false)
|
const [isSigningOut, setIsSigningOut] = useState(false)
|
||||||
|
const [showDeactivationOverlay, setShowDeactivationOverlay] = useState(false)
|
||||||
|
const [isRetryingActivation, setIsRetryingActivation] = useState(false)
|
||||||
|
|
||||||
const isMachineSession = session?.user.role === "machine" || Boolean(machineContext)
|
const isMachineSession = session?.user.role === "machine" || Boolean(machineContext)
|
||||||
|
|
||||||
|
// Monitor de estado da máquina em tempo real via Convex
|
||||||
|
const handleMachineDeactivated = useCallback(() => {
|
||||||
|
console.log("[PortalShell] Máquina foi desativada - exibindo overlay de bloqueio")
|
||||||
|
setShowDeactivationOverlay(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTokenRevoked = useCallback(() => {
|
||||||
|
console.log("[PortalShell] Token foi revogado - redirecionando para login")
|
||||||
|
toast.error("Este dispositivo foi resetado. Faça login novamente.")
|
||||||
|
router.replace("/login")
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const { isActive: machineIsActive } = useMachineStateMonitor({
|
||||||
|
machineId: machineContext?.machineId,
|
||||||
|
onDeactivated: handleMachineDeactivated,
|
||||||
|
onTokenRevoked: handleTokenRevoked,
|
||||||
|
enabled: isMachineSession && !!machineContext?.machineId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verifica também o estado vindo do contexto (polling) como fallback
|
||||||
|
const effectivelyDeactivated = showDeactivationOverlay || machineContext?.isActive === false || !machineIsActive
|
||||||
|
|
||||||
|
// Função para verificar novamente o estado
|
||||||
|
const handleRetryActivation = useCallback(async () => {
|
||||||
|
setIsRetryingActivation(true)
|
||||||
|
try {
|
||||||
|
await refreshMachineContext?.()
|
||||||
|
// Se ainda está ativo após refresh, esconde overlay
|
||||||
|
if (machineContext?.isActive !== false && machineIsActive) {
|
||||||
|
setShowDeactivationOverlay(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao verificar estado:", error)
|
||||||
|
} finally {
|
||||||
|
setIsRetryingActivation(false)
|
||||||
|
}
|
||||||
|
}, [refreshMachineContext, machineContext?.isActive, machineIsActive])
|
||||||
const personaValue = machineContext?.persona ?? session?.user.machinePersona ?? null
|
const personaValue = machineContext?.persona ?? session?.user.machinePersona ?? null
|
||||||
const collaboratorName = machineContext?.assignedUserName?.trim() ?? ""
|
const collaboratorName = machineContext?.assignedUserName?.trim() ?? ""
|
||||||
const collaboratorEmail = machineContext?.assignedUserEmail?.trim() ?? ""
|
const collaboratorEmail = machineContext?.assignedUserEmail?.trim() ?? ""
|
||||||
|
|
@ -74,6 +116,13 @@ export function PortalShell({ children }: PortalShellProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-gradient-to-b from-slate-50 via-slate-50 to-white">
|
<div className="flex min-h-screen flex-col bg-gradient-to-b from-slate-50 via-slate-50 to-white">
|
||||||
|
{/* Overlay de bloqueio quando máquina é desativada */}
|
||||||
|
{isMachineSession && effectivelyDeactivated && (
|
||||||
|
<MachineDeactivationOverlay
|
||||||
|
onRetry={handleRetryActivation}
|
||||||
|
isRetrying={isRetryingActivation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<header className="border-b border-slate-200 bg-white/90 backdrop-blur">
|
<header className="border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-4">
|
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
|
||||||
117
src/hooks/use-machine-state-monitor.ts
Normal file
117
src/hooks/use-machine-state-monitor.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/**
|
||||||
|
* Hook para monitorar o estado da máquina em tempo real via Convex subscription.
|
||||||
|
*
|
||||||
|
* Usado no portal para detectar instantaneamente quando uma máquina é:
|
||||||
|
* - Desativada (isActive = false)
|
||||||
|
* - Resetada (tokens revogados)
|
||||||
|
*
|
||||||
|
* Diferente do polling de 15s do AuthProvider, isso é verdadeiramente real-time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from "react"
|
||||||
|
import { useQuery } from "convex/react"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
|
||||||
|
type UseMachineStateMonitorOptions = {
|
||||||
|
machineId: string | null | undefined
|
||||||
|
onDeactivated?: () => void
|
||||||
|
onTokenRevoked?: () => void
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineStateResult = {
|
||||||
|
isActive: boolean
|
||||||
|
hasValidToken: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
found: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMachineStateMonitor({
|
||||||
|
machineId,
|
||||||
|
onDeactivated,
|
||||||
|
onTokenRevoked,
|
||||||
|
enabled = true,
|
||||||
|
}: UseMachineStateMonitorOptions): MachineStateResult {
|
||||||
|
// Refs para rastrear estado anterior e evitar chamadas duplicadas
|
||||||
|
const previousIsActive = useRef<boolean | null>(null)
|
||||||
|
const previousHasValidToken = useRef<boolean | null>(null)
|
||||||
|
const initialLoadDone = useRef(false)
|
||||||
|
|
||||||
|
// Subscription Convex - só ativa se tiver machineId válido
|
||||||
|
const machineState = useQuery(
|
||||||
|
api.machines.getMachineState,
|
||||||
|
enabled && machineId ? { machineId: machineId as Id<"machines"> } : "skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Callbacks estáveis
|
||||||
|
const handleDeactivated = useCallback(() => {
|
||||||
|
onDeactivated?.()
|
||||||
|
}, [onDeactivated])
|
||||||
|
|
||||||
|
const handleTokenRevoked = useCallback(() => {
|
||||||
|
onTokenRevoked?.()
|
||||||
|
}, [onTokenRevoked])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!machineState) return
|
||||||
|
|
||||||
|
// Na primeira carga, verifica estado inicial E armazena valores
|
||||||
|
if (!initialLoadDone.current) {
|
||||||
|
console.log("[useMachineStateMonitor] Carga inicial", {
|
||||||
|
isActive: machineState.isActive,
|
||||||
|
hasValidToken: machineState.hasValidToken,
|
||||||
|
found: machineState.found,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Se já estiver desativado na carga inicial, chama callback
|
||||||
|
if (machineState.isActive === false) {
|
||||||
|
console.log("[useMachineStateMonitor] Máquina já estava desativada")
|
||||||
|
handleDeactivated()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se token já estiver inválido na carga inicial, chama callback
|
||||||
|
if (machineState.hasValidToken === false) {
|
||||||
|
console.log("[useMachineStateMonitor] Token já estava revogado")
|
||||||
|
handleTokenRevoked()
|
||||||
|
}
|
||||||
|
|
||||||
|
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("[useMachineStateMonitor] Máquina foi desativada")
|
||||||
|
handleDeactivated()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detecta mudança de token válido para inválido
|
||||||
|
if (previousHasValidToken.current === true && machineState.hasValidToken === false) {
|
||||||
|
console.log("[useMachineStateMonitor] Token foi revogado (reset)")
|
||||||
|
handleTokenRevoked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualiza refs
|
||||||
|
previousIsActive.current = machineState.isActive
|
||||||
|
previousHasValidToken.current = machineState.hasValidToken
|
||||||
|
}, [machineState, handleDeactivated, handleTokenRevoked])
|
||||||
|
|
||||||
|
// Reset refs quando machineId muda
|
||||||
|
useEffect(() => {
|
||||||
|
if (!machineId) {
|
||||||
|
previousIsActive.current = null
|
||||||
|
previousHasValidToken.current = null
|
||||||
|
initialLoadDone.current = false
|
||||||
|
}
|
||||||
|
}, [machineId])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive: machineState?.isActive ?? true,
|
||||||
|
hasValidToken: machineState?.hasValidToken ?? true,
|
||||||
|
isLoading: machineState === undefined,
|
||||||
|
found: machineState?.found ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,6 +68,7 @@ type AuthContextValue = {
|
||||||
machineContext: MachineContext | null
|
machineContext: MachineContext | null
|
||||||
machineContextLoading: boolean
|
machineContextLoading: boolean
|
||||||
machineContextError: MachineContextError | null
|
machineContextError: MachineContextError | null
|
||||||
|
refreshMachineContext: (() => Promise<void>) | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue>({
|
const AuthContext = createContext<AuthContextValue>({
|
||||||
|
|
@ -81,6 +82,7 @@ const AuthContext = createContext<AuthContextValue>({
|
||||||
machineContext: null,
|
machineContext: null,
|
||||||
machineContextLoading: false,
|
machineContextLoading: false,
|
||||||
machineContextError: null,
|
machineContextError: null,
|
||||||
|
refreshMachineContext: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
|
|
@ -345,6 +347,35 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
const effectiveConvexUserId = baseRole === "machine" ? (machineContext?.assignedUserId ?? null) : convexUserId
|
const effectiveConvexUserId = baseRole === "machine" ? (machineContext?.assignedUserId ?? null) : convexUserId
|
||||||
|
|
||||||
|
// Função para forçar atualização do contexto de máquina
|
||||||
|
const refreshMachineContext = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/machines/session", { credentials: "include" })
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn("[refreshMachineContext] Falha ao buscar sessão:", response.status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { machine?: MachineContext & { id: string } }
|
||||||
|
const mc = data?.machine
|
||||||
|
if (mc && typeof mc === "object") {
|
||||||
|
setMachineContext({
|
||||||
|
machineId: mc.id,
|
||||||
|
tenantId: mc.tenantId,
|
||||||
|
persona: mc.persona ?? null,
|
||||||
|
assignedUserId: mc.assignedUserId ?? null,
|
||||||
|
assignedUserEmail: mc.assignedUserEmail ?? null,
|
||||||
|
assignedUserName: mc.assignedUserName ?? null,
|
||||||
|
assignedUserRole: mc.assignedUserRole ?? null,
|
||||||
|
companyId: mc.companyId ?? null,
|
||||||
|
isActive: mc.isActive ?? true,
|
||||||
|
})
|
||||||
|
setMachineContextError(null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[refreshMachineContext] Erro:", error)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const value = useMemo<AuthContextValue>(
|
const value = useMemo<AuthContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
session: session ?? null,
|
session: session ?? null,
|
||||||
|
|
@ -357,8 +388,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
machineContext,
|
machineContext,
|
||||||
machineContextLoading,
|
machineContextLoading,
|
||||||
machineContextError,
|
machineContextError,
|
||||||
|
refreshMachineContext,
|
||||||
}),
|
}),
|
||||||
[session, isPending, effectiveConvexUserId, normalizedRole, machineContext, machineContextLoading, machineContextError]
|
[session, isPending, effectiveConvexUserId, normalizedRole, machineContext, machineContextLoading, machineContextError, refreshMachineContext]
|
||||||
)
|
)
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue