Add USB storage device control feature
- Add USB policy fields to machines schema (policy, status, error) - Create usbPolicyEvents table for audit logging - Implement Convex mutations/queries for USB policy management - Add REST API endpoints for desktop agent communication - Create Rust usb_control module for Windows registry manipulation - Integrate USB policy check in agent heartbeat loop - Add USB policy control component in admin device overview - Add localhost:3001 to auth trustedOrigins for dev 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0e9310d6e4
commit
49aa143a80
11 changed files with 1116 additions and 1 deletions
3
apps/desktop/src-tauri/Cargo.lock
generated
3
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -69,6 +69,7 @@ dependencies = [
|
|||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
|
@ -1094,6 +1095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3291,6 +3293,7 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
|
|||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
|
|
|
|||
|
|
@ -36,3 +36,6 @@ parking_lot = "0.12"
|
|||
hostname = "0.4"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.55"
|
||||
|
|
|
|||
|
|
@ -1129,6 +1129,109 @@ async fn post_heartbeat(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UsbPolicyResponse {
|
||||
pending: bool,
|
||||
policy: Option<String>,
|
||||
applied_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UsbPolicyStatusReport {
|
||||
machine_token: String,
|
||||
status: String,
|
||||
error: Option<String>,
|
||||
current_policy: Option<String>,
|
||||
}
|
||||
|
||||
async fn check_and_apply_usb_policy(base_url: &str, token: &str) {
|
||||
let url = format!("{}/api/machines/usb-policy?machineToken={}", base_url, token);
|
||||
|
||||
let response = match HTTP_CLIENT.get(&url).send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
eprintln!("[agent] Falha ao verificar politica USB: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let policy_response: UsbPolicyResponse = match response.json().await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
eprintln!("[agent] Falha ao parsear resposta de politica USB: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !policy_response.pending {
|
||||
return;
|
||||
}
|
||||
|
||||
let policy_str = match policy_response.policy {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("[agent] Politica USB pendente mas sem valor de policy");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("[agent] Aplicando politica USB: {}", policy_str);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use crate::usb_control::{apply_usb_policy, UsbPolicy};
|
||||
|
||||
let policy = match UsbPolicy::from_str(&policy_str) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("[agent] Politica USB invalida: {}", policy_str);
|
||||
report_usb_policy_status(base_url, token, "FAILED", Some(format!("Politica invalida: {}", policy_str)), None).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match apply_usb_policy(policy) {
|
||||
Ok(result) => {
|
||||
eprintln!("[agent] Politica USB aplicada com sucesso: {:?}", result);
|
||||
report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[agent] Falha ao aplicar politica USB: {e}");
|
||||
report_usb_policy_status(base_url, token, "FAILED", Some(e.to_string()), None).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
eprintln!("[agent] Controle de USB nao suportado neste sistema operacional");
|
||||
report_usb_policy_status(base_url, token, "FAILED", Some("Sistema operacional nao suportado".to_string()), None).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn report_usb_policy_status(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
status: &str,
|
||||
error: Option<String>,
|
||||
current_policy: Option<String>,
|
||||
) {
|
||||
let url = format!("{}/api/machines/usb-policy", base_url);
|
||||
|
||||
let report = UsbPolicyStatusReport {
|
||||
machine_token: token.to_string(),
|
||||
status: status.to_string(),
|
||||
error,
|
||||
current_policy,
|
||||
};
|
||||
|
||||
if let Err(e) = HTTP_CLIENT.post(&url).json(&report).send().await {
|
||||
eprintln!("[agent] Falha ao reportar status de politica USB: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
struct HeartbeatHandle {
|
||||
token: String,
|
||||
base_url: String,
|
||||
|
|
@ -1198,6 +1301,9 @@ impl AgentRuntime {
|
|||
eprintln!("[agent] Falha inicial ao enviar heartbeat: {error}");
|
||||
}
|
||||
|
||||
// Verifica politica USB apos heartbeat inicial
|
||||
check_and_apply_usb_policy(&base_clone, &token_clone).await;
|
||||
|
||||
let mut ticker = tokio::time::interval(Duration::from_secs(interval));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
|
|
@ -1215,6 +1321,9 @@ impl AgentRuntime {
|
|||
{
|
||||
eprintln!("[agent] Falha ao enviar heartbeat: {error}");
|
||||
}
|
||||
|
||||
// Verifica politica USB apos cada heartbeat
|
||||
check_and_apply_usb_policy(&base_clone, &token_clone).await;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
mod agent;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod rustdesk;
|
||||
mod usb_control;
|
||||
|
||||
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
|
||||
use usb_control::{UsbPolicy, UsbPolicyResult};
|
||||
use tauri::{Emitter, Manager, WindowEvent};
|
||||
use tauri_plugin_store::Builder as StorePluginBuilder;
|
||||
#[cfg(target_os = "windows")]
|
||||
|
|
@ -100,6 +102,26 @@ fn run_rustdesk_ensure(
|
|||
Err("Provisionamento automático do RustDesk está disponível apenas no Windows.".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn apply_usb_policy(policy: String) -> Result<UsbPolicyResult, String> {
|
||||
let policy_enum = UsbPolicy::from_str(&policy)
|
||||
.ok_or_else(|| format!("Politica USB invalida: {}. Use ALLOW, BLOCK_ALL ou READONLY.", policy))?;
|
||||
|
||||
usb_control::apply_usb_policy(policy_enum).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_usb_policy() -> Result<String, String> {
|
||||
usb_control::get_current_policy()
|
||||
.map(|p| p.as_str().to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn refresh_usb_policy() -> Result<(), String> {
|
||||
usb_control::refresh_group_policy().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
|
|
@ -128,7 +150,10 @@ pub fn run() {
|
|||
start_machine_agent,
|
||||
stop_machine_agent,
|
||||
open_devtools,
|
||||
ensure_rustdesk_and_emit
|
||||
ensure_rustdesk_and_emit,
|
||||
apply_usb_policy,
|
||||
get_usb_policy,
|
||||
refresh_usb_policy
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
296
apps/desktop/src-tauri/src/usb_control.rs
Normal file
296
apps/desktop/src-tauri/src/usb_control.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
//! USB Storage Control Module
|
||||
//!
|
||||
//! Este modulo implementa o controle de dispositivos de armazenamento USB no Windows.
|
||||
//! Utiliza duas abordagens complementares:
|
||||
//! 1. Removable Storage Access Policy (via registro do Windows)
|
||||
//! 2. USBSTOR driver control (como fallback/reforco)
|
||||
//!
|
||||
//! IMPORTANTE: Requer privilegios de administrador para funcionar.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum UsbPolicy {
|
||||
Allow,
|
||||
BlockAll,
|
||||
Readonly,
|
||||
}
|
||||
|
||||
impl UsbPolicy {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_uppercase().as_str() {
|
||||
"ALLOW" => Some(Self::Allow),
|
||||
"BLOCK_ALL" => Some(Self::BlockAll),
|
||||
"READONLY" => Some(Self::Readonly),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Allow => "ALLOW",
|
||||
Self::BlockAll => "BLOCK_ALL",
|
||||
Self::Readonly => "READONLY",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsbPolicyResult {
|
||||
pub success: bool,
|
||||
pub policy: String,
|
||||
pub error: Option<String>,
|
||||
pub applied_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum UsbControlError {
|
||||
#[error("Politica USB invalida: {0}")]
|
||||
InvalidPolicy(String),
|
||||
#[error("Erro de registro do Windows: {0}")]
|
||||
RegistryError(String),
|
||||
#[error("Permissao negada - requer privilegios de administrador")]
|
||||
PermissionDenied,
|
||||
#[error("Sistema operacional nao suportado")]
|
||||
UnsupportedOs,
|
||||
#[error("Erro de I/O: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_impl {
|
||||
use super::*;
|
||||
use winreg::enums::*;
|
||||
use winreg::RegKey;
|
||||
|
||||
// GUID para Removable Storage Devices (Disk)
|
||||
const REMOVABLE_STORAGE_GUID: &str = "{53f56307-b6bf-11d0-94f2-00a0c91efb8b}";
|
||||
|
||||
// Chaves de registro
|
||||
const REMOVABLE_STORAGE_PATH: &str =
|
||||
r"Software\Policies\Microsoft\Windows\RemovableStorageDevices";
|
||||
const USBSTOR_PATH: &str = r"SYSTEM\CurrentControlSet\Services\USBSTOR";
|
||||
const STORAGE_POLICY_PATH: &str = r"SYSTEM\CurrentControlSet\Control\StorageDevicePolicies";
|
||||
|
||||
pub fn apply_usb_policy(policy: UsbPolicy) -> Result<UsbPolicyResult, UsbControlError> {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
// 1. Aplicar Removable Storage Access Policy
|
||||
apply_removable_storage_policy(policy)?;
|
||||
|
||||
// 2. Aplicar USBSTOR como reforco
|
||||
apply_usbstor_policy(policy)?;
|
||||
|
||||
// 3. Aplicar WriteProtect se necessario
|
||||
if policy == UsbPolicy::Readonly {
|
||||
apply_write_protect(true)?;
|
||||
} else {
|
||||
apply_write_protect(false)?;
|
||||
}
|
||||
|
||||
Ok(UsbPolicyResult {
|
||||
success: true,
|
||||
policy: policy.as_str().to_string(),
|
||||
error: None,
|
||||
applied_at: Some(now),
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_removable_storage_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
|
||||
let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID);
|
||||
|
||||
match policy {
|
||||
UsbPolicy::Allow => {
|
||||
// Tenta remover as restricoes, se existirem
|
||||
if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_ALL_ACCESS) {
|
||||
let _ = key.delete_value("Deny_Read");
|
||||
let _ = key.delete_value("Deny_Write");
|
||||
let _ = key.delete_value("Deny_Execute");
|
||||
}
|
||||
// Tenta remover a chave inteira se estiver vazia
|
||||
let _ = hklm.delete_subkey(&full_path);
|
||||
}
|
||||
UsbPolicy::BlockAll => {
|
||||
let (key, _) = hklm
|
||||
.create_subkey(&full_path)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
|
||||
key.set_value("Deny_Read", &1u32)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
key.set_value("Deny_Write", &1u32)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
key.set_value("Deny_Execute", &1u32)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
}
|
||||
UsbPolicy::Readonly => {
|
||||
let (key, _) = hklm
|
||||
.create_subkey(&full_path)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
|
||||
// Permite leitura, bloqueia escrita
|
||||
key.set_value("Deny_Read", &0u32)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
key.set_value("Deny_Write", &1u32)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
key.set_value("Deny_Execute", &0u32)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_usbstor_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
|
||||
let key = hklm
|
||||
.open_subkey_with_flags(USBSTOR_PATH, KEY_ALL_ACCESS)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
|
||||
match policy {
|
||||
UsbPolicy::Allow => {
|
||||
// Start = 3 habilita o driver
|
||||
key.set_value("Start", &3u32)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
}
|
||||
UsbPolicy::BlockAll | UsbPolicy::Readonly => {
|
||||
// Start = 4 desabilita o driver
|
||||
// Nota: Para Readonly, mantemos o driver ativo mas com WriteProtect
|
||||
// Porem, como fallback de seguranca, desabilitamos para BlockAll
|
||||
if policy == UsbPolicy::BlockAll {
|
||||
key.set_value("Start", &4u32)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
} else {
|
||||
// Readonly mantem driver ativo
|
||||
key.set_value("Start", &3u32)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_write_protect(enable: bool) -> Result<(), UsbControlError> {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
|
||||
if enable {
|
||||
let (key, _) = hklm
|
||||
.create_subkey(STORAGE_POLICY_PATH)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
|
||||
key.set_value("WriteProtect", &1u32)
|
||||
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
|
||||
} else {
|
||||
if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
|
||||
let _ = key.set_value("WriteProtect", &0u32);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_current_policy() -> Result<UsbPolicy, UsbControlError> {
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
|
||||
// Verifica Removable Storage Policy primeiro
|
||||
let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID);
|
||||
|
||||
if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_READ) {
|
||||
let deny_read: u32 = key.get_value("Deny_Read").unwrap_or(0);
|
||||
let deny_write: u32 = key.get_value("Deny_Write").unwrap_or(0);
|
||||
|
||||
if deny_read == 1 && deny_write == 1 {
|
||||
return Ok(UsbPolicy::BlockAll);
|
||||
}
|
||||
|
||||
if deny_read == 0 && deny_write == 1 {
|
||||
return Ok(UsbPolicy::Readonly);
|
||||
}
|
||||
}
|
||||
|
||||
// Verifica USBSTOR como fallback
|
||||
if let Ok(key) = hklm.open_subkey_with_flags(USBSTOR_PATH, KEY_READ) {
|
||||
let start: u32 = key.get_value("Start").unwrap_or(3);
|
||||
if start == 4 {
|
||||
return Ok(UsbPolicy::BlockAll);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(UsbPolicy::Allow)
|
||||
}
|
||||
|
||||
pub fn refresh_group_policy() -> Result<(), UsbControlError> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
// Executa gpupdate para forcar atualizacao das politicas
|
||||
let output = Command::new("gpupdate")
|
||||
.args(["/target:computer", "/force"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| UsbControlError::Io(e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Nao e critico se falhar, apenas log
|
||||
eprintln!(
|
||||
"[usb_control] gpupdate retornou erro: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod fallback_impl {
|
||||
use super::*;
|
||||
|
||||
pub fn apply_usb_policy(_policy: UsbPolicy) -> Result<UsbPolicyResult, UsbControlError> {
|
||||
Err(UsbControlError::UnsupportedOs)
|
||||
}
|
||||
|
||||
pub fn get_current_policy() -> Result<UsbPolicy, UsbControlError> {
|
||||
Err(UsbControlError::UnsupportedOs)
|
||||
}
|
||||
|
||||
pub fn refresh_group_policy() -> Result<(), UsbControlError> {
|
||||
Err(UsbControlError::UnsupportedOs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use windows_impl::*;
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub use fallback_impl::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_policy_from_str() {
|
||||
assert_eq!(UsbPolicy::from_str("ALLOW"), Some(UsbPolicy::Allow));
|
||||
assert_eq!(UsbPolicy::from_str("BLOCK_ALL"), Some(UsbPolicy::BlockAll));
|
||||
assert_eq!(UsbPolicy::from_str("READONLY"), Some(UsbPolicy::Readonly));
|
||||
assert_eq!(UsbPolicy::from_str("allow"), Some(UsbPolicy::Allow));
|
||||
assert_eq!(UsbPolicy::from_str("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_as_str() {
|
||||
assert_eq!(UsbPolicy::Allow.as_str(), "ALLOW");
|
||||
assert_eq!(UsbPolicy::BlockAll.as_str(), "BLOCK_ALL");
|
||||
assert_eq!(UsbPolicy::Readonly.as_str(), "READONLY");
|
||||
}
|
||||
}
|
||||
|
|
@ -637,6 +637,11 @@ export default defineSchema({
|
|||
updatedAt: v.number(),
|
||||
registeredBy: v.optional(v.string()),
|
||||
remoteAccess: v.optional(v.any()),
|
||||
usbPolicy: v.optional(v.string()), // ALLOW | BLOCK_ALL | READONLY
|
||||
usbPolicyAppliedAt: v.optional(v.number()),
|
||||
usbPolicyStatus: v.optional(v.string()), // PENDING | APPLIED | FAILED
|
||||
usbPolicyError: v.optional(v.string()),
|
||||
usbPolicyReportedAt: v.optional(v.number()),
|
||||
})
|
||||
.index("by_tenant", ["tenantId"])
|
||||
.index("by_tenant_company", ["tenantId", "companyId"])
|
||||
|
|
@ -644,6 +649,23 @@ export default defineSchema({
|
|||
.index("by_tenant_assigned_email", ["tenantId", "assignedUserEmail"])
|
||||
.index("by_auth_email", ["authEmail"]),
|
||||
|
||||
usbPolicyEvents: defineTable({
|
||||
tenantId: v.string(),
|
||||
machineId: v.id("machines"),
|
||||
actorId: v.optional(v.id("users")),
|
||||
actorEmail: v.optional(v.string()),
|
||||
actorName: v.optional(v.string()),
|
||||
oldPolicy: v.optional(v.string()),
|
||||
newPolicy: v.string(),
|
||||
status: v.string(), // PENDING | APPLIED | FAILED
|
||||
error: v.optional(v.string()),
|
||||
appliedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_machine", ["machineId"])
|
||||
.index("by_machine_created", ["machineId", "createdAt"])
|
||||
.index("by_tenant_created", ["tenantId", "createdAt"]),
|
||||
|
||||
machineAlerts: defineTable({
|
||||
tenantId: v.string(),
|
||||
machineId: v.id("machines"),
|
||||
|
|
|
|||
251
convex/usbPolicy.ts
Normal file
251
convex/usbPolicy.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import { v } from "convex/values"
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import type { Id, Doc } from "./_generated/dataModel"
|
||||
|
||||
const DEFAULT_TENANT_ID = "default"
|
||||
|
||||
export const USB_POLICY_VALUES = ["ALLOW", "BLOCK_ALL", "READONLY"] as const
|
||||
export type UsbPolicyValue = (typeof USB_POLICY_VALUES)[number]
|
||||
|
||||
export const USB_POLICY_STATUS = ["PENDING", "APPLIED", "FAILED"] as const
|
||||
export type UsbPolicyStatus = (typeof USB_POLICY_STATUS)[number]
|
||||
|
||||
export const setUsbPolicy = mutation({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
policy: v.string(),
|
||||
actorId: v.optional(v.id("users")),
|
||||
actorEmail: v.optional(v.string()),
|
||||
actorName: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
throw new Error("Dispositivo nao encontrado")
|
||||
}
|
||||
|
||||
if (!USB_POLICY_VALUES.includes(args.policy as UsbPolicyValue)) {
|
||||
throw new Error(`Politica USB invalida: ${args.policy}. Valores validos: ${USB_POLICY_VALUES.join(", ")}`)
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const oldPolicy = machine.usbPolicy ?? "ALLOW"
|
||||
|
||||
await ctx.db.patch(args.machineId, {
|
||||
usbPolicy: args.policy,
|
||||
usbPolicyStatus: "PENDING",
|
||||
usbPolicyError: undefined,
|
||||
usbPolicyAppliedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await ctx.db.insert("usbPolicyEvents", {
|
||||
tenantId: machine.tenantId,
|
||||
machineId: args.machineId,
|
||||
actorId: args.actorId,
|
||||
actorEmail: args.actorEmail,
|
||||
actorName: args.actorName,
|
||||
oldPolicy,
|
||||
newPolicy: args.policy,
|
||||
status: "PENDING",
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true, policy: args.policy, status: "PENDING" }
|
||||
},
|
||||
})
|
||||
|
||||
export const reportUsbPolicyStatus = mutation({
|
||||
args: {
|
||||
machineToken: v.string(),
|
||||
status: v.string(),
|
||||
error: v.optional(v.string()),
|
||||
currentPolicy: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const tokenHash = args.machineToken
|
||||
|
||||
const tokenRecord = await ctx.db
|
||||
.query("machineTokens")
|
||||
.withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash))
|
||||
.first()
|
||||
|
||||
if (!tokenRecord || tokenRecord.revoked) {
|
||||
throw new Error("Token de maquina invalido ou revogado")
|
||||
}
|
||||
|
||||
if (tokenRecord.expiresAt < Date.now()) {
|
||||
throw new Error("Token de maquina expirado")
|
||||
}
|
||||
|
||||
const machine = await ctx.db.get(tokenRecord.machineId)
|
||||
if (!machine) {
|
||||
throw new Error("Dispositivo nao encontrado")
|
||||
}
|
||||
|
||||
if (!USB_POLICY_STATUS.includes(args.status as UsbPolicyStatus)) {
|
||||
throw new Error(`Status de politica USB invalido: ${args.status}`)
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
await ctx.db.patch(machine._id, {
|
||||
usbPolicyStatus: args.status,
|
||||
usbPolicyError: args.error,
|
||||
usbPolicyReportedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const latestEvent = await ctx.db
|
||||
.query("usbPolicyEvents")
|
||||
.withIndex("by_machine_created", (q) => q.eq("machineId", machine._id))
|
||||
.order("desc")
|
||||
.first()
|
||||
|
||||
if (latestEvent && latestEvent.status === "PENDING") {
|
||||
await ctx.db.patch(latestEvent._id, {
|
||||
status: args.status,
|
||||
error: args.error,
|
||||
appliedAt: args.status === "APPLIED" ? now : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
})
|
||||
|
||||
export const getUsbPolicy = query({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
policy: machine.usbPolicy ?? "ALLOW",
|
||||
status: machine.usbPolicyStatus ?? null,
|
||||
error: machine.usbPolicyError ?? null,
|
||||
appliedAt: machine.usbPolicyAppliedAt ?? null,
|
||||
reportedAt: machine.usbPolicyReportedAt ?? null,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const getPendingUsbPolicy = query({
|
||||
args: {
|
||||
machineToken: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const tokenHash = args.machineToken
|
||||
|
||||
const tokenRecord = await ctx.db
|
||||
.query("machineTokens")
|
||||
.withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash))
|
||||
.first()
|
||||
|
||||
if (!tokenRecord || tokenRecord.revoked || tokenRecord.expiresAt < Date.now()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const machine = await ctx.db.get(tokenRecord.machineId)
|
||||
if (!machine) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (machine.usbPolicyStatus === "PENDING") {
|
||||
return {
|
||||
policy: machine.usbPolicy ?? "ALLOW",
|
||||
appliedAt: machine.usbPolicyAppliedAt,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
export const listUsbPolicyEvents = query({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const limit = args.limit ?? 50
|
||||
|
||||
const events = await ctx.db
|
||||
.query("usbPolicyEvents")
|
||||
.withIndex("by_machine_created", (q) => q.eq("machineId", args.machineId))
|
||||
.order("desc")
|
||||
.take(limit)
|
||||
|
||||
return events.map((event) => ({
|
||||
id: event._id,
|
||||
oldPolicy: event.oldPolicy,
|
||||
newPolicy: event.newPolicy,
|
||||
status: event.status,
|
||||
error: event.error,
|
||||
actorEmail: event.actorEmail,
|
||||
actorName: event.actorName,
|
||||
createdAt: event.createdAt,
|
||||
appliedAt: event.appliedAt,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
export const bulkSetUsbPolicy = mutation({
|
||||
args: {
|
||||
machineIds: v.array(v.id("machines")),
|
||||
policy: v.string(),
|
||||
actorId: v.optional(v.id("users")),
|
||||
actorEmail: v.optional(v.string()),
|
||||
actorName: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
if (!USB_POLICY_VALUES.includes(args.policy as UsbPolicyValue)) {
|
||||
throw new Error(`Politica USB invalida: ${args.policy}`)
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const results: Array<{ machineId: Id<"machines">; success: boolean; error?: string }> = []
|
||||
|
||||
for (const machineId of args.machineIds) {
|
||||
try {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
results.push({ machineId, success: false, error: "Dispositivo nao encontrado" })
|
||||
continue
|
||||
}
|
||||
|
||||
const oldPolicy = machine.usbPolicy ?? "ALLOW"
|
||||
|
||||
await ctx.db.patch(machineId, {
|
||||
usbPolicy: args.policy,
|
||||
usbPolicyStatus: "PENDING",
|
||||
usbPolicyError: undefined,
|
||||
usbPolicyAppliedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await ctx.db.insert("usbPolicyEvents", {
|
||||
tenantId: machine.tenantId,
|
||||
machineId,
|
||||
actorId: args.actorId,
|
||||
actorEmail: args.actorEmail,
|
||||
actorName: args.actorName,
|
||||
oldPolicy,
|
||||
newPolicy: args.policy,
|
||||
status: "PENDING",
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
results.push({ machineId, success: true })
|
||||
} catch (err) {
|
||||
results.push({ machineId, success: false, error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
return { results, total: args.machineIds.length, successful: results.filter((r) => r.success).length }
|
||||
},
|
||||
})
|
||||
97
src/app/api/machines/usb-policy/route.ts
Normal file
97
src/app/api/machines/usb-policy/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { z } from "zod"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||
|
||||
const getPolicySchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
})
|
||||
|
||||
const reportStatusSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
status: z.enum(["PENDING", "APPLIED", "FAILED"]),
|
||||
error: z.string().optional(),
|
||||
currentPolicy: z.string().optional(),
|
||||
})
|
||||
|
||||
const CORS_METHODS = "GET, POST, OPTIONS"
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const origin = request.headers.get("origin")
|
||||
|
||||
let client
|
||||
try {
|
||||
client = createConvexClient()
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const machineToken = url.searchParams.get("machineToken")
|
||||
|
||||
if (!machineToken) {
|
||||
return jsonWithCors({ error: "machineToken e obrigatorio" }, 400, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
try {
|
||||
const pendingPolicy = await client.query(api.usbPolicy.getPendingUsbPolicy, { machineToken })
|
||||
|
||||
if (!pendingPolicy) {
|
||||
return jsonWithCors({ pending: false }, 200, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
return jsonWithCors({
|
||||
pending: true,
|
||||
policy: pendingPolicy.policy,
|
||||
appliedAt: pendingPolicy.appliedAt,
|
||||
}, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.usb-policy] Falha ao buscar politica USB", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
return jsonWithCors({ error: "Falha ao buscar politica USB", details }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const origin = request.headers.get("origin")
|
||||
|
||||
let client
|
||||
try {
|
||||
client = createConvexClient()
|
||||
} catch (error) {
|
||||
if (error instanceof ConvexConfigurationError) {
|
||||
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
const raw = await request.json()
|
||||
payload = reportStatusSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return jsonWithCors(
|
||||
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.mutation(api.usbPolicy.reportUsbPolicyStatus, payload)
|
||||
return jsonWithCors(response, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.usb-policy] Falha ao reportar status de politica USB", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
return jsonWithCors({ error: "Falha ao reportar status", details }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ import type { Id } from "@/convex/_generated/dataModel"
|
|||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
|
||||
import { DeviceCustomFieldManager } from "@/components/admin/devices/device-custom-field-manager"
|
||||
import { UsbPolicyControl } from "@/components/admin/devices/usb-policy-control"
|
||||
import { DatePicker } from "@/components/ui/date-picker"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
|
||||
|
|
@ -4961,6 +4962,16 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManageRemoteAccess && device?.osName?.toLowerCase().includes("windows") ? (
|
||||
<UsbPolicyControl
|
||||
machineId={device.id}
|
||||
machineName={device.displayName ?? device.hostname}
|
||||
actorEmail={primaryLinkedUser?.email}
|
||||
actorName={primaryLinkedUser?.name}
|
||||
disabled={isDeactivated}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{windowsCpuDetails.length > 0 ? (
|
||||
|
|
|
|||
296
src/components/admin/devices/usb-policy-control.tsx
Normal file
296
src/components/admin/devices/usb-policy-control.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { Usb, Shield, ShieldOff, ShieldAlert, Clock, CheckCircle2, XCircle, Loader2, History } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
|
||||
type UsbPolicyValue = "ALLOW" | "BLOCK_ALL" | "READONLY"
|
||||
|
||||
const POLICY_OPTIONS: Array<{ value: UsbPolicyValue; label: string; description: string; icon: typeof Shield }> = [
|
||||
{
|
||||
value: "ALLOW",
|
||||
label: "Permitido",
|
||||
description: "Acesso total a dispositivos USB de armazenamento",
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
value: "BLOCK_ALL",
|
||||
label: "Bloqueado",
|
||||
description: "Nenhum acesso a dispositivos USB de armazenamento",
|
||||
icon: ShieldOff,
|
||||
},
|
||||
{
|
||||
value: "READONLY",
|
||||
label: "Somente leitura",
|
||||
description: "Permite leitura, bloqueia escrita em dispositivos USB",
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
]
|
||||
|
||||
function getPolicyConfig(policy: string | undefined | null) {
|
||||
return POLICY_OPTIONS.find((opt) => opt.value === policy) ?? POLICY_OPTIONS[0]
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string | undefined | null) {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 border-amber-200 bg-amber-50 text-amber-700">
|
||||
<Clock className="size-3" />
|
||||
Pendente
|
||||
</Badge>
|
||||
)
|
||||
case "APPLIED":
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 border-emerald-200 bg-emerald-50 text-emerald-700">
|
||||
<CheckCircle2 className="size-3" />
|
||||
Aplicado
|
||||
</Badge>
|
||||
)
|
||||
case "FAILED":
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 border-red-200 bg-red-50 text-red-700">
|
||||
<XCircle className="size-3" />
|
||||
Falhou
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface UsbPolicyControlProps {
|
||||
machineId: string
|
||||
machineName?: string
|
||||
actorEmail?: string
|
||||
actorName?: string
|
||||
actorId?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function UsbPolicyControl({
|
||||
machineId,
|
||||
machineName,
|
||||
actorEmail,
|
||||
actorName,
|
||||
actorId,
|
||||
disabled = false,
|
||||
}: UsbPolicyControlProps) {
|
||||
const [selectedPolicy, setSelectedPolicy] = useState<UsbPolicyValue>("ALLOW")
|
||||
const [isApplying, setIsApplying] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
const usbPolicy = useQuery(api.usbPolicy.getUsbPolicy, {
|
||||
machineId: machineId as Id<"machines">,
|
||||
})
|
||||
|
||||
const policyEvents = useQuery(
|
||||
api.usbPolicy.listUsbPolicyEvents,
|
||||
showHistory ? { machineId: machineId as Id<"machines">, limit: 10 } : "skip"
|
||||
)
|
||||
|
||||
const setUsbPolicyMutation = useMutation(api.usbPolicy.setUsbPolicy)
|
||||
|
||||
useEffect(() => {
|
||||
if (usbPolicy?.policy) {
|
||||
setSelectedPolicy(usbPolicy.policy as UsbPolicyValue)
|
||||
}
|
||||
}, [usbPolicy?.policy])
|
||||
|
||||
const currentConfig = getPolicyConfig(usbPolicy?.policy)
|
||||
const CurrentIcon = currentConfig.icon
|
||||
|
||||
const handleApplyPolicy = async () => {
|
||||
if (selectedPolicy === usbPolicy?.policy) {
|
||||
toast.info("A politica selecionada ja esta aplicada.")
|
||||
return
|
||||
}
|
||||
|
||||
setIsApplying(true)
|
||||
try {
|
||||
await setUsbPolicyMutation({
|
||||
machineId: machineId as Id<"machines">,
|
||||
policy: selectedPolicy,
|
||||
actorId: actorId ? (actorId as Id<"users">) : undefined,
|
||||
actorEmail,
|
||||
actorName,
|
||||
})
|
||||
toast.success("Politica USB enviada para aplicacao.")
|
||||
} catch (error) {
|
||||
console.error("[usb-policy] Falha ao aplicar politica", error)
|
||||
toast.error("Falha ao aplicar politica USB. Tente novamente.")
|
||||
} finally {
|
||||
setIsApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatEventDate = (timestamp: number) => {
|
||||
return formatDistanceToNow(new Date(timestamp), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Usb className="size-5 text-neutral-500" />
|
||||
<CardTitle className="text-base">Controle USB</CardTitle>
|
||||
</div>
|
||||
{usbPolicy?.status && getStatusBadge(usbPolicy.status)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Gerencie o acesso a dispositivos de armazenamento USB neste dispositivo.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-neutral-50 p-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-white shadow-sm">
|
||||
<CurrentIcon className="size-5 text-neutral-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{currentConfig.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{currentConfig.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{usbPolicy?.error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<p className="text-sm font-medium text-red-700">Erro na aplicacao</p>
|
||||
<p className="text-xs text-red-600">{usbPolicy.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Alterar politica</label>
|
||||
<Select
|
||||
value={selectedPolicy}
|
||||
onValueChange={(value) => setSelectedPolicy(value as UsbPolicyValue)}
|
||||
disabled={disabled || isApplying}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma politica" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{POLICY_OPTIONS.map((option) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4" />
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={handleApplyPolicy}
|
||||
disabled={disabled || isApplying || selectedPolicy === usbPolicy?.policy}
|
||||
size="default"
|
||||
>
|
||||
{isApplying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Aplicando...
|
||||
</>
|
||||
) : (
|
||||
"Aplicar"
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{selectedPolicy === usbPolicy?.policy
|
||||
? "A politica ja esta aplicada"
|
||||
: `Aplicar politica "${getPolicyConfig(selectedPolicy).label}"`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-muted-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<History className="size-4" />
|
||||
{showHistory ? "Ocultar historico" : "Ver historico de alteracoes"}
|
||||
</Button>
|
||||
|
||||
{showHistory && policyEvents && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{policyEvents.length === 0 ? (
|
||||
<p className="text-center text-xs text-muted-foreground py-2">
|
||||
Nenhuma alteracao registrada
|
||||
</p>
|
||||
) : (
|
||||
policyEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex items-start justify-between rounded-md border bg-white p-2 text-xs"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium">
|
||||
{getPolicyConfig(event.oldPolicy).label}
|
||||
</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="font-medium">
|
||||
{getPolicyConfig(event.newPolicy).label}
|
||||
</span>
|
||||
{getStatusBadge(event.status)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{event.actorName ?? event.actorEmail ?? "Sistema"} · {formatEventDate(event.createdAt)}
|
||||
</p>
|
||||
{event.error && (
|
||||
<p className="text-red-600">{event.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{usbPolicy?.reportedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ultimo relato do agente: {formatEventDate(usbPolicy.reportedAt)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ export const auth = betterAuth({
|
|||
env.NEXT_PUBLIC_APP_URL,
|
||||
process.env.NODE_ENV !== "production" ? "http://localhost:3000" : undefined,
|
||||
process.env.NODE_ENV !== "production" ? "http://127.0.0.1:3000" : undefined,
|
||||
process.env.NODE_ENV !== "production" ? "http://localhost:3001" : undefined,
|
||||
process.env.NODE_ENV !== "production" ? "http://127.0.0.1:3001" : undefined,
|
||||
].filter(Boolean) as string[]
|
||||
)
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue