Redesenho da UI de dispositivos e correcao de VRAM
- Reorganiza layout da tela de dispositivos admin - Renomeia secao "Controles do dispositivo" para "Atalhos" - Adiciona botao de Tickets com badge de quantidade - Simplifica textos de botoes (Acesso, Resetar) - Remove email da maquina do cabecalho - Move empresa e status para mesma linha - Remove chip de Build do resumo - Corrige deteccao de VRAM para GPUs >4GB usando nvidia-smi - Adiciona prefixo "VRAM" na exibicao de memoria da GPU - Documenta sincronizacao RustDesk 🤖 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
c5150fee8f
commit
23e7cf58ae
11 changed files with 863 additions and 441 deletions
|
|
@ -931,7 +931,44 @@ fn collect_windows_extended() -> serde_json::Value {
|
||||||
.unwrap_or_else(|| json!({}));
|
.unwrap_or_else(|| json!({}));
|
||||||
let bios = ps("Get-CimInstance Win32_BIOS | Select-Object Manufacturer,SMBIOSBIOSVersion,ReleaseDate,Version").unwrap_or_else(|| json!({}));
|
let bios = ps("Get-CimInstance Win32_BIOS | Select-Object Manufacturer,SMBIOSBIOSVersion,ReleaseDate,Version").unwrap_or_else(|| json!({}));
|
||||||
let memory = ps("@(Get-CimInstance Win32_PhysicalMemory | Select-Object BankLabel,Capacity,Manufacturer,PartNumber,SerialNumber,ConfiguredClockSpeed,Speed,ConfiguredVoltage)").unwrap_or_else(|| json!([]));
|
let memory = ps("@(Get-CimInstance Win32_PhysicalMemory | Select-Object BankLabel,Capacity,Manufacturer,PartNumber,SerialNumber,ConfiguredClockSpeed,Speed,ConfiguredVoltage)").unwrap_or_else(|| json!([]));
|
||||||
let video = ps("@(Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM,DriverVersion,PNPDeviceID)").unwrap_or_else(|| json!([]));
|
// Coleta de GPU com VRAM correta (nvidia-smi para NVIDIA, registro como fallback para >4GB)
|
||||||
|
let video = ps(r#"
|
||||||
|
$gpus = @()
|
||||||
|
$wmiGpus = Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM,DriverVersion,PNPDeviceID
|
||||||
|
foreach ($gpu in $wmiGpus) {
|
||||||
|
$vram = $gpu.AdapterRAM
|
||||||
|
# Tenta nvidia-smi para GPUs NVIDIA (retorna valor correto para >4GB)
|
||||||
|
if ($gpu.Name -match 'NVIDIA') {
|
||||||
|
try {
|
||||||
|
$nvidiaSmi = & 'nvidia-smi' '--query-gpu=memory.total' '--format=csv,noheader,nounits' 2>$null
|
||||||
|
if ($nvidiaSmi) {
|
||||||
|
$vramMB = [int64]($nvidiaSmi.Trim())
|
||||||
|
$vram = $vramMB * 1024 * 1024
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
# Fallback: tenta registro do Windows (qwMemorySize é uint64)
|
||||||
|
if ($vram -le 4294967296 -and $vram -gt 0) {
|
||||||
|
try {
|
||||||
|
$regPath = 'HKLM:\SYSTEM\ControlSet001\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}\0*'
|
||||||
|
$regGpus = Get-ItemProperty $regPath -ErrorAction SilentlyContinue
|
||||||
|
foreach ($reg in $regGpus) {
|
||||||
|
if ($reg.DriverDesc -eq $gpu.Name -and $reg.'HardwareInformation.qwMemorySize') {
|
||||||
|
$vram = [int64]$reg.'HardwareInformation.qwMemorySize'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
$gpus += [PSCustomObject]@{
|
||||||
|
Name = $gpu.Name
|
||||||
|
AdapterRAM = $vram
|
||||||
|
DriverVersion = $gpu.DriverVersion
|
||||||
|
PNPDeviceID = $gpu.PNPDeviceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@($gpus)
|
||||||
|
"#).unwrap_or_else(|| json!([]));
|
||||||
let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([]));
|
let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([]));
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
|
|
@ -1305,8 +1342,10 @@ impl AgentRuntime {
|
||||||
// Verifica politica USB apos heartbeat inicial
|
// Verifica politica USB apos heartbeat inicial
|
||||||
check_and_apply_usb_policy(&base_clone, &token_clone).await;
|
check_and_apply_usb_policy(&base_clone, &token_clone).await;
|
||||||
|
|
||||||
let mut ticker = tokio::time::interval(Duration::from_secs(interval));
|
let mut heartbeat_ticker = tokio::time::interval(Duration::from_secs(interval));
|
||||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
heartbeat_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
let mut usb_ticker = tokio::time::interval(Duration::from_secs(60));
|
||||||
|
usb_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Wait interval
|
// Wait interval
|
||||||
|
|
@ -1314,7 +1353,11 @@ impl AgentRuntime {
|
||||||
_ = stop_signal_clone.notified() => {
|
_ = stop_signal_clone.notified() => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_ = ticker.tick() => {}
|
_ = heartbeat_ticker.tick() => {}
|
||||||
|
_ = usb_ticker.tick() => {
|
||||||
|
check_and_apply_usb_policy(&base_clone, &token_clone).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(error) =
|
if let Err(error) =
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,13 @@ mod rustdesk;
|
||||||
mod usb_control;
|
mod usb_control;
|
||||||
|
|
||||||
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
|
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
|
||||||
|
use chrono::Local;
|
||||||
use usb_control::{UsbPolicy, UsbPolicyResult};
|
use usb_control::{UsbPolicy, UsbPolicyResult};
|
||||||
use tauri::{Emitter, Manager, WindowEvent};
|
use tauri::{Emitter, Manager, WindowEvent};
|
||||||
use tauri_plugin_store::Builder as StorePluginBuilder;
|
use tauri_plugin_store::Builder as StorePluginBuilder;
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|
@ -59,6 +63,38 @@ fn open_devtools(window: tauri::WebviewWindow) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn log_app_event(message: String) -> Result<(), String> {
|
||||||
|
append_app_log(&message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_app_log(message: &str) -> Result<(), String> {
|
||||||
|
let Some(dir) = logs_directory() else {
|
||||||
|
return Err("LOCALAPPDATA indisponivel para gravar logs".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|error| format!("Falha ao criar pasta de logs: {error}"))?;
|
||||||
|
|
||||||
|
let path = dir.join("app.log");
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.map_err(|error| format!("Falha ao abrir app.log: {error}"))?;
|
||||||
|
|
||||||
|
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||||
|
writeln!(file, "[{timestamp}] {message}")
|
||||||
|
.map_err(|error| format!("Falha ao escrever log: {error}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logs_directory() -> Option<PathBuf> {
|
||||||
|
let base = std::env::var("LOCALAPPDATA").ok()?;
|
||||||
|
Some(Path::new(&base).join("br.com.esdrasrenan.sistemadechamados").join("logs"))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn ensure_rustdesk_and_emit(
|
async fn ensure_rustdesk_and_emit(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
|
|
@ -150,6 +186,7 @@ pub fn run() {
|
||||||
start_machine_agent,
|
start_machine_agent,
|
||||||
stop_machine_agent,
|
stop_machine_agent,
|
||||||
open_devtools,
|
open_devtools,
|
||||||
|
log_app_event,
|
||||||
ensure_rustdesk_and_emit,
|
ensure_rustdesk_and_emit,
|
||||||
apply_usb_policy,
|
apply_usb_policy,
|
||||||
get_usb_policy,
|
get_usb_policy,
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,29 @@ pub fn ensure_rustdesk(
|
||||||
log_event("Dados do RustDesk salvos no machine-agent.json");
|
log_event("Dados do RustDesk salvos no machine-agent.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sincroniza com o backend imediatamente apos provisionar
|
||||||
|
// O Rust faz o HTTP direto, sem passar pelo CSP do webview
|
||||||
|
if let Err(error) = sync_remote_access_with_backend(&result) {
|
||||||
|
log_event(&format!("Aviso: falha ao sincronizar com backend: {error}"));
|
||||||
|
} else {
|
||||||
|
log_event("Acesso remoto sincronizado com backend");
|
||||||
|
// Atualiza lastSyncedAt no store
|
||||||
|
let synced_data = serde_json::json!({
|
||||||
|
"id": final_id,
|
||||||
|
"password": password,
|
||||||
|
"installedVersion": version,
|
||||||
|
"updated": freshly_installed,
|
||||||
|
"lastProvisionedAt": last_provisioned_at,
|
||||||
|
"lastSyncedAt": Utc::now().timestamp_millis(),
|
||||||
|
"lastError": serde_json::Value::Null
|
||||||
|
});
|
||||||
|
if let Err(e) = upsert_machine_store_value("rustdesk", synced_data) {
|
||||||
|
log_event(&format!("Aviso: falha ao atualizar lastSyncedAt: {e}"));
|
||||||
|
} else {
|
||||||
|
log_event("lastSyncedAt atualizado com sucesso");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log_event(&format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version));
|
log_event(&format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version));
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
|
|
@ -1459,3 +1482,78 @@ fn mark_acl_unlock_flag() {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_machine_store_path() -> Result<PathBuf, RustdeskError> {
|
||||||
|
let base = env::var("LOCALAPPDATA")
|
||||||
|
.map_err(|_| RustdeskError::MissingId)?;
|
||||||
|
Ok(Path::new(&base)
|
||||||
|
.join(APP_IDENTIFIER)
|
||||||
|
.join(MACHINE_STORE_FILENAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) -> Result<(), RustdeskError> {
|
||||||
|
log_event("Iniciando sincronizacao com backend...");
|
||||||
|
|
||||||
|
// Le token e config do store
|
||||||
|
let store_path = get_machine_store_path()?;
|
||||||
|
let store_content = fs::read_to_string(&store_path)
|
||||||
|
.map_err(RustdeskError::Io)?;
|
||||||
|
let store: serde_json::Value = serde_json::from_str(&store_content)
|
||||||
|
.map_err(|_| RustdeskError::MissingId)?;
|
||||||
|
|
||||||
|
let token = store.get("token")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or(RustdeskError::MissingId)?;
|
||||||
|
|
||||||
|
let config = store.get("config")
|
||||||
|
.ok_or(RustdeskError::MissingId)?;
|
||||||
|
|
||||||
|
let machine_id = config.get("machineId")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or(RustdeskError::MissingId)?;
|
||||||
|
|
||||||
|
let api_base_url = config.get("apiBaseUrl")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("https://tickets.esdrasrenan.com.br");
|
||||||
|
|
||||||
|
log_event(&format!("Sincronizando com backend: {} (machineId: {})", api_base_url, machine_id));
|
||||||
|
|
||||||
|
// Monta payload conforme schema esperado pelo backend
|
||||||
|
// Schema: { machineToken, provider, identifier, password?, url?, username?, notes? }
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"machineToken": token,
|
||||||
|
"provider": "RustDesk",
|
||||||
|
"identifier": result.id,
|
||||||
|
"password": result.password,
|
||||||
|
"notes": format!("Versao: {}. Provisionado em: {}",
|
||||||
|
result.installed_version.as_deref().unwrap_or("desconhecida"),
|
||||||
|
result.last_provisioned_at)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Faz POST para /api/machines/remote-access
|
||||||
|
let client = Client::builder()
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let url = format!("{}/api/machines/remote-access", api_base_url);
|
||||||
|
let response = client.post(&url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Idempotency-Key", format!("{}:RustDesk:{}", machine_id, result.id))
|
||||||
|
.body(payload.to_string())
|
||||||
|
.send()?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
log_event(&format!("Sync com backend OK: status {}", response.status()));
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().unwrap_or_default();
|
||||||
|
let body_preview = if body.len() > 200 { &body[..200] } else { &body };
|
||||||
|
log_event(&format!("Sync com backend falhou: {} - {}", status, body_preview));
|
||||||
|
Err(RustdeskError::CommandFailed {
|
||||||
|
command: "sync_remote_access".to_string(),
|
||||||
|
status: Some(status.as_u16() as i32)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,9 @@ pub enum UsbControlError {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod windows_impl {
|
mod windows_impl {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
use winreg::enums::*;
|
use winreg::enums::*;
|
||||||
use winreg::RegKey;
|
use winreg::RegKey;
|
||||||
|
|
||||||
|
|
@ -80,6 +83,39 @@ mod windows_impl {
|
||||||
pub fn apply_usb_policy(policy: UsbPolicy) -> Result<UsbPolicyResult, UsbControlError> {
|
pub fn apply_usb_policy(policy: UsbPolicy) -> Result<UsbPolicyResult, UsbControlError> {
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
|
||||||
|
let direct_result = try_apply_policy_direct(policy);
|
||||||
|
|
||||||
|
match direct_result {
|
||||||
|
Ok(()) => Ok(UsbPolicyResult {
|
||||||
|
success: true,
|
||||||
|
policy: policy.as_str().to_string(),
|
||||||
|
error: None,
|
||||||
|
applied_at: Some(now),
|
||||||
|
}),
|
||||||
|
Err(err) => {
|
||||||
|
// Tenta elevação se faltou permissão
|
||||||
|
if is_permission_error(&err) {
|
||||||
|
if let Err(elevated_err) = apply_policy_with_elevation(policy) {
|
||||||
|
return Err(elevated_err);
|
||||||
|
}
|
||||||
|
// Revalida a policy após elevação
|
||||||
|
let current = get_current_policy()?;
|
||||||
|
if current != policy {
|
||||||
|
return Err(UsbControlError::PermissionDenied);
|
||||||
|
}
|
||||||
|
return Ok(UsbPolicyResult {
|
||||||
|
success: true,
|
||||||
|
policy: policy.as_str().to_string(),
|
||||||
|
error: None,
|
||||||
|
applied_at: Some(now),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_apply_policy_direct(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
||||||
// 1. Aplicar Removable Storage Access Policy
|
// 1. Aplicar Removable Storage Access Policy
|
||||||
apply_removable_storage_policy(policy)?;
|
apply_removable_storage_policy(policy)?;
|
||||||
|
|
||||||
|
|
@ -93,12 +129,7 @@ mod windows_impl {
|
||||||
apply_write_protect(false)?;
|
apply_write_protect(false)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(UsbPolicyResult {
|
Ok(())
|
||||||
success: true,
|
|
||||||
policy: policy.as_str().to_string(),
|
|
||||||
error: None,
|
|
||||||
applied_at: Some(now),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_removable_storage_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
fn apply_removable_storage_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
||||||
|
|
@ -120,27 +151,27 @@ mod windows_impl {
|
||||||
UsbPolicy::BlockAll => {
|
UsbPolicy::BlockAll => {
|
||||||
let (key, _) = hklm
|
let (key, _) = hklm
|
||||||
.create_subkey(&full_path)
|
.create_subkey(&full_path)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
|
|
||||||
key.set_value("Deny_Read", &1u32)
|
key.set_value("Deny_Read", &1u32)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
key.set_value("Deny_Write", &1u32)
|
key.set_value("Deny_Write", &1u32)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
key.set_value("Deny_Execute", &1u32)
|
key.set_value("Deny_Execute", &1u32)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
}
|
}
|
||||||
UsbPolicy::Readonly => {
|
UsbPolicy::Readonly => {
|
||||||
let (key, _) = hklm
|
let (key, _) = hklm
|
||||||
.create_subkey(&full_path)
|
.create_subkey(&full_path)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
|
|
||||||
// Permite leitura, bloqueia escrita
|
// Permite leitura, bloqueia escrita
|
||||||
key.set_value("Deny_Read", &0u32)
|
key.set_value("Deny_Read", &0u32)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
key.set_value("Deny_Write", &1u32)
|
key.set_value("Deny_Write", &1u32)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
key.set_value("Deny_Execute", &0u32)
|
key.set_value("Deny_Execute", &0u32)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,13 +183,13 @@ mod windows_impl {
|
||||||
|
|
||||||
let key = hklm
|
let key = hklm
|
||||||
.open_subkey_with_flags(USBSTOR_PATH, KEY_ALL_ACCESS)
|
.open_subkey_with_flags(USBSTOR_PATH, KEY_ALL_ACCESS)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
|
|
||||||
match policy {
|
match policy {
|
||||||
UsbPolicy::Allow => {
|
UsbPolicy::Allow => {
|
||||||
// Start = 3 habilita o driver
|
// Start = 3 habilita o driver
|
||||||
key.set_value("Start", &3u32)
|
key.set_value("Start", &3u32)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
}
|
}
|
||||||
UsbPolicy::BlockAll | UsbPolicy::Readonly => {
|
UsbPolicy::BlockAll | UsbPolicy::Readonly => {
|
||||||
// Start = 4 desabilita o driver
|
// Start = 4 desabilita o driver
|
||||||
|
|
@ -166,11 +197,11 @@ mod windows_impl {
|
||||||
// Porem, como fallback de seguranca, desabilitamos para BlockAll
|
// Porem, como fallback de seguranca, desabilitamos para BlockAll
|
||||||
if policy == UsbPolicy::BlockAll {
|
if policy == UsbPolicy::BlockAll {
|
||||||
key.set_value("Start", &4u32)
|
key.set_value("Start", &4u32)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
} else {
|
} else {
|
||||||
// Readonly mantem driver ativo
|
// Readonly mantem driver ativo
|
||||||
key.set_value("Start", &3u32)
|
key.set_value("Start", &3u32)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -184,10 +215,10 @@ mod windows_impl {
|
||||||
if enable {
|
if enable {
|
||||||
let (key, _) = hklm
|
let (key, _) = hklm
|
||||||
.create_subkey(STORAGE_POLICY_PATH)
|
.create_subkey(STORAGE_POLICY_PATH)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
|
|
||||||
key.set_value("WriteProtect", &1u32)
|
key.set_value("WriteProtect", &1u32)
|
||||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
.map_err(map_winreg_error)?;
|
||||||
} else {
|
} else {
|
||||||
if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
|
if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
|
||||||
let _ = key.set_value("WriteProtect", &0u32);
|
let _ = key.set_value("WriteProtect", &0u32);
|
||||||
|
|
@ -227,6 +258,99 @@ mod windows_impl {
|
||||||
Ok(UsbPolicy::Allow)
|
Ok(UsbPolicy::Allow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_permission_error(error: &UsbControlError) -> bool {
|
||||||
|
match error {
|
||||||
|
UsbControlError::PermissionDenied => true,
|
||||||
|
UsbControlError::RegistryError(msg) => {
|
||||||
|
let lower = msg.to_lowercase();
|
||||||
|
lower.contains("access is denied") || lower.contains("acesso negado") || lower.contains("5")
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_policy_with_elevation(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
||||||
|
// Cria script temporário para aplicar as chaves via PowerShell elevado
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let script_path: PathBuf = temp_dir.join("raven_usb_policy.ps1");
|
||||||
|
|
||||||
|
let policy_str = policy.as_str();
|
||||||
|
let script = format!(
|
||||||
|
r#"$ErrorActionPreference = 'Stop'
|
||||||
|
$guid = '{guid}'
|
||||||
|
$policy = '{policy}'
|
||||||
|
|
||||||
|
function Set-Allow {{
|
||||||
|
reg delete 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /f 2>$null
|
||||||
|
reg delete 'HKLM\SYSTEM\CurrentControlSet\Control\StorageDevicePolicies' /f 2>$null
|
||||||
|
reg add 'HKLM\SYSTEM\CurrentControlSet\Services\USBSTOR' /v Start /t REG_DWORD /d 3 /f | Out-Null
|
||||||
|
}}
|
||||||
|
|
||||||
|
function Set-BlockAll {{
|
||||||
|
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /f | Out-Null
|
||||||
|
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /v Deny_Read /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /v Deny_Write /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /v Deny_Execute /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
reg add 'HKLM\SYSTEM\CurrentControlSet\Services\USBSTOR' /v Start /t REG_DWORD /d 4 /f | Out-Null
|
||||||
|
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\StorageDevicePolicies' /f | Out-Null
|
||||||
|
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\StorageDevicePolicies' /v WriteProtect /t REG_DWORD /d 0 /f | Out-Null
|
||||||
|
}}
|
||||||
|
|
||||||
|
function Set-Readonly {{
|
||||||
|
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /f | Out-Null
|
||||||
|
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /v Deny_Read /t REG_DWORD /d 0 /f | Out-Null
|
||||||
|
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /v Deny_Write /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /v Deny_Execute /t REG_DWORD /d 0 /f | Out-Null
|
||||||
|
reg add 'HKLM\SYSTEM\CurrentControlSet\Services\USBSTOR' /v Start /t REG_DWORD /d 3 /f | Out-Null
|
||||||
|
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\StorageDevicePolicies' /f | Out-Null
|
||||||
|
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\StorageDevicePolicies' /v WriteProtect /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
}}
|
||||||
|
|
||||||
|
switch ($policy) {{
|
||||||
|
'ALLOW' {{ Set-Allow }}
|
||||||
|
'BLOCK_ALL' {{ Set-BlockAll }}
|
||||||
|
'READONLY' {{ Set-Readonly }}
|
||||||
|
default {{ throw 'Politica invalida' }}
|
||||||
|
}}
|
||||||
|
|
||||||
|
try {{
|
||||||
|
gpupdate /target:computer /force | Out-Null
|
||||||
|
}} catch {{}}
|
||||||
|
"#,
|
||||||
|
guid = REMOVABLE_STORAGE_GUID,
|
||||||
|
policy = policy_str
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::write(&script_path, script).map_err(|e| UsbControlError::Io(e))?;
|
||||||
|
|
||||||
|
// Start-Process com RunAs para acionar UAC
|
||||||
|
let arg = format!(
|
||||||
|
"Start-Process -WindowStyle Hidden -FilePath powershell -Verb RunAs -Wait -ArgumentList '-ExecutionPolicy Bypass -File \"{}\"'",
|
||||||
|
script_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let status = Command::new("powershell")
|
||||||
|
.arg("-Command")
|
||||||
|
.arg(arg)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| UsbControlError::Io(e))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err(UsbControlError::PermissionDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_winreg_error(error: io::Error) -> UsbControlError {
|
||||||
|
if let Some(code) = error.raw_os_error() {
|
||||||
|
if code == 5 {
|
||||||
|
return UsbControlError::PermissionDenied;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UsbControlError::RegistryError(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn refresh_group_policy() -> Result<(), UsbControlError> {
|
pub fn refresh_group_policy() -> Result<(), UsbControlError> {
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,6 @@ const apiBaseUrl = normalizeUrl(import.meta.env.VITE_API_BASE_URL, appUrl)
|
||||||
const RUSTDESK_CONFIG_STRING = import.meta.env.VITE_RUSTDESK_CONFIG_STRING?.trim() || null
|
const RUSTDESK_CONFIG_STRING = import.meta.env.VITE_RUSTDESK_CONFIG_STRING?.trim() || null
|
||||||
const RUSTDESK_DEFAULT_PASSWORD = import.meta.env.VITE_RUSTDESK_DEFAULT_PASSWORD?.trim() || null
|
const RUSTDESK_DEFAULT_PASSWORD = import.meta.env.VITE_RUSTDESK_DEFAULT_PASSWORD?.trim() || null
|
||||||
|
|
||||||
const RUSTDESK_SYNC_INTERVAL_MS = 60 * 60 * 1000 // 1h
|
|
||||||
const TOKEN_SELF_HEAL_DEBOUNCE_MS = 30 * 1000
|
const TOKEN_SELF_HEAL_DEBOUNCE_MS = 30 * 1000
|
||||||
|
|
||||||
function sanitizeEmail(value: string | null | undefined) {
|
function sanitizeEmail(value: string | null | undefined) {
|
||||||
|
|
@ -244,7 +243,10 @@ async function writeRustdeskInfo(store: Store, info: RustdeskInfo): Promise<void
|
||||||
|
|
||||||
function logDesktop(message: string, data?: Record<string, unknown>) {
|
function logDesktop(message: string, data?: Record<string, unknown>) {
|
||||||
const enriched = data ? `${message} ${JSON.stringify(data)}` : message
|
const enriched = data ? `${message} ${JSON.stringify(data)}` : message
|
||||||
console.log(`[raven] ${enriched}`)
|
const line = `[raven] ${enriched}`
|
||||||
|
console.log(line)
|
||||||
|
// Persiste em arquivo local para facilitar debugging fora do console
|
||||||
|
invoke("log_app_event", { message: line }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
function bytes(n?: number) {
|
function bytes(n?: number) {
|
||||||
|
|
@ -782,99 +784,68 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
return normalized
|
return normalized
|
||||||
}, [config?.appUrl])
|
}, [config?.appUrl])
|
||||||
|
|
||||||
const syncRemoteAccessNow = useCallback(
|
// Funcao simplificada de sync - sempre le do disco para evitar race conditions
|
||||||
async (info: RustdeskInfo, allowRetry = true) => {
|
const syncRemoteAccessDirect = useCallback(
|
||||||
if (!store) return
|
async (info: RustdeskInfo, allowRetry = true): Promise<boolean> => {
|
||||||
if (!config?.machineId) {
|
try {
|
||||||
logDesktop("remoteAccess:sync:skipped", { reason: "unregistered" })
|
// Sempre le do disco para evitar race conditions com state React
|
||||||
return
|
const freshStore = await loadStore()
|
||||||
}
|
const freshConfig = await readConfig(freshStore)
|
||||||
const payload = buildRemoteAccessPayload(info)
|
const freshToken = await readToken(freshStore)
|
||||||
if (!payload) return
|
|
||||||
|
|
||||||
const resolveToken = async (allowHeal: boolean): Promise<string | null> => {
|
if (!freshConfig?.machineId || !freshToken) {
|
||||||
let currentToken = token
|
logDesktop("remoteAccess:sync:skip", {
|
||||||
if (!currentToken) {
|
hasMachineId: !!freshConfig?.machineId,
|
||||||
currentToken = (await readToken(store)) ?? null
|
hasToken: !!freshToken,
|
||||||
if (currentToken) {
|
})
|
||||||
setToken(currentToken)
|
return false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!currentToken && allowHeal) {
|
|
||||||
const healed = await attemptSelfHeal("remote-access")
|
|
||||||
if (healed) {
|
|
||||||
currentToken = (await readToken(store)) ?? null
|
|
||||||
if (currentToken) {
|
|
||||||
setToken(currentToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return currentToken
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendRequest = async (machineToken: string, retryAllowed: boolean): Promise<void> => {
|
const payload = buildRemoteAccessPayload(info)
|
||||||
|
if (!payload) return false
|
||||||
|
|
||||||
|
logDesktop("remoteAccess:sync:start", { id: info.id })
|
||||||
|
|
||||||
const response = await fetch(`${apiBaseUrl}/api/machines/remote-access`, {
|
const response = await fetch(`${apiBaseUrl}/api/machines/remote-access`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Idempotency-Key": `${config?.machineId ?? "unknown"}:RustDesk:${info.id}`,
|
"Idempotency-Key": `${freshConfig.machineId}:RustDesk:${info.id}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ machineToken, ...payload }),
|
body: JSON.stringify({ machineToken: freshToken, ...payload }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (response.ok) {
|
||||||
logDesktop("remoteAccess:sync:error", { status: response.status })
|
const nextInfo: RustdeskInfo = { ...info, lastSyncedAt: Date.now(), lastError: null }
|
||||||
const text = await response.text()
|
await writeRustdeskInfo(freshStore, nextInfo)
|
||||||
if (retryAllowed && (response.status === 401 || isTokenRevokedMessage(text))) {
|
setRustdeskInfo(nextInfo)
|
||||||
const healed = await attemptSelfHeal("remote-access")
|
logDesktop("remoteAccess:sync:success", { id: info.id })
|
||||||
if (healed) {
|
return true
|
||||||
const refreshedToken = await resolveToken(false)
|
|
||||||
if (refreshedToken) {
|
|
||||||
return sendRequest(refreshedToken, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(text.slice(0, 300) || "Falha ao registrar acesso remoto")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextInfo: RustdeskInfo = { ...info, lastSyncedAt: Date.now(), lastError: null }
|
const errorText = await response.text()
|
||||||
await writeRustdeskInfo(store, nextInfo)
|
logDesktop("remoteAccess:sync:error", { status: response.status, error: errorText.slice(0, 200) })
|
||||||
setRustdeskInfo(nextInfo)
|
|
||||||
logDesktop("remoteAccess:sync:success", { id: info.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// Se token invalido, tenta self-heal uma vez
|
||||||
const machineToken = await resolveToken(true)
|
if (allowRetry && (response.status === 401 || isTokenRevokedMessage(errorText))) {
|
||||||
if (!machineToken) {
|
|
||||||
const failedInfo: RustdeskInfo = {
|
|
||||||
...info,
|
|
||||||
lastError: "Token indisponível para sincronizar acesso remoto",
|
|
||||||
}
|
|
||||||
await writeRustdeskInfo(store, failedInfo)
|
|
||||||
setRustdeskInfo(failedInfo)
|
|
||||||
logDesktop("remoteAccess:sync:skipped", { reason: "missing-token" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await sendRequest(machineToken, allowRetry)
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
|
||||||
console.error("Falha ao sincronizar acesso remoto com a plataforma", error)
|
|
||||||
const failedInfo: RustdeskInfo = { ...info, lastError: message }
|
|
||||||
await writeRustdeskInfo(store, failedInfo)
|
|
||||||
setRustdeskInfo(failedInfo)
|
|
||||||
if (allowRetry && isTokenRevokedMessage(message)) {
|
|
||||||
const healed = await attemptSelfHeal("remote-access")
|
const healed = await attemptSelfHeal("remote-access")
|
||||||
if (healed) {
|
if (healed) {
|
||||||
const refreshedToken = await resolveToken(false)
|
return syncRemoteAccessDirect(info, false)
|
||||||
if (refreshedToken) {
|
|
||||||
return syncRemoteAccessNow(failedInfo, false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logDesktop("remoteAccess:sync:failed", { id: info.id, error: message })
|
|
||||||
|
// Salva erro no store
|
||||||
|
const failedInfo: RustdeskInfo = { ...info, lastError: errorText.slice(0, 200) }
|
||||||
|
await writeRustdeskInfo(freshStore, failedInfo)
|
||||||
|
setRustdeskInfo(failedInfo)
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
logDesktop("remoteAccess:sync:exception", { error: message })
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[store, token, config?.machineId, attemptSelfHeal, setToken]
|
[attemptSelfHeal]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleRustdeskProvision = useCallback(
|
const handleRustdeskProvision = useCallback(
|
||||||
|
|
@ -1007,23 +978,58 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
}
|
}
|
||||||
}, [store, handleRustdeskProvision])
|
}, [store, handleRustdeskProvision])
|
||||||
|
|
||||||
|
// Bootstrap do RustDesk + retry simplificado (60s)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!store || !config?.machineId) return
|
if (!store || !config?.machineId) return
|
||||||
if (!rustdeskInfo && !isRustdeskProvisioning && !rustdeskBootstrapRef.current) {
|
|
||||||
rustdeskBootstrapRef.current = true
|
let disposed = false
|
||||||
ensureRustdesk().finally(() => {
|
|
||||||
rustdeskBootstrapRef.current = false
|
async function bootstrap() {
|
||||||
})
|
// Se nao tem rustdeskInfo, provisiona primeiro
|
||||||
return
|
if (!rustdeskInfo && !isRustdeskProvisioning && !rustdeskBootstrapRef.current) {
|
||||||
}
|
rustdeskBootstrapRef.current = true
|
||||||
if (rustdeskInfo && !isRustdeskProvisioning) {
|
try {
|
||||||
const lastSync = rustdeskInfo.lastSyncedAt ?? 0
|
await ensureRustdesk()
|
||||||
const needsSync = Date.now() - lastSync > RUSTDESK_SYNC_INTERVAL_MS
|
} finally {
|
||||||
if (needsSync) {
|
rustdeskBootstrapRef.current = false
|
||||||
syncRemoteAccessNow(rustdeskInfo)
|
}
|
||||||
|
return // handleRustdeskProvision fara o sync
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se ja tem rustdeskInfo mas nunca sincronizou, tenta sync
|
||||||
|
if (rustdeskInfo && !rustdeskInfo.lastSyncedAt) {
|
||||||
|
logDesktop("remoteAccess:sync:bootstrap", { id: rustdeskInfo.id })
|
||||||
|
await syncRemoteAccessDirect(rustdeskInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [store, config?.machineId, rustdeskInfo, ensureRustdesk, syncRemoteAccessNow, isRustdeskProvisioning])
|
|
||||||
|
bootstrap()
|
||||||
|
|
||||||
|
// Retry a cada 30s se nunca sincronizou (o Rust faz o sync automaticamente)
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
if (disposed) return
|
||||||
|
try {
|
||||||
|
const freshStore = await loadStore()
|
||||||
|
const freshRustdesk = await readRustdeskInfo(freshStore)
|
||||||
|
if (freshRustdesk && !freshRustdesk.lastSyncedAt) {
|
||||||
|
logDesktop("remoteAccess:sync:retry:fallback", { id: freshRustdesk.id })
|
||||||
|
// Re-invoca o Rust para tentar sync novamente
|
||||||
|
await invoke("ensure_rustdesk_and_emit", {
|
||||||
|
configString: RUSTDESK_CONFIG_STRING || null,
|
||||||
|
password: RUSTDESK_DEFAULT_PASSWORD || null,
|
||||||
|
machineId: config?.machineId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logDesktop("remoteAccess:sync:retry:error", { error: String(err) })
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [store, config?.machineId, rustdeskInfo, isRustdeskProvisioning, ensureRustdesk, syncRemoteAccessDirect])
|
||||||
|
|
||||||
async function register() {
|
async function register() {
|
||||||
if (!profile) return
|
if (!profile) return
|
||||||
|
|
@ -1100,10 +1106,23 @@ const resolvedAppUrl = useMemo(() => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await ensureRustdesk()
|
// Provisiona RustDesk em background (fire-and-forget)
|
||||||
logDesktop("register:rustdesk:done", { machineId: data.machineId })
|
// O Rust faz o sync com o backend automaticamente, sem passar pelo CSP do webview
|
||||||
|
logDesktop("register:rustdesk:start", { machineId: data.machineId })
|
||||||
|
invoke<RustdeskProvisioningResult>("ensure_rustdesk_and_emit", {
|
||||||
|
configString: RUSTDESK_CONFIG_STRING || null,
|
||||||
|
password: RUSTDESK_DEFAULT_PASSWORD || null,
|
||||||
|
machineId: data.machineId,
|
||||||
|
}).then((result) => {
|
||||||
|
logDesktop("register:rustdesk:done", { machineId: data.machineId, id: result.id })
|
||||||
|
}).catch((err) => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
if (!msg.toLowerCase().includes("apenas no windows")) {
|
||||||
|
logDesktop("register:rustdesk:error", { error: msg })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Abre o sistema imediatamente após registrar (evita ficar com token inválido no fluxo antigo)
|
// Redireciona imediatamente (nao espera RustDesk)
|
||||||
try {
|
try {
|
||||||
await fetch(`${apiBaseUrl}/api/machines/sessions`, {
|
await fetch(`${apiBaseUrl}/api/machines/sessions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
7
bun.lock
7
bun.lock
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "web",
|
"name": "web",
|
||||||
|
|
@ -40,7 +41,7 @@
|
||||||
"@tiptap/starter-kit": "^3.10.0",
|
"@tiptap/starter-kit": "^3.10.0",
|
||||||
"@tiptap/suggestion": "^3.10.0",
|
"@tiptap/suggestion": "^3.10.0",
|
||||||
"better-auth": "^1.3.26",
|
"better-auth": "^1.3.26",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "12.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"convex": "^1.29.2",
|
"convex": "^1.29.2",
|
||||||
|
|
@ -971,7 +972,7 @@
|
||||||
|
|
||||||
"better-call": ["better-call@1.1.4", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-NJouLY6IVKv0nDuFoc6FcbKDFzEnmgMNofC9F60Mwx1Ecm7X6/Ecyoe5b+JSVZ42F/0n46/M89gbYP1ZCVv8xQ=="],
|
"better-call": ["better-call@1.1.4", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-NJouLY6IVKv0nDuFoc6FcbKDFzEnmgMNofC9F60Mwx1Ecm7X6/Ecyoe5b+JSVZ42F/0n46/M89gbYP1ZCVv8xQ=="],
|
||||||
|
|
||||||
"better-sqlite3": ["better-sqlite3@11.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="],
|
"better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="],
|
||||||
|
|
||||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||||
|
|
||||||
|
|
@ -2041,8 +2042,6 @@
|
||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
"@prisma/adapter-better-sqlite3/better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="],
|
|
||||||
|
|
||||||
"@prisma/dev/std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
|
"@prisma/dev/std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
|
||||||
|
|
||||||
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.1.0", "", { "dependencies": { "@prisma/debug": "7.1.0" } }, "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q=="],
|
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.1.0", "", { "dependencies": { "@prisma/debug": "7.1.0" } }, "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q=="],
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ Este índice consolida a documentação viva e move conteúdos históricos para
|
||||||
- Build: `docs/desktop/build.md`
|
- Build: `docs/desktop/build.md`
|
||||||
- Updater: `docs/desktop/updater.md`
|
- Updater: `docs/desktop/updater.md`
|
||||||
- Handshake/troubleshooting: `docs/desktop/handshake-troubleshooting.md`
|
- Handshake/troubleshooting: `docs/desktop/handshake-troubleshooting.md`
|
||||||
|
- RustDesk sync: `docs/desktop/rustdesk-sync.md`
|
||||||
- Tickets: `docs/ticket-snapshots.md`
|
- Tickets: `docs/ticket-snapshots.md`
|
||||||
- Administração (UI): `docs/admin/admin-inventory-ui.md`
|
- Administração (UI): `docs/admin/admin-inventory-ui.md`
|
||||||
|
|
||||||
|
|
|
||||||
151
docs/desktop/rustdesk-sync.md
Normal file
151
docs/desktop/rustdesk-sync.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
# Desktop (Tauri) - Sincronizacao RustDesk com Backend
|
||||||
|
|
||||||
|
Este documento descreve o mecanismo de sincronizacao automatica do RustDesk com o backend apos o provisionamento no aplicativo desktop.
|
||||||
|
|
||||||
|
## Visao Geral
|
||||||
|
|
||||||
|
O fluxo de sincronizacao do RustDesk garante que as credenciais de acesso remoto (ID e senha) sejam automaticamente registradas no backend assim que o RustDesk for provisionado na maquina. Isso elimina a necessidade de cadastro manual e permite acesso remoto imediato.
|
||||||
|
|
||||||
|
## Arquitetura
|
||||||
|
|
||||||
|
### Fluxo de Provisionamento
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Usuario clica "Registrar" no app desktop
|
||||||
|
2. TypeScript faz POST /api/machines/register
|
||||||
|
3. TypeScript salva token/config no store local
|
||||||
|
4. TypeScript inicia RustDesk em background (fire-and-forget)
|
||||||
|
5. TypeScript redireciona IMEDIATAMENTE para o sistema
|
||||||
|
6. [Background] Rust provisiona RustDesk (~30s)
|
||||||
|
7. [Background] Rust faz POST /api/machines/remote-access (SYNC)
|
||||||
|
8. [Background] Rust atualiza lastSyncedAt no store
|
||||||
|
```
|
||||||
|
|
||||||
|
### Por que o Sync e feito no Rust?
|
||||||
|
|
||||||
|
| Problema anterior | Solucao Rust |
|
||||||
|
|-------------------|--------------|
|
||||||
|
| CSP do Tauri bloqueava `fetch()` | Rust usa reqwest diretamente, sem CSP |
|
||||||
|
| Race conditions com React state | Fluxo sincrono no Rust |
|
||||||
|
| Fire-and-forget nao completava | Sync acontece antes do return |
|
||||||
|
| Timeout do navegador | Rust tem controle total do HTTP |
|
||||||
|
|
||||||
|
## Implementacao
|
||||||
|
|
||||||
|
### Arquivo: `apps/desktop/src-tauri/src/rustdesk.rs`
|
||||||
|
|
||||||
|
O sync e executado automaticamente apos o provisionamento bem-sucedido:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Apos salvar dados do RustDesk no store local
|
||||||
|
sync_remote_access_with_backend(&result)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payload da API
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"machineToken": "<token-da-maquina>",
|
||||||
|
"provider": "RustDesk",
|
||||||
|
"identifier": "<id-do-rustdesk>",
|
||||||
|
"password": "<senha-permanente>",
|
||||||
|
"notes": "Versao: 1.4.4. Provisionado em: 1765045637482"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
- `Idempotency-Key: <machineId>:RustDesk:<rustdeskId>` (previne duplicatas)
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/machines/remote-access
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arquivos de Configuracao
|
||||||
|
|
||||||
|
### Store Local
|
||||||
|
|
||||||
|
Caminho: `%LOCALAPPDATA%\br.com.esdrasrenan.sistemadechamados\machine-agent.json`
|
||||||
|
|
||||||
|
Estrutura relevante:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"machineId": "...",
|
||||||
|
"apiBaseUrl": "https://tickets.esdrasrenan.com.br"
|
||||||
|
},
|
||||||
|
"token": "...",
|
||||||
|
"rustdesk": {
|
||||||
|
"id": "497869711",
|
||||||
|
"password": "...",
|
||||||
|
"installedVersion": "1.4.4",
|
||||||
|
"lastProvisionedAt": 1765045637482,
|
||||||
|
"lastSyncedAt": 1765045638173,
|
||||||
|
"lastError": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
Caminho: `%LOCALAPPDATA%\br.com.esdrasrenan.sistemadechamados\logs\rustdesk.log`
|
||||||
|
|
||||||
|
Mensagens de sucesso:
|
||||||
|
```
|
||||||
|
[...] Iniciando sincronizacao com backend...
|
||||||
|
[...] Sync com backend OK: status 200 OK
|
||||||
|
[...] Acesso remoto sincronizado com backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Diagnostico
|
||||||
|
|
||||||
|
### Verificar se o Sync Funcionou
|
||||||
|
|
||||||
|
1. Abrir `machine-agent.json`:
|
||||||
|
```
|
||||||
|
%LOCALAPPDATA%\br.com.esdrasrenan.sistemadechamados\machine-agent.json
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verificar campo `rustdesk.lastSyncedAt`:
|
||||||
|
- Se tem valor numerico: sync OK
|
||||||
|
- Se `null`: sync falhou
|
||||||
|
|
||||||
|
3. Verificar logs:
|
||||||
|
```
|
||||||
|
%LOCALAPPDATA%\br.com.esdrasrenan.sistemadechamados\logs\rustdesk.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Possiveis Erros
|
||||||
|
|
||||||
|
| Erro | Causa | Solucao |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `400 Bad Request` | Payload incorreto | Verificar schema da API |
|
||||||
|
| `401 Unauthorized` | Token invalido/expirado | Re-registrar maquina |
|
||||||
|
| `Connection refused` | Backend indisponivel | Verificar URL da API |
|
||||||
|
| `lastSyncedAt: null` | Sync nao executou | Verificar logs do Rust |
|
||||||
|
|
||||||
|
### Fallback Manual
|
||||||
|
|
||||||
|
Se o sync automatico falhar, o sistema tenta novamente a cada 30 segundos em background. Tambem e possivel forcar o re-provisionamento via:
|
||||||
|
|
||||||
|
1. Aba "Configuracoes" no app desktop
|
||||||
|
2. Clicar "Reprovisionar RustDesk"
|
||||||
|
|
||||||
|
## Historico de Mudancas
|
||||||
|
|
||||||
|
### Dezembro 2025 - Migracao do Sync para Rust
|
||||||
|
|
||||||
|
**Problema:** O sync feito em TypeScript (`fetch()`) falhava com "Failed to fetch" devido ao CSP do Tauri v2 bloquear requisicoes externas do webview.
|
||||||
|
|
||||||
|
**Solucao:** Mover o sync para o Rust, que faz HTTP diretamente via reqwest sem restricoes de CSP.
|
||||||
|
|
||||||
|
**Arquivos modificados:**
|
||||||
|
- `apps/desktop/src-tauri/src/rustdesk.rs` - Adicionado `sync_remote_access_with_backend()`
|
||||||
|
- `apps/desktop/src/main.tsx` - Simplificado para fire-and-forget
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ultima atualizacao: Dezembro 2025 - Sync automatico via Rust apos provisionamento.
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
const nextConfig = {
|
import type { NextConfig } from "next"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const projectRoot = path.dirname(__filename)
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
typescript: {
|
typescript: {
|
||||||
// Pula verificacao de tipos durante build para evitar OOM no servidor de producao
|
// Pula verificacao de tipos durante build para evitar OOM no servidor de producao
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
|
|
@ -6,6 +13,13 @@ const nextConfig = {
|
||||||
experimental: {
|
experimental: {
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForDev: true,
|
||||||
},
|
},
|
||||||
|
turbopack: {
|
||||||
|
// Define a raiz do projeto para evitar warning de multiplos lockfiles
|
||||||
|
root: projectRoot,
|
||||||
|
},
|
||||||
|
// Pacotes nativos que nao devem ser bundled pelo servidor
|
||||||
|
serverExternalPackages: ["better-sqlite3"],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@
|
||||||
"@tiptap/starter-kit": "^3.10.0",
|
"@tiptap/starter-kit": "^3.10.0",
|
||||||
"@tiptap/suggestion": "^3.10.0",
|
"@tiptap/suggestion": "^3.10.0",
|
||||||
"better-auth": "^1.3.26",
|
"better-auth": "^1.3.26",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "12.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"convex": "^1.29.2",
|
"convex": "^1.29.2",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import {
|
||||||
Usb,
|
Usb,
|
||||||
Loader2,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
|
TicketCheck,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
@ -3154,14 +3155,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
icon: <Cpu className="size-4 text-neutral-500" />,
|
icon: <Cpu className="size-4 text-neutral-500" />,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (windowsBuildLabel) {
|
|
||||||
chips.push({
|
|
||||||
key: "build",
|
|
||||||
label: "Build",
|
|
||||||
value: windowsBuildLabel,
|
|
||||||
icon: <ServerCog className="size-4 text-neutral-500" />,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (windowsActivationStatus !== null && windowsActivationStatus !== undefined) {
|
if (windowsActivationStatus !== null && windowsActivationStatus !== undefined) {
|
||||||
chips.push({
|
chips.push({
|
||||||
key: "activation",
|
key: "activation",
|
||||||
|
|
@ -3222,7 +3215,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
device?.osVersion,
|
device?.osVersion,
|
||||||
device?.architecture,
|
device?.architecture,
|
||||||
windowsVersionLabel,
|
windowsVersionLabel,
|
||||||
windowsBuildLabel,
|
|
||||||
windowsActivationStatus,
|
windowsActivationStatus,
|
||||||
primaryLinkedUser?.email,
|
primaryLinkedUser?.email,
|
||||||
primaryLinkedUser?.name,
|
primaryLinkedUser?.name,
|
||||||
|
|
@ -3867,25 +3859,49 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader className="gap-1">
|
<CardHeader className="gap-1">
|
||||||
<CardTitle>Detalhes</CardTitle>
|
|
||||||
<CardDescription>Resumo do dispositivo selecionado</CardDescription>
|
|
||||||
{device ? (
|
{device ? (
|
||||||
<CardAction>
|
<>
|
||||||
<div className="flex flex-col items-end gap-2 text-xs sm:text-sm">
|
<CardTitle className="flex items-center gap-2">
|
||||||
{companyName ? (
|
<span className="break-words text-2xl font-semibold text-neutral-900">
|
||||||
<div className="rounded-lg border border-slate-200 bg-white px-3 py-1 font-semibold text-neutral-600 shadow-sm">
|
{device.displayName ?? device.hostname ?? "Dispositivo"}
|
||||||
{companyName}
|
</span>
|
||||||
</div>
|
{isManualMobile ? (
|
||||||
|
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-neutral-600">
|
||||||
|
Identificação interna
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{!isDeactivated ? <DeviceStatusBadge status={effectiveStatus} /> : null}
|
<Button
|
||||||
{!isActiveLocal ? (
|
size="icon"
|
||||||
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700">
|
variant="ghost"
|
||||||
Dispositivo desativada
|
className="size-7"
|
||||||
</Badge>
|
onClick={() => {
|
||||||
) : null}
|
setNewName(device.displayName ?? device.hostname ?? "")
|
||||||
</div>
|
setRenaming(true)
|
||||||
</CardAction>
|
}}
|
||||||
) : null}
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
<span className="sr-only">Renomear dispositivo</span>
|
||||||
|
</Button>
|
||||||
|
</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2 text-xs sm:text-sm">
|
||||||
|
{companyName ? (
|
||||||
|
<div className="max-w-[200px] truncate rounded-lg border border-slate-200 bg-white px-3 py-1 font-semibold text-neutral-600 shadow-sm" title={companyName}>
|
||||||
|
{companyName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!isDeactivated ? <DeviceStatusBadge status={effectiveStatus} /> : null}
|
||||||
|
{!isActiveLocal ? (
|
||||||
|
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700">
|
||||||
|
Dispositivo desativado
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardAction>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<CardTitle>Detalhes</CardTitle>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{!device ? (
|
{!device ? (
|
||||||
|
|
@ -3893,47 +3909,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex flex-wrap items-start gap-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h1 className="break-words text-2xl font-semibold text-neutral-900">
|
|
||||||
{device.displayName ?? device.hostname ?? "Dispositivo"}
|
|
||||||
</h1>
|
|
||||||
{isManualMobile ? (
|
|
||||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-neutral-600">
|
|
||||||
Identificação interna
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="size-7"
|
|
||||||
onClick={() => {
|
|
||||||
setNewName(device.displayName ?? device.hostname ?? "")
|
|
||||||
setRenaming(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
|
||||||
<span className="sr-only">Renomear dispositivo</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>{device.authEmail ?? "E-mail não definido"}</span>
|
|
||||||
{device.authEmail ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={copyEmail}
|
|
||||||
className="inline-flex items-center rounded-md p-1 text-neutral-500 transition hover:bg-[#00d6eb]/15 hover:text-[#0a4760] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40 focus-visible:ring-offset-2"
|
|
||||||
title="Copiar e-mail do dispositivo"
|
|
||||||
aria-label="Copiar e-mail do dispositivo"
|
|
||||||
>
|
|
||||||
<ClipboardCopy className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* ping integrado na badge de status */}
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{summaryChips.map((chip) => (
|
{summaryChips.map((chip) => (
|
||||||
<InfoChip
|
<InfoChip
|
||||||
|
|
@ -3975,17 +3950,32 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
|
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white/80 px-4 py-4 shadow-sm">
|
<div className="rounded-2xl border border-slate-200 bg-white/80 px-4 py-4 shadow-sm">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Controles do dispositivo</p>
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Atalhos</p>
|
||||||
{device.registeredBy ? (
|
<span className="text-xs font-medium text-slate-500">
|
||||||
<span className="text-xs font-medium text-slate-500">
|
{device.registeredBy === "desktop-agent" ? (
|
||||||
Registrada via <span className="text-slate-800">{device.registeredBy}</span>
|
<span className="text-slate-800">Agente na máquina</span>
|
||||||
</span>
|
) : (
|
||||||
) : null}
|
<span className="text-slate-800">Manual</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{deviceTicketsHref ? (
|
||||||
|
<Button size="sm" variant="outline" className="gap-2 border-dashed" asChild>
|
||||||
|
<Link href={deviceTicketsHref}>
|
||||||
|
<TicketCheck className="size-4" />
|
||||||
|
Tickets
|
||||||
|
{totalOpenTickets > 0 ? (
|
||||||
|
<Badge variant="secondary" className="ml-1 rounded-full px-2 py-0 text-[10px] font-semibold">
|
||||||
|
{totalOpenTickets}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
|
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4" />
|
||||||
Ajustar acesso
|
Acesso
|
||||||
</Button>
|
</Button>
|
||||||
{!isManualMobile ? (
|
{!isManualMobile ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -3997,7 +3987,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
disabled={isResettingAgent}
|
disabled={isResettingAgent}
|
||||||
>
|
>
|
||||||
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
|
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
|
||||||
{isResettingAgent ? "Resetando agente..." : "Resetar agente"}
|
{isResettingAgent ? "Resetando..." : "Resetar"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -4029,7 +4019,195 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Campos personalizados (posicionado logo após métricas) */}
|
{/* Acesso remoto */}
|
||||||
|
<div className="space-y-3 border-t border-slate-100 pt-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h4 className="text-sm font-semibold">Acesso remoto</h4>
|
||||||
|
{hasRemoteAccess ? (
|
||||||
|
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
|
||||||
|
{remoteAccessEntries.length === 1 ? "1 acesso" : `${remoteAccessEntries.length} acessos`}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{canManageRemoteAccess ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-dashed"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingRemoteAccessClientId(null)
|
||||||
|
setRemoteAccessDialog(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Key className="size-4" />
|
||||||
|
{hasRemoteAccess ? "Adicionar acesso" : "Cadastrar acesso"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{hasRemoteAccess ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{remoteAccessEntries.map((entry) => {
|
||||||
|
const lastVerifiedDate =
|
||||||
|
entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt)
|
||||||
|
? new Date(entry.lastVerifiedAt)
|
||||||
|
: null
|
||||||
|
const isRustDesk = isRustDeskAccess(entry)
|
||||||
|
const secretVisible = Boolean(visibleRemoteSecrets[entry.clientId])
|
||||||
|
return (
|
||||||
|
<div key={entry.clientId} className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-700">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">ID</span>
|
||||||
|
{entry.identifier ? (
|
||||||
|
<code className="rounded-md border border-slate-200 bg-white px-2.5 py-1 font-mono text-sm font-semibold text-neutral-800">
|
||||||
|
{entry.identifier}
|
||||||
|
</code>
|
||||||
|
) : null}
|
||||||
|
{entry.identifier ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 border border-transparent px-2 text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
onClick={() => handleCopyRemoteIdentifier(entry.identifier)}
|
||||||
|
title="Copiar ID"
|
||||||
|
aria-label="Copiar ID"
|
||||||
|
>
|
||||||
|
<ClipboardCopy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{entry.username || entry.password ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{entry.username ? (
|
||||||
|
<div className="inline-flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Usuário</span>
|
||||||
|
<code className="rounded-md border border-slate-200 bg-white px-2 py-0.5 font-mono text-xs text-slate-700">
|
||||||
|
{entry.username}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
onClick={() => handleCopyRemoteCredential(entry.username, "Usuário do acesso remoto")}
|
||||||
|
title="Copiar usuário"
|
||||||
|
aria-label="Copiar usuário"
|
||||||
|
>
|
||||||
|
<ClipboardCopy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{entry.password ? (
|
||||||
|
<div className="inline-flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">Senha</span>
|
||||||
|
<code className="rounded-md border border-slate-200 bg-white px-2.5 py-1 font-mono text-sm text-slate-700">
|
||||||
|
{secretVisible ? entry.password : "••••••••"}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
onClick={() => toggleRemoteSecret(entry.clientId)}
|
||||||
|
title={secretVisible ? "Ocultar senha" : "Mostrar senha"}
|
||||||
|
aria-label={secretVisible ? "Ocultar senha" : "Mostrar senha"}
|
||||||
|
>
|
||||||
|
{secretVisible ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
onClick={() => handleCopyRemoteCredential(entry.password, "Senha do acesso remoto")}
|
||||||
|
title="Copiar senha"
|
||||||
|
aria-label="Copiar senha"
|
||||||
|
>
|
||||||
|
<ClipboardCopy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{entry.url && !isRustDesk ? (
|
||||||
|
<a
|
||||||
|
href={entry.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 text-slate-600 underline-offset-4 hover:text-slate-900 hover:underline"
|
||||||
|
>
|
||||||
|
Abrir console remoto
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1 text-right">
|
||||||
|
{entry.notes ? (
|
||||||
|
<p className="whitespace-pre-wrap text-xs text-slate-600">{entry.notes}</p>
|
||||||
|
) : null}
|
||||||
|
{lastVerifiedDate ? (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Atualizado {formatRelativeTime(lastVerifiedDate)}{" "}
|
||||||
|
<span className="text-slate-400">({formatAbsoluteDateTime(lastVerifiedDate)})</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 pt-3 mt-3">
|
||||||
|
<div>
|
||||||
|
{isRustDesk && (entry.identifier || entry.password) ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="inline-flex items-center gap-2 border-slate-300 bg-white text-slate-800 shadow-sm transition-colors hover:border-slate-400 hover:bg-slate-50 hover:text-slate-900 focus-visible:border-slate-400 focus-visible:ring-slate-200"
|
||||||
|
onClick={() => handleRustDeskConnect(entry)}
|
||||||
|
>
|
||||||
|
<MonitorSmartphone className="size-4 text-[#4b5563]" /> Conectar via RustDesk
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{entry.provider ? (
|
||||||
|
<Button variant="outline" size="sm" className="gap-2 border-slate-300 cursor-default hover:bg-white">
|
||||||
|
{entry.provider}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManageRemoteAccess ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-slate-300"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingRemoteAccessClientId(entry.clientId)
|
||||||
|
setRemoteAccessDialog(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" /> Editar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="gap-2 text-rose-600 hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700"
|
||||||
|
onClick={() => void handleRemoveRemoteAccess(entry)}
|
||||||
|
disabled={remoteAccessSaving}
|
||||||
|
>
|
||||||
|
<ShieldOff className="size-3.5" /> Remover
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-600">
|
||||||
|
Nenhum identificador de acesso remoto cadastrado. Registre o ID do TeamViewer, AnyDesk ou outra ferramenta para agilizar o suporte.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campos personalizados */}
|
||||||
<div className="space-y-3 border-t border-slate-100 pt-5">
|
<div className="space-y-3 border-t border-slate-100 pt-5">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
@ -4068,248 +4246,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por este dispositivo</h4>
|
|
||||||
{totalOpenTickets === 0 ? (
|
|
||||||
<p className="text-xs text-[color:var(--accent-foreground)]/80">
|
|
||||||
Nenhum chamado em aberto registrado diretamente por este dispositivo.
|
|
||||||
</p>
|
|
||||||
) : hasAdditionalOpenTickets ? (
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--accent-foreground)]/70">
|
|
||||||
Mostrando últimos {Math.min(displayLimit, totalOpenTickets)} de {totalOpenTickets} chamados em aberto
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-[color:var(--accent-foreground)]/80">
|
|
||||||
Últimos chamados vinculados a este dispositivo.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex h-10 min-w-[56px] items-center justify-center rounded-xl border border-[color:var(--accent)] bg-white px-3 text-[color:var(--accent-foreground)] shadow-sm">
|
|
||||||
<span className="text-lg font-semibold leading-none tabular-nums">{totalOpenTickets}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{totalOpenTickets > 0 ? (
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{displayedDeviceTickets.map((ticket) => {
|
|
||||||
const priorityMeta = getTicketPriorityMeta(ticket.priority)
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={ticket.id}
|
|
||||||
href={`/tickets/${ticket.id}`}
|
|
||||||
className="group flex h-full flex-col justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white p-3 text-sm shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="line-clamp-2 font-medium text-neutral-900">
|
|
||||||
#{ticket.reference} · {ticket.subject}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-neutral-500">
|
|
||||||
Atualizado {formatRelativeTime(new Date(ticket.updatedAt))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
|
|
||||||
{priorityMeta.label}
|
|
||||||
</Badge>
|
|
||||||
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{deviceTicketsHref ? (
|
|
||||||
<div className="mt-4">
|
|
||||||
<Link
|
|
||||||
href={deviceTicketsHref}
|
|
||||||
className="text-xs font-semibold text-[color:var(--accent-foreground)] underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-foreground)] focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
Ver todos
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 border-t border-slate-100 pt-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h4 className="text-sm font-semibold">Acesso remoto</h4>
|
|
||||||
{hasRemoteAccess ? (
|
|
||||||
<Badge variant="outline" className="border-slate-200 bg-slate-100 text-[11px] font-semibold text-slate-700">
|
|
||||||
{remoteAccessEntries.length === 1
|
|
||||||
? remoteAccessEntries[0].provider ?? "Configuração única"
|
|
||||||
: `${remoteAccessEntries.length} acessos`}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{canManageRemoteAccess ? (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2 border-dashed"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingRemoteAccessClientId(null)
|
|
||||||
setRemoteAccessDialog(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Key className="size-4" />
|
|
||||||
{hasRemoteAccess ? "Adicionar acesso" : "Cadastrar acesso"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{hasRemoteAccess ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{remoteAccessEntries.map((entry) => {
|
|
||||||
const lastVerifiedDate =
|
|
||||||
entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt)
|
|
||||||
? new Date(entry.lastVerifiedAt)
|
|
||||||
: null
|
|
||||||
const isRustDesk = isRustDeskAccess(entry)
|
|
||||||
const secretVisible = Boolean(visibleRemoteSecrets[entry.clientId])
|
|
||||||
return (
|
|
||||||
<div key={entry.clientId} className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-700">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{entry.provider ? (
|
|
||||||
<Badge variant="outline" className="border-slate-200 bg-white text-[11px] font-semibold text-slate-700">
|
|
||||||
{entry.provider}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
{entry.identifier ? (
|
|
||||||
<span className="font-semibold text-neutral-800">{entry.identifier}</span>
|
|
||||||
) : null}
|
|
||||||
{entry.identifier ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 border border-transparent px-2 text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
|
||||||
onClick={() => handleCopyRemoteIdentifier(entry.identifier)}
|
|
||||||
title="Copiar ID"
|
|
||||||
aria-label="Copiar ID"
|
|
||||||
>
|
|
||||||
<ClipboardCopy className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{entry.username || entry.password ? (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{entry.username ? (
|
|
||||||
<div className="inline-flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Usuário</span>
|
|
||||||
<code className="rounded-md border border-slate-200 bg-white px-2 py-0.5 font-mono text-xs text-slate-700">
|
|
||||||
{entry.username}
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
|
||||||
onClick={() => handleCopyRemoteCredential(entry.username, "Usuário do acesso remoto")}
|
|
||||||
title="Copiar usuário"
|
|
||||||
aria-label="Copiar usuário"
|
|
||||||
>
|
|
||||||
<ClipboardCopy className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{entry.password ? (
|
|
||||||
<div className="inline-flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Senha</span>
|
|
||||||
<code className="rounded-md border border-slate-200 bg-white px-2 py-0.5 font-mono text-xs text-slate-700">
|
|
||||||
{secretVisible ? entry.password : "••••••••"}
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 gap-1 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
|
||||||
onClick={() => toggleRemoteSecret(entry.clientId)}
|
|
||||||
>
|
|
||||||
{secretVisible ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
|
||||||
{secretVisible ? "Ocultar" : "Mostrar"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
|
||||||
onClick={() => handleCopyRemoteCredential(entry.password, "Senha do acesso remoto")}
|
|
||||||
title="Copiar senha"
|
|
||||||
aria-label="Copiar senha"
|
|
||||||
>
|
|
||||||
<ClipboardCopy className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{entry.url && !isRustDesk ? (
|
|
||||||
<a
|
|
||||||
href={entry.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 text-slate-600 underline-offset-4 hover:text-slate-900 hover:underline"
|
|
||||||
>
|
|
||||||
Abrir console remoto
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{isRustDesk && (entry.identifier || entry.password) ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-1 inline-flex items-center gap-2 border-slate-300 bg-white text-slate-800 shadow-sm transition-colors hover:border-slate-400 hover:bg-slate-50 hover:text-slate-900 focus-visible:border-slate-400 focus-visible:ring-slate-200"
|
|
||||||
onClick={() => handleRustDeskConnect(entry)}
|
|
||||||
>
|
|
||||||
<MonitorSmartphone className="size-4 text-[#4b5563]" /> Conectar via RustDesk
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{entry.notes ? (
|
|
||||||
<p className="whitespace-pre-wrap text-[11px] text-slate-600">{entry.notes}</p>
|
|
||||||
) : null}
|
|
||||||
{lastVerifiedDate ? (
|
|
||||||
<p className="text-[11px] text-slate-500">
|
|
||||||
Atualizado {formatRelativeTime(lastVerifiedDate)}{" "}
|
|
||||||
<span className="text-slate-400">({formatAbsoluteDateTime(lastVerifiedDate)})</span>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{canManageRemoteAccess ? (
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2 border-slate-300"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingRemoteAccessClientId(entry.clientId)
|
|
||||||
setRemoteAccessDialog(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="size-3.5" /> Editar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="gap-2 text-rose-600 hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700"
|
|
||||||
onClick={() => void handleRemoveRemoteAccess(entry)}
|
|
||||||
disabled={remoteAccessSaving}
|
|
||||||
>
|
|
||||||
<ShieldOff className="size-3.5" /> Remover
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-600">
|
|
||||||
Nenhum identificador de acesso remoto cadastrado. Registre o ID do TeamViewer, AnyDesk ou outra ferramenta para agilizar o suporte.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3 border-t border-slate-100 pt-6">
|
<section className="space-y-3 border-t border-slate-100 pt-6">
|
||||||
|
|
@ -4686,7 +4622,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
return (
|
return (
|
||||||
<li key={`gpu-${idx}`}>
|
<li key={`gpu-${idx}`}>
|
||||||
<span className="font-medium text-foreground">{name ?? "Adaptador de vídeo"}</span>
|
<span className="font-medium text-foreground">{name ?? "Adaptador de vídeo"}</span>
|
||||||
{memoryBytes ? <span className="ml-1 text-muted-foreground">{formatBytes(memoryBytes)}</span> : null}
|
{memoryBytes ? <span className="ml-1 text-muted-foreground">VRAM {formatBytes(memoryBytes)}</span> : null}
|
||||||
{vendor ? <span className="ml-1 text-muted-foreground">· {vendor}</span> : null}
|
{vendor ? <span className="ml-1 text-muted-foreground">· {vendor}</span> : null}
|
||||||
{driver ? <span className="ml-1 text-muted-foreground">· Driver {driver}</span> : null}
|
{driver ? <span className="ml-1 text-muted-foreground">· Driver {driver}</span> : null}
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue