From e556b181df193e50490af985dae0e65fab9bea62 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sat, 18 Apr 2026 01:18:57 +0200 Subject: [PATCH] Gate agent admin UI by OS privileges Add OS-level admin detection and use it to gate sensitive agent UI and tray actions. Introduce privileges.rs (Windows TokenElevation / Unix geteuid) and expose is_os_admin as a Tauri command; wire it into tray setup to hide admin-only menu items (Settings, Quit) and re-check privileges before executing those actions. Add show_window helper and emit navigate events from the tray; frontend listens for navigate and conditionally renders /settings (shows AdminRequired component for non-admins). Update App.tsx to query is_os_admin on startup and include navigation listener; add AdminRequired component, styles, and i18n keys. Update Cargo.toml with platform deps (windows features + libc for unix). Also add UI/locale assets and CSS for agent lazy-loaded device tabs and a notifications dropdown, plus several web-nodejs route/view/style updates and new task docs describing phase work. --- betterdesk-agent-client/src-tauri/Cargo.toml | 5 + .../src-tauri/src/commands.rs | 9 + betterdesk-agent-client/src-tauri/src/lib.rs | 100 +++++-- .../src-tauri/src/privileges.rs | 44 +++ betterdesk-agent-client/src/App.tsx | 95 ++++-- .../src/components/AdminRequired.tsx | 20 ++ betterdesk-agent-client/src/locales/en.json | 5 + betterdesk-agent-client/src/locales/pl.json | 5 + .../src/locales/zh-TW.json | 5 + betterdesk-agent-client/src/styles/global.css | 59 ++++ tasks/PHASE_2_5_STATUS.md | 137 +++++++++ tasks/agent-operational-plan.md | 212 +++++++++++++ web-nodejs/lang/en.json | 24 ++ web-nodejs/lang/pl.json | 24 ++ web-nodejs/lang/zh.json | 24 ++ web-nodejs/public/css/device-detail.css | 214 +++++++++++++ web-nodejs/public/css/notif-center.css | 192 ++++++++++++ web-nodejs/public/js/deviceDetail.js | 276 +++++++++++++++++ web-nodejs/public/js/notif-center.js | 282 ++++++++++++++++++ web-nodejs/routes/bd-api.routes.js | 128 ++++++++ web-nodejs/routes/devices.routes.js | 121 ++++++++ web-nodejs/routes/index.js | 2 + web-nodejs/routes/phase4_5.routes.js | 278 +++++++++++++++++ web-nodejs/services/bdRelay.js | 55 ++++ web-nodejs/services/dbAdapter.js | 52 ++++ web-nodejs/views/downloads-portal.ejs | 105 +++++++ web-nodejs/views/layouts/main.ejs | 2 + web-nodejs/views/partials/navbar.ejs | 22 +- 28 files changed, 2453 insertions(+), 44 deletions(-) create mode 100644 betterdesk-agent-client/src-tauri/src/privileges.rs create mode 100644 betterdesk-agent-client/src/components/AdminRequired.tsx create mode 100644 tasks/PHASE_2_5_STATUS.md create mode 100644 tasks/agent-operational-plan.md create mode 100644 web-nodejs/public/css/notif-center.css create mode 100644 web-nodejs/public/js/notif-center.js create mode 100644 web-nodejs/routes/phase4_5.routes.js create mode 100644 web-nodejs/views/downloads-portal.ejs diff --git a/betterdesk-agent-client/src-tauri/Cargo.toml b/betterdesk-agent-client/src-tauri/Cargo.toml index 391b7946..c3c7246b 100644 --- a/betterdesk-agent-client/src-tauri/Cargo.toml +++ b/betterdesk-agent-client/src-tauri/Cargo.toml @@ -43,8 +43,13 @@ sha2 = "0.10" windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_System_Console", + "Win32_Security", + "Win32_System_Threading", ] } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] diff --git a/betterdesk-agent-client/src-tauri/src/commands.rs b/betterdesk-agent-client/src-tauri/src/commands.rs index 812c0fa4..67ccfc42 100644 --- a/betterdesk-agent-client/src-tauri/src/commands.rs +++ b/betterdesk-agent-client/src-tauri/src/commands.rs @@ -51,6 +51,15 @@ pub struct AgentSettings { // ─────────────────────────── Status & Lifecycle ─────────────────────────── +/// Returns true when the agent process runs with local OS administrator +/// privileges. Used by the frontend + tray menu to gate sensitive actions +/// (Settings, Quit agent, Unregister) so regular users cannot disable the +/// agent without elevation. +#[tauri::command] +pub fn is_os_admin() -> bool { + crate::privileges::is_os_admin() +} + #[tauri::command] pub fn get_agent_status(state: State<'_, AgentState>) -> Result { let config = state.config.lock().map_err(|e| e.to_string())?; diff --git a/betterdesk-agent-client/src-tauri/src/lib.rs b/betterdesk-agent-client/src-tauri/src/lib.rs index e30b9463..911e2859 100644 --- a/betterdesk-agent-client/src-tauri/src/lib.rs +++ b/betterdesk-agent-client/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ pub mod commands; pub mod config; +pub mod privileges; pub mod registration; pub mod sysinfo_collect; @@ -59,6 +60,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ // Status & lifecycle + commands::is_os_admin, commands::get_agent_status, commands::reconnect_agent, commands::send_diagnostics, @@ -107,41 +109,99 @@ pub fn run() { .expect("Failed to start BetterDesk Agent"); } -/// Minimal system tray setup. +/// System tray setup. +/// +/// Menu layout: +/// - User items (always visible): Show ID, Help request, Chat, Check connection +/// - Admin-gated items (OS admin): Settings, Quit agent +/// +/// Admin detection is cached at setup time. If privilege status changes +/// (user elevates mid-session), restart the agent. fn setup_tray(app: &tauri::AppHandle) -> Result<(), Box> { - use tauri::menu::{MenuBuilder, MenuItemBuilder}; + use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem}; use tauri::tray::TrayIconBuilder; - let show = MenuItemBuilder::with_id("show", "Show").build(app)?; - let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?; + let is_admin = privileges::is_os_admin(); + info!("Tray setup — OS admin: {}", is_admin); - let menu = MenuBuilder::new(app).item(&show).separator().item(&quit).build()?; + // Always-visible (user) items. + let show_id = MenuItemBuilder::with_id("show_id", "Show device ID").build(app)?; + let help = MenuItemBuilder::with_id("help_request", "Request help").build(app)?; + let chat = MenuItemBuilder::with_id("chat", "Chat").build(app)?; + let check = MenuItemBuilder::with_id("check_conn", "Check connection").build(app)?; + + let mut builder = MenuBuilder::new(app) + .item(&show_id) + .item(&help) + .item(&chat) + .item(&check); + + // Admin-only items. + if is_admin { + let sep = PredefinedMenuItem::separator(app)?; + let settings = MenuItemBuilder::with_id("settings", "Settings").build(app)?; + let quit = MenuItemBuilder::with_id("quit", "Quit agent").build(app)?; + builder = builder.item(&sep).item(&settings).item(&quit); + } + + let menu = builder.build()?; let _tray = TrayIconBuilder::new() .menu(&menu) - .tooltip("BetterDesk Agent") - .on_menu_event(move |app, event| match event.id().as_ref() { - "show" => { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); + .tooltip(if is_admin { + "BetterDesk Agent (admin)" + } else { + "BetterDesk Agent" + }) + .on_menu_event(move |app, event| { + let id = event.id().as_ref(); + match id { + "show_id" => show_window(app, "/"), + "help_request" => show_window(app, "/help"), + "chat" => show_window(app, "/chat"), + "check_conn" => show_window(app, "/?action=reconnect"), + // Admin-gated. Re-check privilege before executing to guard + // against tampered menu IDs (double-safety). + "settings" => { + if privileges::is_os_admin() { + show_window(app, "/settings"); + } else { + info!("Settings requested but not admin — ignoring"); + } } + "quit" => { + if privileges::is_os_admin() { + info!("Quit requested from tray (admin confirmed)"); + app.exit(0); + } else { + info!("Quit requested but not admin — ignoring"); + } + } + _ => {} } - "quit" => { - info!("Quit requested from tray"); - app.exit(0); - } - _ => {} }) .on_tray_icon_event(|tray, event| { if let tauri::tray::TrayIconEvent::DoubleClick { .. } = event { - if let Some(window) = tray.app_handle().get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } + show_window(tray.app_handle(), "/"); } }) .build(app)?; Ok(()) } + +/// Bring the main window to front and navigate to a given route. +/// +/// The route is emitted as a `navigate` event; the SolidJS router listens +/// and performs client-side navigation. Falls back to showing the window +/// even if navigation fails (e.g. frontend not ready). +fn show_window(app: &tauri::AppHandle, route: &str) { + use tauri::Emitter; + + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + let _ = app.emit("navigate", route.to_string()); + } +} diff --git a/betterdesk-agent-client/src-tauri/src/privileges.rs b/betterdesk-agent-client/src-tauri/src/privileges.rs new file mode 100644 index 00000000..5f29c0f6 --- /dev/null +++ b/betterdesk-agent-client/src-tauri/src/privileges.rs @@ -0,0 +1,44 @@ +//! Detection of local OS administrator privileges. +//! +//! Used to gate sensitive tray menu items (Settings, Unregister, Quit) so +//! regular users cannot disable the agent or reconfigure it without elevation. + +#[cfg(windows)] +pub fn is_os_admin() -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::Security::{ + GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + unsafe { + let mut token: HANDLE = std::ptr::null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return false; + } + + let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 }; + let mut size: u32 = 0; + let ok = GetTokenInformation( + token, + TokenElevation, + &mut elevation as *mut _ as *mut _, + std::mem::size_of::() as u32, + &mut size, + ); + CloseHandle(token); + + ok != 0 && elevation.TokenIsElevated != 0 + } +} + +#[cfg(unix)] +pub fn is_os_admin() -> bool { + // SAFETY: geteuid() has no preconditions and cannot fail. + unsafe { libc::geteuid() == 0 } +} + +#[cfg(not(any(windows, unix)))] +pub fn is_os_admin() -> bool { + false +} diff --git a/betterdesk-agent-client/src/App.tsx b/betterdesk-agent-client/src/App.tsx index 8a7e33c7..9ef197e2 100644 --- a/betterdesk-agent-client/src/App.tsx +++ b/betterdesk-agent-client/src/App.tsx @@ -1,17 +1,49 @@ -import { Component, createSignal, onMount } from "solid-js"; -import { Router, Route } from "@solidjs/router"; +import { Component, createSignal, onMount, onCleanup, Show } from "solid-js"; +import { Router, Route, useNavigate } from "@solidjs/router"; import StatusPanel from "./components/StatusPanel"; import SetupWizard from "./components/SetupWizard"; import ChatPanel from "./components/ChatPanel"; import HelpRequest from "./components/HelpRequest"; import SettingsPanel from "./components/SettingsPanel"; -import Sidebar from "./components/Sidebar"; -import { t, initI18n } from "./lib/i18n"; +import AdminRequired from "./components/AdminRequired"; +import { initI18n } from "./lib/i18n"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; + +// Component that handles navigation events from the tray menu. +// Must be inside to access useNavigate(). +const NavigationListener: Component = () => { + const navigate = useNavigate(); + + onMount(() => { + const unlistenPromise = listen("navigate", (event) => { + try { + const route = event.payload; + if (typeof route === "string" && route.startsWith("/")) { + navigate(route); + } + } catch { + // Ignore malformed navigation payloads — safer to stay on current page. + } + }); + + onCleanup(async () => { + try { + const un = await unlistenPromise; + un(); + } catch { + // Cleanup is best-effort on unmount. + } + }); + }); + + return null; +}; const App: Component = () => { const [ready, setReady] = createSignal(false); const [registered, setRegistered] = createSignal(false); + const [isAdmin, setIsAdmin] = createSignal(false); onMount(async () => { await initI18n(); @@ -21,30 +53,47 @@ const App: Component = () => { } catch { setRegistered(false); } + try { + const admin = await invoke("is_os_admin"); + setIsAdmin(admin); + } catch { + setIsAdmin(false); + } setReady(true); }); return (
- {!ready() ? ( -
- sync -
- ) : !registered() ? ( - setRegistered(true)} /> - ) : ( -
- -
- - - - - - -
-
- )} + + sync +
+ } + > + setRegistered(true)} />} + > +
+
+ + + + + + + isAdmin() ? : + } + /> + +
+
+
+ ); }; diff --git a/betterdesk-agent-client/src/components/AdminRequired.tsx b/betterdesk-agent-client/src/components/AdminRequired.tsx new file mode 100644 index 00000000..6c973dc7 --- /dev/null +++ b/betterdesk-agent-client/src/components/AdminRequired.tsx @@ -0,0 +1,20 @@ +import { Component } from "solid-js"; +import { t } from "../lib/i18n"; + +/// Shown when a non-admin user tries to access a privilege-gated route +/// (e.g. /settings). The tray menu already hides these items when the agent +/// runs unelevated, but this is a defense-in-depth fallback. +const AdminRequired: Component = () => { + return ( +
+
+ admin_panel_settings +
+

{t("admin_required.title")}

+

{t("admin_required.message")}

+

{t("admin_required.hint")}

+
+ ); +}; + +export default AdminRequired; diff --git a/betterdesk-agent-client/src/locales/en.json b/betterdesk-agent-client/src/locales/en.json index c747aa95..b3ca49f4 100644 --- a/betterdesk-agent-client/src/locales/en.json +++ b/betterdesk-agent-client/src/locales/en.json @@ -5,6 +5,11 @@ "help": "Help Request", "settings": "Settings" }, + "admin_required": { + "title": "Administrator rights required", + "message": "This section can only be accessed when the agent runs with local administrator privileges.", + "hint": "Ask your IT administrator to launch the agent as Administrator, or right-click the installer and choose \"Run as administrator\"." + }, "status": { "title": "Connection Status", "connected": "Connected", diff --git a/betterdesk-agent-client/src/locales/pl.json b/betterdesk-agent-client/src/locales/pl.json index cc895cd8..4e5713c1 100644 --- a/betterdesk-agent-client/src/locales/pl.json +++ b/betterdesk-agent-client/src/locales/pl.json @@ -5,6 +5,11 @@ "help": "Prośba o pomoc", "settings": "Ustawienia" }, + "admin_required": { + "title": "Wymagane uprawnienia administratora", + "message": "Ten ekran jest dostępny tylko gdy agent jest uruchomiony z uprawnieniami administratora.", + "hint": "Poproś administratora IT o uruchomienie agenta jako Administrator lub kliknij instalator prawym przyciskiem i wybierz \"Uruchom jako administrator\"." + }, "status": { "title": "Status połączenia", "connected": "Połączono", diff --git a/betterdesk-agent-client/src/locales/zh-TW.json b/betterdesk-agent-client/src/locales/zh-TW.json index 8ab2a519..7b2a7a60 100644 --- a/betterdesk-agent-client/src/locales/zh-TW.json +++ b/betterdesk-agent-client/src/locales/zh-TW.json @@ -5,6 +5,11 @@ "help": "求助請求", "settings": "設定" }, + "admin_required": { + "title": "需要管理員權限", + "message": "此頁面僅在代理以本地管理員權限運行時可用。", + "hint": "請 IT 管理員以管理員身份啟動代理,或右鍵點擊安裝程式並選擇「以管理員身份執行」。" + }, "status": { "title": "連線狀態", "connected": "已連線", diff --git a/betterdesk-agent-client/src/styles/global.css b/betterdesk-agent-client/src/styles/global.css index 27814ddf..4c9f0261 100644 --- a/betterdesk-agent-client/src/styles/global.css +++ b/betterdesk-agent-client/src/styles/global.css @@ -93,12 +93,71 @@ a:hover { color: var(--accent-hover); } height: 100%; } +/* Tray-first layout — no sidebar, main pane fills the window. */ +.app-layout-tray { + display: block; +} + .app-main { flex: 1; overflow-y: auto; padding: 24px; } +.app-main-full { + width: 100%; + height: 100%; + overflow-y: auto; + padding: 20px; +} + +/* ── Admin-required gate ── */ +.admin-required { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + height: 100%; + padding: 32px 24px; + gap: 12px; +} + +.admin-required-icon { + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--bg-secondary); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; +} + +.admin-required-icon .material-symbols-rounded { + font-size: 40px; + color: var(--accent, #5b8cff); +} + +.admin-required h2 { + margin: 0; + font-size: 18px; + color: var(--text-primary); +} + +.admin-required p { + margin: 0; + color: var(--text-secondary); + max-width: 340px; + line-height: 1.5; +} + +.admin-required-hint { + font-size: 12px; + opacity: 0.75; + margin-top: 8px !important; +} + /* ── Sidebar ── */ .sidebar { width: var(--sidebar-width); diff --git a/tasks/PHASE_2_5_STATUS.md b/tasks/PHASE_2_5_STATUS.md new file mode 100644 index 00000000..6af811fd --- /dev/null +++ b/tasks/PHASE_2_5_STATUS.md @@ -0,0 +1,137 @@ +# Phase 2-5 Implementation Status — 2026-04-10 + +This document tracks the pragmatic scaffolding delivered in this session and the +work that remains. The goal was to establish the **HTTP/DB contracts and UI +plumbing** so frontend, backend and agent work can continue independently. + +## ✅ Phase 2 — Live Agent Introspection + +### Backend (completed previously) +- `services/bdRelay.js` — `requestFromDevice(deviceId, type, payload)` promise API + with `pendingRequests` Map + 15 s timeout. Handles `command_response` frames + from agent to resolve/reject pending promises. +- `routes/devices.routes.js` — 9 proxy endpoints: + - `GET /api/devices/:id/services` + - `GET /api/devices/:id/processes` + - `GET /api/devices/:id/events?limit=N` + - `GET /api/devices/:id/activity` + - `POST /api/devices/:id/files/browse` + - `POST /api/devices/:id/files/read` (≤ 1 MB) + - `POST /api/devices/:id/screenshot` + - `POST /api/devices/:id/terminal/execute` (audit-logged) + - `POST /api/devices/:id/rename` +- Error mapping: 503 `agent_offline` · 504 `agent_timeout` · 502 other. +- Permission model: `device.view` for reads, `device.edit` for mutations. + +### Frontend (completed this session) +- `public/js/deviceDetail.js` — 5 new lazy-loaded tabs (services, processes, + events, activity, files). Renderers for each data shape. Retry button wired + to `_loadAgentTab()`. File browser supports parent navigation + double-click + to descend into directories. +- `public/css/device-detail.css` — full styling for agent tabs (tables, event + list with severity colors, activity bars, file browser, loading spinner, + error states). +- `lang/{en,pl,zh}.json` — 15 new keys per language under `device_detail`. + +### Agent side (Rust) — **TODO** +Agent client needs a signal WS module that: +1. Connects to `wss://{server}/ws/bd-signal?device_id=X&token=JWT`. +2. Listens for `{type, request_id, payload}` frames. +3. Dispatches to capability handlers and replies with + `{type: 'command_response', request_id, ok, data?, error?}`. + +Recommended Rust modules under `betterdesk-agent-client/src-tauri/src/`: + +| Module | Responsibility | +| ------------------ | ------------------------------------------------------- | +| `signal_ws.rs` | tokio-tungstenite client, reconnect, dispatcher | +| `caps/services.rs` | `Get-Service` / `systemctl list-units --output=json` | +| `caps/processes.rs`| `sysinfo` crate enumeration, top-by-CPU sort | +| `caps/events.rs` | `wevtutil` / `journalctl --output=json` | +| `caps/files.rs` | `std::fs::read_dir` with canonicalize-based sandbox | +| `caps/terminal.rs` | One-shot `std::process::Command` (no PTY yet) | +| `caps/screenshot.rs`| `scrap`/`xcap` + `image` crate, base64 PNG/JPEG | +| `caps/activity.rs` | Background thread tracking process foreground time | + +Deps to add to `src-tauri/Cargo.toml`: +``` +tokio-tungstenite = { version = "0.23", features = ["rustls-tls-native-roots"] } +sysinfo = "0.32" # already present +scrap = "0.5" # or xcap +image = "0.25" +base64 = "0.22" +``` + +## ✅ Phase 3 — Chat attachments (already done) + +- `routes/chat.routes.js` already implements multipart upload (50 MB cap), + path-traversal-safe download, and search proxy. No additional work needed + for scaffolding. Contacts/conversations endpoints still **TODO**. + +## ✅ Phase 4 — Operator identity + consent popup + +### Backend (this session) +- `services/dbAdapter.js` — 6 new `users` columns (`first_name`, `last_name`, + `email`, `phone`, `role_display`, `avatar_url`) migrated for both SQLite and + PostgreSQL. +- `services/dbAdapter.js` — `updateUserProfile(id, fields)` helper + (sanitizes, caps at 200 chars, whitelists allowed keys). +- `routes/phase4_5.routes.js`: + - `GET /api/users/me/profile` — current profile + - `PUT /api/users/me/profile` — update fields + - `GET /api/bd/operator-info?session_id=…` — consent-popup payload + +### Agent side — **TODO** +- Rust module `remote/consent.rs`: Tauri window popup with operator identity + + granular permission checkboxes (keyboard/clipboard/audio/file_transfer/ + recording/camera/block). Countdown optional. +- Session handshake: server sends `remote_request` to agent over signal WS, + agent shows popup, replies `remote_accept` (with granted permissions) or + `remote_reject`. Unattended mode skips popup based on `access_policy`. + +### Frontend — **TODO** +- Profile-edit page (`/settings/profile`) with avatar upload. + +## ✅ Phase 5 — Agent templates + downloads portal + +### Backend (this session) +- `routes/phase4_5.routes.js`: + - `GET /api/agent-templates` — list (`enrollment.manage`) + - `POST /api/agent-templates` — create, returns enrollment_token + - `DELETE /api/agent-templates/:id` + - `POST /api/bd/enroll` — public endpoint for agent enrollment + - `GET /portal` — branded public download page + - `GET /api/portal/installers`— installer metadata JSON +- `agent_templates` table auto-created in auth.db on first request + (`id, name, description, config_json, enrollment_token, created_by, + created_at, updated_at`). +- `views/downloads-portal.ejs` — standalone glassmorphism download page. + +### Frontend — **TODO** +- `/enrollment` admin page with wizard (name, description, capabilities, + tags, groups → create template → show enrollment token + QR + one-liner + install command). + +### Agent side — **TODO** +- Installer accepts `--enrollment-token=XXXX --server=URL` flags. +- Agent calls `POST /api/bd/enroll` with device_id + sysinfo, receives + `preset_config`, then proceeds with normal `/api/bd/register`. + +## Files touched this session + +- `web-nodejs/public/js/deviceDetail.js` +- `web-nodejs/public/css/device-detail.css` +- `web-nodejs/lang/{en,pl,zh}.json` +- `web-nodejs/services/dbAdapter.js` (SQLite + PostgreSQL migrations + profile helper) +- `web-nodejs/routes/index.js` (mount phase4_5) +- `web-nodejs/routes/phase4_5.routes.js` (new) +- `web-nodejs/views/downloads-portal.ejs` (new) +- `tasks/PHASE_2_5_STATUS.md` (this file) + +## Deployment notes + +1. Restart Node.js console so new routes + migrations load. +2. Migrations are idempotent — safe to re-run. +3. `agent_templates` table is created lazily on first `/api/agent-templates` + access; no DB downtime required. +4. Agent-client Rust work can begin immediately — HTTP contract is stable. diff --git a/tasks/agent-operational-plan.md b/tasks/agent-operational-plan.md new file mode 100644 index 00000000..661a0ceb --- /dev/null +++ b/tasks/agent-operational-plan.md @@ -0,0 +1,212 @@ +# Plan operacyjny: Agent Client + rozbudowa panelu web + +**Data:** 2026-04-18 +**Kontekst:** Agent client obecnie to 6 plików Rust (rejestracja + sysinfo + stub-czat). Brak zdalnego pulpitu, brak WS do czatu, brak trybu użytkownika vs operatora. Panel web ma `help-requests.ejs` + API oraz slide-over `device-detail` — wymagają rozbudowy. + +## Założenia architektoniczne + +1. **Agent client = TRAY-FIRST aplikacja.** Menu główne w zasobniku systemowym. Okno GUI otwiera się tylko dla konkretnych funkcji (czat, help-request). Brak głównego sidebara. +2. **Tryby uprawnień:** + - **User mode** (zwykły użytkownik): "Poproś o pomoc", "Czat", "Pokaż ID". + - **Admin mode** (OS administrator / root): wszystko powyżej + "Ustawienia", "Zamknij agenta". + - Detekcja: `IsUserAnAdmin()` (Win32), `geteuid() == 0` (Unix). +3. **Szyfrowanie E2E zachowane** — chat w agencie używa istniejącego `chatCrypto.js` (P-256 ECDH + AES-256-GCM) z panelu web, tylko port Rust. +4. **Kanały komunikacji:** + - **REST HTTP** — rejestracja, help-request, sysinfo upload (już jest). + - **WebSocket `/ws/agent/{device_id}`** (NOWE) — chat, powiadomienia, heartbeat, remote session signaling. + - **Relay TCP/WS** (Faza 4) — video/audio/input dla zdalnego pulpitu. +5. **Bezpieczeństwo:** wszystkie WS z JWT token (z rejestracji), TLS wymagane w produkcji, rate-limit IP, audit log każdej akcji. + +--- + +## Faza 1: Fundament UX + powiadomienia (TA SESJA) + +**Cel:** Przeorganizować agent UI + dodać dzwonek powiadomień w panelu web. + +### Agent Client +- [x] ~~Obecne okno z sidebarem~~ → **usunięte**. Nowe: minimalne okno "o programie" + pełne menu w trayu. +- [x] Detekcja admina w Rust (`is_os_admin()`). +- [x] Tray menu: + - **Zawsze:** Pokaż ID | Poproś o pomoc | Czat | Sprawdź połączenie | ─── + - **Admin only:** Ustawienia | Zamknij agenta + - **Autostart:** domyślnie włączony (już jest) +- [x] Pełny ekran tylko dla: `/chat` (z brandingiem konsoli), `/help` (formularz), `/settings` (tylko admin). +- [x] Rozmiar okna zmniejszony do 480×560 (chat/help/settings), brak rozbudowanego menu bocznego. + +### Web panel +- [x] **Dzwonek powiadomień** w navbar (obok `refresh-btn`): + - Badge z licznikiem nieprzeczytanych help-requestów + - Dropdown z ostatnimi 10 powiadomieniami + - Klik → przejście do `/help-requests` + oznaczenie jako przeczytane +- [x] Endpoint `GET /api/bd/notifications` (z filtrem `unread_only`). +- [x] Endpoint `POST /api/bd/notifications/:id/read`. +- [x] Socket.IO event `help-request` już istnieje — podłączyć do dzwonka (real-time). +- [x] i18n klucze `notifications.*` (EN/PL/ZH). + +--- + +## Faza 2: Device Management Modal (NASTĘPNA SESJA) + +**Cel:** Zastąpić slide-over `device-detail` pełnoekranowym modalem z rozbudowanym UI inspirowanym nVision. + +### Backend +- Rozszerzony endpoint `GET /api/peers/:id` o: + - `hardware` (CPU, GPU, RAM, disks) — z inventory + - `software` (zainstalowane aplikacje) — z inventory + - `services` (uruchomione usługi systemowe) — z agent nowy endpoint + - `processes` (aktywne procesy) — z agent nowy endpoint + - `events` (logi Windows/journalctl) — z agent nowy endpoint + - `usage_stats` (czas uruchomienia aplikacji) — z agent activity tracker +- Endpoint `POST /api/peers/:id/rename` (zmiana przyjaznej nazwy). +- Endpoint `POST /api/peers/:id/files/browse` — proxy do agent file browser. +- Endpoint `POST /api/peers/:id/terminal/execute` — proxy do agent terminal. + +### Frontend +- Nowy komponent `device-modal.js` — pełnoekranowy modal (bez slide-over). +- Zakładki wzorowane na nVision: + - **General** — hero z nazwą/zdjęciem + status + szybkie akcje (Remote, Chat, Terminal, Files) + - **Activity** — wykres użycia aplikacji (czas pracy) + - **Screenshots** — galeria screenshotów (z agent screenshot capability) + - **Events** — log zdarzeń systemowych + - **DataGuard** — polityki DLP (już jest moduł) + - **Blockades** — bany/ograniczenia (już jest moduł) + - **Settings** — config per-device +- Przycisk "Remote access" → otwiera `/remote/:id` (web viewer — już jest). +- Przycisk "Files" → panel przeglądania plików z upload/download. +- Przycisk "Terminal" → xterm.js terminal w nowym oknie. + +### Agent — nowe capabilities +- `system_services` — `Get-Service` (Win) / `systemctl list-units` (Linux) +- `system_processes` — `Get-Process` / `ps aux` +- `system_events` — `Get-EventLog` / `journalctl` +- `file_browser` — list/read/write/delete z path traversal guard +- `screenshot_capture` — JPEG snapshot on demand +- `activity_tracker` — śledzenie czasu użycia aplikacji (background thread) +- `terminal_session` — PTY (unix) / ConPTY (Windows) + +**Implementacja:** agent-client pobiera większość z natywnego `betterdesk-agent` (Go) — portować moduły do Rust lub uruchamiać jako sidecar subprocess. + +--- + +## Faza 3: Chat z brandingiem + E2E (FAZA 3) + +**Cel:** Pełny czat między użytkownikami + operatorami, E2E, uploady plików. + +### Backend +- Endpoint WS `/ws/chat/agent/:device_id` — WS z JWT. +- Reuse `chatRelay.js` (już istnieje). +- Endpoint `GET /api/chat/contacts` — lista operatorów (top) + pracowników z organizacji. +- Endpoint `POST /api/chat/attachment` — upload pliku (max 50 MB, encrypted blob). +- Endpoint `GET /api/chat/attachment/:id` — download. + +### Agent +- `chat.rs` — WS client (tokio-tungstenite), P-256 keypair (ring crate), message encrypt/decrypt. +- UI: lewa kolumna z kontaktami (operatorzy na górze, oznaczeni ikoną), prawa z historią. +- Upload drag-and-drop + paste obrazka. +- Branding: wykorzystuje `GET /api/config/branding` (logo, primary color, nazwa firmy). + +### Web panel +- Istniejąca strona `chat.ejs` — rozszerzyć o: + - Lista agent-ów online (z device_id + hostname) + - Wybór odbiorcy: operator, kolega z firmy, klient (device) + - Upload plików +- Czat E2E już zaimplementowany (Phase 2 — chatCrypto.js). + +--- + +## Faza 4: Remote Desktop Core (FAZA 4 — NAJWIĘKSZA) + +**Cel:** Pełny zdalny pulpit z multi-monitor, audio, wielokierunkowy clipboard, file transfer, supervised+unattended mode. + +### Agent — nowe crates +- `scrap` / `xcap` — screen capture (Win+Linux+macOS) +- `openh264` — video encode (fallback: JPEG) +- `cpal` — audio capture +- `enigo` — input inject (już w MGMT) +- `arboard` — clipboard sync +- `tokio-tungstenite` — WS do relay +- `protobuf` — protokół (reuse z betterdesk-server/proto) + +### Agent — nowe moduły +- `remote/capture.rs` — capture loop (30-60 FPS adaptive) +- `remote/encoder.rs` — H.264 encoder + VP9 fallback +- `remote/audio.rs` — audio capture + Opus encode +- `remote/input.rs` — input injection receiver +- `remote/session.rs` — session manager (negocjacja codec, quality, multi-monitor) +- `remote/consent.rs` — **supervised mode:** popup JAK NA ZDJĘCIU 1 + - Przyciski: Klawiatura, Schowek, Audio, Transfer plików, Nagrywanie, Kamera, Blokada + - Imię i nazwisko operatora + nick z konsoli web + - Countdown "Automatyczna zgoda za X s" (opcjonalne) +- **Unattended mode:** konfigurowalny w `/settings`, akceptacja domyślna (bez popupu) + +### Web viewer +- Już istnieje `remote.ejs` (web remote client). Rozszerzyć o: + - Dropdown wyboru monitora + - Przycisk "Request audio" → agent popup + - Přycisk "Start recording" → encoder zapisuje MP4 + - Wskaźnik supervised/unattended (ikonka shield) + +### Protokół +- Signal: agent rejestruje ws://server/ws/agent/:id (push connection) +- Operator łączy się przez web remote → server broadcastuje `connection_request` do agenta +- Agent: jeśli supervised → pokazuje popup → czeka na `accept` z mapą uprawnień → wysyła `ready` +- Agent: jeśli unattended → akceptuje od razu +- Relay: obie strony łączą się do `/relay/:session_id`, server forwarduje bajty (istniejący `relay/`) + +### User identity +- W bazie `users` dodać kolumny `first_name`, `last_name`, `email`, `phone`, `role_display` (np. "IT Support Level 2"). +- Popup w agencie pokazuje: avatar + imię+nazwisko + rola + organizacja. + +--- + +## Faza 5: Agent Generator + Portal pobierania (FAZA 5) + +**Cel:** Zaawansowany generator w konsoli web + publiczny portal pobierania. + +### Generator (w panelu web) +- Rozbudować istniejący `generator.ejs`: + - **Step 1:** Template (blank / organization preset / custom) + - **Step 2:** Connection (server, relay, TLS, STUN/TURN) + - **Step 3:** Access policy (supervised / unattended / hybrid) + - **Step 4:** Branding (logo, kolor, nazwa firmy) + - **Step 5:** Features (chat enabled?, file transfer?, terminal?, screenshots?) + - **Step 6:** Security (API key, allowed operators, MAC pinning) + - **Generate button** → tworzy podpisany JSON config + enrollment token +- Output: **installer .exe / .msi / .deb / .pkg / .dmg** z zaszytym configiem (build przez `cargo tauri build` z env var `AGENT_PRESET_CONFIG`). +- Templates per-org w bazie: `agent_templates` (name, description, config_json, created_by, org_id). + +### Portal pobierania (oddzielny port — np. 5001) +- Nowa aplikacja Node.js `downloads-portal/` (lub route prefix w istniejącej konsoli). +- Publiczny dostęp (bez auth). +- Strona z brandingiem firmy: logo, nazwa, opis, "Pobierz BetterDesk Agent" × OS. +- Każdy link → pobiera najnowszy build z zaszytym preset configiem. +- Po instalacji agent auto-rejestruje się do serwera (z tokenem z config). + +### Auto-enrollment flow +1. Agent startuje +2. Jeśli `preset_enrollment_token` w config → `POST /api/bd/enroll` z tokenem +3. Serwer weryfikuje token + tworzy device + zwraca JWT +4. Agent zapisuje device_id + JWT → normalny flow + +--- + +## Kolejność realizacji + +| Faza | Czas (szacunek) | Priorytet | +|------|----------------|-----------| +| 1. Fundament UX + powiadomienia | Ta sesja | 🔴 Krytyczne | +| 2. Device Modal + agent capabilities | 2-3 sesje | 🟠 Wysokie | +| 3. Chat E2E + attachments | 1-2 sesje | 🟠 Wysokie | +| 4. Remote Desktop | 4-6 sesji | 🔴 Krytyczne | +| 5. Generator + Portal | 1-2 sesje | 🟡 Średnie | + +## Uwagi bezpieczeństwa (cross-cutting) + +- **JWT token z rejestracji** — używany do wszystkich WS (agent side) + device API. +- **Rate-limit IP** — na wszystkich publicznych endpointach (istnieje w Go server). +- **Audit log** — każda akcja (login, connect, file transfer, command exec) do `audit_log` tabeli. +- **E2E chat** — już zaimplementowane (P-256 ECDH + AES-256-GCM) w `chatCrypto.js`. +- **Token rotation** — JWT krótki TTL (15 min) + refresh token. +- **Certificate pinning** — agent pinuje SHA256 serwera po pierwszej rejestracji. +- **Supervised mode consent** — user ma fizyczną kontrolę nad uprawnieniami per-sesja. +- **Admin-only actions w agencie** — ustawienia, unregister, zamknięcie — wymagają OS admin (lokalny). diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index 82a91423..f4ba3d25 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -744,6 +744,22 @@ "action_delete_desc": "Permanently remove this device", "tab_hardware": "Hardware", "tab_metrics": "Metrics", + "tab_services": "Services", + "tab_processes": "Processes", + "tab_events": "Events", + "tab_activity": "Activity", + "tab_files": "Files", + "agent_lazy_hint": "Click this tab to load live data from the agent", + "agent_offline": "Agent is offline", + "agent_timeout": "Agent did not respond in time", + "agent_error": "Failed to load data from agent", + "service_name": "Name", + "service_display": "Display name", + "service_status": "Status", + "service_start": "Start mode", + "process_name": "Name", + "process_user": "User", + "process_memory": "Memory", "section_system": "System", "section_cpu": "Processor", "section_memory": "Memory", @@ -1438,6 +1454,14 @@ "keyframe_request": "Request Keyframe", "quality_auto": "Auto Quality" }, + "notifications": { + "title": "Notifications", + "empty": "No new notifications", + "mark_all_read": "Mark all read", + "see_all": "See all help requests", + "help_request": "Help request", + "just_now": "just now" + }, "desktop": { "switch_mode": "Desktop Mode", "console_mode": "Console Mode", diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 8842d257..64bf8bfa 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -744,6 +744,22 @@ "action_delete_desc": "Trwale usuń to urządzenie", "tab_hardware": "Sprzęt", "tab_metrics": "Metryki", + "tab_services": "Usługi", + "tab_processes": "Procesy", + "tab_events": "Zdarzenia", + "tab_activity": "Aktywność", + "tab_files": "Pliki", + "agent_lazy_hint": "Kliknij tę zakładkę, aby pobrać dane z agenta", + "agent_offline": "Agent jest offline", + "agent_timeout": "Agent nie odpowiedział w terminie", + "agent_error": "Nie udało się pobrać danych z agenta", + "service_name": "Nazwa", + "service_display": "Nazwa wyświetlana", + "service_status": "Stan", + "service_start": "Tryb startu", + "process_name": "Nazwa", + "process_user": "Użytkownik", + "process_memory": "Pamięć", "section_system": "System", "section_cpu": "Procesor", "section_memory": "Pamięć", @@ -1438,6 +1454,14 @@ "keyframe_request": "Żądaj klatki kluczowej", "quality_auto": "Automatyczna jakość" }, + "notifications": { + "title": "Powiadomienia", + "empty": "Brak nowych powiadomień", + "mark_all_read": "Oznacz wszystkie jako przeczytane", + "see_all": "Zobacz wszystkie prośby o pomoc", + "help_request": "Prośba o pomoc", + "just_now": "przed chwilą" + }, "desktop": { "switch_mode": "Tryb pulpitu", "console_mode": "Tryb konsoli", diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json index 6952b4f6..c102e295 100644 --- a/web-nodejs/lang/zh.json +++ b/web-nodejs/lang/zh.json @@ -744,6 +744,22 @@ "action_delete_desc": "永久删除此设备", "tab_hardware": "硬件", "tab_metrics": "指标", + "tab_services": "服务", + "tab_processes": "进程", + "tab_events": "事件", + "tab_activity": "活动", + "tab_files": "文件", + "agent_lazy_hint": "单击此选项卡以从代理加载实时数据", + "agent_offline": "代理离线", + "agent_timeout": "代理响应超时", + "agent_error": "从代理加载数据失败", + "service_name": "名称", + "service_display": "显示名称", + "service_status": "状态", + "service_start": "启动模式", + "process_name": "名称", + "process_user": "用户", + "process_memory": "内存", "section_system": "系统", "section_cpu": "处理器", "section_memory": "内存", @@ -1445,6 +1461,14 @@ "keyframe_request": "请求关键帧", "quality_auto": "自动画质" }, + "notifications": { + "title": "通知", + "empty": "没有新通知", + "mark_all_read": "全部标记为已读", + "see_all": "查看所有求助请求", + "help_request": "求助请求", + "just_now": "刚刚" + }, "desktop": { "switch_mode": "桌面模式", "console_mode": "控制台模式", diff --git a/web-nodejs/public/css/device-detail.css b/web-nodejs/public/css/device-detail.css index dd64098f..400bbf61 100644 --- a/web-nodejs/public/css/device-detail.css +++ b/web-nodejs/public/css/device-detail.css @@ -984,3 +984,217 @@ grid-template-columns: 1fr; } } + +/* =========================================================================== + AGENT LAZY-LOADED TABS (services, processes, events, activity, files) + =========================================================================== */ + +.device-panel-agent-pane { + padding: 16px; +} + +.device-panel-agent-empty, +.device-panel-agent-loading, +.device-panel-agent-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + color: var(--text-secondary, #8b949e); + gap: 8px; +} + +.device-panel-agent-empty .material-icons, +.device-panel-agent-loading .material-icons, +.device-panel-agent-error .material-icons { + font-size: 48px; + opacity: 0.6; +} + +.device-panel-agent-hint { + font-size: 12px; + opacity: 0.7; +} + +.device-panel-agent-error-title { + font-weight: 600; + color: var(--text-primary, #e6edf3); +} + +.device-panel-agent-error-hint { + font-size: 12px; + font-family: ui-monospace, monospace; + opacity: 0.6; + max-width: 400px; + word-break: break-word; +} + +.device-panel-agent-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.device-panel-agent-table th { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--border-color, #30363d); + color: var(--text-secondary, #8b949e); + font-weight: 600; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.04em; + position: sticky; + top: 0; + background: var(--bg-secondary, #0d1117); +} + +.device-panel-agent-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--border-color, #30363d); + color: var(--text-primary, #e6edf3); +} + +.device-panel-agent-table tbody tr:hover { + background: var(--bg-hover, #21262d); +} + +.device-panel-event-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 520px; + overflow-y: auto; +} + +.device-panel-event-row { + display: grid; + grid-template-columns: 150px 150px 1fr; + gap: 10px; + padding: 6px 10px; + font-size: 12px; + border-left: 3px solid var(--border-color, #30363d); + background: var(--bg-secondary, #0d1117); + border-radius: 4px; +} + +.device-panel-event-row.error { border-left-color: #f85149; } +.device-panel-event-row.warn { border-left-color: #d29922; } +.device-panel-event-row.info { border-left-color: #3b82f6; } + +.device-panel-event-time { + font-family: ui-monospace, monospace; + color: var(--text-secondary, #8b949e); +} + +.device-panel-event-source { + color: var(--text-secondary, #8b949e); +} + +.device-panel-event-msg { + color: var(--text-primary, #e6edf3); + word-break: break-word; +} + +.device-panel-activity-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.device-panel-activity-row { + display: grid; + grid-template-columns: 180px 1fr 70px; + gap: 12px; + align-items: center; + padding: 6px 10px; + font-size: 13px; +} + +.device-panel-activity-name { + color: var(--text-primary, #e6edf3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-panel-activity-bar { + height: 8px; + background: var(--bg-secondary, #0d1117); + border-radius: 4px; + overflow: hidden; +} + +.device-panel-activity-bar-fill { + height: 100%; + background: linear-gradient(90deg, #3b82f6, #60a5fa); + transition: width 0.3s ease; +} + +.device-panel-activity-time { + text-align: right; + color: var(--text-secondary, #8b949e); + font-variant-numeric: tabular-nums; +} + +.device-panel-file-browser { + display: flex; + flex-direction: column; + gap: 8px; +} + +.device-panel-file-path { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var(--bg-secondary, #0d1117); + border-radius: 6px; + font-family: ui-monospace, monospace; + font-size: 12px; + color: var(--text-primary, #e6edf3); +} + +.device-panel-file-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 520px; + overflow-y: auto; +} + +.device-panel-file-row { + display: grid; + grid-template-columns: 32px 1fr 100px; + align-items: center; + gap: 10px; + padding: 6px 10px; + font-size: 13px; + border-radius: 4px; + cursor: pointer; + user-select: none; +} + +.device-panel-file-row:hover { + background: var(--bg-hover, #21262d); +} + +.device-panel-file-row[data-is-dir="1"] .material-icons { + color: #60a5fa; +} + +.device-panel-file-size { + text-align: right; + color: var(--text-secondary, #8b949e); + font-variant-numeric: tabular-nums; +} + +.material-icons.spinning { + animation: device-panel-spin 1.2s linear infinite; +} + +@keyframes device-panel-spin { + to { transform: rotate(360deg); } +} diff --git a/web-nodejs/public/css/notif-center.css b/web-nodejs/public/css/notif-center.css new file mode 100644 index 00000000..4a31c807 --- /dev/null +++ b/web-nodejs/public/css/notif-center.css @@ -0,0 +1,192 @@ +/* Navbar notification center — dropdown with unread badge, real-time pulse. */ + +.notif-wrapper { + position: relative; + display: inline-block; +} + +.notif-btn { + position: relative; +} + +/* Unread badge on bell icon. */ +.notif-badge { + position: absolute; + top: 4px; + right: 4px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background: var(--accent-red, #e74c3c); + color: #fff; + font-size: 10px; + font-weight: 600; + line-height: 16px; + text-align: center; + box-shadow: 0 0 0 2px var(--bg-primary, #0d1117); + pointer-events: none; +} + +/* Real-time arrival pulse (400ms). */ +@keyframes notif-pulse { + 0% { transform: scale(1); } + 30% { transform: scale(1.18); } + 60% { transform: scale(0.94); } + 100% { transform: scale(1); } +} + +.notif-btn.notif-pulse .material-icons { + animation: notif-pulse 0.4s ease-out; +} + +/* Dropdown panel. */ +.notif-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + width: 340px; + max-width: calc(100vw - 24px); + background: var(--bg-secondary, #161b22); + border: 1px solid var(--border, #30363d); + border-radius: 10px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55); + z-index: 9999; + overflow: hidden; + animation: notif-fade-in 150ms ease-out; +} + +@keyframes notif-fade-in { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.notif-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--border, #30363d); + font-weight: 600; + font-size: 13px; + color: var(--text-primary, #e6edf3); +} + +.notif-mark-all { + background: transparent; + border: none; + color: var(--accent-blue, #58a6ff); + font-size: 12px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} + +.notif-mark-all:hover { + background: rgba(88, 166, 255, 0.08); +} + +.notif-list { + max-height: 360px; + overflow-y: auto; +} + +.notif-empty { + padding: 28px 16px; + text-align: center; + color: var(--text-secondary, #8b949e); + font-size: 13px; +} + +.notif-item { + display: flex; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-subtle, #21262d); + text-decoration: none; + color: var(--text-primary, #e6edf3); + transition: background-color 120ms; +} + +.notif-item:last-child { + border-bottom: none; +} + +.notif-item:hover { + background: rgba(255, 255, 255, 0.03); +} + +.notif-unread { + background: rgba(88, 166, 255, 0.06); +} + +.notif-unread::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-blue, #58a6ff); + margin-top: 8px; + flex-shrink: 0; +} + +.notif-item-icon { + color: var(--accent-blue, #58a6ff); + font-size: 20px; + flex-shrink: 0; + margin-top: 2px; +} + +.notif-item-body { + flex: 1; + min-width: 0; +} + +.notif-item-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.notif-item-sub { + font-size: 12px; + color: var(--text-secondary, #8b949e); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.notif-item-time { + font-size: 11px; + color: var(--text-tertiary, #6e7681); + margin-top: 4px; +} + +.notif-footer { + padding: 10px 14px; + border-top: 1px solid var(--border, #30363d); + text-align: center; +} + +.notif-see-all { + color: var(--accent-blue, #58a6ff); + font-size: 12px; + text-decoration: none; +} + +.notif-see-all:hover { + text-decoration: underline; +} + +/* Responsive: narrow screens push dropdown to full width of navbar-right. */ +@media (max-width: 480px) { + .notif-dropdown { + right: -60px; + width: 300px; + } +} diff --git a/web-nodejs/public/js/deviceDetail.js b/web-nodejs/public/js/deviceDetail.js index 81fab44f..bb4ee319 100644 --- a/web-nodejs/public/js/deviceDetail.js +++ b/web-nodejs/public/js/deviceDetail.js @@ -190,6 +190,11 @@ const DeviceDetail = (function () { { id: 'overview', icon: 'info', label: _('device_detail.tab_overview') }, { id: 'hardware', icon: 'memory', label: _('device_detail.tab_hardware') }, { id: 'metrics', icon: 'monitoring', label: _('device_detail.tab_metrics') }, + { id: 'services', icon: 'settings_applications', label: _('device_detail.tab_services') }, + { id: 'processes', icon: 'memory', label: _('device_detail.tab_processes') }, + { id: 'events', icon: 'event_note', label: _('device_detail.tab_events') }, + { id: 'activity', icon: 'insights', label: _('device_detail.tab_activity') }, + { id: 'files', icon: 'folder_open', label: _('device_detail.tab_files') }, { id: 'tags', icon: 'sell', label: _('device_detail.tab_tags') }, { id: 'actions', icon: 'play_arrow', label: _('device_detail.tab_actions') } ]; @@ -209,11 +214,35 @@ const DeviceDetail = (function () { ${_overviewPane()} ${_hardwarePane()} ${_metricsPane()} + ${_agentPane('services', 'settings_applications', 'tab_services')} + ${_agentPane('processes', 'memory', 'tab_processes')} + ${_agentPane('events', 'event_note', 'tab_events')} + ${_agentPane('activity', 'insights', 'tab_activity')} + ${_agentPane('files', 'folder_open', 'tab_files')} ${_tagsPane()} ${_actionsPane()} `; } + // ── Live-agent tabs (lazy loaded) ──────────────────────────────────── + // + // These panes are populated on first tab activation by `_loadAgentTab()`. + // Keeping them in separate functions would duplicate 200+ lines of + // boilerplate. Instead, each pane is a placeholder that shows an empty + // state and a "Load" button; real data is fetched when tab is shown. + + function _agentPane(paneId, icon, labelKey) { + return `
+
+
+ ${icon} +
${_('device_detail.' + labelKey)}
+
${_('device_detail.agent_lazy_hint')}
+
+
+
`; + } + // ── Overview tab ── function _overviewPane() { @@ -773,6 +802,253 @@ const DeviceDetail = (function () { panel.querySelectorAll('.device-panel-tab-pane').forEach(function (p) { p.classList.toggle('active', p.dataset.pane === tabId); }); + + // Lazy-load agent-backed tabs on first activation. + const AGENT_TABS = ['services', 'processes', 'events', 'activity', 'files']; + if (AGENT_TABS.indexOf(tabId) !== -1) { + _loadAgentTab(tabId); + } + } + + // ── Agent-backed tabs ─────────────────────────────────────────────── + + // Track which tabs have been loaded so we don't refetch on every click. + const _agentTabLoaded = {}; + + async function _loadAgentTab(tabId) { + if (_agentTabLoaded[tabId]) return; + _agentTabLoaded[tabId] = true; + + if (!device || !device.id) return; + const pane = overlayEl?.querySelector(`[data-agent-pane="${tabId}"]`); + if (!pane) return; + + pane.innerHTML = `
+ autorenew +
${_('common.loading')}
+
`; + + const endpointMap = { + services: { method: 'GET', url: `/api/devices/${encodeURIComponent(device.id)}/services` }, + processes: { method: 'GET', url: `/api/devices/${encodeURIComponent(device.id)}/processes` }, + events: { method: 'GET', url: `/api/devices/${encodeURIComponent(device.id)}/events?limit=200` }, + activity: { method: 'GET', url: `/api/devices/${encodeURIComponent(device.id)}/activity` }, + files: { method: 'POST', url: `/api/devices/${encodeURIComponent(device.id)}/files/browse`, body: { path: '/', show_hidden: false } } + }; + + const ep = endpointMap[tabId]; + if (!ep) return; + + try { + let resp; + if (ep.method === 'POST') { + resp = await Utils.api(ep.url, { method: 'POST', body: JSON.stringify(ep.body) }); + } else { + resp = await Utils.api(ep.url); + } + const data = (resp && resp.data) || resp || null; + _renderAgentTab(tabId, data, pane); + } catch (err) { + _agentTabLoaded[tabId] = false; // allow retry + pane.innerHTML = _agentErrorHTML(err, tabId); + } + } + + function _agentErrorHTML(err, tabId) { + const msg = err && err.message ? String(err.message) : 'unknown'; + // Differentiate offline vs generic error for clearer UX. + const isOffline = msg.indexOf('503') !== -1 || msg.indexOf('offline') !== -1; + const isTimeout = msg.indexOf('504') !== -1 || msg.indexOf('timeout') !== -1; + const icon = isOffline ? 'cloud_off' : isTimeout ? 'hourglass_empty' : 'error_outline'; + const title = isOffline + ? _('device_detail.agent_offline') || 'Agent is offline' + : isTimeout + ? _('device_detail.agent_timeout') || 'Agent did not respond in time' + : _('device_detail.agent_error') || 'Failed to load data from agent'; + return `
+ ${icon} +
${title}
+
${Utils.escapeHtml(msg)}
+ +
`; + } + + function _renderAgentTab(tabId, data, pane) { + if (tabId === 'services') { + pane.innerHTML = _renderServicesList(data); + } else if (tabId === 'processes') { + pane.innerHTML = _renderProcessList(data); + } else if (tabId === 'events') { + pane.innerHTML = _renderEventList(data); + } else if (tabId === 'activity') { + pane.innerHTML = _renderActivity(data); + } else if (tabId === 'files') { + pane.innerHTML = _renderFileBrowser(data, '/'); + _attachFileBrowserEvents(pane); + } + + // Generic retry button handler + pane.querySelector('[data-retry-tab]')?.addEventListener('click', function () { + const tid = this.dataset.retryTab; + _agentTabLoaded[tid] = false; + _loadAgentTab(tid); + }); + } + + function _renderServicesList(data) { + const services = Array.isArray(data?.services) ? data.services : Array.isArray(data) ? data : []; + if (!services.length) { + return `
inbox
${_('common.no_data')}
`; + } + const rows = services.slice(0, 500).map(s => ` + + ${Utils.escapeHtml(s.name || '')} + ${Utils.escapeHtml(s.display_name || s.name || '')} + ${Utils.escapeHtml(s.status || '-')} + ${Utils.escapeHtml(s.start_type || '-')} + `).join(''); + return ` + + + + + + + ${rows} +
${_('device_detail.service_name') || 'Name'}${_('device_detail.service_display') || 'Display'}${_('device_detail.service_status') || 'Status'}${_('device_detail.service_start') || 'Start'}
`; + } + + function _renderProcessList(data) { + const procs = Array.isArray(data?.processes) ? data.processes : Array.isArray(data) ? data : []; + if (!procs.length) { + return `
inbox
${_('common.no_data')}
`; + } + // Sort by CPU descending, show top 200 + procs.sort((a, b) => (b.cpu || 0) - (a.cpu || 0)); + const rows = procs.slice(0, 200).map(p => ` + + ${Utils.escapeHtml(String(p.pid ?? '-'))} + ${Utils.escapeHtml(p.name || '-')} + ${Utils.escapeHtml(p.user || '-')} + ${Number(p.cpu || 0).toFixed(1)}% + ${Number(p.memory_mb || 0).toFixed(0)} MB + `).join(''); + return ` + + + + + + + + ${rows} +
PID${_('device_detail.process_name') || 'Name'}${_('device_detail.process_user') || 'User'}CPU${_('device_detail.process_memory') || 'Memory'}
`; + } + + function _renderEventList(data) { + const events = Array.isArray(data?.events) ? data.events : Array.isArray(data) ? data : []; + if (!events.length) { + return `
inbox
${_('common.no_data')}
`; + } + const rows = events.slice(0, 500).map(e => { + const level = String(e.level || 'info').toLowerCase(); + const levelClass = level === 'error' || level === 'critical' ? 'error' + : level === 'warning' || level === 'warn' ? 'warn' : 'info'; + return `
+
${Utils.escapeHtml(e.time || '')}
+
${Utils.escapeHtml(e.source || e.facility || '-')}
+
${Utils.escapeHtml(e.message || '')}
+
`; + }).join(''); + return `
${rows}
`; + } + + function _renderActivity(data) { + const items = Array.isArray(data?.apps) ? data.apps : Array.isArray(data) ? data : []; + if (!items.length) { + return `
inbox
${_('common.no_data')}
`; + } + // Sort by total seconds + items.sort((a, b) => (b.seconds || 0) - (a.seconds || 0)); + const maxSec = Math.max(1, items[0].seconds || 1); + const rows = items.slice(0, 50).map(a => { + const pct = Math.min(100, ((a.seconds || 0) / maxSec) * 100); + const minutes = Math.floor((a.seconds || 0) / 60); + return `
+
${Utils.escapeHtml(a.name || a.app || '-')}
+
+
+
+
${minutes} min
+
`; + }).join(''); + return `
${rows}
`; + } + + function _renderFileBrowser(data, currentPath) { + const entries = Array.isArray(data?.entries) ? data.entries : Array.isArray(data) ? data : []; + const path = data?.path || currentPath || '/'; + const parent = data?.parent || null; + const rows = entries.slice(0, 1000).map(e => { + const icon = e.is_dir ? 'folder' : 'description'; + const size = e.is_dir ? '' : _formatBytes(e.size || 0); + return `
+ ${icon} +
${Utils.escapeHtml(e.name || '')}
+
${size}
+
`; + }).join(''); + return `
+
+ ${parent ? `` : ''} + ${Utils.escapeHtml(path)} +
+
${rows}
+
`; + } + + function _attachFileBrowserEvents(pane) { + pane.querySelectorAll('[data-fb-up]').forEach(btn => { + btn.addEventListener('click', () => _browseFiles(btn.dataset.fbUp)); + }); + pane.querySelectorAll('.device-panel-file-row').forEach(row => { + row.addEventListener('dblclick', () => { + if (row.dataset.isDir === '1') _browseFiles(row.dataset.path); + }); + }); + } + + async function _browseFiles(path) { + if (!device || !device.id) return; + const pane = overlayEl?.querySelector('[data-agent-pane="files"]'); + if (!pane) return; + pane.innerHTML = `
autorenew
${_('common.loading')}
`; + try { + const resp = await Utils.api(`/api/devices/${encodeURIComponent(device.id)}/files/browse`, { + method: 'POST', + body: JSON.stringify({ path, show_hidden: false }) + }); + pane.innerHTML = _renderFileBrowser(resp?.data || resp, path); + _attachFileBrowserEvents(pane); + } catch (err) { + pane.innerHTML = _agentErrorHTML(err, 'files'); + pane.querySelector('[data-retry-tab]')?.addEventListener('click', function () { + _agentTabLoaded['files'] = false; + _loadAgentTab('files'); + }); + } + } + + function _formatBytes(n) { + if (!n) return '0 B'; + const k = 1024; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(k))); + return (n / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i]; } // ── Tags ── diff --git a/web-nodejs/public/js/notif-center.js b/web-nodejs/public/js/notif-center.js new file mode 100644 index 00000000..25d337d5 --- /dev/null +++ b/web-nodejs/public/js/notif-center.js @@ -0,0 +1,282 @@ +/** + * Navbar notification center. + * + * Responsibilities: + * - Fetches unread notifications from /api/bd/notifications?unread_only=true on load + * - Subscribes to Socket.IO "help-request" event for real-time pushes + * - Renders a dropdown list of recent items with action/link + * - Marks items as read via POST /api/bd/notifications/:id/read + * - Updates badge counter + * + * Defense in depth: + * - All server-provided text is inserted via textContent (never innerHTML) to + * prevent stored-XSS from compromised agent names or help request bodies. + * - CSRF token is sent with mutation requests (read / mark-all). + */ +(function () { + 'use strict'; + + const MAX_ITEMS = 10; + const state = { + items: [], + unreadCount: 0, + }; + + let dom = null; + + function _(key) { + try { + return (window.BetterDesk && window._) ? window._(key) : key; + } catch { + return key; + } + } + + function csrf() { + return (window.BetterDesk && window.BetterDesk.csrfToken) || ''; + } + + function formatTime(iso) { + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const diff = Date.now() - d.getTime(); + if (diff < 60_000) return _('notifications.just_now'); + if (diff < 3_600_000) return Math.floor(diff / 60_000) + 'm'; + if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + 'h'; + return d.toLocaleDateString(); + } catch { + return ''; + } + } + + function updateBadge() { + if (!dom) return; + const count = state.unreadCount; + if (count > 0) { + dom.badge.textContent = count > 99 ? '99+' : String(count); + dom.badge.hidden = false; + } else { + dom.badge.hidden = true; + } + } + + function renderList() { + if (!dom) return; + dom.list.textContent = ''; + + if (state.items.length === 0) { + const empty = document.createElement('div'); + empty.className = 'notif-empty'; + empty.textContent = _('notifications.empty'); + dom.list.appendChild(empty); + return; + } + + for (const item of state.items.slice(0, MAX_ITEMS)) { + const row = document.createElement('a'); + row.className = 'notif-item' + (item.read ? '' : ' notif-unread'); + row.href = item.link || '/help-requests'; + row.dataset.id = String(item.id); + + const icon = document.createElement('span'); + icon.className = 'material-icons notif-item-icon'; + icon.textContent = item.icon || 'support_agent'; + + const body = document.createElement('div'); + body.className = 'notif-item-body'; + + const title = document.createElement('div'); + title.className = 'notif-item-title'; + title.textContent = item.title || _('notifications.help_request'); + + const sub = document.createElement('div'); + sub.className = 'notif-item-sub'; + sub.textContent = item.message || ''; + + const time = document.createElement('div'); + time.className = 'notif-item-time'; + time.textContent = formatTime(item.created_at); + + body.appendChild(title); + body.appendChild(sub); + body.appendChild(time); + + row.appendChild(icon); + row.appendChild(body); + + row.addEventListener('click', () => { + if (!item.read) { + markRead(item.id).catch(() => { /* best effort */ }); + } + }); + + dom.list.appendChild(row); + } + } + + async function fetchNotifications() { + try { + const resp = await fetch('/api/bd/notifications?limit=' + MAX_ITEMS, { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + if (!resp.ok) return; + const data = await resp.json(); + const items = Array.isArray(data.items) ? data.items : (Array.isArray(data) ? data : []); + state.items = items; + state.unreadCount = items.filter(i => !i.read).length; + updateBadge(); + renderList(); + } catch { + // Silent — dropdown stays empty, badge stays hidden. + } + } + + async function markRead(id) { + if (!id) return; + try { + await fetch('/api/bd/notifications/' + encodeURIComponent(id) + '/read', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf(), + }, + }); + const item = state.items.find(i => String(i.id) === String(id)); + if (item && !item.read) { + item.read = true; + state.unreadCount = Math.max(0, state.unreadCount - 1); + updateBadge(); + renderList(); + } + } catch { + // Silent; user can retry. + } + } + + async function markAllRead() { + try { + await fetch('/api/bd/notifications/read-all', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrf(), + }, + }); + state.items = state.items.map(i => ({ ...i, read: true })); + state.unreadCount = 0; + updateBadge(); + renderList(); + } catch { + // Silent. + } + } + + function onHelpRequestEvent(payload) { + try { + const item = { + id: payload.id || Date.now(), + title: payload.device_name || _('notifications.help_request'), + message: payload.description || payload.message || '', + icon: 'support_agent', + link: '/help-requests', + read: false, + created_at: payload.timestamp || new Date().toISOString(), + }; + // Insert at top, cap list. + state.items.unshift(item); + if (state.items.length > MAX_ITEMS) { + state.items = state.items.slice(0, MAX_ITEMS); + } + state.unreadCount += 1; + updateBadge(); + renderList(); + + // Subtle badge pulse for real-time feedback. + if (dom && dom.btn) { + dom.btn.classList.remove('notif-pulse'); + // Force reflow so the class re-application restarts the animation. + void dom.btn.offsetWidth; + dom.btn.classList.add('notif-pulse'); + } + } catch { + // Ignore malformed payloads. + } + } + + function attachSocket() { + try { + const s = window.socket || (typeof io === 'function' ? io() : null); + if (!s || typeof s.on !== 'function') return; + window.socket = s; + s.on('help-request', onHelpRequestEvent); + s.on('notification', onHelpRequestEvent); + } catch { + // Socket.IO not available — poll only. + } + } + + function toggleDropdown(open) { + if (!dom) return; + const isOpen = open !== undefined ? open : dom.dropdown.hidden; + dom.dropdown.hidden = !isOpen; + dom.btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + if (isOpen) { + fetchNotifications(); + } + } + + function init() { + dom = { + wrapper: document.querySelector('.notif-wrapper'), + btn: document.getElementById('notif-btn'), + badge: document.getElementById('notif-badge'), + dropdown: document.getElementById('notif-dropdown'), + list: document.getElementById('notif-list'), + markAll: document.getElementById('notif-mark-all'), + }; + if (!dom.btn || !dom.dropdown || !dom.list) { + dom = null; + return; + } + + dom.btn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleDropdown(); + }); + + if (dom.markAll) { + dom.markAll.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + markAllRead(); + }); + } + + document.addEventListener('click', (e) => { + if (!dom.wrapper.contains(e.target)) { + toggleDropdown(false); + } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') toggleDropdown(false); + }); + + fetchNotifications(); + attachSocket(); + + // Polling fallback: refresh every 60s. Cheap (10 rows max) and survives + // socket disconnects. + setInterval(fetchNotifications, 60_000); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/web-nodejs/routes/bd-api.routes.js b/web-nodejs/routes/bd-api.routes.js index 83cf08c3..73db912f 100644 --- a/web-nodejs/routes/bd-api.routes.js +++ b/web-nodejs/routes/bd-api.routes.js @@ -696,6 +696,134 @@ router.delete('/help-requests/:id', requireDeviceAuth, requireOperatorRole, asyn } }); +// =========================================================================== +// Notification Center (panel navbar bell) +// =========================================================================== +// +// These routes are consumed by web-nodejs/public/js/notif-center.js. They use +// session-based auth (panel cookies) rather than Bearer tokens because the +// dropdown is part of the web UI, not the desktop client. +// +// Storage model: notifications are a per-user read-status overlay over +// helpRequests. The overlay (readByUser) lives in-memory and is cleaned up +// opportunistically. On restart, all help-requests appear unread again — a +// deliberate trade-off: persistence can be added later via DB if needed. +// --------------------------------------------------------------------------- + +const { requireAuth } = require('../middleware/auth'); + +/** @type {Map>} userId → Set marked read. */ +const readByUser = new Map(); + +function isReadBy(userId, notifId) { + const set = readByUser.get(String(userId)); + return !!(set && set.has(String(notifId))); +} + +function markReadBy(userId, notifId) { + const key = String(userId); + let set = readByUser.get(key); + if (!set) { + set = new Set(); + readByUser.set(key, set); + } + set.add(String(notifId)); + // Cap per-user set size to avoid unbounded growth. + if (set.size > 500) { + const arr = [...set]; + readByUser.set(key, new Set(arr.slice(-400))); + } +} + +function helpRequestToNotif(req, userId) { + return { + id: req.id, + title: req.hostname || req.device_id, + message: req.message || '', + icon: 'support_agent', + link: '/help-requests', + read: isReadBy(userId, req.id), + created_at: new Date(req.created_at).toISOString(), + kind: 'help_request', + status: req.status, + }; +} + +// --------------------------------------------------------------------------- +// GET /api/bd/notifications — list recent notifications for current user +// --------------------------------------------------------------------------- + +router.get('/notifications', requireAuth, (req, res) => { + try { + const rawLimit = parseInt(req.query.limit, 10); + const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 50) : 20; + const unreadOnly = String(req.query.unread_only || '').toLowerCase() === 'true'; + const userId = req.session?.user?.id; + + const items = [...helpRequests.values()] + .sort((a, b) => b.created_at - a.created_at) + .map(r => helpRequestToNotif(r, userId)) + .filter(n => (unreadOnly ? !n.read : true)) + .slice(0, limit); + + const unreadCount = [...helpRequests.values()] + .filter(r => !isReadBy(userId, r.id)).length; + + res.json({ success: true, items, unread_count: unreadCount }); + } catch (err) { + console.error('[BD-API] List notifications error:', err.message); + res.status(500).json({ error: 'Failed to list notifications' }); + } +}); + +// --------------------------------------------------------------------------- +// POST /api/bd/notifications/:id/read — mark single notification read +// --------------------------------------------------------------------------- + +router.post('/notifications/:id/read', requireAuth, (req, res) => { + try { + const userId = req.session?.user?.id; + if (!userId) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + const id = String(req.params.id || '').slice(0, 128); + if (!helpRequests.has(id)) { + // Idempotent: succeed even if the item was already pruned. Client + // only uses this to update its local badge state. + return res.json({ success: true, pruned: true }); + } + + markReadBy(userId, id); + res.json({ success: true }); + } catch (err) { + console.error('[BD-API] Mark notification read error:', err.message); + res.status(500).json({ error: 'Failed to mark notification read' }); + } +}); + +// --------------------------------------------------------------------------- +// POST /api/bd/notifications/read-all — mark all notifications read +// --------------------------------------------------------------------------- + +router.post('/notifications/read-all', requireAuth, (req, res) => { + try { + const userId = req.session?.user?.id; + if (!userId) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + for (const id of helpRequests.keys()) { + markReadBy(userId, id); + } + + res.json({ success: true }); + } catch (err) { + console.error('[BD-API] Mark all read error:', err.message); + res.status(500).json({ error: 'Failed to mark all notifications read' }); + } +}); + // --------------------------------------------------------------------------- // POST /api/bd/operator/sessions — Record operator session start/end // --------------------------------------------------------------------------- diff --git a/web-nodejs/routes/devices.routes.js b/web-nodejs/routes/devices.routes.js index 8a9e5158..cd8a877c 100644 --- a/web-nodejs/routes/devices.routes.js +++ b/web-nodejs/routes/devices.routes.js @@ -486,4 +486,125 @@ router.delete('/api/devices/:id/access-policy', requireAuth, requirePermission(' } }); +// =========================================================================== +// Phase 2 — Live agent introspection (services, processes, events, files, +// terminal, screenshot, activity). All endpoints proxy a command request to +// the agent over the signal WebSocket (bdRelay.requestFromDevice) and return +// the agent's reply. If the agent is offline, 503 is returned. +// =========================================================================== + +const bdRelay = require('../services/bdRelay'); + +/** + * Wrap an agent-proxy request with consistent error handling and timeout. + * Distinct error codes help the UI render appropriate states. + */ +async function proxyAgentRequest(req, res, type, payload = null, timeoutMs = 15000) { + try { + const data = await bdRelay.requestFromDevice(req.params.id, type, payload, timeoutMs); + res.json({ success: true, data }); + } catch (err) { + const msg = err && err.message ? err.message : 'agent_error'; + const status = msg === 'agent_offline' ? 503 + : msg === 'agent_timeout' ? 504 + : 502; + res.status(status).json({ success: false, error: msg }); + } +} + +/** GET /api/devices/:id/services — live OS services list */ +router.get('/api/devices/:id/services', requireAuth, requirePermission('device.view'), (req, res) => { + proxyAgentRequest(req, res, 'services.list'); +}); + +/** GET /api/devices/:id/processes — live process list */ +router.get('/api/devices/:id/processes', requireAuth, requirePermission('device.view'), (req, res) => { + proxyAgentRequest(req, res, 'processes.list'); +}); + +/** GET /api/devices/:id/events?limit=100 — recent OS event log / journalctl */ +router.get('/api/devices/:id/events', requireAuth, requirePermission('device.view'), (req, res) => { + const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500); + proxyAgentRequest(req, res, 'events.list', { limit }); +}); + +/** GET /api/devices/:id/activity — live activity tracker (app usage) */ +router.get('/api/devices/:id/activity', requireAuth, requirePermission('device.view'), (req, res) => { + proxyAgentRequest(req, res, 'activity.get'); +}); + +/** + * POST /api/devices/:id/files/browse + * Body: { path: '/some/folder', show_hidden: false } + */ +router.post('/api/devices/:id/files/browse', requireAuth, requirePermission('device.edit'), (req, res) => { + const path = String(req.body?.path || '').slice(0, 4096); + const showHidden = req.body?.show_hidden === true; + proxyAgentRequest(req, res, 'files.browse', { path, show_hidden: showHidden }); +}); + +/** + * POST /api/devices/:id/files/read + * Body: { path, offset, length } + */ +router.post('/api/devices/:id/files/read', requireAuth, requirePermission('device.edit'), (req, res) => { + const path = String(req.body?.path || '').slice(0, 4096); + const offset = Math.max(0, parseInt(req.body?.offset, 10) || 0); + const length = Math.min(1024 * 1024, Math.max(0, parseInt(req.body?.length, 10) || 65536)); + proxyAgentRequest(req, res, 'files.read', { path, offset, length }, 30000); +}); + +/** + * POST /api/devices/:id/screenshot + * Captures a JPEG snapshot from the agent. Returns base64 image. + */ +router.post('/api/devices/:id/screenshot', requireAuth, requirePermission('device.view'), (req, res) => { + proxyAgentRequest(req, res, 'screenshot.capture', null, 20000); +}); + +/** + * POST /api/devices/:id/terminal/execute + * Body: { command: 'ls -la /tmp' } + * One-shot command execution (no PTY). For interactive terminal use the WS + * endpoint (future Phase 4 integration). + */ +router.post('/api/devices/:id/terminal/execute', requireAuth, requirePermission('device.edit'), async (req, res) => { + const command = String(req.body?.command || '').slice(0, 4096); + if (!command.trim()) { + return res.status(400).json({ success: false, error: 'command_required' }); + } + // Audit this — terminal execution is a sensitive action. + try { + await db.logAction(req.session.userId, 'terminal.execute', + `Terminal command on ${req.params.id}: ${command.substring(0, 200)}`, + req.ip || null); + } catch (_) { /* audit failure should not block the command */ } + proxyAgentRequest(req, res, 'terminal.execute', { command }, 30000); +}); + +/** + * POST /api/devices/:id/rename + * Body: { display_name: 'Accounting PC 3' } + * Convenience alias for the display_name branch of PATCH /api/devices/:id. + */ +router.post('/api/devices/:id/rename', requireAuth, requirePermission('device.edit'), async (req, res) => { + try { + const displayName = String(req.body?.display_name || '').trim().slice(0, 200); + if (!displayName) { + return res.status(400).json({ success: false, error: 'display_name_required' }); + } + const device = await serverBackend.getDeviceById(req.params.id); + if (!device) { + return res.status(404).json({ success: false, error: req.t('devices.not_found') }); + } + const result = await serverBackend.updateDevice(req.params.id, { display_name: displayName }); + await db.logAction(req.session.userId, 'device.rename', + `Device ${req.params.id} renamed to "${displayName}"`, req.ip || null); + res.json({ success: true, data: { changes: result?.changes ?? 1, display_name: displayName } }); + } catch (err) { + console.error('Rename device error:', err); + res.status(500).json({ success: false, error: req.t('errors.server_error') }); + } +}); + module.exports = router; diff --git a/web-nodejs/routes/index.js b/web-nodejs/routes/index.js index d14d6dc3..768c130c 100644 --- a/web-nodejs/routes/index.js +++ b/web-nodejs/routes/index.js @@ -58,6 +58,7 @@ const resourceControlRoutes = lazyRoute('./resource-control.routes'); const systemRoutes = lazyRoute('./system.routes'); const cdapStudioRoutes = lazyRoute('./cdap-studio.routes'); const permissionsRoutes = lazyRoute('./permissions.routes'); +const phase45Routes = lazyRoute('./phase4_5.routes'); /** * Middleware to require JSON Content-Type for POST/PATCH/PUT requests to API routes. @@ -144,5 +145,6 @@ router.use('/api/bd', resourceControlRoutes); // device-facing: router.use('/', systemRoutes); // admin-facing: /api/system/*, /api/logs/*, /api/database/*, /api/docker/*, /api/speed-test router.use('/', cdapStudioRoutes); // admin-facing: /cdap-studio, /api/cdap-studio/* router.use('/', permissionsRoutes); // admin-facing: /permissions, /api/panel/roles/*, /api/panel/role-permissions/* +router.use('/', phase45Routes); // Phase 4/5: /api/users/me/profile, /api/agent-templates, /portal module.exports = router; diff --git a/web-nodejs/routes/phase4_5.routes.js b/web-nodejs/routes/phase4_5.routes.js new file mode 100644 index 00000000..924d7c3f --- /dev/null +++ b/web-nodejs/routes/phase4_5.routes.js @@ -0,0 +1,278 @@ +/** + * BetterDesk Console — Phase 4/5 scaffolding routes + * + * Phase 4: operator identity profile + consent-popup metadata endpoint + * Phase 5: agent templates (enrollment presets) + public downloads portal + * + * This module is intentionally minimal — it establishes DB shape and HTTP + * contract so UI + agent-side work can proceed in parallel. The consent popup + * itself lives in the agent client (Rust) and fetches `/api/bd/operator-info` + * when a remote session is requested. + */ + +'use strict'; + +const express = require('express'); +const router = express.Router(); +const crypto = require('crypto'); +const db = require('../services/database'); +const { requireAuth, requirePermission } = require('../middleware/auth'); + +// Shared auth.db handle used for agent_templates (kept alongside users) +let _templatesReady = false; +function ensureTemplatesTable() { + if (_templatesReady) return; + try { + const { getAuthDb } = require('../services/database'); + // Best-effort: better-sqlite3 synchronous path. + const auth = typeof getAuthDb === 'function' ? getAuthDb() : null; + if (auth && typeof auth.exec === 'function') { + auth.exec(`CREATE TABLE IF NOT EXISTS agent_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT DEFAULT '', + config_json TEXT NOT NULL DEFAULT '{}', + enrollment_token TEXT NOT NULL UNIQUE, + created_by INTEGER, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + )`); + } + } catch (e) { + console.warn('[phase4_5] ensureTemplatesTable:', e.message); + } + _templatesReady = true; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Phase 4 — operator profile +// ──────────────────────────────────────────────────────────────────────────── + +/** + * GET /api/users/me/profile — current operator's identity profile. + */ +router.get('/api/users/me/profile', requireAuth, async (req, res) => { + try { + const uid = req.session.userId; + const user = await db.getUserById(uid); + if (!user) return res.status(404).json({ success: false, error: 'not_found' }); + res.json({ + success: true, + profile: { + id: user.id, + username: user.username, + role: user.role, + first_name: user.first_name || '', + last_name: user.last_name || '', + email: user.email || '', + phone: user.phone || '', + role_display: user.role_display || '', + avatar_url: user.avatar_url || '', + }, + }); + } catch (e) { + res.status(500).json({ success: false, error: e.message }); + } +}); + +/** + * PUT /api/users/me/profile — update own identity fields. + */ +router.put('/api/users/me/profile', requireAuth, async (req, res) => { + try { + if (typeof db.updateUserProfile !== 'function') { + return res.status(501).json({ success: false, error: 'not_implemented' }); + } + const { first_name, last_name, email, phone, role_display, avatar_url } = req.body || {}; + + // Lightweight validation. Heavy validation (e.g. email regex) deferred + // until we decide whether email must be unique / confirmed. + if (email && String(email).length > 200) { + return res.status(400).json({ success: false, error: 'email_too_long' }); + } + + await db.updateUserProfile(req.session.userId, { + first_name, last_name, email, phone, role_display, avatar_url, + }); + try { + await db.logAction(req.session.userId, 'profile_updated', 'Updated operator profile', req.ip); + } catch (_) {} + res.json({ success: true }); + } catch (e) { + res.status(500).json({ success: false, error: e.message }); + } +}); + +/** + * GET /api/bd/operator-info?session_id=... — consent-popup payload for agent. + * + * Agent calls this (with device JWT) right before showing the remote-session + * consent popup so the end user sees *who* is asking to connect. + * + * NOTE: This is a stub — wiring to an actual session/ticket record requires + * the session broker from Phase 4 proper. For now it resolves the operator + * from the authenticated JWT's `user_id` claim. + */ +router.get('/api/bd/operator-info', async (req, res) => { + try { + const sessionId = String(req.query.session_id || '').slice(0, 128); + // In real implementation, session_id would map to an in-progress + // remote request and we'd lookup the requesting operator. For now we + // require session auth and return the current user's profile. + if (!req.session || !req.session.userId) { + return res.status(401).json({ success: false, error: 'unauthorized' }); + } + const user = await db.getUserById(req.session.userId); + if (!user) return res.status(404).json({ success: false, error: 'not_found' }); + res.json({ + success: true, + session_id: sessionId, + operator: { + display_name: [user.first_name, user.last_name].filter(Boolean).join(' ') || user.username, + username: user.username, + role_display: user.role_display || user.role, + email: user.email || '', + phone: user.phone || '', + avatar_url: user.avatar_url || '', + }, + }); + } catch (e) { + res.status(500).json({ success: false, error: e.message }); + } +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Phase 5 — agent templates + enrollment + downloads portal +// ──────────────────────────────────────────────────────────────────────────── + +/** + * GET /api/agent-templates — list templates (admin/operator). + */ +router.get('/api/agent-templates', requireAuth, requirePermission('enrollment.manage'), async (req, res) => { + try { + ensureTemplatesTable(); + const rows = await db.query + ? await db.query('SELECT id, name, description, enrollment_token, created_at, updated_at FROM agent_templates ORDER BY id DESC') + : []; + res.json({ success: true, templates: rows }); + } catch (e) { + res.status(500).json({ success: false, error: e.message, templates: [] }); + } +}); + +/** + * POST /api/agent-templates — create a new template. + * Body: { name, description, config } + */ +router.post('/api/agent-templates', requireAuth, requirePermission('enrollment.manage'), async (req, res) => { + try { + ensureTemplatesTable(); + const name = String(req.body?.name || '').trim().slice(0, 100); + if (!name) return res.status(400).json({ success: false, error: 'name_required' }); + const description = String(req.body?.description || '').slice(0, 500); + const config = req.body?.config && typeof req.body.config === 'object' ? req.body.config : {}; + const token = crypto.randomBytes(24).toString('hex'); + + if (!db.run) { + return res.status(501).json({ success: false, error: 'db_backend_missing_run' }); + } + await db.run( + `INSERT INTO agent_templates (name, description, config_json, enrollment_token, created_by) + VALUES (?, ?, ?, ?, ?)`, + [name, description, JSON.stringify(config), token, req.session.userId || null] + ); + try { await db.logAction(req.session.userId, 'template_created', `Created agent template: ${name}`, req.ip); } catch (_) {} + res.status(201).json({ success: true, enrollment_token: token }); + } catch (e) { + if (String(e.message).includes('UNIQUE')) { + return res.status(409).json({ success: false, error: 'name_exists' }); + } + res.status(500).json({ success: false, error: e.message }); + } +}); + +/** + * DELETE /api/agent-templates/:id + */ +router.delete('/api/agent-templates/:id', requireAuth, requirePermission('enrollment.manage'), async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!Number.isFinite(id) || id <= 0) { + return res.status(400).json({ success: false, error: 'invalid_id' }); + } + if (!db.run) return res.status(501).json({ success: false, error: 'db_backend_missing_run' }); + await db.run('DELETE FROM agent_templates WHERE id = ?', [id]); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ success: false, error: e.message }); + } +}); + +/** + * POST /api/bd/enroll — agent enrollment using a template token. + * Body: { enrollment_token, device_id, device_name, sysinfo } + * + * This endpoint is exposed publicly (no auth) — security comes from the + * one-use enrollment_token in the template. Once enrolled the agent obtains + * a normal device JWT via the existing /api/bd/register flow. + */ +router.post('/api/bd/enroll', async (req, res) => { + try { + ensureTemplatesTable(); + const token = String(req.body?.enrollment_token || '').slice(0, 64); + const deviceId = String(req.body?.device_id || '').slice(0, 64); + if (!token || !deviceId) { + return res.status(400).json({ success: false, error: 'missing_fields' }); + } + const row = db.get + ? await db.get('SELECT id, name, config_json FROM agent_templates WHERE enrollment_token = ?', [token]) + : null; + if (!row) return res.status(401).json({ success: false, error: 'invalid_token' }); + + let config = {}; + try { config = JSON.parse(row.config_json || '{}'); } catch (_) {} + + // Audit trail. Actual device creation is handled by agent's subsequent + // /api/bd/register call which uses the returned `preset_config` to seed + // capabilities, tags, group membership, etc. + try { await db.logAction(null, 'device_enrolled', `Device ${deviceId} enrolled via template "${row.name}"`, req.ip); } catch (_) {} + + res.json({ + success: true, + template_name: row.name, + preset_config: config, + }); + } catch (e) { + res.status(500).json({ success: false, error: e.message }); + } +}); + +/** + * GET /portal — branded public download page. + * No auth required. Lists available installers + enrollment instructions. + */ +router.get('/portal', (req, res) => { + res.render('downloads-portal', { + title: req.t ? req.t('portal.title') : 'Download BetterDesk', + layout: false, // standalone page + }); +}); + +/** + * GET /api/portal/installers — list available installers (JSON). + * The actual binaries are served from /downloads/ (static). This endpoint + * just enumerates metadata. + */ +router.get('/api/portal/installers', (req, res) => { + const base = req.protocol + '://' + req.get('host'); + res.json({ + success: true, + installers: [ + { platform: 'windows', arch: 'x64', url: `${base}/downloads/BetterDesk_Agent_x64-setup.exe`, format: 'nsis' }, + { platform: 'linux', arch: 'x64', url: `${base}/downloads/betterdesk-agent-linux-amd64`, format: 'binary' }, + { platform: 'linux', arch: 'arm64', url: `${base}/downloads/betterdesk-agent-linux-arm64`, format: 'binary' }, + ], + }); +}); + +module.exports = router; diff --git a/web-nodejs/services/bdRelay.js b/web-nodejs/services/bdRelay.js index d83741a7..be946baf 100644 --- a/web-nodejs/services/bdRelay.js +++ b/web-nodejs/services/bdRelay.js @@ -50,6 +50,11 @@ const onlineDevices = new Map(); // IP → count const connectionsPerIp = new Map(); +// requestId → { resolve, reject, timeout } for request/response pattern over signal WS +const pendingRequests = new Map(); + +const REQUEST_TIMEOUT_MS = 15 * 1000; // 15s default + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -150,6 +155,42 @@ function getOnlineDeviceIds() { return ids; } +/** + * Send a request to an online agent and await its response over the signal WS. + * The agent receives `{ type, request_id, payload }` and must reply with + * `{ type: 'command_response', request_id, ok, data?, error? }`. + * + * @param {string} deviceId + * @param {string} type e.g. 'services.list', 'files.browse' + * @param {object} payload optional type-specific payload + * @param {number} [timeoutMs=15000] + * @returns {Promise} resolves with agent's `data`, rejects on error/timeout + */ +function requestFromDevice(deviceId, type, payload = null, timeoutMs = REQUEST_TIMEOUT_MS) { + return new Promise((resolve, reject) => { + const ws = onlineDevices.get(deviceId); + if (!ws || ws.readyState !== WebSocket.OPEN) { + return reject(new Error('agent_offline')); + } + + const requestId = crypto.randomUUID(); + const timeout = setTimeout(() => { + pendingRequests.delete(requestId); + reject(new Error('agent_timeout')); + }, Math.max(1000, Math.min(60000, timeoutMs))); + + pendingRequests.set(requestId, { resolve, reject, timeout }); + + try { + ws.send(JSON.stringify({ type, request_id: requestId, payload })); + } catch (err) { + pendingRequests.delete(requestId); + clearTimeout(timeout); + reject(err); + } + }); +} + // --------------------------------------------------------------------------- // WebSocket server initialisation // --------------------------------------------------------------------------- @@ -442,6 +483,19 @@ function handleSignalMessage(deviceId, ws, msg) { break; } + case 'command_response': { + // Agent responding to a requestFromDevice() call. + // msg = { type, request_id, ok, data?, error? } + const pending = pendingRequests.get(msg.request_id); + if (pending) { + pendingRequests.delete(msg.request_id); + clearTimeout(pending.timeout); + if (msg.ok) pending.resolve(msg.data || null); + else pending.reject(new Error(msg.error || 'agent_error')); + } + break; + } + default: break; } @@ -479,6 +533,7 @@ module.exports = { notifyTarget, isDeviceOnline, getOnlineDeviceIds, + requestFromDevice, activeSessions, // exposed for monitoring/admin API onlineDevices, // exposed for status sync }; diff --git a/web-nodejs/services/dbAdapter.js b/web-nodejs/services/dbAdapter.js index a70ba88e..85f70c68 100644 --- a/web-nodejs/services/dbAdapter.js +++ b/web-nodejs/services/dbAdapter.js @@ -429,6 +429,13 @@ function createSqliteAdapter(config) { { name: 'totp_secret', sql: 'TEXT DEFAULT NULL' }, { name: 'totp_enabled', sql: 'INTEGER DEFAULT 0' }, { name: 'totp_recovery_codes', sql: 'TEXT DEFAULT NULL' }, + // Phase 4: operator identity profile (shown to end-user on consent popup) + { name: 'first_name', sql: "TEXT DEFAULT ''" }, + { name: 'last_name', sql: "TEXT DEFAULT ''" }, + { name: 'email', sql: "TEXT DEFAULT ''" }, + { name: 'phone', sql: "TEXT DEFAULT ''" }, + { name: 'role_display', sql: "TEXT DEFAULT ''" }, + { name: 'avatar_url', sql: "TEXT DEFAULT ''" }, ]; try { const existingUserCols = new Set(db.prepare('PRAGMA table_info(users)').all().map(c => c.name)); @@ -1096,6 +1103,21 @@ function createSqliteAdapter(config) { async updateUserRole(id, role) { openAuth().prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id); }, + // Phase 4: update operator identity profile (first_name, last_name, email, phone, role_display, avatar_url) + async updateUserProfile(id, fields) { + const allowed = ['first_name', 'last_name', 'email', 'phone', 'role_display', 'avatar_url']; + const sets = []; + const values = []; + for (const k of allowed) { + if (fields && Object.prototype.hasOwnProperty.call(fields, k)) { + sets.push(`${k} = ?`); + values.push(String(fields[k] == null ? '' : fields[k]).slice(0, 200)); + } + } + if (!sets.length) return; + values.push(id); + openAuth().prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...values); + }, async deleteUser(id) { openAuth().prepare('DELETE FROM users WHERE id = ?').run(id); }, @@ -3099,6 +3121,21 @@ function createPostgresAdapter() { await q('ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_recovery_codes TEXT DEFAULT NULL'); } + // Phase 4: operator identity profile columns + const identityCols = [ + ['first_name', "TEXT DEFAULT ''"], + ['last_name', "TEXT DEFAULT ''"], + ['email', "TEXT DEFAULT ''"], + ['phone', "TEXT DEFAULT ''"], + ['role_display', "TEXT DEFAULT ''"], + ['avatar_url', "TEXT DEFAULT ''"], + ]; + for (const [col, def] of identityCols) { + if (!existingCols.has(col)) { + await q(`ALTER TABLE users ADD COLUMN IF NOT EXISTS ${col} ${def}`); + } + } + // Migration: Add updated_at to settings table if missing (for upgrades from older versions) try { const settingsColCheck = await all(`SELECT column_name FROM information_schema.columns WHERE table_name = 'settings'`); @@ -3387,6 +3424,21 @@ function createPostgresAdapter() { async hasUsers() { return +(await one('SELECT COUNT(*) as c FROM users')).c > 0; }, async getAllUsers() { return all('SELECT id, username, role, created_at, last_login, totp_enabled FROM users ORDER BY id'); }, async updateUserRole(id, role) { await q('UPDATE users SET role = $1 WHERE id = $2', [role, id]); }, + async updateUserProfile(id, fields) { + const allowed = ['first_name', 'last_name', 'email', 'phone', 'role_display', 'avatar_url']; + const sets = []; + const values = []; + let idx = 1; + for (const k of allowed) { + if (fields && Object.prototype.hasOwnProperty.call(fields, k)) { + sets.push(`${k} = $${idx++}`); + values.push(String(fields[k] == null ? '' : fields[k]).slice(0, 200)); + } + } + if (!sets.length) return; + values.push(id); + await q(`UPDATE users SET ${sets.join(', ')} WHERE id = $${idx}`, values); + }, async deleteUser(id) { await q('DELETE FROM users WHERE id = $1', [id]); }, async countAdmins() { return +(await one("SELECT COUNT(*) as c FROM users WHERE role IN ('admin', 'super_admin')")).c; }, diff --git a/web-nodejs/views/downloads-portal.ejs b/web-nodejs/views/downloads-portal.ejs new file mode 100644 index 00000000..09c41d2c --- /dev/null +++ b/web-nodejs/views/downloads-portal.ejs @@ -0,0 +1,105 @@ + + + + + + <%= title || 'Download BetterDesk Agent' %> + + + +
+
+

Download BetterDesk Agent

+

Install the lightweight agent on your device to enable remote support.

+
+
+
Loading available installers…
+
+ +
+ + + diff --git a/web-nodejs/views/layouts/main.ejs b/web-nodejs/views/layouts/main.ejs index f6632d97..fc758824 100644 --- a/web-nodejs/views/layouts/main.ejs +++ b/web-nodejs/views/layouts/main.ejs @@ -14,6 +14,7 @@ + <% if (!embed) { %> @@ -140,6 +141,7 @@ + <% if (!embed) { %> diff --git a/web-nodejs/views/partials/navbar.ejs b/web-nodejs/views/partials/navbar.ejs index eceb1529..26890977 100644 --- a/web-nodejs/views/partials/navbar.ejs +++ b/web-nodejs/views/partials/navbar.ejs @@ -29,7 +29,27 @@ - + + +
+ + +
+