Implement company provisioning codes and session tweaks

This commit is contained in:
Esdras Renan 2025-10-15 20:45:25 -03:00
parent 0fb9bf59b2
commit 2cba553efa
28 changed files with 1407 additions and 534 deletions

View file

@ -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<CompanyOption[]>([])
const [selectedCompany, setSelectedCompany] = useState<CompanyOption | null>(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<MachineProfile>("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<string, unknown> = {
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<MachineProfile>("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<string, unknown> = { ...profile.inventory }
if (collaboratorInventory) {
@ -590,69 +571,69 @@ function App() {
<div className="grid gap-2">
<label className="text-sm font-medium">Código de provisionamento</label>
<div className="relative">
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 pr-9 text-sm" type={showSecret?"text":"password"} value={provisioningSecret} onChange={(e)=>setProvisioningSecret(e.target.value)} />
<button className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-600" onClick={()=>setShowSecret((v)=>!v)} aria-label="Mostrar/ocultar">
{showSecret ? <EyeOff className="size-4"/> : <Eye className="size-4"/>}
<input
className="w-full rounded-lg border border-slate-300 px-3 py-2 pr-9 text-sm"
type={showSecret ? "text" : "password"}
value={provisioningCode}
onChange={(e) => {
const value = e.target.value
setProvisioningCode(value)
setValidatedCompany(null)
setCodeStatus(null)
}}
/>
<button
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-600"
onClick={() => setShowSecret((v) => !v)}
aria-label="Mostrar/ocultar"
>
{showSecret ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Empresa (nome ou slug, opcional)</label>
<input
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
placeholder="ex.: Paulicon Contábil"
value={company}
onChange={(e) => handleCompanyInputChange(e.target.value)}
/>
{companyLookupPending ? (
<p className="text-xs text-slate-500">Buscando empresas...</p>
) : companyOptions.length > 0 ? (
<div className="max-h-48 w-full overflow-y-auto rounded-md border border-slate-200 bg-white text-sm shadow-sm">
{companyOptions.map((option) => (
<button
key={option.id}
type="button"
className="flex w-full flex-col gap-0.5 px-3 py-2 text-left hover:bg-slate-100"
onClick={() => handleSelectCompany(option)}
>
<span className="font-medium text-slate-800">{option.name}</span>
<span className="text-xs text-slate-500">{option.slug}</span>
</button>
))}
</div>
) : company.trim().length >= 2 ? (
<p className="text-xs text-slate-500">Empresa não encontrada criaremos automaticamente ao registrar.</p>
{isValidatingCode ? (
<p className="text-xs text-slate-500">Validando código...</p>
) : codeStatus ? (
<p
className={`text-xs font-medium ${
codeStatus.tone === "success" ? "text-emerald-600" : "text-rose-600"
}`}
>
{codeStatus.message}
</p>
) : (
<p className="text-xs text-slate-500">Pode informar o nome completo que transformamos no slug registrado.</p>
<p className="text-xs text-slate-500">
Informe o código único fornecido pela equipe para vincular esta máquina a uma empresa.
</p>
)}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Perfil de acesso</label>
<select
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
value={accessRole}
onChange={(e) => setAccessRole((e.target.value as "collaborator" | "manager") ?? "collaborator")}
>
<option value="collaborator">Colaborador (portal)</option>
<option value="manager">Gestor (painel completo)</option>
</select>
<p className="text-xs text-slate-500">
Colaboradores veem apenas seus chamados. Gestores acompanham todos os tickets da empresa.
</p>
</div>
{validatedCompany ? (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
<div className="text-sm font-semibold text-emerald-800">{validatedCompany.name}</div>
<div>Tenant:&nbsp;<span className="font-mono text-emerald-800">{validatedCompany.tenantId}</span></div>
<div>Slug:&nbsp;<span className="font-mono text-emerald-800">{validatedCompany.slug}</span></div>
</div>
) : null}
<div className="grid gap-2">
<label className="text-sm font-medium">
Colaborador (e-mail) <span className="text-rose-500">*</span>
</label>
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="colaborador@empresa.com" value={collabEmail} onChange={(e)=>setCollabEmail(e.target.value)} />
<input
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
placeholder="colaborador@empresa.com"
value={collabEmail}
onChange={(e) => setCollabEmail(e.target.value)}
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Nome do colaborador <span className="text-rose-500">*</span></label>
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="Nome completo" value={collabName} onChange={(e)=>setCollabName(e.target.value)} />
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Tenant (opcional)</label>
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="tenant-atlas" value={tenantId} onChange={(e)=>setTenantId(e.target.value)} />
<label className="text-sm font-medium">
Nome do colaborador <span className="text-rose-500">*</span>
</label>
<input
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
placeholder="Nome completo"
value={collabName}
onChange={(e) => setCollabName(e.target.value)}
/>
</div>
{profile ? (
<div className="mt-2 grid grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs">
@ -675,7 +656,7 @@ function App() {
</div>
) : null}
<div className="mt-2 flex gap-2">
<button disabled={busy} onClick={register} className="rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white hover:bg-black/90 disabled:opacity-60">Registrar máquina</button>
<button disabled={busy || !validatedCompany} onClick={register} className="rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white hover:bg-black/90 disabled:opacity-60">Registrar máquina</button>
</div>
</div>
) : (
@ -684,10 +665,19 @@ function App() {
<TabsList className="h-10">
<TabsTrigger value="resumo" className="rounded-lg px-3">Resumo</TabsTrigger>
<TabsTrigger value="inventario" className="rounded-lg px-3">Inventário</TabsTrigger>
<TabsTrigger value="diagnostico" className="rounded-lg px-3">Diagnóstico</TabsTrigger>
<TabsTrigger value="config" className="rounded-lg px-3">Configurações</TabsTrigger>
</TabsList>
<TabsContent value="resumo" className="mt-4">
{companyName ? (
<div className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-700 shadow-sm">
<div className="text-sm font-semibold text-neutral-900">{companyName}</div>
{config?.collaboratorEmail ? (
<div className="text-xs text-neutral-500">
Vinculado a {config.collaboratorEmail}
</div>
) : null}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="stat-card">
<div className="text-xs text-slate-500">CPU</div>
@ -722,12 +712,6 @@ function App() {
<button onClick={openSystem} className="btn btn-outline">Ver no sistema</button>
</div>
</TabsContent>
<TabsContent value="diagnostico" className="mt-4 space-y-2">
<div className="card">
<p className="text-sm text-slate-700">Token armazenado: <span className="font-mono break-all text-xs">{token?.slice(0, 6)}{token?.slice(-6)}</span></p>
<p className="text-sm text-slate-700">Base URL: <span className="font-mono text-xs">{apiBaseUrl}</span></p>
</div>
</TabsContent>
<TabsContent value="config" className="mt-4 space-y-3">
<div className="grid gap-2">
<label className="label">
@ -792,26 +776,3 @@ function StatusBadge({ status }: { status: string | null }) {
const root = document.getElementById("root") || (() => { const el = document.createElement("div"); el.id = "root"; document.body.appendChild(el); return el })()
createRoot(root).render(<App />)
// DevTools shortcut (F12 / Ctrl+Shift+I) and context menu with modifier
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const key = (e.key || '').toLowerCase()
if (key === 'f12' || (e.ctrlKey && e.shiftKey && key === 'i')) {
invoke('open_devtools').catch(() => {})
e.preventDefault()
}
}
function onContextMenu(e: MouseEvent) {
// Evita abrir sempre: use Ctrl ou Shift + botão direito para abrir DevTools
if (e.ctrlKey || e.shiftKey) {
invoke('open_devtools').catch(() => {})
e.preventDefault()
}
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('contextmenu', onContextMenu)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('contextmenu', onContextMenu)
}
}, [])