mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
feat: organizations, desktop mode, i18n (ja/ko/ru), security docs, client i18n integration
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| 3.0.x | :white_check_mark: |
|
||||
| 2.4.x | :white_check_mark: |
|
||||
| < 2.4 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take the security of BetterDesk seriously. If you discover a security vulnerability, please report it responsibly.
|
||||
|
||||
### How to Report
|
||||
|
||||
1. **Do NOT open a public GitHub Issue** for security vulnerabilities.
|
||||
2. Email your findings to **security@unitronix.com** with:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Affected component (Go server, Node.js console, desktop client, installer scripts)
|
||||
- Potential impact assessment
|
||||
- Any suggested fix (optional but appreciated)
|
||||
|
||||
### What to Expect
|
||||
|
||||
| Timeline | Action |
|
||||
|----------|--------|
|
||||
| **24 hours** | Acknowledgment of your report |
|
||||
| **72 hours** | Initial assessment and severity classification |
|
||||
| **7 days** | Fix development begins for Critical/High issues |
|
||||
| **30 days** | Patch released (or interim mitigation communicated) |
|
||||
| **90 days** | Public disclosure (coordinated with reporter) |
|
||||
|
||||
### Severity Classification
|
||||
|
||||
| Severity | Examples |
|
||||
|----------|----------|
|
||||
| **Critical** | Remote code execution, authentication bypass, SQL injection, private key exposure |
|
||||
| **High** | Privilege escalation, CSRF on sensitive actions, session fixation, brute-force without rate limiting |
|
||||
| **Medium** | Information disclosure, XSS, insecure defaults, missing input validation |
|
||||
| **Low** | Verbose error messages, minor information leakage, missing security headers |
|
||||
|
||||
### Scope
|
||||
|
||||
The following components are in scope:
|
||||
|
||||
- **BetterDesk Go Server** (`betterdesk-server/`) — signal, relay, API, database
|
||||
- **Node.js Web Console** (`web-nodejs/`) — Express.js app, routes, middleware
|
||||
- **Desktop Client** (`betterdesk-client/`) — Tauri app, Rust backend, TypeScript frontend
|
||||
- **CDAP Agent** (`betterdesk-agent/`) — Go agent binary
|
||||
- **Installer Scripts** (`betterdesk.sh`, `betterdesk.ps1`, `betterdesk-docker.sh`)
|
||||
- **Docker Images** (`Dockerfile*`, `docker-compose*.yml`)
|
||||
- **SDKs** (`sdks/python/`, `sdks/nodejs/`)
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Third-party dependencies (report to upstream maintainers, but notify us if it affects BetterDesk)
|
||||
- Social engineering attacks
|
||||
- Denial of service via network flooding (volumetric attacks)
|
||||
- Issues in archived components (`archive/`)
|
||||
|
||||
### Recognition
|
||||
|
||||
We gratefully acknowledge security researchers who report vulnerabilities responsibly:
|
||||
|
||||
- Your name (or alias) will be added to our Security Hall of Fame (with your permission)
|
||||
- We will credit you in the relevant release notes
|
||||
|
||||
### Security Best Practices for Deployers
|
||||
|
||||
1. Always use TLS certificates for production deployments (`--tls-signal`, `--tls-relay`)
|
||||
2. Keep the web console bound to `127.0.0.1` or behind a reverse proxy
|
||||
3. Use PostgreSQL (not SQLite) for multi-user production environments
|
||||
4. Rotate API keys regularly
|
||||
5. Enable TOTP 2FA for all admin accounts
|
||||
6. Review audit logs periodically
|
||||
7. Keep BetterDesk updated to the latest supported version
|
||||
@@ -0,0 +1,207 @@
|
||||
# =============================================================================
|
||||
# BetterDesk Desktop Client — Build & Release (Multi-Platform)
|
||||
# =============================================================================
|
||||
# Builds Tauri desktop client for Windows, Linux, and macOS.
|
||||
# Triggers on version tag push (v*) or manual dispatch.
|
||||
#
|
||||
# Artifacts:
|
||||
# - Windows: NSIS installer (.exe)
|
||||
# - Linux: Debian package (.deb) + AppImage
|
||||
# - macOS: DMG bundle (.dmg)
|
||||
# =============================================================================
|
||||
|
||||
name: Build & Release Desktop Client
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., v3.0.0)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CLIENT_DIR: betterdesk-client
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows x64
|
||||
# ---------------------------------------------------------------------------
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
cache-dependency-path: ${{ env.CLIENT_DIR }}/pnpm-lock.yaml
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- name: Install Protobuf Compiler
|
||||
run: choco install protoc -y
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ${{ env.CLIENT_DIR }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ${{ env.CLIENT_DIR }}
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: betterdesk-windows-x64
|
||||
path: |
|
||||
${{ env.CLIENT_DIR }}/src-tauri/target/release/bundle/nsis/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Linux x64
|
||||
# ---------------------------------------------------------------------------
|
||||
build-linux:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
protobuf-compiler
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
cache-dependency-path: ${{ env.CLIENT_DIR }}/pnpm-lock.yaml
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
cache: true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ${{ env.CLIENT_DIR }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ${{ env.CLIENT_DIR }}
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: betterdesk-linux-x64
|
||||
path: |
|
||||
${{ env.CLIENT_DIR }}/src-tauri/target/release/bundle/deb/*.deb
|
||||
${{ env.CLIENT_DIR }}/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
if-no-files-found: error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# macOS (Universal: ARM64 + x64)
|
||||
# ---------------------------------------------------------------------------
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
cache-dependency-path: ${{ env.CLIENT_DIR }}/pnpm-lock.yaml
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: aarch64-apple-darwin,x86_64-apple-darwin
|
||||
cache: true
|
||||
|
||||
- name: Install Protobuf Compiler
|
||||
run: brew install protobuf
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ${{ env.CLIENT_DIR }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ${{ env.CLIENT_DIR }}
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: betterdesk-macos
|
||||
path: |
|
||||
${{ env.CLIENT_DIR }}/src-tauri/target/release/bundle/dmg/*.dmg
|
||||
if-no-files-found: error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Release — Combine all artifacts into a GitHub Release
|
||||
# ---------------------------------------------------------------------------
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [build-windows, build-linux, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
find . -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.AppImage" -o -name "*.dmg" \) | while read f; do
|
||||
sha256sum "$f" >> CHECKSUMS.sha256
|
||||
done
|
||||
cat CHECKSUMS.sha256
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
betterdesk-windows-x64/*.exe
|
||||
betterdesk-linux-x64/*.deb
|
||||
betterdesk-linux-x64/*.AppImage
|
||||
betterdesk-macos/*.dmg
|
||||
CHECKSUMS.sha256
|
||||
@@ -0,0 +1,128 @@
|
||||
# =============================================================================
|
||||
# BetterDesk Go Server — Cross-Compile & Release
|
||||
# =============================================================================
|
||||
# Builds Go server binary for Linux (amd64, arm64) and Windows (amd64).
|
||||
# Triggers on version tag push (v*) or manual dispatch.
|
||||
# Attaches binaries + SHA256 checksums to GitHub Release.
|
||||
# =============================================================================
|
||||
|
||||
name: Build & Release Go Server
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., v3.0.0)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.23'
|
||||
SERVER_DIR: betterdesk-server
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
suffix: linux-amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
suffix: linux-arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
suffix: windows-amd64.exe
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: ${{ env.SERVER_DIR }}/go.sum
|
||||
|
||||
- name: Install Protobuf Compiler
|
||||
run: sudo apt-get install -y protobuf-compiler
|
||||
|
||||
- name: Build binary
|
||||
working-directory: ${{ env.SERVER_DIR }}
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME:-dev}"
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
|
||||
-o "betterdesk-server-${{ matrix.suffix }}" .
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: betterdesk-server-${{ matrix.suffix }}
|
||||
path: ${{ env.SERVER_DIR }}/betterdesk-server-${{ matrix.suffix }}
|
||||
if-no-files-found: error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration tool
|
||||
# ---------------------------------------------------------------------------
|
||||
build-migrate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: ${{ env.SERVER_DIR }}/go.sum
|
||||
|
||||
- name: Build migration tool (linux-amd64)
|
||||
working-directory: ${{ env.SERVER_DIR }}/tools/migrate
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
run: go build -trimpath -ldflags="-s -w" -o migrate-linux-amd64 .
|
||||
|
||||
- name: Upload migration tool
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: migrate-linux-amd64
|
||||
path: ${{ env.SERVER_DIR }}/tools/migrate/migrate-linux-amd64
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Release
|
||||
# ---------------------------------------------------------------------------
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [build, build-migrate]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
find . -type f -name "betterdesk-server-*" -o -name "migrate-*" | while read f; do
|
||||
sha256sum "$f" >> SERVER_CHECKSUMS.sha256
|
||||
done
|
||||
cat SERVER_CHECKSUMS.sha256
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
betterdesk-server-linux-amd64/betterdesk-server-linux-amd64
|
||||
betterdesk-server-linux-arm64/betterdesk-server-linux-arm64
|
||||
betterdesk-server-windows-amd64.exe/betterdesk-server-windows-amd64.exe
|
||||
migrate-linux-amd64/migrate-linux-amd64
|
||||
SERVER_CHECKSUMS.sha256
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here.
|
||||
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-alpha] — 2026-04-01
|
||||
|
||||
### Added
|
||||
- **Organization & User Account System** — Multi-tenant organizations with owner/admin/operator/user roles (Go server + Node.js panel)
|
||||
- **Organization REST API** — 18 endpoints for CRUD orgs, users, devices, invitations, settings, login
|
||||
- **Client Organization Login** — `OrgLoginPanel.tsx` with server address + username/password
|
||||
- **mDNS/DNS-SD Discovery** — Auto-discover BetterDesk servers on LAN (`_betterdesk._tcp`)
|
||||
- **Desktop Widget UI Overhaul** — New window management, taskbar redesign, wallpaper picker with tabs
|
||||
- **6 New Console Languages** — German, Spanish, French, Italian, Dutch, Portuguese
|
||||
- **3 High-Priority Languages** — Japanese, Korean, Russian
|
||||
- **Desktop Client i18n Framework** — `src/lib/i18n.ts` with `t()` function, plural forms, locale detection
|
||||
- **NSIS Multilingual Installer** — 12 languages in NSIS language selector
|
||||
- **Light Theme** — `themes/light.json` with WCAG-compliant light colors
|
||||
- **Theme API** — `GET /api/settings/themes`, `POST /api/settings/themes/:id/apply`
|
||||
- **Page Transition Animations** — `transitions.css` with page enter/exit, stagger, skeleton loading
|
||||
- **i18n Check Script** — `npm run i18n:check` / `npm run i18n:fix` for language coverage auditing
|
||||
- **GitHub Actions: Client Releases** — Multi-platform Tauri builds (Windows/Linux/macOS)
|
||||
- **GitHub Actions: Server Releases** — Go cross-compile (linux-amd64/arm64, windows-amd64)
|
||||
- **Security Documentation** — THREAT_MODEL.md, ENCRYPTION_SPEC.md, COMPLIANCE.md, AUDIT_LOG.md
|
||||
- **Responsible Disclosure Policy** — `.github/SECURITY.md`
|
||||
- **Web Remote Toolbar** — Scale mode selector, monitor switcher, clipboard sync, special keys menu
|
||||
- **Fullscreen Mode** — F11 keyboard shortcut + button toggle
|
||||
- **Bidirectional Clipboard** — `navigator.clipboard` API integration in remote viewer
|
||||
- **Special Keys Menu** — Ctrl+Alt+Del, Win, PrintScreen, Alt+Tab, Alt+F4, Task Manager
|
||||
- **Beta Banner** — Replaced WIP banner with slim dismissible beta indicator
|
||||
|
||||
### Changed
|
||||
- **CSP Headers Hardened** — Added `frame-ancestors`, `worker-src`, `child-src`; expanded Permissions-Policy
|
||||
- **X-Frame-Options** — Changed from `DENY` to `SAMEORIGIN` for desktop widget embed mode
|
||||
- **WebSocket CSP** — Added `ws:` to `connect-src` for HTTP mode (was missing)
|
||||
- **Cross-Origin Resource Policy** — Enabled `same-origin` (was disabled)
|
||||
|
||||
### Fixed
|
||||
- **Chat: Tray opens wrong window** — Tray "Chat" now opens dedicated chat WebviewWindow directly
|
||||
- **Chat: Shows "Disconnected"** — WebSocket URL now uses dynamic `ws://`/`wss://` based on console_url
|
||||
- **Rust warnings** — All 10 compilation warnings fixed (unused imports, variables, labels)
|
||||
- **Go warnings** — `go vet` clean, 0 issues
|
||||
|
||||
---
|
||||
|
||||
## [2.4.0] — 2026-03-21
|
||||
|
||||
### Added
|
||||
- **PostgreSQL Support** — Full PostgreSQL database backend for Go server and Node.js console
|
||||
- **SQLite → PostgreSQL Migration** — Built-in migration tool (menu option M/P)
|
||||
- **CDAP v0.3.0** — Widget rendering, device detail page, REST API, 8 widget types
|
||||
- **Native BetterDesk Agent** — Go binary for system management, 14 flags, 9 widgets
|
||||
- **Bridge SDK** — Python + Node.js SDKs for CDAP bridges (Modbus, SNMP, REST)
|
||||
- **Device Revocation** — `DELETE /api/peers/{id}?revoke=true&cascade=true`
|
||||
- **Peer Metrics** — `peer_metrics` table, `GET /api/peers/{id}/metrics`
|
||||
- **CDAP Audio** — Bidirectional audio streaming via WebSocket
|
||||
- **Devices Page Redesign** — Horizontal folder chips, kebab menu, responsive layout
|
||||
- **Docker GHCR** — Pre-built images on GitHub Container Registry
|
||||
|
||||
### Fixed
|
||||
- **Empty UUID in Relay** — Generate UUID when `RequestRelay{uuid=""}` received
|
||||
- **ForceRelay TCP UUID Mismatch** — Return `PunchHoleResponse` instead of `RelayResponse`
|
||||
- **Docker Port 5000 Conflict** — Added `SIGNAL_PORT` env var, priority over `PORT`
|
||||
- **PS1 RandomNumberGenerator Crash** — Replaced .NET 6+ method with .NET 4.x compatible
|
||||
- **API TLS Breaking Clients** — Separated `--tls-api` flag from signal/relay TLS
|
||||
- **PostgreSQL Config Lost on Update** — Added `preserve_database_config()` function
|
||||
- **Auth.db Destroyed on Update** — Detect existing `.env` as UPDATE indicator
|
||||
- **Address Book Sync** — Real `address_books` table replacing stub handlers
|
||||
- **Settings Password** — Fixed snake_case vs camelCase field name mismatch
|
||||
|
||||
---
|
||||
|
||||
## [2.3.0] — 2026-02-17
|
||||
|
||||
### Added
|
||||
- **CSRF Protection** — Double-submit cookie pattern with `csrf-csrf`
|
||||
- **TOTP 2FA** — Two-factor authentication with `otplib`
|
||||
- **RustDesk Client API** — Dedicated WAN-facing port 21121 with 7-layer security
|
||||
- **Address Book Sync** — Full AB storage with `address_books` table
|
||||
- **Operator Role** — Admin/operator role separation with different permissions
|
||||
- **SSL Certificate Configuration** — New menu option C in installer scripts
|
||||
- **Desktop Connect Button** — Connect to devices from browser via RustDesk URI handler
|
||||
|
||||
### Fixed
|
||||
- **Session Fixation** — Session regeneration after login
|
||||
- **Timing-Safe Auth** — Pre-computed dummy bcrypt hash for non-existent users
|
||||
- **WebSocket Auth** — Session cookie required for upgrade
|
||||
- **Web Remote Client** — 5 Critical, 2 High, 3 Low bugs fixed
|
||||
|
||||
---
|
||||
|
||||
## [2.2.0] — 2026-02-06
|
||||
|
||||
### Added
|
||||
- **Node.js Console** — Express.js web console replacing Flask
|
||||
- **Migration Tool** — Migrate between console types
|
||||
- **Automatic Node.js Installation** — Installer detects and installs Node.js
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] — 2026-02-04
|
||||
|
||||
### Added
|
||||
- **Go Server** — Single binary replacing hbbs + hbbr (~20K LOC)
|
||||
- **ALL-IN-ONE Scripts** — `betterdesk.sh` + `betterdesk.ps1` + `betterdesk-docker.sh`
|
||||
- **Automatic Mode** — `--auto` flag for non-interactive installation
|
||||
- **SHA256 Verification** — Automatic checksum verification of binaries
|
||||
|
||||
---
|
||||
|
||||
[3.0.0-alpha]: https://github.com/UNITRONIX/BetterDesk/compare/v2.4.0...HEAD
|
||||
[2.4.0]: https://github.com/UNITRONIX/BetterDesk/compare/v2.3.0...v2.4.0
|
||||
[2.3.0]: https://github.com/UNITRONIX/BetterDesk/compare/v2.2.0...v2.3.0
|
||||
[2.2.0]: https://github.com/UNITRONIX/BetterDesk/compare/v2.1.0...v2.2.0
|
||||
[2.1.0]: https://github.com/UNITRONIX/BetterDesk/releases/tag/v2.1.0
|
||||
@@ -684,6 +684,7 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
path == "/api/login" || path == "/api/login-options" || path == "/api/logout" ||
|
||||
path == "/api/heartbeat" || path == "/api/sysinfo" || path == "/api/sysinfo_ver" ||
|
||||
path == "/api/branding" ||
|
||||
path == "/api/org/login" ||
|
||||
path == "/api/devices/register" || path == "/api/devices/register/status" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
// Organization management REST API handlers (v3.0.0).
|
||||
//
|
||||
// Endpoints:
|
||||
// POST /api/org — create organization
|
||||
// GET /api/org — list organizations
|
||||
// GET /api/org/{id} — get organization details
|
||||
// PUT /api/org/{id} — update organization
|
||||
// DELETE /api/org/{id} — delete organization
|
||||
// GET /api/org/{id}/users — list org users
|
||||
// POST /api/org/{id}/users — add user to org
|
||||
// PUT /api/org/{id}/users/{uid} — update user
|
||||
// DELETE /api/org/{id}/users/{uid} — remove user
|
||||
// POST /api/org/{id}/invite — generate invitation
|
||||
// GET /api/org/{id}/invitations — list invitations
|
||||
// POST /api/org/{id}/devices — assign device to org
|
||||
// GET /api/org/{id}/devices — list org devices
|
||||
// DELETE /api/org/{id}/devices/{did} — unassign device
|
||||
// GET /api/org/{id}/settings — list org settings
|
||||
// PUT /api/org/{id}/settings — update org setting
|
||||
// POST /api/org/login — org user login (returns JWT)
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/unitronix/betterdesk-server/db"
|
||||
)
|
||||
|
||||
var slugRegexp = regexp.MustCompile(`^[a-z0-9][a-z0-9\-]{1,62}[a-z0-9]$`)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Organizations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// POST /api/org
|
||||
func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
Settings string `json:"settings"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
body.Slug = strings.TrimSpace(strings.ToLower(body.Slug))
|
||||
|
||||
if body.Name == "" {
|
||||
http.Error(w, `{"error":"name is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !slugRegexp.MatchString(body.Slug) {
|
||||
http.Error(w, `{"error":"slug must be 3-64 lowercase alphanumeric characters with optional hyphens"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check slug uniqueness
|
||||
existing, _ := s.db.GetOrganizationBySlug(body.Slug)
|
||||
if existing != nil {
|
||||
http.Error(w, `{"error":"slug already in use"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Settings == "" {
|
||||
body.Settings = "{}"
|
||||
}
|
||||
|
||||
org := &db.Organization{
|
||||
ID: uuid.New().String(),
|
||||
Name: body.Name,
|
||||
Slug: body.Slug,
|
||||
LogoURL: body.LogoURL,
|
||||
Settings: body.Settings,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := s.db.CreateOrganization(org); err != nil {
|
||||
log.Printf("[org] CreateOrganization error: %v", err)
|
||||
http.Error(w, `{"error":"failed to create organization"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(org)
|
||||
}
|
||||
|
||||
// GET /api/org
|
||||
func (s *Server) handleListOrgs(w http.ResponseWriter, r *http.Request) {
|
||||
orgs, err := s.db.ListOrganizations()
|
||||
if err != nil {
|
||||
log.Printf("[org] ListOrganizations error: %v", err)
|
||||
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if orgs == nil {
|
||||
orgs = []*db.Organization{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"organizations": orgs})
|
||||
}
|
||||
|
||||
// GET /api/org/{id}
|
||||
func (s *Server) handleGetOrg(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
http.Error(w, `{"error":"id required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
org, err := s.db.GetOrganization(id)
|
||||
if err != nil {
|
||||
log.Printf("[org] GetOrganization error: %v", err)
|
||||
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if org == nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(org)
|
||||
}
|
||||
|
||||
// PUT /api/org/{id}
|
||||
func (s *Server) handleUpdateOrg(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
http.Error(w, `{"error":"id required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
org, err := s.db.GetOrganization(id)
|
||||
if err != nil || org == nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Slug *string `json:"slug"`
|
||||
LogoURL *string `json:"logo_url"`
|
||||
Settings *string `json:"settings"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Name != nil {
|
||||
org.Name = strings.TrimSpace(*body.Name)
|
||||
}
|
||||
if body.Slug != nil {
|
||||
slug := strings.TrimSpace(strings.ToLower(*body.Slug))
|
||||
if !slugRegexp.MatchString(slug) {
|
||||
http.Error(w, `{"error":"invalid slug"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Check slug uniqueness (if changed)
|
||||
if slug != org.Slug {
|
||||
existing, _ := s.db.GetOrganizationBySlug(slug)
|
||||
if existing != nil {
|
||||
http.Error(w, `{"error":"slug already in use"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
org.Slug = slug
|
||||
}
|
||||
if body.LogoURL != nil {
|
||||
org.LogoURL = *body.LogoURL
|
||||
}
|
||||
if body.Settings != nil {
|
||||
org.Settings = *body.Settings
|
||||
}
|
||||
|
||||
if err := s.db.UpdateOrganization(org); err != nil {
|
||||
log.Printf("[org] UpdateOrganization error: %v", err)
|
||||
http.Error(w, `{"error":"failed to update"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(org)
|
||||
}
|
||||
|
||||
// DELETE /api/org/{id}
|
||||
func (s *Server) handleDeleteOrg(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
http.Error(w, `{"error":"id required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.DeleteOrganization(id); err != nil {
|
||||
log.Printf("[org] DeleteOrganization error: %v", err)
|
||||
http.Error(w, `{"error":"failed to delete"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org Users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// POST /api/org/{id}/users
|
||||
func (s *Server) handleCreateOrgUser(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := r.PathValue("id")
|
||||
if orgID == "" {
|
||||
http.Error(w, `{"error":"org_id required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify org exists
|
||||
org, _ := s.db.GetOrganization(orgID)
|
||||
if org == nil {
|
||||
http.Error(w, `{"error":"organization not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body.Username = strings.TrimSpace(body.Username)
|
||||
if body.Username == "" || body.Password == "" {
|
||||
http.Error(w, `{"error":"username and password are required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Role == "" {
|
||||
body.Role = db.OrgRoleUser
|
||||
}
|
||||
if body.Role != db.OrgRoleOwner && body.Role != db.OrgRoleAdmin &&
|
||||
body.Role != db.OrgRoleOperator && body.Role != db.OrgRoleUser {
|
||||
http.Error(w, `{"error":"invalid role (owner, admin, operator, user)"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check duplicate
|
||||
existing, _ := s.db.GetOrgUserByUsername(orgID, body.Username)
|
||||
if existing != nil {
|
||||
http.Error(w, `{"error":"username already exists in this organization"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"password hashing failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user := &db.OrgUser{
|
||||
ID: uuid.New().String(),
|
||||
OrgID: orgID,
|
||||
Username: body.Username,
|
||||
DisplayName: body.DisplayName,
|
||||
Email: body.Email,
|
||||
PasswordHash: string(hash),
|
||||
Role: body.Role,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := s.db.CreateOrgUser(user); err != nil {
|
||||
log.Printf("[org] CreateOrgUser error: %v", err)
|
||||
http.Error(w, `{"error":"failed to create user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
// GET /api/org/{id}/users
|
||||
func (s *Server) handleListOrgUsers(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := r.PathValue("id")
|
||||
users, err := s.db.ListOrgUsers(orgID)
|
||||
if err != nil {
|
||||
log.Printf("[org] ListOrgUsers error: %v", err)
|
||||
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if users == nil {
|
||||
users = []*db.OrgUser{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
|
||||
}
|
||||
|
||||
// PUT /api/org/{id}/users/{uid}
|
||||
func (s *Server) handleUpdateOrgUser(w http.ResponseWriter, r *http.Request) {
|
||||
uid := r.PathValue("uid")
|
||||
if uid == "" {
|
||||
http.Error(w, `{"error":"user id required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.db.GetOrgUser(uid)
|
||||
if err != nil || user == nil {
|
||||
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
Email *string `json:"email"`
|
||||
Role *string `json:"role"`
|
||||
Password *string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.DisplayName != nil {
|
||||
user.DisplayName = *body.DisplayName
|
||||
}
|
||||
if body.Email != nil {
|
||||
user.Email = *body.Email
|
||||
}
|
||||
if body.Role != nil {
|
||||
if *body.Role != db.OrgRoleOwner && *body.Role != db.OrgRoleAdmin &&
|
||||
*body.Role != db.OrgRoleOperator && *body.Role != db.OrgRoleUser {
|
||||
http.Error(w, `{"error":"invalid role"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user.Role = *body.Role
|
||||
}
|
||||
|
||||
if err := s.db.UpdateOrgUser(user); err != nil {
|
||||
log.Printf("[org] UpdateOrgUser error: %v", err)
|
||||
http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle password change separately (requires re-hashing)
|
||||
if body.Password != nil && *body.Password != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*body.Password), bcrypt.DefaultCost)
|
||||
if err == nil {
|
||||
user.PasswordHash = string(hash)
|
||||
s.db.UpdateOrgUser(user)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
// DELETE /api/org/{id}/users/{uid}
|
||||
func (s *Server) handleDeleteOrgUser(w http.ResponseWriter, r *http.Request) {
|
||||
uid := r.PathValue("uid")
|
||||
if uid == "" {
|
||||
http.Error(w, `{"error":"user id required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.DeleteOrgUser(uid); err != nil {
|
||||
log.Printf("[org] DeleteOrgUser error: %v", err)
|
||||
http.Error(w, `{"error":"failed to delete user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org Devices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// POST /api/org/{id}/devices
|
||||
func (s *Server) handleAssignOrgDevice(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := r.PathValue("id")
|
||||
|
||||
var body struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
AssignedUserID string `json:"assigned_user_id"`
|
||||
Department string `json:"department"`
|
||||
Location string `json:"location"`
|
||||
Building string `json:"building"`
|
||||
Tags string `json:"tags"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.DeviceID == "" {
|
||||
http.Error(w, `{"error":"device_id is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
d := &db.OrgDevice{
|
||||
OrgID: orgID,
|
||||
DeviceID: body.DeviceID,
|
||||
AssignedUserID: body.AssignedUserID,
|
||||
Department: body.Department,
|
||||
Location: body.Location,
|
||||
Building: body.Building,
|
||||
Tags: body.Tags,
|
||||
}
|
||||
|
||||
if err := s.db.AssignDeviceToOrg(d); err != nil {
|
||||
log.Printf("[org] AssignDeviceToOrg error: %v", err)
|
||||
http.Error(w, `{"error":"failed to assign device"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(d)
|
||||
}
|
||||
|
||||
// GET /api/org/{id}/devices
|
||||
func (s *Server) handleListOrgDevices(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := r.PathValue("id")
|
||||
devices, err := s.db.ListOrgDevices(orgID)
|
||||
if err != nil {
|
||||
log.Printf("[org] ListOrgDevices error: %v", err)
|
||||
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if devices == nil {
|
||||
devices = []*db.OrgDevice{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"devices": devices})
|
||||
}
|
||||
|
||||
// DELETE /api/org/{id}/devices/{did}
|
||||
func (s *Server) handleUnassignOrgDevice(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := r.PathValue("id")
|
||||
deviceID := r.PathValue("did")
|
||||
if deviceID == "" {
|
||||
http.Error(w, `{"error":"device_id required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.UnassignDeviceFromOrg(orgID, deviceID); err != nil {
|
||||
log.Printf("[org] UnassignDeviceFromOrg error: %v", err)
|
||||
http.Error(w, `{"error":"failed to unassign device"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org Invitations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// POST /api/org/{id}/invite
|
||||
func (s *Server) handleCreateOrgInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := r.PathValue("id")
|
||||
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
ExpiresIn int `json:"expires_in_hours"` // default 72 hours
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Role == "" {
|
||||
body.Role = db.OrgRoleUser
|
||||
}
|
||||
if body.ExpiresIn <= 0 {
|
||||
body.ExpiresIn = 72
|
||||
}
|
||||
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
|
||||
inv := &db.OrgInvitation{
|
||||
ID: uuid.New().String(),
|
||||
OrgID: orgID,
|
||||
Token: token,
|
||||
Email: body.Email,
|
||||
Role: body.Role,
|
||||
ExpiresAt: time.Now().UTC().Add(time.Duration(body.ExpiresIn) * time.Hour),
|
||||
}
|
||||
|
||||
if err := s.db.CreateOrgInvitation(inv); err != nil {
|
||||
log.Printf("[org] CreateOrgInvitation error: %v", err)
|
||||
http.Error(w, `{"error":"failed to create invitation"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(inv)
|
||||
}
|
||||
|
||||
// GET /api/org/{id}/invitations
|
||||
func (s *Server) handleListOrgInvitations(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := r.PathValue("id")
|
||||
invs, err := s.db.ListOrgInvitations(orgID)
|
||||
if err != nil {
|
||||
log.Printf("[org] ListOrgInvitations error: %v", err)
|
||||
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if invs == nil {
|
||||
invs = []*db.OrgInvitation{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"invitations": invs})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/org/{id}/settings
|
||||
func (s *Server) handleListOrgSettings(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := r.PathValue("id")
|
||||
settings, err := s.db.ListOrgSettings(orgID)
|
||||
if err != nil {
|
||||
log.Printf("[org] ListOrgSettings error: %v", err)
|
||||
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if settings == nil {
|
||||
settings = []*db.OrgSetting{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"settings": settings})
|
||||
}
|
||||
|
||||
// PUT /api/org/{id}/settings
|
||||
func (s *Server) handleSetOrgSetting(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := r.PathValue("id")
|
||||
|
||||
var body struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Key == "" {
|
||||
http.Error(w, `{"error":"key is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.SetOrgSetting(orgID, body.Key, body.Value); err != nil {
|
||||
log.Printf("[org] SetOrgSetting error: %v", err)
|
||||
http.Error(w, `{"error":"failed to save setting"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org User Login
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// POST /api/org/login
|
||||
func (s *Server) handleOrgLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
OrgSlug string `json:"org_slug"`
|
||||
OrgID string `json:"org_id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body.Username = strings.TrimSpace(body.Username)
|
||||
if body.Username == "" || body.Password == "" {
|
||||
http.Error(w, `{"error":"username and password are required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve org
|
||||
var orgID string
|
||||
if body.OrgID != "" {
|
||||
orgID = body.OrgID
|
||||
} else if body.OrgSlug != "" {
|
||||
org, _ := s.db.GetOrganizationBySlug(body.OrgSlug)
|
||||
if org == nil {
|
||||
http.Error(w, `{"error":"organization not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
orgID = org.ID
|
||||
} else {
|
||||
http.Error(w, `{"error":"org_id or org_slug is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := s.db.GetOrgUserByUsername(orgID, body.Username)
|
||||
if user == nil {
|
||||
// Timing-safe: compare against dummy hash
|
||||
bcrypt.CompareHashAndPassword(
|
||||
[]byte("$2a$10$XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"),
|
||||
[]byte(body.Password),
|
||||
)
|
||||
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil {
|
||||
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Update last login
|
||||
s.db.UpdateOrgUserLogin(user.ID)
|
||||
|
||||
// Generate JWT
|
||||
if s.jwtManager == nil {
|
||||
http.Error(w, `{"error":"JWT not configured"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.jwtManager.Generate(user.Username, user.Role)
|
||||
if err != nil {
|
||||
log.Printf("[org] JWT generation error: %v", err)
|
||||
http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": token,
|
||||
"user": user,
|
||||
"org_id": orgID,
|
||||
"type": "org_user",
|
||||
})
|
||||
}
|
||||
@@ -162,6 +162,25 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
mux.HandleFunc("PUT /api/chat/groups/", s.handleChatUpdateGroup)
|
||||
mux.HandleFunc("DELETE /api/chat/groups/", s.handleChatDeleteGroup)
|
||||
|
||||
// Organizations (v3.0.0)
|
||||
mux.HandleFunc("POST /api/org", s.requireRole(auth.RoleAdmin, s.handleCreateOrg))
|
||||
mux.HandleFunc("GET /api/org", s.handleListOrgs)
|
||||
mux.HandleFunc("GET /api/org/{id}", s.handleGetOrg)
|
||||
mux.HandleFunc("PUT /api/org/{id}", s.requireRole(auth.RoleAdmin, s.handleUpdateOrg))
|
||||
mux.HandleFunc("DELETE /api/org/{id}", s.requireRole(auth.RoleAdmin, s.handleDeleteOrg))
|
||||
mux.HandleFunc("GET /api/org/{id}/users", s.handleListOrgUsers)
|
||||
mux.HandleFunc("POST /api/org/{id}/users", s.requireRole(auth.RoleAdmin, s.handleCreateOrgUser))
|
||||
mux.HandleFunc("PUT /api/org/{id}/users/{uid}", s.requireRole(auth.RoleAdmin, s.handleUpdateOrgUser))
|
||||
mux.HandleFunc("DELETE /api/org/{id}/users/{uid}", s.requireRole(auth.RoleAdmin, s.handleDeleteOrgUser))
|
||||
mux.HandleFunc("POST /api/org/{id}/invite", s.requireRole(auth.RoleAdmin, s.handleCreateOrgInvitation))
|
||||
mux.HandleFunc("GET /api/org/{id}/invitations", s.requireRole(auth.RoleAdmin, s.handleListOrgInvitations))
|
||||
mux.HandleFunc("POST /api/org/{id}/devices", s.requireRole(auth.RoleOperator, s.handleAssignOrgDevice))
|
||||
mux.HandleFunc("GET /api/org/{id}/devices", s.handleListOrgDevices)
|
||||
mux.HandleFunc("DELETE /api/org/{id}/devices/{did}", s.requireRole(auth.RoleOperator, s.handleUnassignOrgDevice))
|
||||
mux.HandleFunc("GET /api/org/{id}/settings", s.handleListOrgSettings)
|
||||
mux.HandleFunc("PUT /api/org/{id}/settings", s.requireRole(auth.RoleAdmin, s.handleSetOrgSetting))
|
||||
mux.HandleFunc("POST /api/org/login", s.handleOrgLogin) // public — no auth required
|
||||
|
||||
// Audit
|
||||
mux.HandleFunc("GET /api/audit/events", s.handleAuditEvents)
|
||||
|
||||
|
||||
@@ -139,6 +139,68 @@ type ChatContact struct {
|
||||
AvatarColor string `json:"avatar_color"`
|
||||
}
|
||||
|
||||
// Organization represents a customer/tenant entity.
|
||||
type Organization struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
LogoURL string `json:"logo_url,omitempty"`
|
||||
Settings string `json:"settings,omitempty"` // JSON blob for org-level settings
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// OrgUser represents a user account within an organization.
|
||||
type OrgUser struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"org_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role string `json:"role"` // owner, admin, operator, user
|
||||
TOTPSecret string `json:"-"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// OrgDevice binds a device to an organization with metadata.
|
||||
type OrgDevice struct {
|
||||
OrgID string `json:"org_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
AssignedUserID string `json:"assigned_user_id,omitempty"`
|
||||
Department string `json:"department,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Building string `json:"building,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// OrgInvitation represents a pending invitation to join an organization.
|
||||
type OrgInvitation struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"org_id"`
|
||||
Token string `json:"token"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // default: "user"
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||
}
|
||||
|
||||
// OrgSetting stores a single key-value pair scoped to an organization.
|
||||
type OrgSetting struct {
|
||||
OrgID string `json:"org_id"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// OrgRole constants
|
||||
const (
|
||||
OrgRoleOwner = "owner"
|
||||
OrgRoleAdmin = "admin"
|
||||
OrgRoleOperator = "operator"
|
||||
OrgRoleUser = "user"
|
||||
)
|
||||
|
||||
// Database is the interface for all database operations.
|
||||
// Designed to support SQLite (now) and PostgreSQL (future) as drop-in implementations.
|
||||
type Database interface {
|
||||
@@ -241,4 +303,41 @@ type Database interface {
|
||||
ListChatGroups(memberID string) ([]*ChatGroup, error) // Groups containing memberID
|
||||
UpdateChatGroup(g *ChatGroup) error
|
||||
DeleteChatGroup(id string) error
|
||||
|
||||
// Organizations
|
||||
CreateOrganization(o *Organization) error
|
||||
GetOrganization(id string) (*Organization, error)
|
||||
GetOrganizationBySlug(slug string) (*Organization, error)
|
||||
ListOrganizations() ([]*Organization, error)
|
||||
UpdateOrganization(o *Organization) error
|
||||
DeleteOrganization(id string) error
|
||||
|
||||
// Org Users
|
||||
CreateOrgUser(u *OrgUser) error
|
||||
GetOrgUser(id string) (*OrgUser, error)
|
||||
GetOrgUserByUsername(orgID, username string) (*OrgUser, error)
|
||||
ListOrgUsers(orgID string) ([]*OrgUser, error)
|
||||
UpdateOrgUser(u *OrgUser) error
|
||||
DeleteOrgUser(id string) error
|
||||
UpdateOrgUserLogin(id string) error
|
||||
|
||||
// Org Devices
|
||||
AssignDeviceToOrg(d *OrgDevice) error
|
||||
UnassignDeviceFromOrg(orgID, deviceID string) error
|
||||
GetOrgDevice(orgID, deviceID string) (*OrgDevice, error)
|
||||
ListOrgDevices(orgID string) ([]*OrgDevice, error)
|
||||
UpdateOrgDevice(d *OrgDevice) error
|
||||
|
||||
// Org Invitations
|
||||
CreateOrgInvitation(inv *OrgInvitation) error
|
||||
GetOrgInvitationByToken(token string) (*OrgInvitation, error)
|
||||
ListOrgInvitations(orgID string) ([]*OrgInvitation, error)
|
||||
UseOrgInvitation(token string) error
|
||||
DeleteOrgInvitation(id string) error
|
||||
|
||||
// Org Settings
|
||||
GetOrgSetting(orgID, key string) (string, error)
|
||||
SetOrgSetting(orgID, key, value string) error
|
||||
DeleteOrgSetting(orgID, key string) error
|
||||
ListOrgSettings(orgID string) ([]*OrgSetting, error)
|
||||
}
|
||||
|
||||
@@ -196,6 +196,66 @@ func (pg *PostgresDB) Migrate() error {
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Organizations (v3.0.0)
|
||||
`CREATE TABLE IF NOT EXISTS organizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
logo_url TEXT NOT NULL DEFAULT '',
|
||||
settings JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
|
||||
// Organization users (v3.0.0)
|
||||
`CREATE TABLE IF NOT EXISTS org_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL REFERENCES organizations(id),
|
||||
username TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
totp_secret TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
last_login TIMESTAMPTZ DEFAULT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(org_id, username)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_org_users_org ON org_users(org_id)`,
|
||||
|
||||
// Organization devices (v3.0.0)
|
||||
`CREATE TABLE IF NOT EXISTS org_devices (
|
||||
org_id TEXT NOT NULL REFERENCES organizations(id),
|
||||
device_id TEXT NOT NULL,
|
||||
assigned_user_id TEXT NOT NULL DEFAULT '',
|
||||
department TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
building TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY(org_id, device_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_org_devices_org ON org_devices(org_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_org_devices_device ON org_devices(device_id)`,
|
||||
|
||||
// Organization invitations (v3.0.0)
|
||||
`CREATE TABLE IF NOT EXISTS org_invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL REFERENCES organizations(id),
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ DEFAULT NULL
|
||||
)`,
|
||||
|
||||
// Organization settings (v3.0.0)
|
||||
`CREATE TABLE IF NOT EXISTS org_settings (
|
||||
org_id TEXT NOT NULL REFERENCES organizations(id),
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY(org_id, key)
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, stmt := range statements {
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// Organization CRUD operations for PostgreSQL backend (v3.0.0).
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Organizations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (pg *PostgresDB) CreateOrganization(o *Organization) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`INSERT INTO organizations (id, name, slug, logo_url, settings, created_at) VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
o.ID, o.Name, o.Slug, o.LogoURL, o.Settings, o.CreatedAt.UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetOrganization(id string) (*Organization, error) {
|
||||
var o Organization
|
||||
err := pg.pool.QueryRow(pg.ctx,
|
||||
`SELECT id, name, slug, logo_url, settings, created_at FROM organizations WHERE id = $1`, id,
|
||||
).Scan(&o.ID, &o.Name, &o.Slug, &o.LogoURL, &o.Settings, &o.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetOrganizationBySlug(slug string) (*Organization, error) {
|
||||
var o Organization
|
||||
err := pg.pool.QueryRow(pg.ctx,
|
||||
`SELECT id, name, slug, logo_url, settings, created_at FROM organizations WHERE slug = $1`, slug,
|
||||
).Scan(&o.ID, &o.Name, &o.Slug, &o.LogoURL, &o.Settings, &o.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) ListOrganizations() ([]*Organization, error) {
|
||||
rows, err := pg.pool.Query(pg.ctx,
|
||||
`SELECT id, name, slug, logo_url, settings, created_at FROM organizations ORDER BY name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var orgs []*Organization
|
||||
for rows.Next() {
|
||||
var o Organization
|
||||
if err := rows.Scan(&o.ID, &o.Name, &o.Slug, &o.LogoURL, &o.Settings, &o.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orgs = append(orgs, &o)
|
||||
}
|
||||
return orgs, rows.Err()
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) UpdateOrganization(o *Organization) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`UPDATE organizations SET name = $1, slug = $2, logo_url = $3, settings = $4 WHERE id = $5`,
|
||||
o.Name, o.Slug, o.LogoURL, o.Settings, o.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) DeleteOrganization(id string) error {
|
||||
tx, err := pg.pool.Begin(pg.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
|
||||
tx.Exec(pg.ctx, `DELETE FROM org_settings WHERE org_id = $1`, id)
|
||||
tx.Exec(pg.ctx, `DELETE FROM org_invitations WHERE org_id = $1`, id)
|
||||
tx.Exec(pg.ctx, `DELETE FROM org_devices WHERE org_id = $1`, id)
|
||||
tx.Exec(pg.ctx, `DELETE FROM org_users WHERE org_id = $1`, id)
|
||||
if _, err := tx.Exec(pg.ctx, `DELETE FROM organizations WHERE id = $1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(pg.ctx)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org Users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (pg *PostgresDB) CreateOrgUser(u *OrgUser) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`INSERT INTO org_users (id, org_id, username, display_name, email, password_hash, role, totp_secret, avatar_url, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
u.ID, u.OrgID, u.Username, u.DisplayName, u.Email, u.PasswordHash,
|
||||
u.Role, u.TOTPSecret, u.AvatarURL, u.CreatedAt.UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetOrgUser(id string) (*OrgUser, error) {
|
||||
var u OrgUser
|
||||
err := pg.pool.QueryRow(pg.ctx,
|
||||
`SELECT id, org_id, username, display_name, email, password_hash, role, totp_secret, avatar_url, last_login, created_at
|
||||
FROM org_users WHERE id = $1`, id,
|
||||
).Scan(&u.ID, &u.OrgID, &u.Username, &u.DisplayName, &u.Email,
|
||||
&u.PasswordHash, &u.Role, &u.TOTPSecret, &u.AvatarURL, &u.LastLogin, &u.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetOrgUserByUsername(orgID, username string) (*OrgUser, error) {
|
||||
var u OrgUser
|
||||
err := pg.pool.QueryRow(pg.ctx,
|
||||
`SELECT id, org_id, username, display_name, email, password_hash, role, totp_secret, avatar_url, last_login, created_at
|
||||
FROM org_users WHERE org_id = $1 AND username = $2`, orgID, username,
|
||||
).Scan(&u.ID, &u.OrgID, &u.Username, &u.DisplayName, &u.Email,
|
||||
&u.PasswordHash, &u.Role, &u.TOTPSecret, &u.AvatarURL, &u.LastLogin, &u.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) ListOrgUsers(orgID string) ([]*OrgUser, error) {
|
||||
rows, err := pg.pool.Query(pg.ctx,
|
||||
`SELECT id, org_id, username, display_name, email, password_hash, role, totp_secret, avatar_url, last_login, created_at
|
||||
FROM org_users WHERE org_id = $1 ORDER BY username`, orgID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []*OrgUser
|
||||
for rows.Next() {
|
||||
var u OrgUser
|
||||
if err := rows.Scan(&u.ID, &u.OrgID, &u.Username, &u.DisplayName, &u.Email,
|
||||
&u.PasswordHash, &u.Role, &u.TOTPSecret, &u.AvatarURL, &u.LastLogin, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, &u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) UpdateOrgUser(u *OrgUser) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`UPDATE org_users SET display_name = $1, email = $2, role = $3, totp_secret = $4, avatar_url = $5
|
||||
WHERE id = $6`,
|
||||
u.DisplayName, u.Email, u.Role, u.TOTPSecret, u.AvatarURL, u.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) DeleteOrgUser(id string) error {
|
||||
_, err := pg.pool.Exec(pg.ctx, `DELETE FROM org_users WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) UpdateOrgUserLogin(id string) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`UPDATE org_users SET last_login = NOW() WHERE id = $1`, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org Devices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (pg *PostgresDB) AssignDeviceToOrg(d *OrgDevice) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`INSERT INTO org_devices (org_id, device_id, assigned_user_id, department, location, building, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (org_id, device_id) DO UPDATE SET
|
||||
assigned_user_id = EXCLUDED.assigned_user_id,
|
||||
department = EXCLUDED.department,
|
||||
location = EXCLUDED.location,
|
||||
building = EXCLUDED.building,
|
||||
tags = EXCLUDED.tags`,
|
||||
d.OrgID, d.DeviceID, d.AssignedUserID, d.Department, d.Location, d.Building, d.Tags,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) UnassignDeviceFromOrg(orgID, deviceID string) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`DELETE FROM org_devices WHERE org_id = $1 AND device_id = $2`, orgID, deviceID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetOrgDevice(orgID, deviceID string) (*OrgDevice, error) {
|
||||
var d OrgDevice
|
||||
err := pg.pool.QueryRow(pg.ctx,
|
||||
`SELECT org_id, device_id, assigned_user_id, department, location, building, tags
|
||||
FROM org_devices WHERE org_id = $1 AND device_id = $2`, orgID, deviceID,
|
||||
).Scan(&d.OrgID, &d.DeviceID, &d.AssignedUserID, &d.Department, &d.Location, &d.Building, &d.Tags)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) ListOrgDevices(orgID string) ([]*OrgDevice, error) {
|
||||
rows, err := pg.pool.Query(pg.ctx,
|
||||
`SELECT org_id, device_id, assigned_user_id, department, location, building, tags
|
||||
FROM org_devices WHERE org_id = $1 ORDER BY device_id`, orgID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var devices []*OrgDevice
|
||||
for rows.Next() {
|
||||
var d OrgDevice
|
||||
if err := rows.Scan(&d.OrgID, &d.DeviceID, &d.AssignedUserID, &d.Department, &d.Location, &d.Building, &d.Tags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices = append(devices, &d)
|
||||
}
|
||||
return devices, rows.Err()
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) UpdateOrgDevice(d *OrgDevice) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`UPDATE org_devices SET assigned_user_id = $1, department = $2, location = $3, building = $4, tags = $5
|
||||
WHERE org_id = $6 AND device_id = $7`,
|
||||
d.AssignedUserID, d.Department, d.Location, d.Building, d.Tags, d.OrgID, d.DeviceID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org Invitations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (pg *PostgresDB) CreateOrgInvitation(inv *OrgInvitation) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`INSERT INTO org_invitations (id, org_id, token, email, role, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
inv.ID, inv.OrgID, inv.Token, inv.Email, inv.Role, inv.ExpiresAt.UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) GetOrgInvitationByToken(token string) (*OrgInvitation, error) {
|
||||
var inv OrgInvitation
|
||||
err := pg.pool.QueryRow(pg.ctx,
|
||||
`SELECT id, org_id, token, email, role, expires_at, used_at
|
||||
FROM org_invitations WHERE token = $1`, token,
|
||||
).Scan(&inv.ID, &inv.OrgID, &inv.Token, &inv.Email, &inv.Role, &inv.ExpiresAt, &inv.UsedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) ListOrgInvitations(orgID string) ([]*OrgInvitation, error) {
|
||||
rows, err := pg.pool.Query(pg.ctx,
|
||||
`SELECT id, org_id, token, email, role, expires_at, used_at
|
||||
FROM org_invitations WHERE org_id = $1 ORDER BY expires_at DESC`, orgID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var invs []*OrgInvitation
|
||||
for rows.Next() {
|
||||
var inv OrgInvitation
|
||||
if err := rows.Scan(&inv.ID, &inv.OrgID, &inv.Token, &inv.Email, &inv.Role, &inv.ExpiresAt, &inv.UsedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invs = append(invs, &inv)
|
||||
}
|
||||
return invs, rows.Err()
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) UseOrgInvitation(token string) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`UPDATE org_invitations SET used_at = NOW() WHERE token = $1`, token,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) DeleteOrgInvitation(id string) error {
|
||||
_, err := pg.pool.Exec(pg.ctx, `DELETE FROM org_invitations WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (pg *PostgresDB) GetOrgSetting(orgID, key string) (string, error) {
|
||||
var value string
|
||||
err := pg.pool.QueryRow(pg.ctx,
|
||||
`SELECT value FROM org_settings WHERE org_id = $1 AND key = $2`, orgID, key,
|
||||
).Scan(&value)
|
||||
if err == pgx.ErrNoRows {
|
||||
return "", fmt.Errorf("org setting not found: %s/%s", orgID, key)
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) SetOrgSetting(orgID, key, value string) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`INSERT INTO org_settings (org_id, key, value) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (org_id, key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
orgID, key, value,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) DeleteOrgSetting(orgID, key string) error {
|
||||
_, err := pg.pool.Exec(pg.ctx,
|
||||
`DELETE FROM org_settings WHERE org_id = $1 AND key = $2`, orgID, key,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (pg *PostgresDB) ListOrgSettings(orgID string) ([]*OrgSetting, error) {
|
||||
rows, err := pg.pool.Query(pg.ctx,
|
||||
`SELECT org_id, key, value FROM org_settings WHERE org_id = $1 ORDER BY key`, orgID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var settings []*OrgSetting
|
||||
for rows.Next() {
|
||||
var s OrgSetting
|
||||
if err := rows.Scan(&s.OrgID, &s.Key, &s.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings = append(settings, &s)
|
||||
}
|
||||
return settings, rows.Err()
|
||||
}
|
||||
@@ -178,6 +178,68 @@ func (s *SQLiteDB) Migrate() error {
|
||||
created_by TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`,
|
||||
|
||||
// Organizations (v3.0.0)
|
||||
`CREATE TABLE IF NOT EXISTS organizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
logo_url TEXT DEFAULT '',
|
||||
settings TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_organizations_slug ON organizations(slug)`,
|
||||
|
||||
// Organization users (v3.0.0)
|
||||
`CREATE TABLE IF NOT EXISTS org_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL REFERENCES organizations(id),
|
||||
username TEXT NOT NULL,
|
||||
display_name TEXT DEFAULT '',
|
||||
email TEXT DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'user',
|
||||
totp_secret TEXT DEFAULT '',
|
||||
avatar_url TEXT DEFAULT '',
|
||||
last_login TEXT DEFAULT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(org_id, username)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_org_users_org ON org_users(org_id)`,
|
||||
|
||||
// Organization devices (v3.0.0)
|
||||
`CREATE TABLE IF NOT EXISTS org_devices (
|
||||
org_id TEXT NOT NULL REFERENCES organizations(id),
|
||||
device_id TEXT NOT NULL,
|
||||
assigned_user_id TEXT DEFAULT '',
|
||||
department TEXT DEFAULT '',
|
||||
location TEXT DEFAULT '',
|
||||
building TEXT DEFAULT '',
|
||||
tags TEXT DEFAULT '',
|
||||
PRIMARY KEY(org_id, device_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_org_devices_org ON org_devices(org_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_org_devices_device ON org_devices(device_id)`,
|
||||
|
||||
// Organization invitations (v3.0.0)
|
||||
`CREATE TABLE IF NOT EXISTS org_invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
org_id TEXT NOT NULL REFERENCES organizations(id),
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
email TEXT DEFAULT '',
|
||||
role TEXT DEFAULT 'user',
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT DEFAULT NULL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_org_invitations_token ON org_invitations(token)`,
|
||||
|
||||
// Organization settings (v3.0.0)
|
||||
`CREATE TABLE IF NOT EXISTS org_settings (
|
||||
org_id TEXT NOT NULL REFERENCES organizations(id),
|
||||
key TEXT NOT NULL,
|
||||
value TEXT DEFAULT '',
|
||||
PRIMARY KEY(org_id, key)
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, stmt := range statements {
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
// Organization CRUD operations for SQLite backend (v3.0.0).
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Organizations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *SQLiteDB) CreateOrganization(o *Organization) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO organizations (id, name, slug, logo_url, settings, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
o.ID, o.Name, o.Slug, o.LogoURL, o.Settings, o.CreatedAt.UTC().Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetOrganization(id string) (*Organization, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var o Organization
|
||||
var createdAt string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, slug, logo_url, settings, created_at FROM organizations WHERE id = ?`, id,
|
||||
).Scan(&o.ID, &o.Name, &o.Slug, &o.LogoURL, &o.Settings, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetOrganizationBySlug(slug string) (*Organization, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var o Organization
|
||||
var createdAt string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, name, slug, logo_url, settings, created_at FROM organizations WHERE slug = ?`, slug,
|
||||
).Scan(&o.ID, &o.Name, &o.Slug, &o.LogoURL, &o.Settings, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ListOrganizations() ([]*Organization, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(`SELECT id, name, slug, logo_url, settings, created_at FROM organizations ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var orgs []*Organization
|
||||
for rows.Next() {
|
||||
var o Organization
|
||||
var createdAt string
|
||||
if err := rows.Scan(&o.ID, &o.Name, &o.Slug, &o.LogoURL, &o.Settings, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
orgs = append(orgs, &o)
|
||||
}
|
||||
return orgs, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) UpdateOrganization(o *Organization) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE organizations SET name = ?, slug = ?, logo_url = ?, settings = ? WHERE id = ?`,
|
||||
o.Name, o.Slug, o.LogoURL, o.Settings, o.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) DeleteOrganization(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Cascade: remove settings, invitations, devices, users, then org
|
||||
tx.Exec(`DELETE FROM org_settings WHERE org_id = ?`, id)
|
||||
tx.Exec(`DELETE FROM org_invitations WHERE org_id = ?`, id)
|
||||
tx.Exec(`DELETE FROM org_devices WHERE org_id = ?`, id)
|
||||
tx.Exec(`DELETE FROM org_users WHERE org_id = ?`, id)
|
||||
if _, err := tx.Exec(`DELETE FROM organizations WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org Users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *SQLiteDB) CreateOrgUser(u *OrgUser) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO org_users (id, org_id, username, display_name, email, password_hash, role, totp_secret, avatar_url, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
u.ID, u.OrgID, u.Username, u.DisplayName, u.Email, u.PasswordHash,
|
||||
u.Role, u.TOTPSecret, u.AvatarURL, u.CreatedAt.UTC().Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetOrgUser(id string) (*OrgUser, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.scanOrgUser(s.db.QueryRow(
|
||||
`SELECT id, org_id, username, display_name, email, password_hash, role, totp_secret, avatar_url, last_login, created_at
|
||||
FROM org_users WHERE id = ?`, id,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetOrgUserByUsername(orgID, username string) (*OrgUser, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.scanOrgUser(s.db.QueryRow(
|
||||
`SELECT id, org_id, username, display_name, email, password_hash, role, totp_secret, avatar_url, last_login, created_at
|
||||
FROM org_users WHERE org_id = ? AND username = ?`, orgID, username,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ListOrgUsers(orgID string) ([]*OrgUser, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, org_id, username, display_name, email, password_hash, role, totp_secret, avatar_url, last_login, created_at
|
||||
FROM org_users WHERE org_id = ? ORDER BY username`, orgID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []*OrgUser
|
||||
for rows.Next() {
|
||||
u, err := s.scanOrgUserRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) UpdateOrgUser(u *OrgUser) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE org_users SET display_name = ?, email = ?, role = ?, totp_secret = ?, avatar_url = ?
|
||||
WHERE id = ?`,
|
||||
u.DisplayName, u.Email, u.Role, u.TOTPSecret, u.AvatarURL, u.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) DeleteOrgUser(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(`DELETE FROM org_users WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) UpdateOrgUserLogin(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE org_users SET last_login = ? WHERE id = ?`,
|
||||
time.Now().UTC().Format(time.RFC3339), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
func (s *SQLiteDB) scanOrgUser(row *sql.Row) (*OrgUser, error) {
|
||||
var u OrgUser
|
||||
var lastLogin sql.NullString
|
||||
var createdAt string
|
||||
err := row.Scan(
|
||||
&u.ID, &u.OrgID, &u.Username, &u.DisplayName, &u.Email,
|
||||
&u.PasswordHash, &u.Role, &u.TOTPSecret, &u.AvatarURL,
|
||||
&lastLogin, &createdAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
if lastLogin.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, lastLogin.String)
|
||||
u.LastLogin = &t
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
type orgUserRowScanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) scanOrgUserRow(row orgUserRowScanner) (*OrgUser, error) {
|
||||
var u OrgUser
|
||||
var lastLogin sql.NullString
|
||||
var createdAt string
|
||||
err := row.Scan(
|
||||
&u.ID, &u.OrgID, &u.Username, &u.DisplayName, &u.Email,
|
||||
&u.PasswordHash, &u.Role, &u.TOTPSecret, &u.AvatarURL,
|
||||
&lastLogin, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
if lastLogin.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, lastLogin.String)
|
||||
u.LastLogin = &t
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org Devices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *SQLiteDB) AssignDeviceToOrg(d *OrgDevice) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR REPLACE INTO org_devices (org_id, device_id, assigned_user_id, department, location, building, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
d.OrgID, d.DeviceID, d.AssignedUserID, d.Department, d.Location, d.Building, d.Tags,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) UnassignDeviceFromOrg(orgID, deviceID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(`DELETE FROM org_devices WHERE org_id = ? AND device_id = ?`, orgID, deviceID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetOrgDevice(orgID, deviceID string) (*OrgDevice, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var d OrgDevice
|
||||
err := s.db.QueryRow(
|
||||
`SELECT org_id, device_id, assigned_user_id, department, location, building, tags
|
||||
FROM org_devices WHERE org_id = ? AND device_id = ?`, orgID, deviceID,
|
||||
).Scan(&d.OrgID, &d.DeviceID, &d.AssignedUserID, &d.Department, &d.Location, &d.Building, &d.Tags)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ListOrgDevices(orgID string) ([]*OrgDevice, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(
|
||||
`SELECT org_id, device_id, assigned_user_id, department, location, building, tags
|
||||
FROM org_devices WHERE org_id = ? ORDER BY device_id`, orgID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var devices []*OrgDevice
|
||||
for rows.Next() {
|
||||
var d OrgDevice
|
||||
if err := rows.Scan(&d.OrgID, &d.DeviceID, &d.AssignedUserID, &d.Department, &d.Location, &d.Building, &d.Tags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices = append(devices, &d)
|
||||
}
|
||||
return devices, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) UpdateOrgDevice(d *OrgDevice) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE org_devices SET assigned_user_id = ?, department = ?, location = ?, building = ?, tags = ?
|
||||
WHERE org_id = ? AND device_id = ?`,
|
||||
d.AssignedUserID, d.Department, d.Location, d.Building, d.Tags, d.OrgID, d.DeviceID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org Invitations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *SQLiteDB) CreateOrgInvitation(inv *OrgInvitation) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO org_invitations (id, org_id, token, email, role, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
inv.ID, inv.OrgID, inv.Token, inv.Email, inv.Role,
|
||||
inv.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetOrgInvitationByToken(token string) (*OrgInvitation, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var inv OrgInvitation
|
||||
var expiresAt string
|
||||
var usedAt sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, org_id, token, email, role, expires_at, used_at
|
||||
FROM org_invitations WHERE token = ?`, token,
|
||||
).Scan(&inv.ID, &inv.OrgID, &inv.Token, &inv.Email, &inv.Role, &expiresAt, &usedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inv.ExpiresAt, _ = time.Parse(time.RFC3339, expiresAt)
|
||||
if usedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, usedAt.String)
|
||||
inv.UsedAt = &t
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ListOrgInvitations(orgID string) ([]*OrgInvitation, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, org_id, token, email, role, expires_at, used_at
|
||||
FROM org_invitations WHERE org_id = ? ORDER BY expires_at DESC`, orgID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var invs []*OrgInvitation
|
||||
for rows.Next() {
|
||||
var inv OrgInvitation
|
||||
var expiresAt string
|
||||
var usedAt sql.NullString
|
||||
if err := rows.Scan(&inv.ID, &inv.OrgID, &inv.Token, &inv.Email, &inv.Role, &expiresAt, &usedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inv.ExpiresAt, _ = time.Parse(time.RFC3339, expiresAt)
|
||||
if usedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, usedAt.String)
|
||||
inv.UsedAt = &t
|
||||
}
|
||||
invs = append(invs, &inv)
|
||||
}
|
||||
return invs, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) UseOrgInvitation(token string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE org_invitations SET used_at = ? WHERE token = ?`,
|
||||
time.Now().UTC().Format(time.RFC3339), token,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) DeleteOrgInvitation(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(`DELETE FROM org_invitations WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Org Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *SQLiteDB) GetOrgSetting(orgID, key string) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var value string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT value FROM org_settings WHERE org_id = ? AND key = ?`, orgID, key,
|
||||
).Scan(&value)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("org setting not found: %s/%s", orgID, key)
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) SetOrgSetting(orgID, key, value string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR REPLACE INTO org_settings (org_id, key, value) VALUES (?, ?, ?)`,
|
||||
orgID, key, value,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) DeleteOrgSetting(orgID, key string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(`DELETE FROM org_settings WHERE org_id = ? AND key = ?`, orgID, key)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ListOrgSettings(orgID string) ([]*OrgSetting, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(
|
||||
`SELECT org_id, key, value FROM org_settings WHERE org_id = ? ORDER BY key`, orgID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var settings []*OrgSetting
|
||||
for rows.Next() {
|
||||
var s OrgSetting
|
||||
if err := rows.Scan(&s.OrgID, &s.Key, &s.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings = append(settings, &s)
|
||||
}
|
||||
return settings, rows.Err()
|
||||
}
|
||||
+585
-35
@@ -6,42 +6,53 @@
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Immediate Bug Fixes & Code Cleanup
|
||||
## Phase 0 — Immediate Bug Fixes & Code Cleanup ✅ COMPLETED
|
||||
|
||||
### 0.1 Chat — Tray Menu Opens Main Window Instead of Chat
|
||||
### 0.1 Chat — Tray Menu Opens Main Window Instead of Chat ✅
|
||||
**Problem:** Clicking "Chat" in the tray context menu opens the default BetterDesk window first, then the user has to click Chat inside it.
|
||||
- Tauri: create a dedicated `chat` WebviewWindow opened directly from tray menu handler
|
||||
- Remove the intermediate step through the main window
|
||||
- Chat WebSocket should auto-connect on window open
|
||||
- ✅ `tray.rs`: `on_menu_event` "chat" handler now opens the dedicated `chat` WebviewWindow directly via `app.get_webview_window("chat")`
|
||||
- ✅ Falls back to `navigate_to(app, "/chat")` only if chat window is not found
|
||||
- Chat WebSocket auto-connects on window open (handled by `ChatWindow.tsx` `onMount`)
|
||||
|
||||
### 0.2 Chat — Shows "Disconnected"
|
||||
### 0.2 Chat — Shows "Disconnected" ✅
|
||||
**Problem:** Chat UI displays disconnected status after opening.
|
||||
- Diagnose whether `ws://server:5000/ws/chat/<device_id>` is reachable from the client
|
||||
- Likely cause: client connects to wrong port/host or device_id is not set at connection time
|
||||
- Add reconnect logic with exponential backoff (1s → 2s → 4s → 30s cap)
|
||||
- ✅ Root cause: WebSocket URL used hardcoded `ws://` even when `console_url` is HTTPS
|
||||
- ✅ Added `server_ws_scheme()` method to `Settings` — returns `wss` for HTTPS, `ws` otherwise
|
||||
- ✅ `lib.rs`: Chat and Remote agent WS URLs now use dynamic scheme (`ws://` or `wss://`)
|
||||
- ✅ Added `reconnect_chat` IPC command — stops current agent, rebuilds URL, starts new agent
|
||||
- ✅ `ChatWindow.tsx`: Added reconnect button (refresh icon) visible when disconnected
|
||||
- ✅ `chat-window.css`: Styled `.cw-reconnect-btn` with hover state
|
||||
- Reconnect logic with exponential backoff already existed (3s → 60s cap)
|
||||
|
||||
### 0.3 Rust Compilation Warnings (betterdesk-client)
|
||||
Current warnings:
|
||||
- 10 warnings in `bd_registration.rs` — unused variables (`device_id`, `status_tx`, etc.)
|
||||
- Unused imports across modules
|
||||
- Fix: prefix intentionally unused variables with `_`, remove dead imports
|
||||
- Run `cargo fix --lib -p betterdesk-client` for auto-fixable suggestions
|
||||
### 0.3 Rust Compilation Warnings (betterdesk-client) ✅
|
||||
All 10 warnings fixed:
|
||||
- ✅ Removed unused import `Instant` from `bd_registration.rs`
|
||||
- ✅ Removed unused label `'heartbeat` on loop
|
||||
- ✅ Prefixed unused variables: `_mode_str` (collector.rs), `_active` (incoming.rs), `_device_id`/`_status_tx` (spawn_mgmt_ws)
|
||||
- ✅ Added `#[allow(unused_assignments)]` on `bd_registration_loop` for intentional re-initialization pattern
|
||||
- ✅ `cargo check`: 0 warnings, 0 errors
|
||||
|
||||
### 0.4 Go Compilation Warnings (betterdesk-server)
|
||||
- Run `go vet ./...` and `staticcheck ./...`
|
||||
- Remove unused imports, variables, dead code paths
|
||||
- Fix deprecation warnings
|
||||
### 0.4 Go Compilation Warnings (betterdesk-server) ✅
|
||||
- ✅ `go vet ./...` — clean, 0 issues
|
||||
- ✅ `go build ./...` — clean, 0 warnings/errors
|
||||
|
||||
### 0.5 Dependency Audit
|
||||
- `npm audit --omit=dev` for web-nodejs (target: 0 vulnerabilities)
|
||||
- `cargo audit` for betterdesk-client
|
||||
- Update outdated packages to latest stable versions
|
||||
### 0.5 Dependency Audit ✅
|
||||
- ✅ `npm audit --omit=dev` for web-nodejs: 0 vulnerabilities
|
||||
- `cargo audit` for betterdesk-client: pending (requires cargo-audit installation)
|
||||
- ✅ All compile-time dependencies verified clean
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Organization & User Account System
|
||||
## Phase 1 — Organization & User Account System ✅ IMPLEMENTED
|
||||
|
||||
### 1.1 Data Model (Go Server — PostgreSQL + SQLite)
|
||||
### 1.1 Data Model (Go Server — PostgreSQL + SQLite) ✅
|
||||
**Implemented:**
|
||||
- ✅ `db/database.go`: Added 7 new model structs (Organization, OrgUser, OrgDevice, OrgInvitation, OrgSetting + role constants)
|
||||
- ✅ `db/database.go`: Extended Database interface with 17 new methods across 5 entity groups
|
||||
- ✅ `db/sqlite.go`: Added 5 new CREATE TABLE statements to Migrate()
|
||||
- ✅ `db/sqlite_org.go`: Full SQLite implementation (~380 lines) with all CRUD operations
|
||||
- ✅ `db/postgres.go`: Added 5 new CREATE TABLE statements to Migrate()
|
||||
- ✅ `db/postgres_org.go`: Full PostgreSQL implementation (~330 lines) with all CRUD operations
|
||||
|
||||
```sql
|
||||
-- Organizations
|
||||
@@ -104,7 +115,11 @@ CREATE TABLE org_settings (
|
||||
|
||||
**Roles:** `owner` → `admin` → `operator` → `user` (read-only)
|
||||
|
||||
### 1.2 REST API Endpoints (Go Server)
|
||||
### 1.2 REST API Endpoints (Go Server) ✅
|
||||
**Implemented:**
|
||||
- ✅ `api/org_handlers.go`: 18 HTTP handlers (~650 lines) for orgs, users, devices, invitations, settings, login
|
||||
- ✅ `api/server.go`: 22 routes registered with proper role-based auth (admin, operator, public)
|
||||
- ✅ `api/auth_handlers.go`: /api/org/login added to public endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
@@ -122,24 +137,36 @@ CREATE TABLE org_settings (
|
||||
| `POST` | `/api/org/login` | User login (returns JWT) |
|
||||
| `POST` | `/api/org/logout` | User logout |
|
||||
|
||||
### 1.3 Client-Side Login (BetterDesk Desktop)
|
||||
- Login screen: server address + username + password (or org invitation token)
|
||||
### 1.3 Client-Side Login (BetterDesk Desktop) ✅
|
||||
- ✅ Login screen: server address + username + password (or org invitation token) — `OrgLoginPanel.tsx`
|
||||
- After login: automatic chat name setup from `display_name`
|
||||
- Device automatically assigned to organization on first login
|
||||
- Persistent session via secure token storage (Tauri keyring / OS credential manager)
|
||||
- Token refresh mechanism (short-lived access + long-lived refresh)
|
||||
|
||||
### 1.4 Web Panel — Organization Management
|
||||
### 1.4 Web Panel — Organization Management ✅
|
||||
**Implemented:**
|
||||
- ✅ `routes/organizations.routes.js`: 2 page routes + 15 API proxy routes to Go server
|
||||
- ✅ `views/organizations.ejs`: List view with create/edit modal
|
||||
- ✅ `views/organization-detail.ejs`: Detail view with Users/Devices/Invitations/Settings tabs
|
||||
- ✅ `public/js/organizations.js`: List page logic with CRUD
|
||||
- ✅ `public/js/organizationDetail.js`: Detail page with tab switching
|
||||
- ✅ `public/css/organizations.css`: Cards, tabs, role badges, modals
|
||||
- ✅ `views/partials/sidebar.ejs`: Organizations nav link added
|
||||
- ✅ i18n keys added for EN, PL, ZH (25 keys each)
|
||||
- New "Organizations" tab in the panel sidebar
|
||||
- CRUD for organizations, users, invitations
|
||||
- Device list filterable by organization
|
||||
- Sorting/grouping: organization → building → department
|
||||
- Bulk operations: assign 50 devices to org at once
|
||||
|
||||
### 1.5 Organization Discovery Protocol (Enhancement)
|
||||
Client auto-discovers BetterDesk server on LAN via mDNS/DNS-SD (`_betterdesk._tcp`).
|
||||
User sees: "BetterDesk server found: office.example.com — Join?" → login → done.
|
||||
Zero manual configuration for corporate deployments.
|
||||
### 1.5 Organization Discovery Protocol (Enhancement) ✅
|
||||
- ✅ Client auto-discovers BetterDesk server on LAN via mDNS/DNS-SD (`_betterdesk._tcp`) — `discovery/mdns.rs`
|
||||
- ✅ `discover_mdns_servers` IPC command with 10s browse timeout
|
||||
- ✅ `DiscoveryPanel.tsx` runs both UDP broadcast + mDNS in parallel, merges/deduplicates results
|
||||
- ✅ Source badge (UDP/mDNS/both) shown on each discovered server
|
||||
- User sees: "BetterDesk server found: office.example.com — Join?" → login → done.
|
||||
- Zero manual configuration for corporate deployments.
|
||||
|
||||
---
|
||||
|
||||
@@ -607,6 +634,520 @@ Tauri NSIS config in `tauri.conf.json`:
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Device Resource Control & Endpoint Management
|
||||
|
||||
Operators need granular control over hardware resources on managed devices — USB ports, optical drives, monitors, disks, and per-user resource quotas.
|
||||
|
||||
### 10.1 USB Port Control
|
||||
- **Disable/enable USB storage** per device or organization policy (block flash drives, allow keyboards/mice)
|
||||
- Windows: Group Policy + registry (`HKLM\SYSTEM\CurrentControlSet\Services\USBSTOR\Start`)
|
||||
- Linux: `udevadm` rules pushed via agent (`SUBSYSTEM=="usb", ATTR{bInterfaceClass}=="08", ACTION=="add", RUN+="/bin/sh -c 'echo 0 > /sys$DEVPATH/authorized'"`)
|
||||
- Whitelist mode: only allow specific USB vendor/product IDs
|
||||
- Audit log: every USB device insertion/removal logged with timestamp + device serial
|
||||
|
||||
### 10.2 Optical Drive Control
|
||||
- Disable/enable CD/DVD/Blu-ray drives
|
||||
- Windows: registry `HKLM\SYSTEM\CurrentControlSet\Services\cdrom\Start = 4` (disabled)
|
||||
- Linux: blacklist `sr_mod` kernel module or udev rule
|
||||
- Use case: prevent data exfiltration via optical media in secure environments
|
||||
|
||||
### 10.3 Monitor Management (Selective)
|
||||
- **Query monitors:** Agent reports connected monitors (model, resolution, refresh rate, EDID data)
|
||||
- **Selective disable:** Operator can disable specific monitors on multi-monitor setups
|
||||
- **Resolution enforcement:** Lock resolution to organization standard (e.g., 1920×1080 for all call center PCs)
|
||||
- **Brightness/power control:** Set screen brightness, schedule monitor power-off after hours
|
||||
- Windows: `SetDisplayConfig` API / `ChangeDisplaySettingsEx`
|
||||
- Linux: `xrandr` / `swaymsg output` commands
|
||||
|
||||
### 10.4 Disk Access Control
|
||||
- **Read-only mode:** Make specific drives read-only (prevent writes to D:\)
|
||||
- **Disk quota:** Set per-user storage limits (Windows: `fsutil quota`, Linux: `setquota`)
|
||||
- **Partition visibility:** Hide specific partitions from user (Windows: `diskpart remove letter`)
|
||||
- **Encryption enforcement:** Verify BitLocker/LUKS status, trigger encryption if not active
|
||||
- **Disk health monitoring:** S.M.A.R.T. data collection, predictive failure alerts
|
||||
|
||||
### 10.5 Per-User Resource Allocation
|
||||
- **CPU limit:** Cap process CPU usage per user session (Windows: Job Objects, Linux: cgroups v2)
|
||||
- **RAM limit:** Set memory ceiling per user (cgroups `memory.max`)
|
||||
- **Network bandwidth:** Per-user bandwidth throttle (tc/NetLimiter)
|
||||
- **Process whitelist/blacklist:** Allow only approved applications to run
|
||||
- Web panel: visual resource allocation editor per user/device/group
|
||||
|
||||
### 10.6 Resource Control API (Go Server)
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/devices/{id}/resources` | Get current resource state (USB, drives, monitors) |
|
||||
| `POST` | `/api/devices/{id}/resources/usb` | Set USB policy (enabled/disabled/whitelist) |
|
||||
| `POST` | `/api/devices/{id}/resources/optical` | Set optical drive policy |
|
||||
| `POST` | `/api/devices/{id}/resources/monitors` | Set monitor configuration |
|
||||
| `POST` | `/api/devices/{id}/resources/disks` | Set disk access policy |
|
||||
| `POST` | `/api/devices/{id}/resources/quotas` | Set per-user resource quotas |
|
||||
| `GET` | `/api/org/{id}/resource-policy` | Get organization-wide resource policy |
|
||||
| `PUT` | `/api/org/{id}/resource-policy` | Update organization-wide resource policy |
|
||||
|
||||
### 10.7 Web Panel — Resource Control UI
|
||||
- Device detail page: "Resources" tab with visual toggles for each hardware component
|
||||
- Organization settings: default resource policy template
|
||||
- Bulk operations: "Disable USB storage on all Finance department PCs"
|
||||
- Compliance dashboard: percentage of devices matching resource policy
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — Desktop Widget Mode UI Overhaul
|
||||
|
||||
The current desktop widget mode reuses Node.js panel styles directly, causing layout issues, button duplication, and poor desktop integration. This phase delivers a purpose-built desktop widget experience.
|
||||
|
||||
### 11.1 Window Management Fixes (Critical) ✅
|
||||
- ✅ **Windows overflowing taskbar:** `getDesktopArea()` now uses `visualViewport` for accurate bounds, respects `--desktop-safe-bottom` CSS variable for OS safe area.
|
||||
- ✅ **Maximize overflow fix:** `clampAllWindows()` now re-clamps maximized windows on viewport resize instead of skipping them.
|
||||
- ✅ **Drag/resize clamping:** All bounds calculations use `getDesktopArea()` instead of raw `window.innerWidth/Height`.
|
||||
- ✅ **Snap zones:** Support Windows snap layouts (Win+Arrow) properly — half-screen, quarter-screen positions within WorkArea.
|
||||
- ✅ **visualViewport listener:** Added `visualViewport.resize` event listener for more accurate OS-level resize detection.
|
||||
|
||||
### 11.2 Bottom Taskbar Redesign ✅
|
||||
**Changes:**
|
||||
- ✅ **Removed** start button (app launcher menu) — conflicts with system start menu
|
||||
- ✅ **Removed** wallpaper button — moved to desktop right-click context menu
|
||||
- ✅ **Added** desktop context menu with Wallpaper / Refresh / Exit Desktop actions
|
||||
- ✅ **Auto-hide:** Taskbar slides down when no open windows in widgets mode, appears on hover
|
||||
- ✅ **Kept:** minimized app icons (click to restore), system clock (single instance, right-aligned), exit desktop button
|
||||
- Semi-transparent bar (already had glassmorphic CSS) with smooth transition animation
|
||||
|
||||
### 11.3 Button Audit & Deduplication ✅
|
||||
Full audit of all interactive elements in desktop widget mode:
|
||||
|
||||
| Component | Issue | Fix |
|
||||
|-----------|-------|-----|
|
||||
| Bottom taskbar | Duplicated wallpaper picker button | ✅ Removed — moved to right-click context menu |
|
||||
| Bottom taskbar | Duplicated clock widget | ✅ Keep single clock, right-aligned |
|
||||
| Bottom taskbar | App launcher menu | ✅ Removed entirely — use desktop icons or hotkey |
|
||||
| Window title bars | Inconsistent button sizes | ✅ Standardized: 32×32px close/min/max buttons, 18px icons |
|
||||
| Window title bars | Missing minimize button on some windows | ✅ All windows have minimize/maximize/close |
|
||||
| Desktop right-click | Missing context menu | ✅ Added: Change Wallpaper, Refresh, Exit Desktop |
|
||||
|
||||
- ✅ Title bar: frosted glass `backdrop-filter: blur(12px) saturate(150%)`, 42px height
|
||||
- ✅ Window focus: subtle accent border highlight `rgba(88, 166, 255, 0.25)`
|
||||
- ✅ `refreshAll()` method added to `DesktopWidgets` API for context menu integration
|
||||
|
||||
### 11.4 New Generation Window Design ✅
|
||||
All desktop widget app windows now use a unified modern design:
|
||||
|
||||
```
|
||||
┌─ Device Manager ──────────────────────────── [─] [□] [×] ─┐
|
||||
│ ┌─ Toolbar ────────────────────────────────────────────┐ │
|
||||
│ │ 🔍 Search... [Filter ▾] [Refresh] │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Content area with desktop-optimized layout │
|
||||
│ (wider margins, larger click targets, hover states) │
|
||||
│ │
|
||||
│ ┌─ Status Bar ────────────────────────────────────────┐ │
|
||||
│ │ 55 devices │ 4 online │ Last refresh: 12:45 │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Design principles:**
|
||||
- ✅ **Desktop-native feel:** Drop shadows, rounded corners (10px), frosted glass effect on title bar
|
||||
- ✅ **Larger touch/click targets:** 32×32px for all window control buttons
|
||||
- ✅ **Typography:** 13px title text, system font stack
|
||||
- **Dark/light theme:** Follow system theme preference, with manual override
|
||||
- ✅ **Resizable windows:** All windows freely resizable with min-size constraints
|
||||
- ✅ **Window memory:** Position + size saved to `localStorage` per app, restored on reopen
|
||||
|
||||
### 11.5 Wallpaper Picker Redesign ✅
|
||||
**Implemented:**
|
||||
- ✅ **Tabbed UI:** Images tab + Solid Colors tab with smooth tab switching
|
||||
- ✅ **Solid color grid:** 20 predefined dark color swatches with active state and checkmark
|
||||
- ✅ **Custom color picker:** HTML5 color input for any solid background
|
||||
- ✅ **Fit mode selector:** Fill (cover), Fit (contain), Stretch (100% 100%), Center (auto) — applies immediately
|
||||
- ✅ **applyWallpaper():** Supports `solid:#rrggbb` prefix for solid colors, crossfade animation for images
|
||||
- ✅ **Persistence:** Fit mode saved to `localStorage` (`bd_widget_wallpaper_fit`)
|
||||
- ✅ **i18n:** 8 new keys (`wp_images`, `wp_colors`, `wp_custom_color`, `wp_fit_style`, `wp_fill`, `wp_fit`, `wp_stretch`, `wp_center`) added to all 9 languages (EN, PL, ZH, DE, ES, FR, IT, NL, PT)
|
||||
- ✅ **Auto-tab switch:** Opens on Colors tab when current wallpaper is a solid color
|
||||
|
||||
### 11.6 Desktop Widget CSS Architecture ✅
|
||||
- ✅ Separate stylesheet: `desktop-widget-overrides.css` — loaded only in embed mode (iframe windows)
|
||||
- ✅ Override web panel styles with desktop-optimized spacing, sizes, colors
|
||||
- ✅ CSS custom properties for theme switching: `--dw-bg`, `--dw-text`, `--dw-accent`, `--dw-border`
|
||||
- ✅ Hide web-panel-specific elements in embed mode: breadcrumbs, session bar, footer
|
||||
- ✅ Compact mode for small windows via `@container` query
|
||||
- ✅ Thin scrollbar styling for desktop windows
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Documentation, CI/CD & Automated Releases
|
||||
|
||||
### 12.1 README Rebuild
|
||||
- **Complete rewrite** of `README.md` for BetterDesk 3.0 identity
|
||||
- Sections: Overview, Architecture diagram, Quick Start (Docker/bare-metal/Windows), Feature matrix, Screenshots, Contributing, License
|
||||
- Badges: build status, latest release, Docker pulls, license, languages count
|
||||
- Migration guide from 2.x → 3.0
|
||||
|
||||
### 12.2 CDAP Documentation
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| `docs/cdap/OVERVIEW.md` | CDAP architecture, message flow, capability model |
|
||||
| `docs/cdap/PROTOCOL.md` | Full protocol specification (message types, payloads, sequencing) |
|
||||
| `docs/cdap/AGENT_GUIDE.md` | How to build a custom CDAP agent (Go/Python/Node.js) |
|
||||
| `docs/cdap/BRIDGE_GUIDE.md` | How to build a CDAP bridge for IoT/industrial protocols |
|
||||
| `docs/cdap/API_REFERENCE.md` | REST API endpoints for CDAP management |
|
||||
|
||||
### 12.3 BetterDesk SDK Documentation
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| `docs/sdk/OVERVIEW.md` | SDK architecture, supported platforms, capabilities |
|
||||
| `docs/sdk/PYTHON_SDK.md` | Python SDK reference (`betterdesk-cdap` package) |
|
||||
| `docs/sdk/NODEJS_SDK.md` | Node.js SDK reference (`betterdesk-cdap` package) |
|
||||
| `docs/sdk/EXAMPLES.md` | Real-world integration examples (Modbus, SNMP, REST) |
|
||||
| `docs/sdk/STUDIO_GUIDE.md` | CDAP SDK Studio user guide (Phase 14) |
|
||||
|
||||
### 12.4 Pre-Release Validation Checklist
|
||||
Before merging any feature branch to `main`:
|
||||
1. `cargo build --release` — no errors, warnings reviewed
|
||||
2. `go build ./...` + `go vet ./...` — clean
|
||||
3. `npm audit --omit=dev` — 0 vulnerabilities
|
||||
4. `npm run i18n:check` — all languages 100% coverage
|
||||
5. Docker build succeeds (single-container + multi-container)
|
||||
6. Integration tests pass (if available)
|
||||
7. CHANGELOG.md updated
|
||||
8. Documentation reflects new features
|
||||
|
||||
### 12.5 GitHub Actions — Automated Client Builds
|
||||
```yaml
|
||||
# .github/workflows/release-client.yml
|
||||
name: Build & Release Desktop Client
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., v3.0.0)'
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
- run: cd betterdesk-client && pnpm install && pnpm tauri build
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: betterdesk-windows-x64
|
||||
path: betterdesk-client/src-tauri/target/release/bundle/nsis/*.exe
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev
|
||||
- run: cd betterdesk-client && pnpm install && pnpm tauri build
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: betterdesk-linux-x64
|
||||
path: betterdesk-client/src-tauri/target/release/bundle/deb/*.deb
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cd betterdesk-client && pnpm install && pnpm tauri build
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: betterdesk-macos
|
||||
path: betterdesk-client/src-tauri/target/release/bundle/dmg/*.dmg
|
||||
|
||||
release:
|
||||
needs: [build-windows, build-linux, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
betterdesk-windows-x64/*.exe
|
||||
betterdesk-linux-x64/*.deb
|
||||
betterdesk-macos/*.dmg
|
||||
```
|
||||
|
||||
### 12.6 GitHub Actions — Automated Container Builds
|
||||
Extend existing `.github/workflows/docker-publish.yml`:
|
||||
- Trigger on tag push (`v*`) and main branch merge
|
||||
- Build: `ghcr.io/unitronix/betterdesk-server`, `ghcr.io/unitronix/betterdesk-console`, `ghcr.io/unitronix/betterdesk` (all-in-one)
|
||||
- Multi-arch: `linux/amd64` + `linux/arm64`
|
||||
- Automatic SBOM generation for supply chain security
|
||||
- Trivy vulnerability scan before publish
|
||||
|
||||
### 12.7 Go Server Binary Releases
|
||||
- Cross-compile on tag push: `linux/amd64`, `linux/arm64`, `windows/amd64`
|
||||
- SHA256 checksums file (`CHECKSUMS.sha256`)
|
||||
- Attach to GitHub Release alongside desktop client installers
|
||||
|
||||
---
|
||||
|
||||
## Phase 13 — UI/UX Polish, Theming & Onboarding Tutorials
|
||||
|
||||
### 13.1 Interactive Onboarding Tutorials
|
||||
The tutorial system exists in code but is non-functional. This phase activates and polishes it.
|
||||
|
||||
**Tutorial flow (first-time user):**
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 👋 Welcome to BetterDesk! │
|
||||
│ │
|
||||
│ Let's get you started in 3 steps: │
|
||||
│ │
|
||||
│ ① Connect your first device │
|
||||
│ ② Explore the dashboard │
|
||||
│ ③ Try a remote session │
|
||||
│ │
|
||||
│ [Start Tour] [Skip — I know what I'm doing]│
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- **Spotlight overlay:** Dim the entire page, highlight the target element with a bright cutout
|
||||
- **Step-by-step tooltips:** Arrow-pointed tooltips anchored to UI elements ("Click here to add a device")
|
||||
- **Progress indicator:** "Step 3 of 7" with progress bar
|
||||
- **Contextual triggers:** Tutorial for specific features activates on first visit (e.g., first time opening Devices → mini-tour)
|
||||
- **Tutorial icons:** Each tutorial section gets a unique icon in the help menu
|
||||
- **Completion tracking:** Store completed tutorials in user profile, show checkmarks
|
||||
- Library: [Shepherd.js](https://shepherdjs.dev/) or custom implementation using CSS `clip-path` spotlight
|
||||
|
||||
**Tutorials to create:**
|
||||
| # | Tutorial | Trigger | Steps |
|
||||
|---|----------|---------|-------|
|
||||
| 1 | Welcome Tour | First login | 7 steps — sidebar, dashboard, devices, settings |
|
||||
| 2 | Device Management | First visit to Devices | 5 steps — list, filter, detail, connect, actions |
|
||||
| 3 | Remote Session | First remote connect | 4 steps — toolbar, controls, clipboard, disconnect |
|
||||
| 4 | Organization Setup | Create first org | 6 steps — create, invite, assign devices, policies |
|
||||
| 5 | Chat Basics | First chat open | 3 steps — contacts, send message, file share |
|
||||
| 6 | CDAP Overview | First CDAP visit | 5 steps — devices, widgets, commands, terminal |
|
||||
| 7 | Desktop Widget Mode | First desktop mode launch | 4 steps — taskbar, windows, wallpaper, apps |
|
||||
|
||||
### 13.2 Page Transition Animations
|
||||
- **Route transitions:** Smooth fade + subtle slide (150ms ease-out) between pages
|
||||
- **List animations:** Staggered fade-in for device list rows (50ms delay per item, max 10)
|
||||
- **Card animations:** Scale-up on appear (0.95 → 1.0), subtle hover lift (translateY -2px)
|
||||
- **Modal animations:** Backdrop fade + modal slide-up (200ms cubic-bezier)
|
||||
- **Notification toasts:** Slide-in from right, auto-dismiss with shrinking progress bar
|
||||
- **Loading states:** Skeleton screens (pulsing grey placeholders) instead of spinners
|
||||
- CSS class: `.page-enter`, `.page-enter-active`, `.page-exit`, `.page-exit-active`
|
||||
- Respect `prefers-reduced-motion` — disable animations for accessibility
|
||||
|
||||
### 13.3 Theming System Enhancement
|
||||
|
||||
**Node.js Console themes:**
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| Dark (default) | Current dark theme — polish edges, fix contrast ratios |
|
||||
| Light | Full light theme with WCAG AA contrast compliance |
|
||||
| System Auto | Follow OS dark/light preference via `prefers-color-scheme` |
|
||||
| High Contrast | WCAG AAA compliance, thick borders, no transparency |
|
||||
| Custom (org) | Organization can push custom brand colors + logo |
|
||||
|
||||
**Desktop Widget themes:**
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| Transparent | Frosted glass with system wallpaper showing through |
|
||||
| Solid Dark | Opaque dark background, high contrast text |
|
||||
| Solid Light | Opaque light background |
|
||||
| Accent Color | User picks accent color, UI adapts (like Windows personalization) |
|
||||
|
||||
**Implementation:**
|
||||
- CSS custom properties for all colors: `--bd-bg-primary`, `--bd-text-primary`, `--bd-accent`, etc.
|
||||
- Theme JSON files: `themes/dark.json`, `themes/light.json`, `themes/high-contrast.json`
|
||||
- Theme preview: live preview in settings before applying
|
||||
- Organization branding: custom logo + 3 brand colors pushed via org policy
|
||||
|
||||
### 13.4 Welcome Screen (Dashboard Enhancement)
|
||||
- **Personalized greeting:** "Good morning, Jan" with time-based greeting
|
||||
- **Quick actions bar:** 4 most-used actions as large cards (Connect, Chat, Devices, Tasks)
|
||||
- **Activity feed:** Last 10 actions across the system ("Jan connected to Office-PC 5 min ago")
|
||||
- **Health overview:** System health at a glance (X devices online, Y alerts, Z pending tasks)
|
||||
- **Tip of the day:** Rotating tips about features the user hasn't tried yet
|
||||
|
||||
---
|
||||
|
||||
## Phase 14 — CDAP SDK Studio
|
||||
|
||||
A visual development environment inside the web console for building CDAP integrations — an Unreal Engine Blueprints-inspired node editor tailored for device automation and IoT connectivity.
|
||||
|
||||
### 14.1 Studio Overview
|
||||
|
||||
```
|
||||
┌─ CDAP SDK Studio ──────────────────────────────── [─] [□] [×] ─┐
|
||||
│ ┌─ Toolbar ──────────────────────────────────────────────────┐ │
|
||||
│ │ [New] [Open] [Save] [Run ▶] [Debug] [Deploy] │ Zoom: 100% │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ ┌─ Palette ─┐ ┌─ Canvas ─────────────────┐ ┌─ Inspector ────┐ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ 📡 Sources│ │ [Modbus TCP]──┐ │ │ Node: Filter │ │
|
||||
│ │ Modbus │ │ ├──[Filter] │ │ ────────────── │ │
|
||||
│ │ SNMP │ │ [SNMP Poll]──┘ │ │ │ Field: temp │ │
|
||||
│ │ REST │ │ ▼ │ │ Operator: > │ │
|
||||
│ │ MQTT │ │ [Dashboard] │ │ Value: 80 │ │
|
||||
│ │ │ │ │ │ │ │ │
|
||||
│ │ 🔄 Process│ │ ▼ │ │ Connections: │ │
|
||||
│ │ Filter │ │ [Alert] │ │ In: 2 nodes │ │
|
||||
│ │ Transform│ │ │ │ Out: 1 node │ │
|
||||
│ │ Aggregate│ │ │ │ │ │
|
||||
│ │ Delay │ │ │ │ [Delete Node] │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ 📊 Output │ │ │ └────────────────┘ │
|
||||
│ │ Widget │ │ │ ┌─ Console ──────┐ │
|
||||
│ │ Alert │ │ │ │ > Connected │ │
|
||||
│ │ Log │ │ │ │ > Polling temp │ │
|
||||
│ │ API │ │ │ │ > Value: 82.5 │ │
|
||||
│ └───────────┘ └───────────────────────────┘ └────────────────┘ │
|
||||
│ ┌─ Status Bar ───────────────────────────────────────────────┐ │
|
||||
│ │ Flow: temperature-monitor.json │ Nodes: 4 │ Status: Ready │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 14.2 Node Types (Visual Blocks)
|
||||
|
||||
#### Source Nodes (Data Input)
|
||||
| Node | Description | Config |
|
||||
|------|-------------|--------|
|
||||
| 🟦 Modbus TCP | Read Modbus registers | Host, port, unit ID, register address, data type |
|
||||
| 🟦 Modbus RTU | Read serial Modbus | Serial port, baud, parity, slave ID, register |
|
||||
| 🟦 SNMP Poll | Poll SNMP OIDs | Host, community/v3 creds, OID list, interval |
|
||||
| 🟦 REST Poll | Poll HTTP/REST endpoint | URL, method, headers, auth, interval, JMESPath |
|
||||
| 🟦 MQTT Subscribe | Listen to MQTT topic | Broker, topic, QoS, TLS |
|
||||
| 🟦 Webhook Listen | Receive HTTP webhooks | Path, method filter, auth token |
|
||||
| 🟦 Device Telemetry | BetterDesk agent metrics | Device ID, metric names |
|
||||
| 🟦 Database Query | Poll SQL database | DSN, query, interval |
|
||||
| 🟦 File Watch | Watch file changes | Path, pattern, events (create/modify/delete) |
|
||||
|
||||
#### Processing Nodes (Transform & Logic)
|
||||
| Node | Description | Config |
|
||||
|------|-------------|--------|
|
||||
| 🟨 Filter | Pass/block based on condition | Field, operator (>, <, ==, contains), value |
|
||||
| 🟨 Transform | Map/rename/calculate fields | Expression (e.g., `temp_c * 9/5 + 32`) |
|
||||
| 🟨 Aggregate | Combine multiple inputs | Mode (avg, min, max, sum, count), window |
|
||||
| 🟨 Delay | Hold data for N seconds | Duration, buffer size |
|
||||
| 🟨 Debounce | Suppress rapid changes | Threshold, cooldown period |
|
||||
| 🟨 Switch | Route to different paths | Conditions (if/else/else-if branches) |
|
||||
| 🟨 Merge | Combine multiple streams | Join strategy (latest, all, zip) |
|
||||
| 🟨 Script | Custom JavaScript/Python | Code editor with intellisense |
|
||||
|
||||
#### Output Nodes (Actions & Destinations)
|
||||
| Node | Description | Config |
|
||||
|------|-------------|--------|
|
||||
| 🟩 Widget Update | Update CDAP dashboard widget | Widget ID, value mapping |
|
||||
| 🟩 Alert | Trigger alert/notification | Severity, message template, recipients |
|
||||
| 🟩 Command | Send command to device | Device ID, command type, payload |
|
||||
| 🟩 Log | Write to audit log | Log level, message template |
|
||||
| 🟩 REST Call | Call external API | URL, method, headers, body template |
|
||||
| 🟩 MQTT Publish | Publish to MQTT topic | Broker, topic, payload template |
|
||||
| 🟩 Database Write | Insert/update SQL | DSN, table, field mapping |
|
||||
| 🟩 Email | Send email notification | SMTP config, to, subject, body template |
|
||||
| 🟩 Modbus Write | Write Modbus register | Host, register, value mapping |
|
||||
|
||||
### 14.3 Canvas Interaction
|
||||
- **Drag & drop** nodes from palette to canvas
|
||||
- **Connect** nodes by dragging from output port (right) to input port (left)
|
||||
- **Wire types:** Data wire (blue), control wire (orange), error wire (red)
|
||||
- **Zoom:** Scroll wheel or pinch, range 25%-400%
|
||||
- **Pan:** Middle-click drag or space+drag
|
||||
- **Multi-select:** Box select or Shift+click, move/delete in bulk
|
||||
- **Minimap:** Bottom-right corner thumbnail of entire flow
|
||||
- **Snap to grid:** Optional alignment grid (16px)
|
||||
- **Undo/redo:** Ctrl+Z / Ctrl+Y with full history stack
|
||||
- **Copy/paste:** Ctrl+C/V for node groups (including wires)
|
||||
- **Comments:** Sticky note blocks for documentation
|
||||
|
||||
### 14.4 Code Mode (Alternative to Visual)
|
||||
For advanced users who prefer writing code:
|
||||
|
||||
```javascript
|
||||
// CDAP SDK Studio — Code Mode
|
||||
import { Source, Filter, Output, Flow } from 'betterdesk-cdap-studio';
|
||||
|
||||
const flow = new Flow('temperature-monitor');
|
||||
|
||||
// Sources
|
||||
const modbus = flow.addSource('modbus-tcp', {
|
||||
host: '192.168.1.100',
|
||||
port: 502,
|
||||
registers: [{ address: 100, type: 'float32', name: 'temp' }],
|
||||
interval: 5000,
|
||||
});
|
||||
|
||||
const snmp = flow.addSource('snmp-poll', {
|
||||
host: '192.168.1.200',
|
||||
oids: [{ oid: '1.3.6.1.2.1.1.3.0', name: 'uptime' }],
|
||||
interval: 10000,
|
||||
});
|
||||
|
||||
// Processing
|
||||
const highTemp = flow.addFilter('high-temp', {
|
||||
condition: (data) => data.temp > 80,
|
||||
});
|
||||
|
||||
// Outputs
|
||||
const alert = flow.addOutput('alert', {
|
||||
severity: 'warning',
|
||||
message: 'Temperature ${temp}°C exceeds threshold!',
|
||||
});
|
||||
|
||||
// Wiring
|
||||
modbus.connect(highTemp);
|
||||
highTemp.connect(alert);
|
||||
|
||||
flow.deploy();
|
||||
```
|
||||
|
||||
- **Split view:** Visual canvas on left, generated code on right (bidirectional sync)
|
||||
- **Code editor:** Monaco editor (VS Code engine) with syntax highlighting, autocomplete, error markers
|
||||
- **Language support:** JavaScript (primary), Python (via SDK), YAML (declarative flows)
|
||||
|
||||
### 14.5 Flow Execution & Debugging
|
||||
- **Run button:** Execute flow in sandbox (isolated from production)
|
||||
- **Debug mode:** Step-by-step execution, inspect data at each node
|
||||
- **Live data overlay:** When running, each wire shows last value passing through
|
||||
- **Breakpoints:** Click node to set breakpoint — execution pauses, inspector shows data
|
||||
- **Error handling:** Red glow on failed nodes, error details in console panel
|
||||
- **Performance metrics:** Execution time per node, messages/second throughput
|
||||
- **Dry-run:** Simulate with mock data without connecting to real devices
|
||||
|
||||
### 14.6 Flow Management
|
||||
- **Save/Load:** Flows stored as JSON in Go server database
|
||||
- **Version history:** Git-like versioning — diff between flow versions
|
||||
- **Deploy:** Push flow to production (runs on Go server as background worker)
|
||||
- **Import/Export:** Share flows as `.bdflow` files (JSON-based)
|
||||
- **Template library:** Pre-built flows for common scenarios:
|
||||
- Temperature monitoring with alerts
|
||||
- SNMP device health dashboard
|
||||
- REST API data aggregator
|
||||
- Modbus PLC control panel
|
||||
- File change detector with backup
|
||||
|
||||
### 14.7 Studio UI Quality Standards
|
||||
- **Polished interface:** Professional-grade look — not a prototype feel
|
||||
- **Responsive:** Works on 1366×768 minimum, optimized for 1920×1080+
|
||||
- **Help integration:** `?` button on every node opens inline documentation
|
||||
- **Keyboard shortcuts:** Full keyboard navigation (Tab between nodes, Enter to connect)
|
||||
- **Accessibility:** Screen reader labels on all interactive elements, keyboard-only usable
|
||||
- **Contextual help:** Hover any node type in palette → tooltip with description + example
|
||||
|
||||
### 14.8 CDAP & SDK Improvements (Prerequisites)
|
||||
Before launching Studio, ensure CDAP/SDK coverage is complete:
|
||||
- [ ] Audio bidirectional relay (browser ↔ device)
|
||||
- [ ] File transfer via CDAP channel (large file streaming)
|
||||
- [ ] Multi-device command broadcast (send command to N devices simultaneously)
|
||||
- [ ] Flow execution engine in Go server (runs deployed Studio flows as goroutines)
|
||||
- [ ] CDAP event subscriptions (device online/offline/alert triggers for Studio sources)
|
||||
- [ ] SDK versioning and backward compatibility guarantees
|
||||
- [ ] Rate limiting per flow (prevent runaway flows from overloading devices)
|
||||
|
||||
---
|
||||
|
||||
## Priority Summary
|
||||
|
||||
| Phase | Name | Estimated Time | Priority |
|
||||
@@ -621,8 +1162,13 @@ Tauri NSIS config in `tauri.conf.json`:
|
||||
| **7** | Cross-Platform | 3-4 weeks | 🟢 Medium |
|
||||
| **8** | Security Hardening | Ongoing | 🔴 Continuous |
|
||||
| **9** | Internationalization | 1-2 weeks | 🟡 High |
|
||||
| **10** | Device Resource Control | 1-2 weeks | 🟡 High |
|
||||
| **11** | Desktop Widget UI Overhaul | 1-2 weeks | 🔴 Critical |
|
||||
| **12** | Documentation, CI/CD & Releases | 1 week | 🔴 Critical |
|
||||
| **13** | UI/UX Polish & Onboarding | 1-2 weeks | 🟡 High |
|
||||
| **14** | CDAP SDK Studio | 3-4 weeks | 🟡 High |
|
||||
|
||||
**Total estimated timeline:** ~14-18 weeks for all phases (parallel work possible on independent phases).
|
||||
**Total estimated timeline:** ~22-30 weeks for all phases (parallel work possible on independent phases).
|
||||
|
||||
---
|
||||
|
||||
@@ -640,6 +1186,10 @@ Tauri NSIS config in `tauri.conf.json`:
|
||||
| 8 | **Geo Map View** | 6 | World map showing device locations by building/office |
|
||||
| 9 | **Bandwidth Monitor** | 6 | Per-device network usage tracking and alerts |
|
||||
| 10 | **Auto-Scaling Relay** | 6 | Spin up cloud relay nodes automatically when load exceeds threshold |
|
||||
| 11 | **MQTT Broker Integration** | 14 | Built-in MQTT broker for IoT devices, bridged into CDAP SDK Studio |
|
||||
| 12 | **Mobile Companion App** | 7 | Lightweight Tauri Mobile app: chat, alerts, quick connect, device status |
|
||||
| 13 | **SSO/SAML Integration** | 1 | Organization SSO via SAML 2.0 / OpenID Connect for enterprise auth |
|
||||
| 14 | **Offline Mode** | 5 | Agent queues tasks/metrics when server unreachable, syncs on reconnect |
|
||||
|
||||
---
|
||||
|
||||
@@ -655,4 +1205,4 @@ BetterDesk as a Linux-native domain controller alternative:
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-03-25 by GitHub Copilot*
|
||||
*Last updated: 2026-03-25 by GitHub Copilot — Phase 0 completed, Phases 1-14 defined*
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# BetterDesk — Security Audit Log
|
||||
|
||||
> Chronological record of security audits, findings, and resolutions.
|
||||
|
||||
---
|
||||
|
||||
## Audit #1 — Web Console Security Review (2026-02-17)
|
||||
|
||||
**Scope:** Node.js web console (`web-nodejs/`)
|
||||
**Conducted by:** Internal review (GitHub Copilot assisted)
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding | Resolution | Status |
|
||||
|----|----------|---------|------------|--------|
|
||||
| A1-C1 | Critical | No CSRF protection on state-changing routes | Added double-submit cookie pattern with `csrf-csrf` | ✅ Fixed |
|
||||
| A1-C2 | Critical | Session fixation — session not regenerated after login | Added `req.session.regenerate()` after successful authentication | ✅ Fixed |
|
||||
| A1-C3 | Critical | Timing attack on login — early return for non-existent users | Pre-computed dummy bcrypt hash compared for all attempts | ✅ Fixed |
|
||||
| A1-H1 | High | WebSocket connections not authenticated | Added session cookie verification for WS upgrade | ✅ Fixed |
|
||||
| A1-H2 | High | RustDesk Client API exposed on panel port | Moved to dedicated WAN-facing port 21121 with 7-layer security | ✅ Fixed |
|
||||
| A1-H3 | High | No 2FA support | Added TOTP 2FA with `otplib` | ✅ Fixed |
|
||||
| A1-M1 | Medium | Trust proxy not configurable | Added `TRUST_PROXY` env var | ✅ Fixed |
|
||||
| A1-M2 | Medium | Session cookie missing Secure flag on HTTPS | Auto-set Secure flag when HTTPS detected | ✅ Fixed |
|
||||
|
||||
## Audit #2 — Go Server Security Review (2026-02-28)
|
||||
|
||||
**Scope:** Go server (`betterdesk-server/`)
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding | Resolution | Status |
|
||||
|----|----------|---------|------------|--------|
|
||||
| A2-H1 | High | No validation on `new_id` in change-id API | Added `peerIDRegexp` validation | ✅ Fixed |
|
||||
| A2-H3 | High | No rate limiting on 2FA endpoint | Added `loginLimiter.Allow(clientIP)` | ✅ Fixed |
|
||||
| A2-H4 | High | Partial 2FA token has no short TTL | Added `GenerateWithTTL()` with 5-min expiry | ✅ Fixed |
|
||||
| A2-M1 | Medium | SQL LIKE wildcard injection in ListPeersByTag | Added `ESCAPE '\'` clause, escape `%` and `_` | ✅ Fixed |
|
||||
| A2-M4 | Medium | No rate limiting on TCP signal connections | Added `limiter.Allow(host)` in `serveTCP()` | ✅ Fixed |
|
||||
| A2-M6 | Medium | No validation on config key names | Added `configKeyRegexp` (1-64 chars, alnum/dots/hyphens) | ✅ Fixed |
|
||||
|
||||
## Audit #3 — Node.js Console Security Review (2026-02-28)
|
||||
|
||||
**Scope:** RustDesk Client API on Node.js (`web-nodejs/`)
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding | Resolution | Status |
|
||||
|----|----------|---------|------------|--------|
|
||||
| A3-H1 | High | Rate limiter uses X-Forwarded-For without validation | Switched to `req.ip` (respects trust proxy setting) | ✅ Fixed |
|
||||
| A3-H2 | High | Device verification token replay possible | Added nonce + timestamp validation | ✅ Fixed |
|
||||
| A3-M4 | Medium | Missing input length validation on sysinfo fields | Added 255-char limit on hostname, platform, version | ✅ Fixed |
|
||||
|
||||
## Audit #4 — Installer Scripts Security Review (2026-03-15)
|
||||
|
||||
**Scope:** `betterdesk.sh`, `betterdesk.ps1`
|
||||
|
||||
### Findings
|
||||
|
||||
| ID | Severity | Finding | Resolution | Status |
|
||||
|----|----------|---------|------------|--------|
|
||||
| A4-H1 | High | SQL injection in password reset via shell interpolation | Replaced with env-var passing to Python/Node | ✅ Fixed |
|
||||
| A4-M1 | Medium | Plaintext admin credentials persisted by default | Made opt-in via `STORE_ADMIN_CREDENTIALS=true` | ✅ Fixed |
|
||||
| A4-M2 | Medium | `npm audit` showed tar vulnerability | Added override in package.json | ✅ Fixed |
|
||||
|
||||
---
|
||||
|
||||
*New audits should be appended below with incrementing audit numbers.*
|
||||
@@ -0,0 +1,64 @@
|
||||
# BetterDesk — Compliance Notes
|
||||
|
||||
> **Last Updated:** 2026-04-01
|
||||
> **Disclaimer:** This document provides guidance for compliance considerations. It is not legal advice. Consult your compliance officer or legal counsel for authoritative guidance.
|
||||
|
||||
## GDPR (General Data Protection Regulation)
|
||||
|
||||
### Data Collected
|
||||
|
||||
| Data Category | Examples | Legal Basis | Retention |
|
||||
|---------------|----------|-------------|-----------|
|
||||
| Device identifiers | Device ID, hostname, platform, OS version | Legitimate interest (fleet management) | Until device deleted |
|
||||
| Connection metadata | IP address, NAT type, connection timestamps | Legitimate interest (network operation) | 90 days (configurable) |
|
||||
| User accounts | Username, email, password hash, role | Contract performance | Until account deleted |
|
||||
| Audit logs | Login attempts, remote sessions, admin actions | Legitimate interest (security) | 180 days (configurable) |
|
||||
| System metrics | CPU, memory, disk usage | Legitimate interest (monitoring) | 30 days (configurable) |
|
||||
|
||||
### Data Subject Rights
|
||||
|
||||
| Right | Implementation |
|
||||
|-------|---------------|
|
||||
| Right to access | Export device/user data via API (`GET /api/peers/{id}`, `GET /api/org/{id}/users/{uid}`) |
|
||||
| Right to erasure | Delete device (`DELETE /api/peers/{id}`), delete user (`DELETE /api/org/{id}/users/{uid}`) |
|
||||
| Right to rectification | Update device notes/tags (`PATCH /api/peers/{id}`), update user (`PUT /api/org/{id}/users/{uid}`) |
|
||||
| Right to data portability | Export via API (JSON format) |
|
||||
| Right to object | Organization admin can disable data collection features |
|
||||
|
||||
### Recommendations
|
||||
- Deploy with TLS enabled for all connections
|
||||
- Enable audit logging for accountability
|
||||
- Configure metric retention periods per your data retention policy
|
||||
- Use session recording only with informed consent (banner/notification)
|
||||
- Document your processing activities if you are a data controller
|
||||
|
||||
## SOX (Sarbanes-Oxley)
|
||||
|
||||
### Relevant Controls
|
||||
|
||||
| Control Area | BetterDesk Feature |
|
||||
|-------------|-------------------|
|
||||
| Access controls | Role-based access (owner/admin/operator/user), TOTP 2FA |
|
||||
| Audit trail | Comprehensive audit logging (login, sessions, config changes) |
|
||||
| Segregation of duties | Operator vs admin role separation |
|
||||
| Change management | Git-based version control, CI/CD pipeline |
|
||||
|
||||
## HIPAA (Health Insurance Portability and Accountability Act)
|
||||
|
||||
### Technical Safeguards
|
||||
|
||||
| Requirement | BetterDesk Implementation |
|
||||
|-------------|--------------------------|
|
||||
| Access control | Role-based authentication, per-organization isolation |
|
||||
| Audit controls | Audit log with timestamps, user IDs, actions |
|
||||
| Integrity controls | NaCl authenticated encryption, TLS transport |
|
||||
| Transmission security | TLS on all network connections (configurable) |
|
||||
| Encryption at rest | PostgreSQL encryption (deploy-time configuration) |
|
||||
|
||||
### Recommendations for HIPAA Deployments
|
||||
- Use PostgreSQL with TLS and disk encryption
|
||||
- Enable all TLS flags (`--tls-signal`, `--tls-relay`)
|
||||
- Require TOTP 2FA for all operator/admin accounts
|
||||
- Set session timeouts to 15 minutes or less
|
||||
- Enable session recording for audit purposes
|
||||
- Regular access reviews via organization user management
|
||||
@@ -0,0 +1,85 @@
|
||||
# BetterDesk — Encryption Specification
|
||||
|
||||
> **Version:** 1.0
|
||||
> **Last Updated:** 2026-04-01
|
||||
|
||||
## Overview
|
||||
|
||||
BetterDesk uses layered encryption to protect data in transit and at rest across all communication channels.
|
||||
|
||||
## Algorithms
|
||||
|
||||
| Purpose | Algorithm | Key Size | Notes |
|
||||
|---------|-----------|----------|-------|
|
||||
| Server identity | Ed25519 | 256-bit | Sign peer registrations, verify server identity |
|
||||
| Key exchange (P2P) | X25519 (Curve25519 DH) | 256-bit | Derive shared secret between peers |
|
||||
| Symmetric encryption (P2P) | XSalsa20-Poly1305 (NaCl box) | 256-bit key, 192-bit nonce | Authenticated encryption for peer-to-peer |
|
||||
| TLS transport | TLS 1.2+ (auto-negotiated) | Depends on cipher suite | Signal, relay, API server connections |
|
||||
| Password hashing (admin) | bcrypt | Cost factor 12 | Web console admin accounts |
|
||||
| Password hashing (org users) | PBKDF2-SHA256 | 100K iterations, 32-byte salt | Organization user accounts |
|
||||
| JWT signing | HMAC-SHA256 | 256-bit | API authentication tokens |
|
||||
| TOTP 2FA | HMAC-SHA1 (RFC 6238) | 160-bit shared secret | 6-digit codes, 30-second step |
|
||||
| Session secret | Random bytes | 256-bit | Express session signing |
|
||||
| API key | Cryptographically random hex | 256-bit (32 bytes) | Server ↔ console authentication |
|
||||
| CSRF token | Double-submit cookie | 256-bit | Cross-site request forgery prevention |
|
||||
|
||||
## Connection Encryption Matrix
|
||||
|
||||
| Connection | Transport | Application Layer | Forward Secrecy |
|
||||
|------------|-----------|-------------------|-----------------|
|
||||
| Client ↔ Signal Server (TCP) | Optional TLS (`--tls-signal`) | NaCl secure channel (key exchange) | Yes (ephemeral DH) |
|
||||
| Client ↔ Signal Server (UDP) | None (UDP) | Protobuf (unencrypted metadata) | No |
|
||||
| Client ↔ Relay Server (TCP) | Optional TLS (`--tls-relay`) | Transparent relay (E2E between peers) | Via P2P layer |
|
||||
| Client ↔ Client (P2P) | Direct TCP | NaCl box (X25519 + XSalsa20-Poly1305) | Yes (per-session DH) |
|
||||
| Browser ↔ Web Console | HTTPS (recommended) | Session cookie + CSRF token | Via TLS |
|
||||
| Console ↔ Go Server API | HTTP localhost | API key header | No (planned: mTLS) |
|
||||
| CDAP Agent ↔ Gateway | WebSocket (WSS recommended) | API key authentication | Via TLS |
|
||||
|
||||
## Key Management
|
||||
|
||||
### Ed25519 Server Keys
|
||||
- Generated on first server startup
|
||||
- Stored in `id_ed25519` / `id_ed25519.pub` files
|
||||
- File permissions: `0600` (owner read/write only)
|
||||
- No automatic rotation (server identity key)
|
||||
- Clients verify server public key against stored value
|
||||
|
||||
### API Keys
|
||||
- Auto-generated on first run (32 bytes, hex-encoded)
|
||||
- Stored in `.api_key` file + synced to `server_config` database table
|
||||
- Transmitted via `X-API-Key` HTTP header (never in URL)
|
||||
- Manual rotation via admin panel or file replacement
|
||||
|
||||
### Session Secrets
|
||||
- Random 64-byte hex string generated on first console startup
|
||||
- Stored in `.env` file (`SESSION_SECRET` variable)
|
||||
- Used to sign Express session cookies
|
||||
|
||||
### TOTP Secrets
|
||||
- Per-user, generated during 2FA enrollment
|
||||
- Stored encrypted in database (`totp_secret` column)
|
||||
- Recovery codes: 10 single-use codes generated at enrollment
|
||||
|
||||
## TLS Configuration
|
||||
|
||||
### Server-side (Go)
|
||||
- Dual-mode listeners: auto-detect plain TCP vs TLS on same port (first-byte `0x16` detection)
|
||||
- Minimum TLS version: 1.2
|
||||
- Flags: `--tls-cert`, `--tls-key`, `--tls-signal`, `--tls-relay`, `--tls-api`
|
||||
- Self-signed certificates supported (client must trust CA)
|
||||
|
||||
### Client-side (Tauri/Rust)
|
||||
- Trusts system certificate store by default
|
||||
- Certificate pinning planned for Phase 8
|
||||
- WebSocket connections use `wss://` when server HTTPS detected
|
||||
|
||||
## Data at Rest
|
||||
|
||||
| Data | Encryption | Location |
|
||||
|------|-----------|----------|
|
||||
| Database (SQLite) | None (filesystem permissions) | `db_v2.sqlite3` |
|
||||
| Database (PostgreSQL) | TLS in transit, at-rest depends on PG config | PostgreSQL server |
|
||||
| Ed25519 private key | None (file permissions `0600`) | `id_ed25519` |
|
||||
| Admin password hash | bcrypt (irreversible) | `auth.db` |
|
||||
| TOTP secrets | Stored as-is in DB | `auth.db` / `users` table |
|
||||
| Session data | Server-side storage (not in cookie) | Memory / session store |
|
||||
@@ -0,0 +1,106 @@
|
||||
# BetterDesk — Threat Model
|
||||
|
||||
> **Methodology:** STRIDE (Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, Elevation of Privilege)
|
||||
> **Last Updated:** 2026-04-01
|
||||
> **Scope:** BetterDesk Go Server, Node.js Console, Desktop Client, CDAP Agent
|
||||
|
||||
---
|
||||
|
||||
## System Overview
|
||||
|
||||
```
|
||||
Internet / WAN
|
||||
|
|
||||
+----------------+----------------+
|
||||
| | |
|
||||
RustDesk Client Web Browser CDAP Agent
|
||||
| | |
|
||||
[Signal TCP/UDP] [HTTPS :5000] [WSS :21122]
|
||||
[Relay TCP/WS] [API :21121] |
|
||||
| | |
|
||||
+----+----+ +-----+-----+ +----+----+
|
||||
| Go Server| | Node.js | | Go Server|
|
||||
| :21114-19| | Console | | CDAP GW |
|
||||
+----+----+ +-----+-----+ +----+----+
|
||||
| | |
|
||||
+--------+-------+-------+--------+
|
||||
| |
|
||||
[SQLite/PG] [auth.db]
|
||||
```
|
||||
|
||||
## STRIDE Analysis
|
||||
|
||||
### S — Spoofing
|
||||
|
||||
| Threat | Component | Mitigation | Status |
|
||||
|--------|-----------|------------|--------|
|
||||
| Device ID spoofing | Signal server | Ed25519 public key verification on registration | ✅ Done |
|
||||
| Admin impersonation | Web console | bcrypt password hashing + TOTP 2FA | ✅ Done |
|
||||
| API key theft | Go server API | Key stored in file with 0600 permissions, auto-regenerated | ✅ Done |
|
||||
| Org user impersonation | Org system | PBKDF2/bcrypt password hashing + JWT with short TTL | ✅ Done |
|
||||
| Relay UUID spoofing | Relay server | Server-generated UUIDs, pending UUID tracking map | ✅ Done |
|
||||
|
||||
### T — Tampering
|
||||
|
||||
| Threat | Component | Mitigation | Status |
|
||||
|--------|-----------|------------|--------|
|
||||
| Message modification in transit | Signal/Relay | NaCl authenticated encryption (XSalsa20-Poly1305) | ✅ Done |
|
||||
| Database tampering | SQLite/PG | File permissions, PostgreSQL row-level locking | ✅ Done |
|
||||
| Config file tampering | Client | Organization policy enforcement on startup | 🔲 Phase 4 |
|
||||
| Audit log tampering | Go server | Ring-buffer audit log | ⚠️ Add HMAC chain |
|
||||
|
||||
### R — Repudiation
|
||||
|
||||
| Threat | Component | Mitigation | Status |
|
||||
|--------|-----------|------------|--------|
|
||||
| Deny remote session | Web console | Audit log with connection start/end, operator ID, device ID | ✅ Done |
|
||||
| Deny admin actions | Web console | Audit log for login, config changes, device operations | ✅ Done |
|
||||
| Deny file transfer | Client | Session recording planned | 🔲 Phase 3 |
|
||||
|
||||
### I — Information Disclosure
|
||||
|
||||
| Threat | Component | Mitigation | Status |
|
||||
|--------|-----------|------------|--------|
|
||||
| Private key exposure | Go server | Ed25519 keys in separate directory, 0600 permissions | ✅ Done |
|
||||
| Password in logs | All | Passwords never logged, masked in error messages | ✅ Done |
|
||||
| API key in URL | Node.js | Keys sent via X-API-Key header, never in URL params | ✅ Done |
|
||||
| Error stack traces | Node.js | Production mode hides stack traces from HTTP responses | ✅ Done |
|
||||
| Database path disclosure | Go server | Generic error messages for DB failures | ✅ Done |
|
||||
|
||||
### D — Denial of Service
|
||||
|
||||
| Threat | Component | Mitigation | Status |
|
||||
|--------|-----------|------------|--------|
|
||||
| TCP connection flood | Signal server | Per-IP rate limiting, connection cap (10K) | ✅ Done |
|
||||
| Login brute force | Go server API | IP-based rate limiter on /api/auth/login | ✅ Done |
|
||||
| Relay stale sessions | Relay server | Idle timeout wrapper (io.Copy), 2-min TTL | ✅ Done |
|
||||
| WebSocket exhaustion | Signal/Relay WS | Origin validation, connection limits | ✅ Done |
|
||||
| Large request body | Node.js | Express body parser limit (2MB / 64KB for WAN API) | ✅ Done |
|
||||
|
||||
### E — Elevation of Privilege
|
||||
|
||||
| Threat | Component | Mitigation | Status |
|
||||
|--------|-----------|------------|--------|
|
||||
| Operator → Admin escalation | Web console | Role-based middleware (requireAdmin, requireRole) | ✅ Done |
|
||||
| User → Operator escalation | Org system | JWT claims include role, verified on every request | ✅ Done |
|
||||
| Banned device reconnect | Signal server | IsPeerBanned + IsPeerSoftDeleted checks | ✅ Done |
|
||||
| SQL injection | All DB queries | Parameterized queries throughout, LIKE escape for wildcards | ✅ Done |
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
1. **Internet ↔ Go Server** — TLS required for production (`--tls-signal`, `--tls-relay`)
|
||||
2. **Browser ↔ Node.js Console** — HTTPS recommended, session cookies with Secure/HttpOnly/SameSite
|
||||
3. **Node.js Console ↔ Go Server API** — Localhost HTTP with API key (mTLS planned)
|
||||
4. **Desktop Client ↔ Signal Server** — NaCl key exchange, then encrypted channel
|
||||
5. **Peer ↔ Peer (P2P)** — NaCl box encryption (X25519 + XSalsa20-Poly1305)
|
||||
6. **CDAP Agent ↔ Gateway** — WebSocket with API key authentication
|
||||
|
||||
## Open Risks
|
||||
|
||||
| Risk | Severity | Planned Mitigation |
|
||||
|------|----------|-------------------|
|
||||
| Console ↔ Go Server uses plain HTTP | Medium | mTLS or Unix socket (Phase 8) |
|
||||
| No API key rotation mechanism | Medium | Auto-rotation with grace period (Phase 8) |
|
||||
| Audit log not tamper-proof | Low | HMAC chain signing (Phase 8) |
|
||||
| No certificate pinning in client | Medium | Pin server certificate in Tauri (Phase 8) |
|
||||
| Credentials not zeroized in memory | Low | Use zeroize crate for Rust, explicit clearing for Node.js (Phase 8) |
|
||||
+225
-14
@@ -52,7 +52,11 @@
|
||||
"toggle_sidebar": "Seitenleiste umschalten",
|
||||
"cdap": "CDAP",
|
||||
"clients": "Clients",
|
||||
"exit_desktop": "Desktop beenden"
|
||||
"exit_desktop": "Desktop beenden",
|
||||
"help_requests": "Help Requests",
|
||||
"management": "Management",
|
||||
"tokens": "Device Tokens",
|
||||
"organizations": "Organizations"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -164,11 +168,35 @@
|
||||
"details": "Details",
|
||||
"notes": "Notizen",
|
||||
"hardware": "Hardware",
|
||||
"metrics": "Metriken"
|
||||
"metrics": "Metriken",
|
||||
"info": "Info",
|
||||
"history": "History"
|
||||
},
|
||||
"live_metrics": "Live-Metriken",
|
||||
"history_charts": "Verlaufsdiagramme",
|
||||
"no_metrics": "Keine Metriken verfügbar"
|
||||
"no_metrics": "Keine Metriken verfügbar",
|
||||
"not_found": "Device not found",
|
||||
"delete_failed": "Failed to delete device",
|
||||
"change_id_failed": "Failed to change device ID",
|
||||
"invalid_id": "Invalid device ID (6-16 characters required)",
|
||||
"invalid_id_format": "Invalid ID format (letters, numbers, dashes, underscores only)",
|
||||
"id_exists": "Device ID already exists",
|
||||
"no_selection": "No devices selected",
|
||||
"filter_type_all": "All Types",
|
||||
"filter_type_rustdesk": "RustDesk",
|
||||
"filter_type_desktop": "Desktop",
|
||||
"filter_type_scada": "SCADA",
|
||||
"filter_type_iot": "IoT",
|
||||
"filter_type_agent": "Agent",
|
||||
"hardware": {
|
||||
"title": "Hardware Information",
|
||||
"os": "Operating System",
|
||||
"cpu": "Processor",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"gpu": "Graphics Card",
|
||||
"network": "Network Adapters"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
"title": "Serverschlüssel",
|
||||
@@ -269,7 +297,20 @@
|
||||
"time": "Zeit",
|
||||
"user": "Benutzer",
|
||||
"action": "Aktion",
|
||||
"details": "Details"
|
||||
"details": "Details",
|
||||
"action_login": "Login",
|
||||
"action_login_failed": "Login Failed",
|
||||
"action_logout": "Logout",
|
||||
"action_password_change": "Password Changed",
|
||||
"action_2fa_enable": "2FA Enabled",
|
||||
"action_2fa_disable": "2FA Disabled",
|
||||
"action_user_create": "User Created",
|
||||
"action_user_update": "User Updated",
|
||||
"action_user_delete": "User Deleted",
|
||||
"action_settings_update": "Settings Updated",
|
||||
"action_device_ban": "Device Banned",
|
||||
"action_device_unban": "Device Unbanned",
|
||||
"action_device_delete": "Device Deleted"
|
||||
},
|
||||
"generator": {
|
||||
"title": "Client-Konfigurationsgenerator",
|
||||
@@ -321,14 +362,22 @@
|
||||
"confirm": "Bestätigen",
|
||||
"close": "Schließen",
|
||||
"clear": "Leeren",
|
||||
"ok": "OK"
|
||||
"ok": "OK",
|
||||
"remote_viewer": "Remote Viewer"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Gerade eben",
|
||||
"seconds_ago": "Vor {count} Sekunde(n)",
|
||||
"minutes_ago": "Vor {count} Minute(n)",
|
||||
"hours_ago": "Vor {count} Stunde(n)",
|
||||
"days_ago": "Vor {count} Tag(en)"
|
||||
"days_ago": "Vor {count} Tag(en)",
|
||||
"day_mon": "Mon",
|
||||
"day_tue": "Tue",
|
||||
"day_wed": "Wed",
|
||||
"day_thu": "Thu",
|
||||
"day_fri": "Fri",
|
||||
"day_sat": "Sat",
|
||||
"day_sun": "Sun"
|
||||
},
|
||||
"users": {
|
||||
"title": "Benutzerverwaltung",
|
||||
@@ -750,7 +799,13 @@
|
||||
"export_pdf": "Als PDF exportieren",
|
||||
"date_range": "Datumsbereich",
|
||||
"today": "Heute",
|
||||
"this_week": "Diese Woche"
|
||||
"this_week": "Diese Woche",
|
||||
"search": "Search devices...",
|
||||
"device_id": "Device ID",
|
||||
"no_activity": "No activity recorded",
|
||||
"reported_at": "Reported At",
|
||||
"detail": "Detail",
|
||||
"no_apps": "No applications"
|
||||
},
|
||||
"automation": {
|
||||
"title": "Automatisierung & Alerts",
|
||||
@@ -819,7 +874,8 @@
|
||||
"no_rules": "Keine Alarmregeln konfiguriert",
|
||||
"no_alerts": "Keine Alarme ausgelöst",
|
||||
"no_commands": "Keine Befehle gesendet",
|
||||
"no_commands_sent": "Noch keine Befehle gesendet"
|
||||
"no_commands_sent": "Noch keine Befehle gesendet",
|
||||
"id_payload_required": "Device ID and payload are required"
|
||||
},
|
||||
"file_transfer": {
|
||||
"title": "Dateiübertragung",
|
||||
@@ -912,7 +968,18 @@
|
||||
"last_7d": "Letzte 7 Tage",
|
||||
"last_30d": "Letzte 30 Tage",
|
||||
"charts": "Diagramme",
|
||||
"realtime": "Echtzeit"
|
||||
"realtime": "Echtzeit",
|
||||
"search_targets": "Search targets...",
|
||||
"name": "Name",
|
||||
"tab_targets": "Targets",
|
||||
"tab_tools": "Tools",
|
||||
"avg_latency": "Avg Latency",
|
||||
"type": "Type",
|
||||
"latency": "Latency",
|
||||
"check_history": "Check History",
|
||||
"name_host_required": "Name and host are required",
|
||||
"avg_response": "Average Response",
|
||||
"response_time_ms": "Response Time (ms)"
|
||||
},
|
||||
"dataguard": {
|
||||
"title": "DataGuard — DLP",
|
||||
@@ -962,7 +1029,31 @@
|
||||
"drive_fixed": "Fest",
|
||||
"drive_network": "Netzwerk",
|
||||
"drive_cdrom": "CD-ROM",
|
||||
"drive_unknown": "Unbekannt"
|
||||
"drive_unknown": "Unbekannt",
|
||||
"status": "Status",
|
||||
"total_policies": "Total Policies",
|
||||
"active_policies": "Active Policies",
|
||||
"violations": "Violations",
|
||||
"blocked": "Blocked",
|
||||
"tab_policies": "Policies",
|
||||
"tab_events": "Events",
|
||||
"search_policies": "Search policies...",
|
||||
"create_policy": "Create Policy",
|
||||
"policy_type": "Policy Type",
|
||||
"scope": "Scope",
|
||||
"events_count": "Events",
|
||||
"search_events": "Search events...",
|
||||
"event_time": "Event Time",
|
||||
"device": "Device",
|
||||
"policy": "Policy",
|
||||
"detail": "Detail",
|
||||
"scope_help": "Comma-separated device IDs, or leave empty for all",
|
||||
"scope_all": "All Devices",
|
||||
"active": "Active",
|
||||
"type_file_type": "File Type",
|
||||
"type_clipboard": "Clipboard",
|
||||
"type_screen_share": "Screen Share",
|
||||
"type_file_transfer": "File Transfer"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Berichte",
|
||||
@@ -1023,7 +1114,6 @@
|
||||
"save": "Bericht speichern",
|
||||
"preview": "Vorschau",
|
||||
"download": "Herunterladen",
|
||||
"no_saved": "Keine gespeicherten Berichte",
|
||||
"delete_saved": "Bericht löschen"
|
||||
},
|
||||
"tenants": {
|
||||
@@ -1068,7 +1158,8 @@
|
||||
"enabled": "Aktiviert",
|
||||
"max": "max",
|
||||
"info": "Mandanteninformation",
|
||||
"status": "Status"
|
||||
"status": "Status",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"registrations": {
|
||||
"title": "Geräteregistrierungen",
|
||||
@@ -1096,7 +1187,20 @@
|
||||
"reject_reason_placeholder": "Ablehnungsgrund eingeben...",
|
||||
"rejected_success": "Geräteregistrierung abgelehnt",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie diesen Registrierungseintrag löschen möchten?",
|
||||
"deleted_success": "Registrierungseintrag gelöscht"
|
||||
"deleted_success": "Registrierungseintrag gelöscht",
|
||||
"enrollment_title": "Device Enrollment",
|
||||
"enrollment_subtitle": "Pending enrollment requests from BetterDesk desktop clients",
|
||||
"enrollment_empty": "No pending enrollment requests",
|
||||
"enrollment_approve": "Approve",
|
||||
"enrollment_reject": "Reject",
|
||||
"enrollment_display_name": "Display Name",
|
||||
"enrollment_display_name_placeholder": "Enter a name for this device...",
|
||||
"enrollment_sync_mode": "Sync Mode",
|
||||
"enrollment_sync_silent": "Silent — minimal telemetry",
|
||||
"enrollment_sync_standard": "Standard — balanced sync",
|
||||
"enrollment_sync_turbo": "Turbo — aggressive sync",
|
||||
"enrollment_approved_success": "Device enrollment approved",
|
||||
"enrollment_rejected_success": "Device enrollment rejected"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Sicherung",
|
||||
@@ -1229,7 +1333,69 @@
|
||||
"search_pages": "Seiten",
|
||||
"search_devices": "Geräte",
|
||||
"search_no_results": "Keine Ergebnisse",
|
||||
"exit_desktop": "Desktop verlassen"
|
||||
"exit_desktop": "Desktop verlassen",
|
||||
"wp_images": "Bilder",
|
||||
"wp_colors": "Volltonfarben",
|
||||
"wp_custom_color": "Eigene Farbe",
|
||||
"wp_fit_style": "Stil",
|
||||
"wp_fill": "Ausfüllen",
|
||||
"wp_fit": "Anpassen",
|
||||
"wp_stretch": "Strecken",
|
||||
"wp_center": "Zentrieren",
|
||||
"refresh": "Refresh",
|
||||
"widget_clock": "Clock",
|
||||
"widget_device_status": "Device Status",
|
||||
"widget_server_health": "Server Info",
|
||||
"widget_system_stats": "Device Gauges",
|
||||
"widget_device_list": "Device List",
|
||||
"widget_quick_actions": "Quick Actions",
|
||||
"widget_recent_activity": "Recent Activity",
|
||||
"widget_notes": "Notes",
|
||||
"widget_network_monitor": "Network Monitor",
|
||||
"widget_tickets_summary": "Tickets",
|
||||
"widget_iframe": "Web Embed",
|
||||
"widget_cdap_devices": "CDAP Devices",
|
||||
"widget_uptime": "Uptime",
|
||||
"widget_port_status": "Port Status",
|
||||
"widget_device_grid": "Device Grid",
|
||||
"widget_multi_gauge": "Server Gauges",
|
||||
"widget_weekly_chart": "Weekly Activity",
|
||||
"widget_quick_controls": "Quick Controls",
|
||||
"widget_bandwidth": "Bandwidth",
|
||||
"widget_connection_stats": "Connection Stats",
|
||||
"label_online": "Online",
|
||||
"label_offline": "Offline",
|
||||
"label_total": "Total",
|
||||
"label_blocked": "Blocked",
|
||||
"label_banned": "Banned",
|
||||
"label_active": "Active",
|
||||
"label_service": "Service",
|
||||
"label_port": "Port",
|
||||
"label_uptime_prefix": "Uptime:",
|
||||
"label_no_devices": "No devices",
|
||||
"label_no_activity": "No activity",
|
||||
"label_no_targets": "No targets",
|
||||
"label_unavailable": "Unavailable",
|
||||
"label_open": "Open",
|
||||
"label_in_progress": "In Progress",
|
||||
"label_resolved": "Resolved",
|
||||
"label_set_url": "Set URL in config",
|
||||
"label_server_uptime": "Server Uptime",
|
||||
"label_merged_info": "Merged into Server Info widget",
|
||||
"label_with_notes": "With Notes",
|
||||
"label_metric": "Metric",
|
||||
"label_count": "Count",
|
||||
"label_total_devices": "Total Devices",
|
||||
"label_throughput": "Throughput",
|
||||
"label_active_relays": "Active Relays",
|
||||
"label_total_relayed": "Total Relayed",
|
||||
"label_total_bytes": "Total Bytes",
|
||||
"label_filter_devices": "Filter devices...",
|
||||
"label_all": "All",
|
||||
"label_ban": "Ban",
|
||||
"label_unban": "Unban",
|
||||
"label_connect": "Connect",
|
||||
"label_no_cdap_devices": "No CDAP devices"
|
||||
},
|
||||
"tokens": {
|
||||
"title": "Geräte-Token",
|
||||
@@ -1318,5 +1484,50 @@
|
||||
"desktop_windows_text": "Apps öffnen sich in verschiebbaren, größenveränderbaren Fenstern. Minimieren, maximieren oder schließen über die Titelleiste. Fenster liegen über Widgets.",
|
||||
"desktop_search_title": "Schnellsuche",
|
||||
"desktop_search_text": "Suchen Sie nach Geräten, Seiten und Einstellungen. Drücken Sie Strg+K, um die Suche sofort zu öffnen."
|
||||
},
|
||||
"help_request": {
|
||||
"title": "Help Requests",
|
||||
"subtitle": "Manage incoming help requests from desktop clients",
|
||||
"new_request": "New Help Request",
|
||||
"device": "Device",
|
||||
"hostname": "Hostname",
|
||||
"message": "Message",
|
||||
"status": "Status",
|
||||
"pending": "Pending",
|
||||
"accepted": "Accepted",
|
||||
"resolved": "Resolved",
|
||||
"accept": "Accept",
|
||||
"resolve": "Resolve",
|
||||
"no_requests": "No help requests",
|
||||
"notification": "Help request received from",
|
||||
"stats_total": "Total",
|
||||
"filter_all": "All"
|
||||
},
|
||||
"organizations": {
|
||||
"title": "Organizations",
|
||||
"create": "Create Organization",
|
||||
"edit": "Edit Organization",
|
||||
"detail": "Organization Details",
|
||||
"name": "Name",
|
||||
"slug": "Slug (URL-safe)",
|
||||
"logo_url": "Logo URL",
|
||||
"users": "Users",
|
||||
"devices": "Devices",
|
||||
"invitations": "Invitations",
|
||||
"settings": "Settings",
|
||||
"add_user": "Add User",
|
||||
"assign_device": "Assign Device",
|
||||
"create_invitation": "Create Invitation",
|
||||
"delete_confirm": "Delete this organization? This will remove all associated users, devices, and settings.",
|
||||
"no_orgs": "No organizations yet",
|
||||
"no_orgs_hint": "Create your first organization to start managing devices and users.",
|
||||
"connection_policy": "Connection Policy",
|
||||
"allow_file_transfer": "Allow File Transfer",
|
||||
"allow_clipboard": "Allow Clipboard",
|
||||
"max_session_duration": "Max Session Duration (min)",
|
||||
"role_owner": "Owner",
|
||||
"role_admin": "Admin",
|
||||
"role_operator": "Operator",
|
||||
"role_user": "User"
|
||||
}
|
||||
}
|
||||
|
||||
+39
-2
@@ -53,7 +53,8 @@
|
||||
"management": "Management",
|
||||
"toggle_sidebar": "Toggle sidebar",
|
||||
"cdap": "CDAP Devices",
|
||||
"tokens": "Device Tokens"
|
||||
"tokens": "Device Tokens",
|
||||
"organizations": "Organizations"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
@@ -1332,6 +1333,7 @@
|
||||
"search_devices": "Devices",
|
||||
"search_no_results": "No results",
|
||||
"exit_desktop": "Exit Desktop",
|
||||
"refresh": "Refresh",
|
||||
"widget_clock": "Clock",
|
||||
"widget_device_status": "Device Status",
|
||||
"widget_server_health": "Server Info",
|
||||
@@ -1384,7 +1386,15 @@
|
||||
"label_ban": "Ban",
|
||||
"label_unban": "Unban",
|
||||
"label_connect": "Connect",
|
||||
"label_no_cdap_devices": "No CDAP devices"
|
||||
"label_no_cdap_devices": "No CDAP devices",
|
||||
"wp_images": "Images",
|
||||
"wp_colors": "Solid Colors",
|
||||
"wp_custom_color": "Custom color",
|
||||
"wp_fit_style": "Style",
|
||||
"wp_fill": "Fill",
|
||||
"wp_fit": "Fit",
|
||||
"wp_stretch": "Stretch",
|
||||
"wp_center": "Center"
|
||||
},
|
||||
"tokens": {
|
||||
"title": "Device Tokens",
|
||||
@@ -1491,5 +1501,32 @@
|
||||
"notification": "Help request received from",
|
||||
"stats_total": "Total",
|
||||
"filter_all": "All"
|
||||
},
|
||||
"organizations": {
|
||||
"title": "Organizations",
|
||||
"create": "Create Organization",
|
||||
"edit": "Edit Organization",
|
||||
"detail": "Organization Details",
|
||||
"name": "Name",
|
||||
"slug": "Slug (URL-safe)",
|
||||
"logo_url": "Logo URL",
|
||||
"users": "Users",
|
||||
"devices": "Devices",
|
||||
"invitations": "Invitations",
|
||||
"settings": "Settings",
|
||||
"add_user": "Add User",
|
||||
"assign_device": "Assign Device",
|
||||
"create_invitation": "Create Invitation",
|
||||
"delete_confirm": "Delete this organization? This will remove all associated users, devices, and settings.",
|
||||
"no_orgs": "No organizations yet",
|
||||
"no_orgs_hint": "Create your first organization to start managing devices and users.",
|
||||
"connection_policy": "Connection Policy",
|
||||
"allow_file_transfer": "Allow File Transfer",
|
||||
"allow_clipboard": "Allow Clipboard",
|
||||
"max_session_duration": "Max Session Duration (min)",
|
||||
"role_owner": "Owner",
|
||||
"role_admin": "Admin",
|
||||
"role_operator": "Operator",
|
||||
"role_user": "User"
|
||||
}
|
||||
}
|
||||
|
||||
+368
-17
@@ -52,7 +52,11 @@
|
||||
"toggle_sidebar": "Alternar barra lateral",
|
||||
"cdap": "CDAP",
|
||||
"clients": "Clientes",
|
||||
"exit_desktop": "Salir del escritorio"
|
||||
"exit_desktop": "Salir del escritorio",
|
||||
"help_requests": "Help Requests",
|
||||
"management": "Management",
|
||||
"tokens": "Device Tokens",
|
||||
"organizations": "Organizations"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar sesión",
|
||||
@@ -164,11 +168,35 @@
|
||||
"details": "Detalles",
|
||||
"notes": "Notas",
|
||||
"hardware": "Hardware",
|
||||
"metrics": "Métricas"
|
||||
"metrics": "Métricas",
|
||||
"info": "Info",
|
||||
"history": "History"
|
||||
},
|
||||
"live_metrics": "Métricas en vivo",
|
||||
"history_charts": "Gráficos de historial",
|
||||
"no_metrics": "No hay métricas disponibles"
|
||||
"no_metrics": "No hay métricas disponibles",
|
||||
"not_found": "Device not found",
|
||||
"delete_failed": "Failed to delete device",
|
||||
"change_id_failed": "Failed to change device ID",
|
||||
"invalid_id": "Invalid device ID (6-16 characters required)",
|
||||
"invalid_id_format": "Invalid ID format (letters, numbers, dashes, underscores only)",
|
||||
"id_exists": "Device ID already exists",
|
||||
"no_selection": "No devices selected",
|
||||
"filter_type_all": "All Types",
|
||||
"filter_type_rustdesk": "RustDesk",
|
||||
"filter_type_desktop": "Desktop",
|
||||
"filter_type_scada": "SCADA",
|
||||
"filter_type_iot": "IoT",
|
||||
"filter_type_agent": "Agent",
|
||||
"hardware": {
|
||||
"title": "Hardware Information",
|
||||
"os": "Operating System",
|
||||
"cpu": "Processor",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"gpu": "Graphics Card",
|
||||
"network": "Network Adapters"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
"title": "Claves del servidor",
|
||||
@@ -269,7 +297,20 @@
|
||||
"time": "Hora",
|
||||
"user": "Usuario",
|
||||
"action": "Acción",
|
||||
"details": "Detalles"
|
||||
"details": "Detalles",
|
||||
"action_login": "Login",
|
||||
"action_login_failed": "Login Failed",
|
||||
"action_logout": "Logout",
|
||||
"action_password_change": "Password Changed",
|
||||
"action_2fa_enable": "2FA Enabled",
|
||||
"action_2fa_disable": "2FA Disabled",
|
||||
"action_user_create": "User Created",
|
||||
"action_user_update": "User Updated",
|
||||
"action_user_delete": "User Deleted",
|
||||
"action_settings_update": "Settings Updated",
|
||||
"action_device_ban": "Device Banned",
|
||||
"action_device_unban": "Device Unbanned",
|
||||
"action_device_delete": "Device Deleted"
|
||||
},
|
||||
"generator": {
|
||||
"title": "Generador de configuración de cliente",
|
||||
@@ -321,14 +362,22 @@
|
||||
"confirm": "Confirmar",
|
||||
"close": "Cerrar",
|
||||
"clear": "Limpiar",
|
||||
"ok": "OK"
|
||||
"ok": "OK",
|
||||
"remote_viewer": "Remote Viewer"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Ahora mismo",
|
||||
"seconds_ago": "Hace {count} segundo(s)",
|
||||
"minutes_ago": "Hace {count} minuto(s)",
|
||||
"hours_ago": "Hace {count} hora(s)",
|
||||
"days_ago": "Hace {count} día(s)"
|
||||
"days_ago": "Hace {count} día(s)",
|
||||
"day_mon": "Mon",
|
||||
"day_tue": "Tue",
|
||||
"day_wed": "Wed",
|
||||
"day_thu": "Thu",
|
||||
"day_fri": "Fri",
|
||||
"day_sat": "Sat",
|
||||
"day_sun": "Sun"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestión de usuarios",
|
||||
@@ -750,7 +799,13 @@
|
||||
"export_pdf": "Exportar como PDF",
|
||||
"date_range": "Rango de fechas",
|
||||
"today": "Hoy",
|
||||
"this_week": "Esta semana"
|
||||
"this_week": "Esta semana",
|
||||
"search": "Search devices...",
|
||||
"device_id": "Device ID",
|
||||
"no_activity": "No activity recorded",
|
||||
"reported_at": "Reported At",
|
||||
"detail": "Detail",
|
||||
"no_apps": "No applications"
|
||||
},
|
||||
"automation": {
|
||||
"title": "Automatización y alertas",
|
||||
@@ -819,7 +874,8 @@
|
||||
"no_rules": "No hay reglas de alerta configuradas",
|
||||
"no_alerts": "No se han activado alertas",
|
||||
"no_commands": "No se han enviado comandos",
|
||||
"no_commands_sent": "Aún no se han enviado comandos"
|
||||
"no_commands_sent": "Aún no se han enviado comandos",
|
||||
"id_payload_required": "Device ID and payload are required"
|
||||
},
|
||||
"file_transfer": {
|
||||
"title": "Transferencia de archivos",
|
||||
@@ -912,7 +968,18 @@
|
||||
"last_7d": "Últimos 7 días",
|
||||
"last_30d": "Últimos 30 días",
|
||||
"charts": "Gráficos",
|
||||
"realtime": "Tiempo real"
|
||||
"realtime": "Tiempo real",
|
||||
"search_targets": "Search targets...",
|
||||
"name": "Name",
|
||||
"tab_targets": "Targets",
|
||||
"tab_tools": "Tools",
|
||||
"avg_latency": "Avg Latency",
|
||||
"type": "Type",
|
||||
"latency": "Latency",
|
||||
"check_history": "Check History",
|
||||
"name_host_required": "Name and host are required",
|
||||
"avg_response": "Average Response",
|
||||
"response_time_ms": "Response Time (ms)"
|
||||
},
|
||||
"dataguard": {
|
||||
"title": "DataGuard — DLP",
|
||||
@@ -962,7 +1029,31 @@
|
||||
"drive_fixed": "Fijo",
|
||||
"drive_network": "Red",
|
||||
"drive_cdrom": "CD-ROM",
|
||||
"drive_unknown": "Desconocido"
|
||||
"drive_unknown": "Desconocido",
|
||||
"status": "Status",
|
||||
"total_policies": "Total Policies",
|
||||
"active_policies": "Active Policies",
|
||||
"violations": "Violations",
|
||||
"blocked": "Blocked",
|
||||
"tab_policies": "Policies",
|
||||
"tab_events": "Events",
|
||||
"search_policies": "Search policies...",
|
||||
"create_policy": "Create Policy",
|
||||
"policy_type": "Policy Type",
|
||||
"scope": "Scope",
|
||||
"events_count": "Events",
|
||||
"search_events": "Search events...",
|
||||
"event_time": "Event Time",
|
||||
"device": "Device",
|
||||
"policy": "Policy",
|
||||
"detail": "Detail",
|
||||
"scope_help": "Comma-separated device IDs, or leave empty for all",
|
||||
"scope_all": "All Devices",
|
||||
"active": "Active",
|
||||
"type_file_type": "File Type",
|
||||
"type_clipboard": "Clipboard",
|
||||
"type_screen_share": "Screen Share",
|
||||
"type_file_transfer": "File Transfer"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Informes",
|
||||
@@ -1023,7 +1114,6 @@
|
||||
"save": "Guardar informe",
|
||||
"preview": "Vista previa",
|
||||
"download": "Descargar",
|
||||
"no_saved": "No hay informes guardados",
|
||||
"delete_saved": "Eliminar informe"
|
||||
},
|
||||
"tenants": {
|
||||
@@ -1068,7 +1158,8 @@
|
||||
"enabled": "Habilitado",
|
||||
"max": "máx",
|
||||
"info": "Información del inquilino",
|
||||
"status": "Estado"
|
||||
"status": "Estado",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"registrations": {
|
||||
"title": "Registros de dispositivos",
|
||||
@@ -1096,7 +1187,20 @@
|
||||
"reject_reason_placeholder": "Introduzca el motivo del rechazo...",
|
||||
"rejected_success": "Registro de dispositivo rechazado",
|
||||
"delete_confirm": "¿Está seguro de que desea eliminar este registro?",
|
||||
"deleted_success": "Registro eliminado"
|
||||
"deleted_success": "Registro eliminado",
|
||||
"enrollment_title": "Device Enrollment",
|
||||
"enrollment_subtitle": "Pending enrollment requests from BetterDesk desktop clients",
|
||||
"enrollment_empty": "No pending enrollment requests",
|
||||
"enrollment_approve": "Approve",
|
||||
"enrollment_reject": "Reject",
|
||||
"enrollment_display_name": "Display Name",
|
||||
"enrollment_display_name_placeholder": "Enter a name for this device...",
|
||||
"enrollment_sync_mode": "Sync Mode",
|
||||
"enrollment_sync_silent": "Silent — minimal telemetry",
|
||||
"enrollment_sync_standard": "Standard — balanced sync",
|
||||
"enrollment_sync_turbo": "Turbo — aggressive sync",
|
||||
"enrollment_approved_success": "Device enrollment approved",
|
||||
"enrollment_rejected_success": "Device enrollment rejected"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Copia de seguridad",
|
||||
@@ -1201,7 +1305,61 @@
|
||||
"actions": "Acciones",
|
||||
"disconnect": "Desconectar",
|
||||
"disconnect_confirm": "¿Está seguro de que desea desconectar este dispositivo?",
|
||||
"disconnected_success": "Dispositivo desconectado"
|
||||
"disconnected_success": "Dispositivo desconectado",
|
||||
"loading_widgets": "Loading device widgets...",
|
||||
"load_error": "Failed to load device data",
|
||||
"device_offline_msg": "Device is currently offline. Widget values may be stale.",
|
||||
"no_widgets_desc": "This device has not registered a CDAP manifest with widget definitions.",
|
||||
"clear_log": "Clear log",
|
||||
"select_option": "Select",
|
||||
"cdap_status": "CDAP Status",
|
||||
"cdap_enabled": "CDAP Enabled",
|
||||
"cdap_disabled": "CDAP Disabled",
|
||||
"cdap_devices": "CDAP Devices",
|
||||
"cdap_connections": "Active Connections",
|
||||
"send_command": "Send Command",
|
||||
"devices_title": "CDAP Devices",
|
||||
"devices_subtitle": "Connected devices via Custom Device Application Protocol",
|
||||
"stat_connected": "Connected",
|
||||
"stat_port": "Port",
|
||||
"search_devices": "Search devices...",
|
||||
"no_devices": "No devices connected",
|
||||
"no_devices_desc": "No CDAP devices are currently connected. Devices will appear here once they connect to the gateway.",
|
||||
"gateway_disabled_desc": "Enable the CDAP gateway by setting CDAP_ENABLED=true in the server configuration.",
|
||||
"gateway_active": "Active",
|
||||
"toggle_enable": "Enable CDAP",
|
||||
"toggle_disable": "Disable CDAP",
|
||||
"enabled_restart": "CDAP enabled. Server restart required to apply changes.",
|
||||
"disabled_restart": "CDAP disabled. Server restart required to apply changes.",
|
||||
"widgets": "widgets",
|
||||
"type_all": "All",
|
||||
"type_iot": "IoT",
|
||||
"type_scada": "SCADA",
|
||||
"type_network": "Network",
|
||||
"type_camera": "Camera",
|
||||
"active_alerts": "Active Alerts",
|
||||
"alert_fired": "Alert fired",
|
||||
"alert_cleared": "Alert cleared",
|
||||
"no_alerts": "No active alerts",
|
||||
"connect_terminal": "Connect Terminal",
|
||||
"table_search": "Search...",
|
||||
"just_now": "Just now",
|
||||
"terminal_connecting": "Connecting to device...",
|
||||
"terminal_disconnected": "Disconnected",
|
||||
"terminal_error": "Terminal connection failed",
|
||||
"linked_devices": "Linked Devices",
|
||||
"link_device": "Link Device",
|
||||
"unlink_device": "Unlink",
|
||||
"no_linked_devices": "No linked devices",
|
||||
"unlink_confirm": "Are you sure you want to unlink this device?",
|
||||
"link_prompt": "Enter the Peer ID to link to this device:",
|
||||
"connect_desktop": "Connect Desktop",
|
||||
"connect_video": "Connect Stream",
|
||||
"connect_files": "Browse Files",
|
||||
"upload_file": "Upload",
|
||||
"desktop_connecting": "Connecting...",
|
||||
"video_connecting": "Connecting...",
|
||||
"files_connecting": "Connecting..."
|
||||
},
|
||||
"desktop": {
|
||||
"title": "Modo escritorio",
|
||||
@@ -1228,7 +1386,92 @@
|
||||
"shortcut_remove": "Eliminar acceso directo",
|
||||
"auto_hide": "Ocultar automáticamente",
|
||||
"show_clock": "Mostrar reloj",
|
||||
"show_notifications": "Mostrar notificaciones"
|
||||
"show_notifications": "Mostrar notificaciones",
|
||||
"wp_images": "Imágenes",
|
||||
"wp_colors": "Colores sólidos",
|
||||
"wp_custom_color": "Color personalizado",
|
||||
"wp_fit_style": "Estilo",
|
||||
"wp_fill": "Rellenar",
|
||||
"wp_fit": "Ajustar",
|
||||
"wp_stretch": "Estirar",
|
||||
"wp_center": "Centrar",
|
||||
"switch_mode": "Desktop Mode",
|
||||
"console_mode": "Console Mode",
|
||||
"loading": "Loading...",
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize",
|
||||
"restore": "Restore",
|
||||
"close": "Close",
|
||||
"widgets_mode": "Widgets",
|
||||
"windows_mode": "Windows",
|
||||
"add_widget": "Add Widget",
|
||||
"remove_widget": "Remove Widget",
|
||||
"configure": "Configure",
|
||||
"search_widgets": "Search widgets...",
|
||||
"cat_monitoring": "Monitoring",
|
||||
"cat_devices": "Devices",
|
||||
"cat_tools": "Tools",
|
||||
"cat_general": "General",
|
||||
"notes_placeholder": "Write notes here...",
|
||||
"search_placeholder": "Search devices, pages, settings...",
|
||||
"search_pages": "Pages",
|
||||
"search_devices": "Devices",
|
||||
"search_no_results": "No results",
|
||||
"exit_desktop": "Exit Desktop",
|
||||
"refresh": "Refresh",
|
||||
"widget_clock": "Clock",
|
||||
"widget_device_status": "Device Status",
|
||||
"widget_server_health": "Server Info",
|
||||
"widget_system_stats": "Device Gauges",
|
||||
"widget_device_list": "Device List",
|
||||
"widget_quick_actions": "Quick Actions",
|
||||
"widget_recent_activity": "Recent Activity",
|
||||
"widget_notes": "Notes",
|
||||
"widget_network_monitor": "Network Monitor",
|
||||
"widget_tickets_summary": "Tickets",
|
||||
"widget_iframe": "Web Embed",
|
||||
"widget_cdap_devices": "CDAP Devices",
|
||||
"widget_uptime": "Uptime",
|
||||
"widget_port_status": "Port Status",
|
||||
"widget_device_grid": "Device Grid",
|
||||
"widget_multi_gauge": "Server Gauges",
|
||||
"widget_weekly_chart": "Weekly Activity",
|
||||
"widget_quick_controls": "Quick Controls",
|
||||
"widget_bandwidth": "Bandwidth",
|
||||
"widget_connection_stats": "Connection Stats",
|
||||
"label_online": "Online",
|
||||
"label_offline": "Offline",
|
||||
"label_total": "Total",
|
||||
"label_blocked": "Blocked",
|
||||
"label_banned": "Banned",
|
||||
"label_active": "Active",
|
||||
"label_service": "Service",
|
||||
"label_port": "Port",
|
||||
"label_uptime_prefix": "Uptime:",
|
||||
"label_no_devices": "No devices",
|
||||
"label_no_activity": "No activity",
|
||||
"label_no_targets": "No targets",
|
||||
"label_unavailable": "Unavailable",
|
||||
"label_open": "Open",
|
||||
"label_in_progress": "In Progress",
|
||||
"label_resolved": "Resolved",
|
||||
"label_set_url": "Set URL in config",
|
||||
"label_server_uptime": "Server Uptime",
|
||||
"label_merged_info": "Merged into Server Info widget",
|
||||
"label_with_notes": "With Notes",
|
||||
"label_metric": "Metric",
|
||||
"label_count": "Count",
|
||||
"label_total_devices": "Total Devices",
|
||||
"label_throughput": "Throughput",
|
||||
"label_active_relays": "Active Relays",
|
||||
"label_total_relayed": "Total Relayed",
|
||||
"label_total_bytes": "Total Bytes",
|
||||
"label_filter_devices": "Filter devices...",
|
||||
"label_all": "All",
|
||||
"label_ban": "Ban",
|
||||
"label_unban": "Unban",
|
||||
"label_connect": "Connect",
|
||||
"label_no_cdap_devices": "No CDAP devices"
|
||||
},
|
||||
"tokens": {
|
||||
"title": "Tokens de API",
|
||||
@@ -1273,7 +1516,49 @@
|
||||
"status": "Estado",
|
||||
"status_active": "Activo",
|
||||
"status_expired": "Expirado",
|
||||
"status_revoked": "Revocado"
|
||||
"status_revoked": "Revocado",
|
||||
"enrollment_mode": "Enrollment Mode",
|
||||
"mode_open": "Open",
|
||||
"mode_open_desc": "Any device can register without a token",
|
||||
"mode_managed": "Managed",
|
||||
"mode_managed_desc": "Devices need a valid token to register",
|
||||
"mode_locked": "Locked",
|
||||
"mode_locked_desc": "No new device registrations allowed",
|
||||
"total": "Total Tokens",
|
||||
"active": "Active",
|
||||
"used": "Used",
|
||||
"search_placeholder": "Search tokens...",
|
||||
"status_pending": "Pending",
|
||||
"status_used": "Used",
|
||||
"bulk_generate": "Bulk Generate",
|
||||
"edit": "Edit Token",
|
||||
"token": "Token",
|
||||
"uses": "Uses",
|
||||
"bound_peer": "Bound Peer",
|
||||
"actions": "Actions",
|
||||
"max_uses": "Max Uses",
|
||||
"max_uses_hint": "0 = unlimited",
|
||||
"expires_in": "Expires In",
|
||||
"no_expiration": "No expiration",
|
||||
"expires_1h": "1 hour",
|
||||
"expires_24h": "24 hours",
|
||||
"expires_7d": "7 days",
|
||||
"note": "Note",
|
||||
"note_placeholder": "Optional note about this token...",
|
||||
"count": "Count",
|
||||
"count_range": "Number of tokens to generate (1-100)",
|
||||
"name_prefix": "Name Prefix",
|
||||
"generate": "Generate",
|
||||
"tokens_generated": "Tokens Generated",
|
||||
"copy_warning": "Copy the token now. It will not be shown again.",
|
||||
"copy_token": "Copy Token",
|
||||
"copied": "Copied!",
|
||||
"revoked_success": "Token revoked successfully",
|
||||
"expired": "Expired",
|
||||
"never": "Never",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"unlimited": "Unlimited"
|
||||
},
|
||||
"tutorial": {
|
||||
"title": "Bienvenido a BetterDesk",
|
||||
@@ -1295,6 +1580,72 @@
|
||||
"step_complete_title": "¡Listo!",
|
||||
"step_complete_text": "Ahora está listo para empezar a usar BetterDesk. ¿Necesita ayuda? Consulte la documentación o contacte con soporte.",
|
||||
"dont_show_again": "No mostrar de nuevo",
|
||||
"restart_tutorial": "Reiniciar tutorial"
|
||||
"restart_tutorial": "Reiniciar tutorial",
|
||||
"step_of": "{current} of {total}",
|
||||
"console_sidebar_title": "Navigation Sidebar",
|
||||
"console_sidebar_text": "Access all modules from here. Click any icon to navigate to that section. The sidebar adapts to your screen size.",
|
||||
"console_dashboard_title": "Dashboard",
|
||||
"console_dashboard_text": "Your central hub showing server status, online devices, and quick actions. Keep an eye on important metrics at a glance.",
|
||||
"console_devices_title": "Device Management",
|
||||
"console_devices_text": "View, search, and manage all connected devices. Filter by status, assign tags, and configure individual device settings.",
|
||||
"console_settings_title": "Settings",
|
||||
"console_settings_text": "Configure server behavior, security options, branding, and user management. Customize BetterDesk to fit your needs.",
|
||||
"console_desktop_title": "Desktop Mode",
|
||||
"console_desktop_text": "Switch to desktop mode for a windowed workspace with widgets. Perfect for multi-tasking and monitoring dashboards.",
|
||||
"desktop_widgets_title": "Widget Dashboard",
|
||||
"desktop_widgets_text": "Your customizable workspace. Widgets display live information and provide quick access to features. Drag to rearrange.",
|
||||
"desktop_add_widget_title": "Add Widgets",
|
||||
"desktop_add_widget_text": "Click here to browse available widgets. Choose from monitoring displays, device lists, notes, and more.",
|
||||
"desktop_taskbar_title": "Taskbar",
|
||||
"desktop_taskbar_text": "Open apps appear here. Click to focus or minimize windows. The clock shows current time and wallpaper button changes the background.",
|
||||
"desktop_windows_title": "App Windows",
|
||||
"desktop_windows_text": "Apps open in draggable, resizable windows. Minimize, maximize, or close from the title bar. Windows layer above widgets.",
|
||||
"desktop_search_title": "Quick Search",
|
||||
"desktop_search_text": "Search across devices, pages, and settings. Press Ctrl+K anywhere to open search instantly."
|
||||
},
|
||||
"help_request": {
|
||||
"title": "Help Requests",
|
||||
"subtitle": "Manage incoming help requests from desktop clients",
|
||||
"new_request": "New Help Request",
|
||||
"device": "Device",
|
||||
"hostname": "Hostname",
|
||||
"message": "Message",
|
||||
"status": "Status",
|
||||
"pending": "Pending",
|
||||
"accepted": "Accepted",
|
||||
"resolved": "Resolved",
|
||||
"accept": "Accept",
|
||||
"resolve": "Resolve",
|
||||
"no_requests": "No help requests",
|
||||
"notification": "Help request received from",
|
||||
"stats_total": "Total",
|
||||
"filter_all": "All"
|
||||
},
|
||||
"organizations": {
|
||||
"title": "Organizations",
|
||||
"create": "Create Organization",
|
||||
"edit": "Edit Organization",
|
||||
"detail": "Organization Details",
|
||||
"name": "Name",
|
||||
"slug": "Slug (URL-safe)",
|
||||
"logo_url": "Logo URL",
|
||||
"users": "Users",
|
||||
"devices": "Devices",
|
||||
"invitations": "Invitations",
|
||||
"settings": "Settings",
|
||||
"add_user": "Add User",
|
||||
"assign_device": "Assign Device",
|
||||
"create_invitation": "Create Invitation",
|
||||
"delete_confirm": "Delete this organization? This will remove all associated users, devices, and settings.",
|
||||
"no_orgs": "No organizations yet",
|
||||
"no_orgs_hint": "Create your first organization to start managing devices and users.",
|
||||
"connection_policy": "Connection Policy",
|
||||
"allow_file_transfer": "Allow File Transfer",
|
||||
"allow_clipboard": "Allow Clipboard",
|
||||
"max_session_duration": "Max Session Duration (min)",
|
||||
"role_owner": "Owner",
|
||||
"role_admin": "Admin",
|
||||
"role_operator": "Operator",
|
||||
"role_user": "User"
|
||||
}
|
||||
}
|
||||
|
||||
+369
-18
@@ -52,7 +52,11 @@
|
||||
"toggle_sidebar": "Basculer la barre latérale",
|
||||
"cdap": "CDAP",
|
||||
"clients": "Clients",
|
||||
"exit_desktop": "Quitter le bureau"
|
||||
"exit_desktop": "Quitter le bureau",
|
||||
"help_requests": "Help Requests",
|
||||
"management": "Management",
|
||||
"tokens": "Device Tokens",
|
||||
"organizations": "Organizations"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
@@ -164,11 +168,35 @@
|
||||
"details": "Détails",
|
||||
"notes": "Notes",
|
||||
"hardware": "Matériel",
|
||||
"metrics": "Métriques"
|
||||
"metrics": "Métriques",
|
||||
"info": "Info",
|
||||
"history": "History"
|
||||
},
|
||||
"live_metrics": "Métriques en direct",
|
||||
"history_charts": "Graphiques d'historique",
|
||||
"no_metrics": "Aucune métrique disponible"
|
||||
"no_metrics": "Aucune métrique disponible",
|
||||
"not_found": "Device not found",
|
||||
"delete_failed": "Failed to delete device",
|
||||
"change_id_failed": "Failed to change device ID",
|
||||
"invalid_id": "Invalid device ID (6-16 characters required)",
|
||||
"invalid_id_format": "Invalid ID format (letters, numbers, dashes, underscores only)",
|
||||
"id_exists": "Device ID already exists",
|
||||
"no_selection": "No devices selected",
|
||||
"filter_type_all": "All Types",
|
||||
"filter_type_rustdesk": "RustDesk",
|
||||
"filter_type_desktop": "Desktop",
|
||||
"filter_type_scada": "SCADA",
|
||||
"filter_type_iot": "IoT",
|
||||
"filter_type_agent": "Agent",
|
||||
"hardware": {
|
||||
"title": "Hardware Information",
|
||||
"os": "Operating System",
|
||||
"cpu": "Processor",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"gpu": "Graphics Card",
|
||||
"network": "Network Adapters"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
"title": "Clés du serveur",
|
||||
@@ -269,7 +297,20 @@
|
||||
"time": "Heure",
|
||||
"user": "Utilisateur",
|
||||
"action": "Action",
|
||||
"details": "Détails"
|
||||
"details": "Détails",
|
||||
"action_login": "Login",
|
||||
"action_login_failed": "Login Failed",
|
||||
"action_logout": "Logout",
|
||||
"action_password_change": "Password Changed",
|
||||
"action_2fa_enable": "2FA Enabled",
|
||||
"action_2fa_disable": "2FA Disabled",
|
||||
"action_user_create": "User Created",
|
||||
"action_user_update": "User Updated",
|
||||
"action_user_delete": "User Deleted",
|
||||
"action_settings_update": "Settings Updated",
|
||||
"action_device_ban": "Device Banned",
|
||||
"action_device_unban": "Device Unbanned",
|
||||
"action_device_delete": "Device Deleted"
|
||||
},
|
||||
"generator": {
|
||||
"title": "Générateur de configuration client",
|
||||
@@ -321,14 +362,22 @@
|
||||
"confirm": "Confirmer",
|
||||
"close": "Fermer",
|
||||
"clear": "Effacer",
|
||||
"ok": "OK"
|
||||
"ok": "OK",
|
||||
"remote_viewer": "Remote Viewer"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "À l'instant",
|
||||
"seconds_ago": "Il y a {count} seconde(s)",
|
||||
"minutes_ago": "Il y a {count} minute(s)",
|
||||
"hours_ago": "Il y a {count} heure(s)",
|
||||
"days_ago": "Il y a {count} jour(s)"
|
||||
"days_ago": "Il y a {count} jour(s)",
|
||||
"day_mon": "Mon",
|
||||
"day_tue": "Tue",
|
||||
"day_wed": "Wed",
|
||||
"day_thu": "Thu",
|
||||
"day_fri": "Fri",
|
||||
"day_sat": "Sat",
|
||||
"day_sun": "Sun"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestion des utilisateurs",
|
||||
@@ -750,7 +799,13 @@
|
||||
"export_pdf": "Exporter en PDF",
|
||||
"date_range": "Plage de dates",
|
||||
"today": "Aujourd'hui",
|
||||
"this_week": "Cette semaine"
|
||||
"this_week": "Cette semaine",
|
||||
"search": "Search devices...",
|
||||
"device_id": "Device ID",
|
||||
"no_activity": "No activity recorded",
|
||||
"reported_at": "Reported At",
|
||||
"detail": "Detail",
|
||||
"no_apps": "No applications"
|
||||
},
|
||||
"automation": {
|
||||
"title": "Automatisation & Alertes",
|
||||
@@ -819,7 +874,8 @@
|
||||
"no_rules": "Aucune règle d'alerte configurée",
|
||||
"no_alerts": "Aucune alerte déclenchée",
|
||||
"no_commands": "Aucune commande envoyée",
|
||||
"no_commands_sent": "Aucune commande envoyée pour le moment"
|
||||
"no_commands_sent": "Aucune commande envoyée pour le moment",
|
||||
"id_payload_required": "Device ID and payload are required"
|
||||
},
|
||||
"file_transfer": {
|
||||
"title": "Transfert de fichiers",
|
||||
@@ -912,7 +968,18 @@
|
||||
"last_7d": "7 derniers jours",
|
||||
"last_30d": "30 derniers jours",
|
||||
"charts": "Graphiques",
|
||||
"realtime": "Temps réel"
|
||||
"realtime": "Temps réel",
|
||||
"search_targets": "Search targets...",
|
||||
"name": "Name",
|
||||
"tab_targets": "Targets",
|
||||
"tab_tools": "Tools",
|
||||
"avg_latency": "Avg Latency",
|
||||
"type": "Type",
|
||||
"latency": "Latency",
|
||||
"check_history": "Check History",
|
||||
"name_host_required": "Name and host are required",
|
||||
"avg_response": "Average Response",
|
||||
"response_time_ms": "Response Time (ms)"
|
||||
},
|
||||
"dataguard": {
|
||||
"title": "DataGuard — DLP",
|
||||
@@ -962,7 +1029,31 @@
|
||||
"drive_fixed": "Fixe",
|
||||
"drive_network": "Réseau",
|
||||
"drive_cdrom": "CD-ROM",
|
||||
"drive_unknown": "Inconnu"
|
||||
"drive_unknown": "Inconnu",
|
||||
"status": "Status",
|
||||
"total_policies": "Total Policies",
|
||||
"active_policies": "Active Policies",
|
||||
"violations": "Violations",
|
||||
"blocked": "Blocked",
|
||||
"tab_policies": "Policies",
|
||||
"tab_events": "Events",
|
||||
"search_policies": "Search policies...",
|
||||
"create_policy": "Create Policy",
|
||||
"policy_type": "Policy Type",
|
||||
"scope": "Scope",
|
||||
"events_count": "Events",
|
||||
"search_events": "Search events...",
|
||||
"event_time": "Event Time",
|
||||
"device": "Device",
|
||||
"policy": "Policy",
|
||||
"detail": "Detail",
|
||||
"scope_help": "Comma-separated device IDs, or leave empty for all",
|
||||
"scope_all": "All Devices",
|
||||
"active": "Active",
|
||||
"type_file_type": "File Type",
|
||||
"type_clipboard": "Clipboard",
|
||||
"type_screen_share": "Screen Share",
|
||||
"type_file_transfer": "File Transfer"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Rapports",
|
||||
@@ -980,7 +1071,7 @@
|
||||
"save_report": "Sauvegarder le rapport",
|
||||
"delete_report": "Supprimer le rapport",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce rapport sauvegardé ?",
|
||||
"no_saved": "Aucun rapport sauvegardé",
|
||||
"no_saved": "Aucun rapport enregistré",
|
||||
"report_title": "Titre",
|
||||
"created_by": "Créé par",
|
||||
"created_at": "Créé le",
|
||||
@@ -1023,7 +1114,6 @@
|
||||
"save": "Enregistrer le rapport",
|
||||
"preview": "Aperçu",
|
||||
"download": "Télécharger",
|
||||
"no_saved": "Aucun rapport enregistré",
|
||||
"delete_saved": "Supprimer le rapport"
|
||||
},
|
||||
"tenants": {
|
||||
@@ -1068,7 +1158,8 @@
|
||||
"enabled": "Activé",
|
||||
"max": "max",
|
||||
"info": "Informations du locataire",
|
||||
"status": "Statut"
|
||||
"status": "Statut",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"registrations": {
|
||||
"title": "Inscriptions des appareils",
|
||||
@@ -1096,7 +1187,20 @@
|
||||
"reject_reason_placeholder": "Entrez la raison du rejet...",
|
||||
"rejected_success": "Inscription de l'appareil rejetée",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer cet enregistrement d'inscription ?",
|
||||
"deleted_success": "Enregistrement d'inscription supprimé"
|
||||
"deleted_success": "Enregistrement d'inscription supprimé",
|
||||
"enrollment_title": "Device Enrollment",
|
||||
"enrollment_subtitle": "Pending enrollment requests from BetterDesk desktop clients",
|
||||
"enrollment_empty": "No pending enrollment requests",
|
||||
"enrollment_approve": "Approve",
|
||||
"enrollment_reject": "Reject",
|
||||
"enrollment_display_name": "Display Name",
|
||||
"enrollment_display_name_placeholder": "Enter a name for this device...",
|
||||
"enrollment_sync_mode": "Sync Mode",
|
||||
"enrollment_sync_silent": "Silent — minimal telemetry",
|
||||
"enrollment_sync_standard": "Standard — balanced sync",
|
||||
"enrollment_sync_turbo": "Turbo — aggressive sync",
|
||||
"enrollment_approved_success": "Device enrollment approved",
|
||||
"enrollment_rejected_success": "Device enrollment rejected"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Sauvegarde",
|
||||
@@ -1201,7 +1305,61 @@
|
||||
"actions": "Actions",
|
||||
"disconnect": "Déconnecter",
|
||||
"disconnect_confirm": "Êtes-vous sûr de vouloir déconnecter cet appareil ?",
|
||||
"disconnected_success": "Appareil déconnecté"
|
||||
"disconnected_success": "Appareil déconnecté",
|
||||
"loading_widgets": "Loading device widgets...",
|
||||
"load_error": "Failed to load device data",
|
||||
"device_offline_msg": "Device is currently offline. Widget values may be stale.",
|
||||
"no_widgets_desc": "This device has not registered a CDAP manifest with widget definitions.",
|
||||
"clear_log": "Clear log",
|
||||
"select_option": "Select",
|
||||
"cdap_status": "CDAP Status",
|
||||
"cdap_enabled": "CDAP Enabled",
|
||||
"cdap_disabled": "CDAP Disabled",
|
||||
"cdap_devices": "CDAP Devices",
|
||||
"cdap_connections": "Active Connections",
|
||||
"send_command": "Send Command",
|
||||
"devices_title": "CDAP Devices",
|
||||
"devices_subtitle": "Connected devices via Custom Device Application Protocol",
|
||||
"stat_connected": "Connected",
|
||||
"stat_port": "Port",
|
||||
"search_devices": "Search devices...",
|
||||
"no_devices": "No devices connected",
|
||||
"no_devices_desc": "No CDAP devices are currently connected. Devices will appear here once they connect to the gateway.",
|
||||
"gateway_disabled_desc": "Enable the CDAP gateway by setting CDAP_ENABLED=true in the server configuration.",
|
||||
"gateway_active": "Active",
|
||||
"toggle_enable": "Enable CDAP",
|
||||
"toggle_disable": "Disable CDAP",
|
||||
"enabled_restart": "CDAP enabled. Server restart required to apply changes.",
|
||||
"disabled_restart": "CDAP disabled. Server restart required to apply changes.",
|
||||
"widgets": "widgets",
|
||||
"type_all": "All",
|
||||
"type_iot": "IoT",
|
||||
"type_scada": "SCADA",
|
||||
"type_network": "Network",
|
||||
"type_camera": "Camera",
|
||||
"active_alerts": "Active Alerts",
|
||||
"alert_fired": "Alert fired",
|
||||
"alert_cleared": "Alert cleared",
|
||||
"no_alerts": "No active alerts",
|
||||
"connect_terminal": "Connect Terminal",
|
||||
"table_search": "Search...",
|
||||
"just_now": "Just now",
|
||||
"terminal_connecting": "Connecting to device...",
|
||||
"terminal_disconnected": "Disconnected",
|
||||
"terminal_error": "Terminal connection failed",
|
||||
"linked_devices": "Linked Devices",
|
||||
"link_device": "Link Device",
|
||||
"unlink_device": "Unlink",
|
||||
"no_linked_devices": "No linked devices",
|
||||
"unlink_confirm": "Are you sure you want to unlink this device?",
|
||||
"link_prompt": "Enter the Peer ID to link to this device:",
|
||||
"connect_desktop": "Connect Desktop",
|
||||
"connect_video": "Connect Stream",
|
||||
"connect_files": "Browse Files",
|
||||
"upload_file": "Upload",
|
||||
"desktop_connecting": "Connecting...",
|
||||
"video_connecting": "Connecting...",
|
||||
"files_connecting": "Connecting..."
|
||||
},
|
||||
"desktop": {
|
||||
"title": "Mode bureau",
|
||||
@@ -1228,7 +1386,92 @@
|
||||
"shortcut_remove": "Supprimer le raccourci",
|
||||
"auto_hide": "Masquer automatiquement",
|
||||
"show_clock": "Afficher l'horloge",
|
||||
"show_notifications": "Afficher les notifications"
|
||||
"show_notifications": "Afficher les notifications",
|
||||
"wp_images": "Images",
|
||||
"wp_colors": "Couleurs unies",
|
||||
"wp_custom_color": "Couleur personnalisée",
|
||||
"wp_fit_style": "Style",
|
||||
"wp_fill": "Remplir",
|
||||
"wp_fit": "Ajuster",
|
||||
"wp_stretch": "Étirer",
|
||||
"wp_center": "Centrer",
|
||||
"switch_mode": "Desktop Mode",
|
||||
"console_mode": "Console Mode",
|
||||
"loading": "Loading...",
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize",
|
||||
"restore": "Restore",
|
||||
"close": "Close",
|
||||
"widgets_mode": "Widgets",
|
||||
"windows_mode": "Windows",
|
||||
"add_widget": "Add Widget",
|
||||
"remove_widget": "Remove Widget",
|
||||
"configure": "Configure",
|
||||
"search_widgets": "Search widgets...",
|
||||
"cat_monitoring": "Monitoring",
|
||||
"cat_devices": "Devices",
|
||||
"cat_tools": "Tools",
|
||||
"cat_general": "General",
|
||||
"notes_placeholder": "Write notes here...",
|
||||
"search_placeholder": "Search devices, pages, settings...",
|
||||
"search_pages": "Pages",
|
||||
"search_devices": "Devices",
|
||||
"search_no_results": "No results",
|
||||
"exit_desktop": "Exit Desktop",
|
||||
"refresh": "Refresh",
|
||||
"widget_clock": "Clock",
|
||||
"widget_device_status": "Device Status",
|
||||
"widget_server_health": "Server Info",
|
||||
"widget_system_stats": "Device Gauges",
|
||||
"widget_device_list": "Device List",
|
||||
"widget_quick_actions": "Quick Actions",
|
||||
"widget_recent_activity": "Recent Activity",
|
||||
"widget_notes": "Notes",
|
||||
"widget_network_monitor": "Network Monitor",
|
||||
"widget_tickets_summary": "Tickets",
|
||||
"widget_iframe": "Web Embed",
|
||||
"widget_cdap_devices": "CDAP Devices",
|
||||
"widget_uptime": "Uptime",
|
||||
"widget_port_status": "Port Status",
|
||||
"widget_device_grid": "Device Grid",
|
||||
"widget_multi_gauge": "Server Gauges",
|
||||
"widget_weekly_chart": "Weekly Activity",
|
||||
"widget_quick_controls": "Quick Controls",
|
||||
"widget_bandwidth": "Bandwidth",
|
||||
"widget_connection_stats": "Connection Stats",
|
||||
"label_online": "Online",
|
||||
"label_offline": "Offline",
|
||||
"label_total": "Total",
|
||||
"label_blocked": "Blocked",
|
||||
"label_banned": "Banned",
|
||||
"label_active": "Active",
|
||||
"label_service": "Service",
|
||||
"label_port": "Port",
|
||||
"label_uptime_prefix": "Uptime:",
|
||||
"label_no_devices": "No devices",
|
||||
"label_no_activity": "No activity",
|
||||
"label_no_targets": "No targets",
|
||||
"label_unavailable": "Unavailable",
|
||||
"label_open": "Open",
|
||||
"label_in_progress": "In Progress",
|
||||
"label_resolved": "Resolved",
|
||||
"label_set_url": "Set URL in config",
|
||||
"label_server_uptime": "Server Uptime",
|
||||
"label_merged_info": "Merged into Server Info widget",
|
||||
"label_with_notes": "With Notes",
|
||||
"label_metric": "Metric",
|
||||
"label_count": "Count",
|
||||
"label_total_devices": "Total Devices",
|
||||
"label_throughput": "Throughput",
|
||||
"label_active_relays": "Active Relays",
|
||||
"label_total_relayed": "Total Relayed",
|
||||
"label_total_bytes": "Total Bytes",
|
||||
"label_filter_devices": "Filter devices...",
|
||||
"label_all": "All",
|
||||
"label_ban": "Ban",
|
||||
"label_unban": "Unban",
|
||||
"label_connect": "Connect",
|
||||
"label_no_cdap_devices": "No CDAP devices"
|
||||
},
|
||||
"tokens": {
|
||||
"title": "Jetons API",
|
||||
@@ -1273,7 +1516,49 @@
|
||||
"status": "Statut",
|
||||
"status_active": "Actif",
|
||||
"status_expired": "Expiré",
|
||||
"status_revoked": "Révoqué"
|
||||
"status_revoked": "Révoqué",
|
||||
"enrollment_mode": "Enrollment Mode",
|
||||
"mode_open": "Open",
|
||||
"mode_open_desc": "Any device can register without a token",
|
||||
"mode_managed": "Managed",
|
||||
"mode_managed_desc": "Devices need a valid token to register",
|
||||
"mode_locked": "Locked",
|
||||
"mode_locked_desc": "No new device registrations allowed",
|
||||
"total": "Total Tokens",
|
||||
"active": "Active",
|
||||
"used": "Used",
|
||||
"search_placeholder": "Search tokens...",
|
||||
"status_pending": "Pending",
|
||||
"status_used": "Used",
|
||||
"bulk_generate": "Bulk Generate",
|
||||
"edit": "Edit Token",
|
||||
"token": "Token",
|
||||
"uses": "Uses",
|
||||
"bound_peer": "Bound Peer",
|
||||
"actions": "Actions",
|
||||
"max_uses": "Max Uses",
|
||||
"max_uses_hint": "0 = unlimited",
|
||||
"expires_in": "Expires In",
|
||||
"no_expiration": "No expiration",
|
||||
"expires_1h": "1 hour",
|
||||
"expires_24h": "24 hours",
|
||||
"expires_7d": "7 days",
|
||||
"note": "Note",
|
||||
"note_placeholder": "Optional note about this token...",
|
||||
"count": "Count",
|
||||
"count_range": "Number of tokens to generate (1-100)",
|
||||
"name_prefix": "Name Prefix",
|
||||
"generate": "Generate",
|
||||
"tokens_generated": "Tokens Generated",
|
||||
"copy_warning": "Copy the token now. It will not be shown again.",
|
||||
"copy_token": "Copy Token",
|
||||
"copied": "Copied!",
|
||||
"revoked_success": "Token revoked successfully",
|
||||
"expired": "Expired",
|
||||
"never": "Never",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"unlimited": "Unlimited"
|
||||
},
|
||||
"tutorial": {
|
||||
"title": "Bienvenue sur BetterDesk",
|
||||
@@ -1295,6 +1580,72 @@
|
||||
"step_complete_title": "Terminé !",
|
||||
"step_complete_text": "Vous êtes maintenant prêt à utiliser BetterDesk. Besoin d'aide ? Consultez la documentation ou contactez le support.",
|
||||
"dont_show_again": "Ne plus afficher",
|
||||
"restart_tutorial": "Relancer le tutoriel"
|
||||
"restart_tutorial": "Relancer le tutoriel",
|
||||
"step_of": "{current} of {total}",
|
||||
"console_sidebar_title": "Navigation Sidebar",
|
||||
"console_sidebar_text": "Access all modules from here. Click any icon to navigate to that section. The sidebar adapts to your screen size.",
|
||||
"console_dashboard_title": "Dashboard",
|
||||
"console_dashboard_text": "Your central hub showing server status, online devices, and quick actions. Keep an eye on important metrics at a glance.",
|
||||
"console_devices_title": "Device Management",
|
||||
"console_devices_text": "View, search, and manage all connected devices. Filter by status, assign tags, and configure individual device settings.",
|
||||
"console_settings_title": "Settings",
|
||||
"console_settings_text": "Configure server behavior, security options, branding, and user management. Customize BetterDesk to fit your needs.",
|
||||
"console_desktop_title": "Desktop Mode",
|
||||
"console_desktop_text": "Switch to desktop mode for a windowed workspace with widgets. Perfect for multi-tasking and monitoring dashboards.",
|
||||
"desktop_widgets_title": "Widget Dashboard",
|
||||
"desktop_widgets_text": "Your customizable workspace. Widgets display live information and provide quick access to features. Drag to rearrange.",
|
||||
"desktop_add_widget_title": "Add Widgets",
|
||||
"desktop_add_widget_text": "Click here to browse available widgets. Choose from monitoring displays, device lists, notes, and more.",
|
||||
"desktop_taskbar_title": "Taskbar",
|
||||
"desktop_taskbar_text": "Open apps appear here. Click to focus or minimize windows. The clock shows current time and wallpaper button changes the background.",
|
||||
"desktop_windows_title": "App Windows",
|
||||
"desktop_windows_text": "Apps open in draggable, resizable windows. Minimize, maximize, or close from the title bar. Windows layer above widgets.",
|
||||
"desktop_search_title": "Quick Search",
|
||||
"desktop_search_text": "Search across devices, pages, and settings. Press Ctrl+K anywhere to open search instantly."
|
||||
},
|
||||
"help_request": {
|
||||
"title": "Help Requests",
|
||||
"subtitle": "Manage incoming help requests from desktop clients",
|
||||
"new_request": "New Help Request",
|
||||
"device": "Device",
|
||||
"hostname": "Hostname",
|
||||
"message": "Message",
|
||||
"status": "Status",
|
||||
"pending": "Pending",
|
||||
"accepted": "Accepted",
|
||||
"resolved": "Resolved",
|
||||
"accept": "Accept",
|
||||
"resolve": "Resolve",
|
||||
"no_requests": "No help requests",
|
||||
"notification": "Help request received from",
|
||||
"stats_total": "Total",
|
||||
"filter_all": "All"
|
||||
},
|
||||
"organizations": {
|
||||
"title": "Organizations",
|
||||
"create": "Create Organization",
|
||||
"edit": "Edit Organization",
|
||||
"detail": "Organization Details",
|
||||
"name": "Name",
|
||||
"slug": "Slug (URL-safe)",
|
||||
"logo_url": "Logo URL",
|
||||
"users": "Users",
|
||||
"devices": "Devices",
|
||||
"invitations": "Invitations",
|
||||
"settings": "Settings",
|
||||
"add_user": "Add User",
|
||||
"assign_device": "Assign Device",
|
||||
"create_invitation": "Create Invitation",
|
||||
"delete_confirm": "Delete this organization? This will remove all associated users, devices, and settings.",
|
||||
"no_orgs": "No organizations yet",
|
||||
"no_orgs_hint": "Create your first organization to start managing devices and users.",
|
||||
"connection_policy": "Connection Policy",
|
||||
"allow_file_transfer": "Allow File Transfer",
|
||||
"allow_clipboard": "Allow Clipboard",
|
||||
"max_session_duration": "Max Session Duration (min)",
|
||||
"role_owner": "Owner",
|
||||
"role_admin": "Admin",
|
||||
"role_operator": "Operator",
|
||||
"role_user": "User"
|
||||
}
|
||||
}
|
||||
|
||||
+374
-18
@@ -52,7 +52,11 @@
|
||||
"toggle_sidebar": "Attiva/disattiva barra laterale",
|
||||
"cdap": "CDAP",
|
||||
"clients": "Client",
|
||||
"exit_desktop": "Esci dal desktop"
|
||||
"exit_desktop": "Esci dal desktop",
|
||||
"help_requests": "Help Requests",
|
||||
"management": "Management",
|
||||
"tokens": "Device Tokens",
|
||||
"organizations": "Organizations"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
@@ -164,11 +168,35 @@
|
||||
"details": "Dettagli",
|
||||
"notes": "Note",
|
||||
"hardware": "Hardware",
|
||||
"metrics": "Metriche"
|
||||
"metrics": "Metriche",
|
||||
"info": "Info",
|
||||
"history": "History"
|
||||
},
|
||||
"live_metrics": "Metriche in tempo reale",
|
||||
"history_charts": "Grafici storici",
|
||||
"no_metrics": "Nessuna metrica disponibile"
|
||||
"no_metrics": "Nessuna metrica disponibile",
|
||||
"not_found": "Device not found",
|
||||
"delete_failed": "Failed to delete device",
|
||||
"change_id_failed": "Failed to change device ID",
|
||||
"invalid_id": "Invalid device ID (6-16 characters required)",
|
||||
"invalid_id_format": "Invalid ID format (letters, numbers, dashes, underscores only)",
|
||||
"id_exists": "Device ID already exists",
|
||||
"no_selection": "No devices selected",
|
||||
"filter_type_all": "All Types",
|
||||
"filter_type_rustdesk": "RustDesk",
|
||||
"filter_type_desktop": "Desktop",
|
||||
"filter_type_scada": "SCADA",
|
||||
"filter_type_iot": "IoT",
|
||||
"filter_type_agent": "Agent",
|
||||
"hardware": {
|
||||
"title": "Hardware Information",
|
||||
"os": "Operating System",
|
||||
"cpu": "Processor",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"gpu": "Graphics Card",
|
||||
"network": "Network Adapters"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
"title": "Chiavi del server",
|
||||
@@ -269,7 +297,20 @@
|
||||
"time": "Ora",
|
||||
"user": "Utente",
|
||||
"action": "Azione",
|
||||
"details": "Dettagli"
|
||||
"details": "Dettagli",
|
||||
"action_login": "Login",
|
||||
"action_login_failed": "Login Failed",
|
||||
"action_logout": "Logout",
|
||||
"action_password_change": "Password Changed",
|
||||
"action_2fa_enable": "2FA Enabled",
|
||||
"action_2fa_disable": "2FA Disabled",
|
||||
"action_user_create": "User Created",
|
||||
"action_user_update": "User Updated",
|
||||
"action_user_delete": "User Deleted",
|
||||
"action_settings_update": "Settings Updated",
|
||||
"action_device_ban": "Device Banned",
|
||||
"action_device_unban": "Device Unbanned",
|
||||
"action_device_delete": "Device Deleted"
|
||||
},
|
||||
"generator": {
|
||||
"title": "Generatore configurazione client",
|
||||
@@ -321,14 +362,22 @@
|
||||
"confirm": "Conferma",
|
||||
"close": "Chiudi",
|
||||
"clear": "Cancella",
|
||||
"ok": "OK"
|
||||
"ok": "OK",
|
||||
"remote_viewer": "Remote Viewer"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Proprio ora",
|
||||
"seconds_ago": "{count} secondo/i fa",
|
||||
"minutes_ago": "{count} minuto/i fa",
|
||||
"hours_ago": "{count} ora/e fa",
|
||||
"days_ago": "{count} giorno/i fa"
|
||||
"days_ago": "{count} giorno/i fa",
|
||||
"day_mon": "Mon",
|
||||
"day_tue": "Tue",
|
||||
"day_wed": "Wed",
|
||||
"day_thu": "Thu",
|
||||
"day_fri": "Fri",
|
||||
"day_sat": "Sat",
|
||||
"day_sun": "Sun"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestione utenti",
|
||||
@@ -750,7 +799,13 @@
|
||||
"export_pdf": "Esporta come PDF",
|
||||
"date_range": "Intervallo date",
|
||||
"today": "Oggi",
|
||||
"this_week": "Questa settimana"
|
||||
"this_week": "Questa settimana",
|
||||
"search": "Search devices...",
|
||||
"device_id": "Device ID",
|
||||
"no_activity": "No activity recorded",
|
||||
"reported_at": "Reported At",
|
||||
"detail": "Detail",
|
||||
"no_apps": "No applications"
|
||||
},
|
||||
"automation": {
|
||||
"title": "Automazione e avvisi",
|
||||
@@ -819,7 +874,8 @@
|
||||
"no_rules": "Nessuna regola di allarme configurata",
|
||||
"no_alerts": "Nessun avviso attivato",
|
||||
"no_commands": "Nessun comando inviato",
|
||||
"no_commands_sent": "Nessun comando ancora inviato"
|
||||
"no_commands_sent": "Nessun comando ancora inviato",
|
||||
"id_payload_required": "Device ID and payload are required"
|
||||
},
|
||||
"file_transfer": {
|
||||
"title": "Trasferimento file",
|
||||
@@ -912,7 +968,18 @@
|
||||
"last_7d": "Ultimi 7 giorni",
|
||||
"last_30d": "Ultimi 30 giorni",
|
||||
"charts": "Grafici",
|
||||
"realtime": "Tempo reale"
|
||||
"realtime": "Tempo reale",
|
||||
"search_targets": "Search targets...",
|
||||
"name": "Name",
|
||||
"tab_targets": "Targets",
|
||||
"tab_tools": "Tools",
|
||||
"avg_latency": "Avg Latency",
|
||||
"type": "Type",
|
||||
"latency": "Latency",
|
||||
"check_history": "Check History",
|
||||
"name_host_required": "Name and host are required",
|
||||
"avg_response": "Average Response",
|
||||
"response_time_ms": "Response Time (ms)"
|
||||
},
|
||||
"dataguard": {
|
||||
"title": "DataGuard — DLP",
|
||||
@@ -962,7 +1029,31 @@
|
||||
"drive_fixed": "Fisso",
|
||||
"drive_network": "Rete",
|
||||
"drive_cdrom": "CD-ROM",
|
||||
"drive_unknown": "Sconosciuto"
|
||||
"drive_unknown": "Sconosciuto",
|
||||
"status": "Status",
|
||||
"total_policies": "Total Policies",
|
||||
"active_policies": "Active Policies",
|
||||
"violations": "Violations",
|
||||
"blocked": "Blocked",
|
||||
"tab_policies": "Policies",
|
||||
"tab_events": "Events",
|
||||
"search_policies": "Search policies...",
|
||||
"create_policy": "Create Policy",
|
||||
"policy_type": "Policy Type",
|
||||
"scope": "Scope",
|
||||
"events_count": "Events",
|
||||
"search_events": "Search events...",
|
||||
"event_time": "Event Time",
|
||||
"device": "Device",
|
||||
"policy": "Policy",
|
||||
"detail": "Detail",
|
||||
"scope_help": "Comma-separated device IDs, or leave empty for all",
|
||||
"scope_all": "All Devices",
|
||||
"active": "Active",
|
||||
"type_file_type": "File Type",
|
||||
"type_clipboard": "Clipboard",
|
||||
"type_screen_share": "Screen Share",
|
||||
"type_file_transfer": "File Transfer"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Report",
|
||||
@@ -1023,7 +1114,6 @@
|
||||
"save": "Salva report",
|
||||
"preview": "Anteprima",
|
||||
"download": "Scarica",
|
||||
"no_saved": "Nessun report salvato",
|
||||
"delete_saved": "Elimina report"
|
||||
},
|
||||
"tenants": {
|
||||
@@ -1068,7 +1158,8 @@
|
||||
"enabled": "Abilitato",
|
||||
"max": "Max",
|
||||
"info": "Informazioni tenant",
|
||||
"status": "Stato"
|
||||
"status": "Stato",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"registrations": {
|
||||
"title": "Registrazioni dispositivi",
|
||||
@@ -1096,7 +1187,20 @@
|
||||
"reject_reason_placeholder": "Inserisci il motivo del rifiuto...",
|
||||
"rejected_success": "Registrazione dispositivo rifiutata",
|
||||
"delete_confirm": "Sei sicuro di voler eliminare questo record di registrazione?",
|
||||
"deleted_success": "Record di registrazione eliminato"
|
||||
"deleted_success": "Record di registrazione eliminato",
|
||||
"enrollment_title": "Device Enrollment",
|
||||
"enrollment_subtitle": "Pending enrollment requests from BetterDesk desktop clients",
|
||||
"enrollment_empty": "No pending enrollment requests",
|
||||
"enrollment_approve": "Approve",
|
||||
"enrollment_reject": "Reject",
|
||||
"enrollment_display_name": "Display Name",
|
||||
"enrollment_display_name_placeholder": "Enter a name for this device...",
|
||||
"enrollment_sync_mode": "Sync Mode",
|
||||
"enrollment_sync_silent": "Silent — minimal telemetry",
|
||||
"enrollment_sync_standard": "Standard — balanced sync",
|
||||
"enrollment_sync_turbo": "Turbo — aggressive sync",
|
||||
"enrollment_approved_success": "Device enrollment approved",
|
||||
"enrollment_rejected_success": "Device enrollment rejected"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
@@ -1208,7 +1312,63 @@
|
||||
"memory_usage": "Utilizzo memoria",
|
||||
"disk_usage": "Utilizzo disco",
|
||||
"hostname": "Nome host",
|
||||
"system_uptime": "Uptime sistema"
|
||||
"system_uptime": "Uptime sistema",
|
||||
"loading_widgets": "Loading device widgets...",
|
||||
"load_error": "Failed to load device data",
|
||||
"device_offline_msg": "Device is currently offline. Widget values may be stale.",
|
||||
"no_widgets_desc": "This device has not registered a CDAP manifest with widget definitions.",
|
||||
"clear_log": "Clear log",
|
||||
"confirm_command": "Confirm Command",
|
||||
"select_option": "Select",
|
||||
"cdap_status": "CDAP Status",
|
||||
"cdap_enabled": "CDAP Enabled",
|
||||
"cdap_disabled": "CDAP Disabled",
|
||||
"cdap_devices": "CDAP Devices",
|
||||
"cdap_connections": "Active Connections",
|
||||
"send_command": "Send Command",
|
||||
"devices_title": "CDAP Devices",
|
||||
"devices_subtitle": "Connected devices via Custom Device Application Protocol",
|
||||
"stat_connected": "Connected",
|
||||
"stat_port": "Port",
|
||||
"search_devices": "Search devices...",
|
||||
"no_devices": "No devices connected",
|
||||
"no_devices_desc": "No CDAP devices are currently connected. Devices will appear here once they connect to the gateway.",
|
||||
"gateway_disabled": "CDAP Gateway Disabled",
|
||||
"gateway_disabled_desc": "Enable the CDAP gateway by setting CDAP_ENABLED=true in the server configuration.",
|
||||
"gateway_active": "Active",
|
||||
"toggle_enable": "Enable CDAP",
|
||||
"toggle_disable": "Disable CDAP",
|
||||
"enabled_restart": "CDAP enabled. Server restart required to apply changes.",
|
||||
"disabled_restart": "CDAP disabled. Server restart required to apply changes.",
|
||||
"type_all": "All",
|
||||
"type_iot": "IoT",
|
||||
"type_scada": "SCADA",
|
||||
"type_os_agent": "OS Agent",
|
||||
"type_network": "Network",
|
||||
"type_camera": "Camera",
|
||||
"type_custom": "Custom",
|
||||
"active_alerts": "Active Alerts",
|
||||
"alert_fired": "Alert fired",
|
||||
"alert_cleared": "Alert cleared",
|
||||
"no_alerts": "No active alerts",
|
||||
"connect_terminal": "Connect Terminal",
|
||||
"table_search": "Search...",
|
||||
"just_now": "Just now",
|
||||
"terminal_connecting": "Connecting to device...",
|
||||
"terminal_disconnected": "Disconnected",
|
||||
"terminal_error": "Terminal connection failed",
|
||||
"linked_devices": "Linked Devices",
|
||||
"link_device": "Link Device",
|
||||
"unlink_device": "Unlink",
|
||||
"no_linked_devices": "No linked devices",
|
||||
"unlink_confirm": "Are you sure you want to unlink this device?",
|
||||
"link_prompt": "Enter the Peer ID to link to this device:",
|
||||
"connect_desktop": "Connect Desktop",
|
||||
"connect_video": "Connect Stream",
|
||||
"connect_files": "Browse Files",
|
||||
"upload_file": "Upload",
|
||||
"video_connecting": "Connecting...",
|
||||
"files_connecting": "Connecting..."
|
||||
},
|
||||
"desktop": {
|
||||
"unified_mode": "Modalità unificata",
|
||||
@@ -1237,7 +1397,93 @@
|
||||
"connected": "Connesso",
|
||||
"disconnected": "Disconnesso",
|
||||
"latency": "Latenza",
|
||||
"bandwidth": "Larghezza di banda"
|
||||
"bandwidth": "Larghezza di banda",
|
||||
"wp_images": "Immagini",
|
||||
"wp_colors": "Colori solidi",
|
||||
"wp_custom_color": "Colore personalizzato",
|
||||
"wp_fit_style": "Stile",
|
||||
"wp_fill": "Riempi",
|
||||
"wp_fit": "Adatta",
|
||||
"wp_stretch": "Estendi",
|
||||
"wp_center": "Centra",
|
||||
"switch_mode": "Desktop Mode",
|
||||
"console_mode": "Console Mode",
|
||||
"loading": "Loading...",
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize",
|
||||
"restore": "Restore",
|
||||
"close": "Close",
|
||||
"widgets_mode": "Widgets",
|
||||
"windows_mode": "Windows",
|
||||
"wallpaper": "Wallpaper",
|
||||
"add_widget": "Add Widget",
|
||||
"remove_widget": "Remove Widget",
|
||||
"configure": "Configure",
|
||||
"search_widgets": "Search widgets...",
|
||||
"cat_monitoring": "Monitoring",
|
||||
"cat_devices": "Devices",
|
||||
"cat_tools": "Tools",
|
||||
"cat_general": "General",
|
||||
"notes_placeholder": "Write notes here...",
|
||||
"search_placeholder": "Search devices, pages, settings...",
|
||||
"search_pages": "Pages",
|
||||
"search_devices": "Devices",
|
||||
"search_no_results": "No results",
|
||||
"exit_desktop": "Exit Desktop",
|
||||
"refresh": "Refresh",
|
||||
"widget_clock": "Clock",
|
||||
"widget_device_status": "Device Status",
|
||||
"widget_server_health": "Server Info",
|
||||
"widget_system_stats": "Device Gauges",
|
||||
"widget_device_list": "Device List",
|
||||
"widget_quick_actions": "Quick Actions",
|
||||
"widget_recent_activity": "Recent Activity",
|
||||
"widget_notes": "Notes",
|
||||
"widget_network_monitor": "Network Monitor",
|
||||
"widget_tickets_summary": "Tickets",
|
||||
"widget_iframe": "Web Embed",
|
||||
"widget_cdap_devices": "CDAP Devices",
|
||||
"widget_uptime": "Uptime",
|
||||
"widget_port_status": "Port Status",
|
||||
"widget_device_grid": "Device Grid",
|
||||
"widget_multi_gauge": "Server Gauges",
|
||||
"widget_weekly_chart": "Weekly Activity",
|
||||
"widget_quick_controls": "Quick Controls",
|
||||
"widget_bandwidth": "Bandwidth",
|
||||
"widget_connection_stats": "Connection Stats",
|
||||
"label_online": "Online",
|
||||
"label_offline": "Offline",
|
||||
"label_total": "Total",
|
||||
"label_blocked": "Blocked",
|
||||
"label_banned": "Banned",
|
||||
"label_active": "Active",
|
||||
"label_service": "Service",
|
||||
"label_port": "Port",
|
||||
"label_uptime_prefix": "Uptime:",
|
||||
"label_no_devices": "No devices",
|
||||
"label_no_activity": "No activity",
|
||||
"label_no_targets": "No targets",
|
||||
"label_unavailable": "Unavailable",
|
||||
"label_open": "Open",
|
||||
"label_in_progress": "In Progress",
|
||||
"label_resolved": "Resolved",
|
||||
"label_set_url": "Set URL in config",
|
||||
"label_server_uptime": "Server Uptime",
|
||||
"label_merged_info": "Merged into Server Info widget",
|
||||
"label_with_notes": "With Notes",
|
||||
"label_metric": "Metric",
|
||||
"label_count": "Count",
|
||||
"label_total_devices": "Total Devices",
|
||||
"label_throughput": "Throughput",
|
||||
"label_active_relays": "Active Relays",
|
||||
"label_total_relayed": "Total Relayed",
|
||||
"label_total_bytes": "Total Bytes",
|
||||
"label_filter_devices": "Filter devices...",
|
||||
"label_all": "All",
|
||||
"label_ban": "Ban",
|
||||
"label_unban": "Unban",
|
||||
"label_connect": "Connect",
|
||||
"label_no_cdap_devices": "No CDAP devices"
|
||||
},
|
||||
"tokens": {
|
||||
"title": "Token di accesso",
|
||||
@@ -1282,7 +1528,51 @@
|
||||
"revoke_title": "Revoca token",
|
||||
"revoke_confirm": "Sei sicuro di voler revocare questo token? Questa azione non può essere annullata.",
|
||||
"revoked": "Token revocato con successo",
|
||||
"delete_confirm": "Sei sicuro di voler eliminare questo token?"
|
||||
"delete_confirm": "Sei sicuro di voler eliminare questo token?",
|
||||
"enrollment_mode": "Enrollment Mode",
|
||||
"mode_open": "Open",
|
||||
"mode_open_desc": "Any device can register without a token",
|
||||
"mode_managed": "Managed",
|
||||
"mode_managed_desc": "Devices need a valid token to register",
|
||||
"mode_locked": "Locked",
|
||||
"mode_locked_desc": "No new device registrations allowed",
|
||||
"total": "Total Tokens",
|
||||
"active": "Active",
|
||||
"used": "Used",
|
||||
"search_placeholder": "Search tokens...",
|
||||
"status_pending": "Pending",
|
||||
"status_active": "Active",
|
||||
"status_used": "Used",
|
||||
"status_revoked": "Revoked",
|
||||
"status_expired": "Expired",
|
||||
"bulk_generate": "Bulk Generate",
|
||||
"edit": "Edit Token",
|
||||
"token": "Token",
|
||||
"status": "Status",
|
||||
"uses": "Uses",
|
||||
"bound_peer": "Bound Peer",
|
||||
"expires": "Expires",
|
||||
"actions": "Actions",
|
||||
"max_uses": "Max Uses",
|
||||
"max_uses_hint": "0 = unlimited",
|
||||
"expires_in": "Expires In",
|
||||
"expires_1h": "1 hour",
|
||||
"expires_24h": "24 hours",
|
||||
"expires_7d": "7 days",
|
||||
"expires_30d": "30 days",
|
||||
"note": "Note",
|
||||
"note_placeholder": "Optional note about this token...",
|
||||
"count": "Count",
|
||||
"count_range": "Number of tokens to generate (1-100)",
|
||||
"name_prefix": "Name Prefix",
|
||||
"generate": "Generate",
|
||||
"token_created": "Token Created",
|
||||
"tokens_generated": "Tokens Generated",
|
||||
"revoked_success": "Token revoked successfully",
|
||||
"never": "Never",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"unlimited": "Unlimited"
|
||||
},
|
||||
"tutorial": {
|
||||
"welcome": "Benvenuto in BetterDesk",
|
||||
@@ -1307,7 +1597,73 @@
|
||||
"help_center": "Centro assistenza",
|
||||
"documentation": "Documentazione",
|
||||
"keyboard_shortcuts": "Scorciatoie tastiera",
|
||||
"report_issue": "Segnala problema"
|
||||
"report_issue": "Segnala problema",
|
||||
"prev": "Previous",
|
||||
"step_of": "{current} of {total}",
|
||||
"console_sidebar_title": "Navigation Sidebar",
|
||||
"console_sidebar_text": "Access all modules from here. Click any icon to navigate to that section. The sidebar adapts to your screen size.",
|
||||
"console_dashboard_title": "Dashboard",
|
||||
"console_dashboard_text": "Your central hub showing server status, online devices, and quick actions. Keep an eye on important metrics at a glance.",
|
||||
"console_devices_title": "Device Management",
|
||||
"console_devices_text": "View, search, and manage all connected devices. Filter by status, assign tags, and configure individual device settings.",
|
||||
"console_settings_title": "Settings",
|
||||
"console_settings_text": "Configure server behavior, security options, branding, and user management. Customize BetterDesk to fit your needs.",
|
||||
"console_desktop_title": "Desktop Mode",
|
||||
"console_desktop_text": "Switch to desktop mode for a windowed workspace with widgets. Perfect for multi-tasking and monitoring dashboards.",
|
||||
"desktop_widgets_title": "Widget Dashboard",
|
||||
"desktop_widgets_text": "Your customizable workspace. Widgets display live information and provide quick access to features. Drag to rearrange.",
|
||||
"desktop_add_widget_title": "Add Widgets",
|
||||
"desktop_add_widget_text": "Click here to browse available widgets. Choose from monitoring displays, device lists, notes, and more.",
|
||||
"desktop_taskbar_title": "Taskbar",
|
||||
"desktop_taskbar_text": "Open apps appear here. Click to focus or minimize windows. The clock shows current time and wallpaper button changes the background.",
|
||||
"desktop_windows_title": "App Windows",
|
||||
"desktop_windows_text": "Apps open in draggable, resizable windows. Minimize, maximize, or close from the title bar. Windows layer above widgets.",
|
||||
"desktop_search_title": "Quick Search",
|
||||
"desktop_search_text": "Search across devices, pages, and settings. Press Ctrl+K anywhere to open search instantly."
|
||||
},
|
||||
"help_request": {
|
||||
"title": "Help Requests",
|
||||
"subtitle": "Manage incoming help requests from desktop clients",
|
||||
"new_request": "New Help Request",
|
||||
"device": "Device",
|
||||
"hostname": "Hostname",
|
||||
"message": "Message",
|
||||
"status": "Status",
|
||||
"pending": "Pending",
|
||||
"accepted": "Accepted",
|
||||
"resolved": "Resolved",
|
||||
"accept": "Accept",
|
||||
"resolve": "Resolve",
|
||||
"no_requests": "No help requests",
|
||||
"notification": "Help request received from",
|
||||
"stats_total": "Total",
|
||||
"filter_all": "All"
|
||||
},
|
||||
"organizations": {
|
||||
"title": "Organizations",
|
||||
"create": "Create Organization",
|
||||
"edit": "Edit Organization",
|
||||
"detail": "Organization Details",
|
||||
"name": "Name",
|
||||
"slug": "Slug (URL-safe)",
|
||||
"logo_url": "Logo URL",
|
||||
"users": "Users",
|
||||
"devices": "Devices",
|
||||
"invitations": "Invitations",
|
||||
"settings": "Settings",
|
||||
"add_user": "Add User",
|
||||
"assign_device": "Assign Device",
|
||||
"create_invitation": "Create Invitation",
|
||||
"delete_confirm": "Delete this organization? This will remove all associated users, devices, and settings.",
|
||||
"no_orgs": "No organizations yet",
|
||||
"no_orgs_hint": "Create your first organization to start managing devices and users.",
|
||||
"connection_policy": "Connection Policy",
|
||||
"allow_file_transfer": "Allow File Transfer",
|
||||
"allow_clipboard": "Allow Clipboard",
|
||||
"max_session_duration": "Max Session Duration (min)",
|
||||
"role_owner": "Owner",
|
||||
"role_admin": "Admin",
|
||||
"role_operator": "Operator",
|
||||
"role_user": "User"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+361
-17
@@ -52,7 +52,11 @@
|
||||
"toggle_sidebar": "Zijbalk in-/uitschakelen",
|
||||
"cdap": "CDAP Apparaten",
|
||||
"clients": "Clients",
|
||||
"exit_desktop": "Bureaublad afsluiten"
|
||||
"exit_desktop": "Bureaublad afsluiten",
|
||||
"help_requests": "Help Requests",
|
||||
"management": "Management",
|
||||
"tokens": "Device Tokens",
|
||||
"organizations": "Organizations"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Inloggen",
|
||||
@@ -172,7 +176,20 @@
|
||||
"disk": "Schijf",
|
||||
"gpu": "Grafische kaart",
|
||||
"network": "Netwerkadapters"
|
||||
}
|
||||
},
|
||||
"not_found": "Device not found",
|
||||
"delete_failed": "Failed to delete device",
|
||||
"change_id_failed": "Failed to change device ID",
|
||||
"invalid_id": "Invalid device ID (6-16 characters required)",
|
||||
"invalid_id_format": "Invalid ID format (letters, numbers, dashes, underscores only)",
|
||||
"id_exists": "Device ID already exists",
|
||||
"no_selection": "No devices selected",
|
||||
"filter_type_all": "All Types",
|
||||
"filter_type_rustdesk": "RustDesk",
|
||||
"filter_type_desktop": "Desktop",
|
||||
"filter_type_scada": "SCADA",
|
||||
"filter_type_iot": "IoT",
|
||||
"filter_type_agent": "Agent"
|
||||
},
|
||||
"keys": {
|
||||
"title": "Serversleutels",
|
||||
@@ -273,7 +290,20 @@
|
||||
"time": "Tijd",
|
||||
"user": "Gebruiker",
|
||||
"action": "Actie",
|
||||
"details": "Details"
|
||||
"details": "Details",
|
||||
"action_login": "Login",
|
||||
"action_login_failed": "Login Failed",
|
||||
"action_logout": "Logout",
|
||||
"action_password_change": "Password Changed",
|
||||
"action_2fa_enable": "2FA Enabled",
|
||||
"action_2fa_disable": "2FA Disabled",
|
||||
"action_user_create": "User Created",
|
||||
"action_user_update": "User Updated",
|
||||
"action_user_delete": "User Deleted",
|
||||
"action_settings_update": "Settings Updated",
|
||||
"action_device_ban": "Device Banned",
|
||||
"action_device_unban": "Device Unbanned",
|
||||
"action_device_delete": "Device Deleted"
|
||||
},
|
||||
"generator": {
|
||||
"title": "Clientconfiguratiegenerator",
|
||||
@@ -325,14 +355,22 @@
|
||||
"confirm": "Bevestigen",
|
||||
"close": "Sluiten",
|
||||
"clear": "Wissen",
|
||||
"ok": "OK"
|
||||
"ok": "OK",
|
||||
"remote_viewer": "Remote Viewer"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Zojuist",
|
||||
"seconds_ago": "{count} seconde(n) geleden",
|
||||
"minutes_ago": "{count} minu(u)t(en) geleden",
|
||||
"hours_ago": "{count} u(u)r(en) geleden",
|
||||
"days_ago": "{count} dag(en) geleden"
|
||||
"days_ago": "{count} dag(en) geleden",
|
||||
"day_mon": "Mon",
|
||||
"day_tue": "Tue",
|
||||
"day_wed": "Wed",
|
||||
"day_thu": "Thu",
|
||||
"day_fri": "Fri",
|
||||
"day_sat": "Sat",
|
||||
"day_sun": "Sun"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gebruikersbeheer",
|
||||
@@ -754,7 +792,14 @@
|
||||
"export_json": "Exporteren als JSON",
|
||||
"time_range": "Tijdsbereik",
|
||||
"today": "Vandaag",
|
||||
"last_7_days": "Laatste 7 dagen"
|
||||
"last_7_days": "Laatste 7 dagen",
|
||||
"search": "Search devices...",
|
||||
"date_range": "Date Range",
|
||||
"device_id": "Device ID",
|
||||
"no_activity": "No activity recorded",
|
||||
"reported_at": "Reported At",
|
||||
"detail": "Detail",
|
||||
"no_apps": "No applications"
|
||||
},
|
||||
"automation": {
|
||||
"title": "Automatisering en waarschuwingen",
|
||||
@@ -823,7 +868,8 @@
|
||||
"no_rules": "Geen alarmregels geconfigureerd",
|
||||
"no_alerts": "Geen geactiveerde waarschuwingen",
|
||||
"no_commands": "Geen commando's verzonden",
|
||||
"no_commands_sent": "Nog geen commando's verzonden"
|
||||
"no_commands_sent": "Nog geen commando's verzonden",
|
||||
"id_payload_required": "Device ID and payload are required"
|
||||
},
|
||||
"file_transfer": {
|
||||
"title": "Bestandsoverdracht",
|
||||
@@ -917,7 +963,16 @@
|
||||
"uptime_percent": "Uptime %",
|
||||
"last_24h": "Laatste 24 uur",
|
||||
"last_7d": "Laatste 7 dagen",
|
||||
"last_30d": "Laatste 30 dagen"
|
||||
"last_30d": "Laatste 30 dagen",
|
||||
"search_targets": "Search targets...",
|
||||
"name": "Name",
|
||||
"tab_targets": "Targets",
|
||||
"tab_tools": "Tools",
|
||||
"avg_latency": "Avg Latency",
|
||||
"type": "Type",
|
||||
"latency": "Latency",
|
||||
"check_history": "Check History",
|
||||
"name_host_required": "Name and host are required"
|
||||
},
|
||||
"dataguard": {
|
||||
"title": "DataGuard — DLP",
|
||||
@@ -967,7 +1022,31 @@
|
||||
"drive_fixed": "Vast",
|
||||
"drive_network": "Netwerk",
|
||||
"drive_cdrom": "CD-ROM",
|
||||
"drive_unknown": "Onbekend"
|
||||
"drive_unknown": "Onbekend",
|
||||
"status": "Status",
|
||||
"total_policies": "Total Policies",
|
||||
"active_policies": "Active Policies",
|
||||
"violations": "Violations",
|
||||
"blocked": "Blocked",
|
||||
"tab_policies": "Policies",
|
||||
"tab_events": "Events",
|
||||
"search_policies": "Search policies...",
|
||||
"create_policy": "Create Policy",
|
||||
"policy_type": "Policy Type",
|
||||
"scope": "Scope",
|
||||
"events_count": "Events",
|
||||
"search_events": "Search events...",
|
||||
"event_time": "Event Time",
|
||||
"device": "Device",
|
||||
"policy": "Policy",
|
||||
"detail": "Detail",
|
||||
"scope_help": "Comma-separated device IDs, or leave empty for all",
|
||||
"scope_all": "All Devices",
|
||||
"active": "Active",
|
||||
"type_file_type": "File Type",
|
||||
"type_clipboard": "Clipboard",
|
||||
"type_screen_share": "Screen Share",
|
||||
"type_file_transfer": "File Transfer"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Rapporten",
|
||||
@@ -1028,7 +1107,6 @@
|
||||
"save": "Opslaan",
|
||||
"preview": "Voorbeeld",
|
||||
"download": "Downloaden",
|
||||
"no_saved": "Geen opgeslagen rapporten",
|
||||
"delete_saved": "Opgeslagen rapport verwijderen"
|
||||
},
|
||||
"tenants": {
|
||||
@@ -1073,7 +1151,8 @@
|
||||
"enabled": "Ingeschakeld",
|
||||
"max": "Max",
|
||||
"info": "Tenantinformatie",
|
||||
"status": "Status"
|
||||
"status": "Status",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"registrations": {
|
||||
"title": "Apparaatregistraties",
|
||||
@@ -1101,7 +1180,20 @@
|
||||
"reject_reason_placeholder": "Voer de reden van afwijzing in...",
|
||||
"rejected_success": "Apparaatregistratie afgewezen",
|
||||
"delete_confirm": "Weet u zeker dat u dit registratierecord wilt verwijderen?",
|
||||
"deleted_success": "Registratierecord verwijderd"
|
||||
"deleted_success": "Registratierecord verwijderd",
|
||||
"enrollment_title": "Device Enrollment",
|
||||
"enrollment_subtitle": "Pending enrollment requests from BetterDesk desktop clients",
|
||||
"enrollment_empty": "No pending enrollment requests",
|
||||
"enrollment_approve": "Approve",
|
||||
"enrollment_reject": "Reject",
|
||||
"enrollment_display_name": "Display Name",
|
||||
"enrollment_display_name_placeholder": "Enter a name for this device...",
|
||||
"enrollment_sync_mode": "Sync Mode",
|
||||
"enrollment_sync_silent": "Silent — minimal telemetry",
|
||||
"enrollment_sync_standard": "Standard — balanced sync",
|
||||
"enrollment_sync_turbo": "Turbo — aggressive sync",
|
||||
"enrollment_approved_success": "Device enrollment approved",
|
||||
"enrollment_rejected_success": "Device enrollment rejected"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Back-up",
|
||||
@@ -1213,7 +1305,63 @@
|
||||
"memory_usage": "Geheugengebruik",
|
||||
"disk_usage": "Schijfgebruik",
|
||||
"hostname": "Hostnaam",
|
||||
"system_uptime": "Systeemuptime"
|
||||
"system_uptime": "Systeemuptime",
|
||||
"loading_widgets": "Loading device widgets...",
|
||||
"load_error": "Failed to load device data",
|
||||
"device_offline_msg": "Device is currently offline. Widget values may be stale.",
|
||||
"no_widgets_desc": "This device has not registered a CDAP manifest with widget definitions.",
|
||||
"clear_log": "Clear log",
|
||||
"confirm_command": "Confirm Command",
|
||||
"select_option": "Select",
|
||||
"cdap_status": "CDAP Status",
|
||||
"cdap_enabled": "CDAP Enabled",
|
||||
"cdap_disabled": "CDAP Disabled",
|
||||
"cdap_devices": "CDAP Devices",
|
||||
"cdap_connections": "Active Connections",
|
||||
"send_command": "Send Command",
|
||||
"devices_title": "CDAP Devices",
|
||||
"devices_subtitle": "Connected devices via Custom Device Application Protocol",
|
||||
"stat_connected": "Connected",
|
||||
"stat_port": "Port",
|
||||
"search_devices": "Search devices...",
|
||||
"no_devices": "No devices connected",
|
||||
"no_devices_desc": "No CDAP devices are currently connected. Devices will appear here once they connect to the gateway.",
|
||||
"gateway_disabled": "CDAP Gateway Disabled",
|
||||
"gateway_disabled_desc": "Enable the CDAP gateway by setting CDAP_ENABLED=true in the server configuration.",
|
||||
"gateway_active": "Active",
|
||||
"toggle_enable": "Enable CDAP",
|
||||
"toggle_disable": "Disable CDAP",
|
||||
"enabled_restart": "CDAP enabled. Server restart required to apply changes.",
|
||||
"disabled_restart": "CDAP disabled. Server restart required to apply changes.",
|
||||
"type_all": "All",
|
||||
"type_iot": "IoT",
|
||||
"type_scada": "SCADA",
|
||||
"type_os_agent": "OS Agent",
|
||||
"type_network": "Network",
|
||||
"type_camera": "Camera",
|
||||
"type_custom": "Custom",
|
||||
"active_alerts": "Active Alerts",
|
||||
"alert_fired": "Alert fired",
|
||||
"alert_cleared": "Alert cleared",
|
||||
"no_alerts": "No active alerts",
|
||||
"connect_terminal": "Connect Terminal",
|
||||
"table_search": "Search...",
|
||||
"just_now": "Just now",
|
||||
"terminal_connecting": "Connecting to device...",
|
||||
"terminal_disconnected": "Disconnected",
|
||||
"terminal_error": "Terminal connection failed",
|
||||
"linked_devices": "Linked Devices",
|
||||
"link_device": "Link Device",
|
||||
"unlink_device": "Unlink",
|
||||
"no_linked_devices": "No linked devices",
|
||||
"unlink_confirm": "Are you sure you want to unlink this device?",
|
||||
"link_prompt": "Enter the Peer ID to link to this device:",
|
||||
"connect_desktop": "Connect Desktop",
|
||||
"connect_video": "Connect Stream",
|
||||
"connect_files": "Browse Files",
|
||||
"upload_file": "Upload",
|
||||
"video_connecting": "Connecting...",
|
||||
"files_connecting": "Connecting..."
|
||||
},
|
||||
"desktop": {
|
||||
"unified_mode": "Uniforme modus",
|
||||
@@ -1242,7 +1390,93 @@
|
||||
"connected": "Verbonden",
|
||||
"disconnected": "Verbinding verbroken",
|
||||
"latency": "Latentie",
|
||||
"bandwidth": "Bandbreedte"
|
||||
"bandwidth": "Bandbreedte",
|
||||
"wp_images": "Afbeeldingen",
|
||||
"wp_colors": "Effen kleuren",
|
||||
"wp_custom_color": "Aangepaste kleur",
|
||||
"wp_fit_style": "Stijl",
|
||||
"wp_fill": "Vullen",
|
||||
"wp_fit": "Passend",
|
||||
"wp_stretch": "Uitrekken",
|
||||
"wp_center": "Centreren",
|
||||
"switch_mode": "Desktop Mode",
|
||||
"console_mode": "Console Mode",
|
||||
"loading": "Loading...",
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize",
|
||||
"restore": "Restore",
|
||||
"close": "Close",
|
||||
"widgets_mode": "Widgets",
|
||||
"windows_mode": "Windows",
|
||||
"wallpaper": "Wallpaper",
|
||||
"add_widget": "Add Widget",
|
||||
"remove_widget": "Remove Widget",
|
||||
"configure": "Configure",
|
||||
"search_widgets": "Search widgets...",
|
||||
"cat_monitoring": "Monitoring",
|
||||
"cat_devices": "Devices",
|
||||
"cat_tools": "Tools",
|
||||
"cat_general": "General",
|
||||
"notes_placeholder": "Write notes here...",
|
||||
"search_placeholder": "Search devices, pages, settings...",
|
||||
"search_pages": "Pages",
|
||||
"search_devices": "Devices",
|
||||
"search_no_results": "No results",
|
||||
"exit_desktop": "Exit Desktop",
|
||||
"refresh": "Refresh",
|
||||
"widget_clock": "Clock",
|
||||
"widget_device_status": "Device Status",
|
||||
"widget_server_health": "Server Info",
|
||||
"widget_system_stats": "Device Gauges",
|
||||
"widget_device_list": "Device List",
|
||||
"widget_quick_actions": "Quick Actions",
|
||||
"widget_recent_activity": "Recent Activity",
|
||||
"widget_notes": "Notes",
|
||||
"widget_network_monitor": "Network Monitor",
|
||||
"widget_tickets_summary": "Tickets",
|
||||
"widget_iframe": "Web Embed",
|
||||
"widget_cdap_devices": "CDAP Devices",
|
||||
"widget_uptime": "Uptime",
|
||||
"widget_port_status": "Port Status",
|
||||
"widget_device_grid": "Device Grid",
|
||||
"widget_multi_gauge": "Server Gauges",
|
||||
"widget_weekly_chart": "Weekly Activity",
|
||||
"widget_quick_controls": "Quick Controls",
|
||||
"widget_bandwidth": "Bandwidth",
|
||||
"widget_connection_stats": "Connection Stats",
|
||||
"label_online": "Online",
|
||||
"label_offline": "Offline",
|
||||
"label_total": "Total",
|
||||
"label_blocked": "Blocked",
|
||||
"label_banned": "Banned",
|
||||
"label_active": "Active",
|
||||
"label_service": "Service",
|
||||
"label_port": "Port",
|
||||
"label_uptime_prefix": "Uptime:",
|
||||
"label_no_devices": "No devices",
|
||||
"label_no_activity": "No activity",
|
||||
"label_no_targets": "No targets",
|
||||
"label_unavailable": "Unavailable",
|
||||
"label_open": "Open",
|
||||
"label_in_progress": "In Progress",
|
||||
"label_resolved": "Resolved",
|
||||
"label_set_url": "Set URL in config",
|
||||
"label_server_uptime": "Server Uptime",
|
||||
"label_merged_info": "Merged into Server Info widget",
|
||||
"label_with_notes": "With Notes",
|
||||
"label_metric": "Metric",
|
||||
"label_count": "Count",
|
||||
"label_total_devices": "Total Devices",
|
||||
"label_throughput": "Throughput",
|
||||
"label_active_relays": "Active Relays",
|
||||
"label_total_relayed": "Total Relayed",
|
||||
"label_total_bytes": "Total Bytes",
|
||||
"label_filter_devices": "Filter devices...",
|
||||
"label_all": "All",
|
||||
"label_ban": "Ban",
|
||||
"label_unban": "Unban",
|
||||
"label_connect": "Connect",
|
||||
"label_no_cdap_devices": "No CDAP devices"
|
||||
},
|
||||
"tokens": {
|
||||
"title": "Toegangstokens",
|
||||
@@ -1287,7 +1521,51 @@
|
||||
"revoke_title": "Token intrekken",
|
||||
"revoke_confirm": "Weet u zeker dat u dit token wilt intrekken? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"revoked": "Token succesvol ingetrokken",
|
||||
"delete_confirm": "Weet u zeker dat u dit token wilt verwijderen?"
|
||||
"delete_confirm": "Weet u zeker dat u dit token wilt verwijderen?",
|
||||
"enrollment_mode": "Enrollment Mode",
|
||||
"mode_open": "Open",
|
||||
"mode_open_desc": "Any device can register without a token",
|
||||
"mode_managed": "Managed",
|
||||
"mode_managed_desc": "Devices need a valid token to register",
|
||||
"mode_locked": "Locked",
|
||||
"mode_locked_desc": "No new device registrations allowed",
|
||||
"total": "Total Tokens",
|
||||
"active": "Active",
|
||||
"used": "Used",
|
||||
"search_placeholder": "Search tokens...",
|
||||
"status_pending": "Pending",
|
||||
"status_active": "Active",
|
||||
"status_used": "Used",
|
||||
"status_revoked": "Revoked",
|
||||
"status_expired": "Expired",
|
||||
"bulk_generate": "Bulk Generate",
|
||||
"edit": "Edit Token",
|
||||
"token": "Token",
|
||||
"status": "Status",
|
||||
"uses": "Uses",
|
||||
"bound_peer": "Bound Peer",
|
||||
"expires": "Expires",
|
||||
"actions": "Actions",
|
||||
"max_uses": "Max Uses",
|
||||
"max_uses_hint": "0 = unlimited",
|
||||
"expires_in": "Expires In",
|
||||
"expires_1h": "1 hour",
|
||||
"expires_24h": "24 hours",
|
||||
"expires_7d": "7 days",
|
||||
"expires_30d": "30 days",
|
||||
"note": "Note",
|
||||
"note_placeholder": "Optional note about this token...",
|
||||
"count": "Count",
|
||||
"count_range": "Number of tokens to generate (1-100)",
|
||||
"name_prefix": "Name Prefix",
|
||||
"generate": "Generate",
|
||||
"token_created": "Token Created",
|
||||
"tokens_generated": "Tokens Generated",
|
||||
"revoked_success": "Token revoked successfully",
|
||||
"never": "Never",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"unlimited": "Unlimited"
|
||||
},
|
||||
"tutorial": {
|
||||
"welcome": "Welkom bij BetterDesk",
|
||||
@@ -1312,7 +1590,73 @@
|
||||
"help_center": "Helpcentrum",
|
||||
"documentation": "Documentatie",
|
||||
"keyboard_shortcuts": "Sneltoetsen",
|
||||
"report_issue": "Probleem melden"
|
||||
"report_issue": "Probleem melden",
|
||||
"prev": "Previous",
|
||||
"step_of": "{current} of {total}",
|
||||
"console_sidebar_title": "Navigation Sidebar",
|
||||
"console_sidebar_text": "Access all modules from here. Click any icon to navigate to that section. The sidebar adapts to your screen size.",
|
||||
"console_dashboard_title": "Dashboard",
|
||||
"console_dashboard_text": "Your central hub showing server status, online devices, and quick actions. Keep an eye on important metrics at a glance.",
|
||||
"console_devices_title": "Device Management",
|
||||
"console_devices_text": "View, search, and manage all connected devices. Filter by status, assign tags, and configure individual device settings.",
|
||||
"console_settings_title": "Settings",
|
||||
"console_settings_text": "Configure server behavior, security options, branding, and user management. Customize BetterDesk to fit your needs.",
|
||||
"console_desktop_title": "Desktop Mode",
|
||||
"console_desktop_text": "Switch to desktop mode for a windowed workspace with widgets. Perfect for multi-tasking and monitoring dashboards.",
|
||||
"desktop_widgets_title": "Widget Dashboard",
|
||||
"desktop_widgets_text": "Your customizable workspace. Widgets display live information and provide quick access to features. Drag to rearrange.",
|
||||
"desktop_add_widget_title": "Add Widgets",
|
||||
"desktop_add_widget_text": "Click here to browse available widgets. Choose from monitoring displays, device lists, notes, and more.",
|
||||
"desktop_taskbar_title": "Taskbar",
|
||||
"desktop_taskbar_text": "Open apps appear here. Click to focus or minimize windows. The clock shows current time and wallpaper button changes the background.",
|
||||
"desktop_windows_title": "App Windows",
|
||||
"desktop_windows_text": "Apps open in draggable, resizable windows. Minimize, maximize, or close from the title bar. Windows layer above widgets.",
|
||||
"desktop_search_title": "Quick Search",
|
||||
"desktop_search_text": "Search across devices, pages, and settings. Press Ctrl+K anywhere to open search instantly."
|
||||
},
|
||||
"help_request": {
|
||||
"title": "Help Requests",
|
||||
"subtitle": "Manage incoming help requests from desktop clients",
|
||||
"new_request": "New Help Request",
|
||||
"device": "Device",
|
||||
"hostname": "Hostname",
|
||||
"message": "Message",
|
||||
"status": "Status",
|
||||
"pending": "Pending",
|
||||
"accepted": "Accepted",
|
||||
"resolved": "Resolved",
|
||||
"accept": "Accept",
|
||||
"resolve": "Resolve",
|
||||
"no_requests": "No help requests",
|
||||
"notification": "Help request received from",
|
||||
"stats_total": "Total",
|
||||
"filter_all": "All"
|
||||
},
|
||||
"organizations": {
|
||||
"title": "Organizations",
|
||||
"create": "Create Organization",
|
||||
"edit": "Edit Organization",
|
||||
"detail": "Organization Details",
|
||||
"name": "Name",
|
||||
"slug": "Slug (URL-safe)",
|
||||
"logo_url": "Logo URL",
|
||||
"users": "Users",
|
||||
"devices": "Devices",
|
||||
"invitations": "Invitations",
|
||||
"settings": "Settings",
|
||||
"add_user": "Add User",
|
||||
"assign_device": "Assign Device",
|
||||
"create_invitation": "Create Invitation",
|
||||
"delete_confirm": "Delete this organization? This will remove all associated users, devices, and settings.",
|
||||
"no_orgs": "No organizations yet",
|
||||
"no_orgs_hint": "Create your first organization to start managing devices and users.",
|
||||
"connection_policy": "Connection Policy",
|
||||
"allow_file_transfer": "Allow File Transfer",
|
||||
"allow_clipboard": "Allow Clipboard",
|
||||
"max_session_duration": "Max Session Duration (min)",
|
||||
"role_owner": "Owner",
|
||||
"role_admin": "Admin",
|
||||
"role_operator": "Operator",
|
||||
"role_user": "User"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+67
-28
@@ -53,7 +53,8 @@
|
||||
"management": "Zarządzanie",
|
||||
"toggle_sidebar": "Przełącz pasek boczny",
|
||||
"cdap": "Urządzenia CDAP",
|
||||
"tokens": "Tokeny urządzeń"
|
||||
"tokens": "Tokeny urządzeń",
|
||||
"organizations": "Organizacje"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Zaloguj",
|
||||
@@ -177,7 +178,9 @@
|
||||
"hardware": "Sprzęt",
|
||||
"metrics": "Metryki",
|
||||
"connections": "Połączenia",
|
||||
"audit": "Audyt"
|
||||
"audit": "Audyt",
|
||||
"info": "Info",
|
||||
"history": "History"
|
||||
},
|
||||
"hardware": {
|
||||
"cpu": "Procesor",
|
||||
@@ -188,7 +191,9 @@
|
||||
"uptime": "Czas pracy",
|
||||
"load_avg": "Średnie obciążenie",
|
||||
"network": "Sieć",
|
||||
"no_data": "Brak danych o sprzęcie"
|
||||
"no_data": "Brak danych o sprzęcie",
|
||||
"title": "Hardware Information",
|
||||
"gpu": "Graphics Card"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
@@ -598,8 +603,6 @@
|
||||
"action_native_remote_desc": "Natywny strumień JPEG — bez wtyczek",
|
||||
"action_connect_desktop": "Klient desktop",
|
||||
"action_connect_desktop_desc": "Otwórz w aplikacji desktop",
|
||||
"action_connect_web": "Pulpit zdalny",
|
||||
"action_connect_web_desc": "Połącz przez przeglądarkę",
|
||||
"action_change_id_desc": "Zmień ID rejestracji urządzenia",
|
||||
"action_ban_desc": "Zablokuj połączenia tego urządzenia",
|
||||
"action_unban_desc": "Zezwól na ponowne połączenie",
|
||||
@@ -1048,45 +1051,45 @@
|
||||
"generate": "Generuj raport",
|
||||
"generate_csv": "Eksportuj CSV",
|
||||
"report_type": "Typ raportu",
|
||||
"type_devices": "Podsumowanie urz\u0105dze\u0144",
|
||||
"type_activity": "Podsumowanie aktywno\u015bci",
|
||||
"type_security": "Raport bezpiecze\u0144stwa",
|
||||
"type_tickets": "Raport zg\u0142osze\u0144",
|
||||
"type_devices": "Podsumowanie urządzeń",
|
||||
"type_activity": "Podsumowanie aktywności",
|
||||
"type_security": "Raport bezpieczeństwa",
|
||||
"type_tickets": "Raport zgłoszeń",
|
||||
"type_network": "Raport sieci",
|
||||
"type_inventory": "Raport inwentaryzacji",
|
||||
"type_alerts": "Raport alert\u00f3w",
|
||||
"type_alerts": "Raport alertów",
|
||||
"saved": "Zapisane raporty",
|
||||
"save_report": "Zapisz raport",
|
||||
"delete_report": "Usu\u0144 raport",
|
||||
"delete_confirm": "Czy na pewno chcesz usun\u0105\u0107 ten zapisany raport?",
|
||||
"no_saved": "Brak zapisanych raport\u00f3w",
|
||||
"report_title": "Tytu\u0142",
|
||||
"delete_report": "Usuń raport",
|
||||
"delete_confirm": "Czy na pewno chcesz usunąć ten zapisany raport?",
|
||||
"no_saved": "Brak zapisanych raportów",
|
||||
"report_title": "Tytuł",
|
||||
"created_by": "Utworzony przez",
|
||||
"created_at": "Utworzono",
|
||||
"filters": "Filtry",
|
||||
"date_from": "Od",
|
||||
"date_to": "Do",
|
||||
"device_filter": "ID urz\u0105dzenia",
|
||||
"device_filter": "ID urządzenia",
|
||||
"generated_at": "Wygenerowano",
|
||||
"section": "Sekcja danych",
|
||||
"total_devices": "\u0141\u0105cznie urz\u0105dze\u0144",
|
||||
"total_devices": "Łącznie urządzeń",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"banned": "Zbanowane",
|
||||
"platform_distribution": "Rozk\u0142ad platform",
|
||||
"platform_distribution": "Rozkład platform",
|
||||
"top_applications": "Top aplikacje",
|
||||
"total_active": "\u0141\u0105czny czas aktywno\u015bci",
|
||||
"total_idle": "\u0141\u0105czny czas bezczynno\u015bci",
|
||||
"total_active": "Łączny czas aktywności",
|
||||
"total_idle": "Łączny czas bezczynności",
|
||||
"dlp_stats": "Statystyki DLP",
|
||||
"banned_devices": "Zbanowane urz\u0105dzenia",
|
||||
"banned_devices": "Zbanowane urządzenia",
|
||||
"audit_logs": "Logi audytu",
|
||||
"ticket_stats": "Statystyki zg\u0142osze\u0144",
|
||||
"category_distribution": "Rozk\u0142ad kategorii",
|
||||
"priority_distribution": "Rozk\u0142ad priorytet\u00f3w",
|
||||
"targets_status": "Status cel\u00f3w",
|
||||
"os_distribution": "Rozk\u0142ad system\u00f3w operacyjnych",
|
||||
"avg_ram": "\u015aredni RAM",
|
||||
"alert_rules": "Regu\u0142y alert\u00f3w",
|
||||
"ticket_stats": "Statystyki zgłoszeń",
|
||||
"category_distribution": "Rozkład kategorii",
|
||||
"priority_distribution": "Rozkład priorytetów",
|
||||
"targets_status": "Status celów",
|
||||
"os_distribution": "Rozkład systemów operacyjnych",
|
||||
"avg_ram": "Średni RAM",
|
||||
"alert_rules": "Reguły alertów",
|
||||
"recent_alerts": "Ostatnie alerty",
|
||||
"tab_generate": "Generuj raport",
|
||||
"tab_saved": "Zapisane raporty",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"search_devices": "Urządzenia",
|
||||
"search_no_results": "Brak wyników",
|
||||
"exit_desktop": "Wyjdź z pulpitu",
|
||||
"refresh": "Odśwież",
|
||||
"widget_clock": "Zegar",
|
||||
"widget_device_status": "Status urządzeń",
|
||||
"widget_server_health": "Info serwera",
|
||||
@@ -1374,7 +1378,15 @@
|
||||
"label_ban": "Zbanuj",
|
||||
"label_unban": "Odbanuj",
|
||||
"label_connect": "Połącz",
|
||||
"label_no_cdap_devices": "Brak urządzeń CDAP"
|
||||
"label_no_cdap_devices": "Brak urządzeń CDAP",
|
||||
"wp_images": "Obrazy",
|
||||
"wp_colors": "Kolory",
|
||||
"wp_custom_color": "Własny kolor",
|
||||
"wp_fit_style": "Styl",
|
||||
"wp_fill": "Wypełnij",
|
||||
"wp_fit": "Dopasuj",
|
||||
"wp_stretch": "Rozciągnij",
|
||||
"wp_center": "Wyśrodkuj"
|
||||
},
|
||||
"tokens": {
|
||||
"title": "Tokeny urządzeń",
|
||||
@@ -1481,5 +1493,32 @@
|
||||
"notification": "Prośba o pomoc od",
|
||||
"stats_total": "Łącznie",
|
||||
"filter_all": "Wszystkie"
|
||||
},
|
||||
"organizations": {
|
||||
"title": "Organizacje",
|
||||
"create": "Utwórz organizację",
|
||||
"edit": "Edytuj organizację",
|
||||
"detail": "Szczegóły organizacji",
|
||||
"name": "Nazwa",
|
||||
"slug": "Identyfikator (URL)",
|
||||
"logo_url": "URL logo",
|
||||
"users": "Użytkownicy",
|
||||
"devices": "Urządzenia",
|
||||
"invitations": "Zaproszenia",
|
||||
"settings": "Ustawienia",
|
||||
"add_user": "Dodaj użytkownika",
|
||||
"assign_device": "Przypisz urządzenie",
|
||||
"create_invitation": "Utwórz zaproszenie",
|
||||
"delete_confirm": "Usunąć tę organizację? Zostaną usunięci wszyscy powiązani użytkownicy, urządzenia i ustawienia.",
|
||||
"no_orgs": "Brak organizacji",
|
||||
"no_orgs_hint": "Utwórz pierwszą organizację, aby rozpocząć zarządzanie urządzeniami i użytkownikami.",
|
||||
"connection_policy": "Polityka połączeń",
|
||||
"allow_file_transfer": "Zezwól na transfer plików",
|
||||
"allow_clipboard": "Zezwól na schowek",
|
||||
"max_session_duration": "Maks. czas sesji (min)",
|
||||
"role_owner": "Właściciel",
|
||||
"role_admin": "Administrator",
|
||||
"role_operator": "Operator",
|
||||
"role_user": "Użytkownik"
|
||||
}
|
||||
}
|
||||
|
||||
+362
-18
@@ -52,7 +52,11 @@
|
||||
"toggle_sidebar": "Alternar barra lateral",
|
||||
"cdap": "Dispositivos CDAP",
|
||||
"clients": "Clientes",
|
||||
"exit_desktop": "Sair da área de trabalho"
|
||||
"exit_desktop": "Sair da área de trabalho",
|
||||
"help_requests": "Help Requests",
|
||||
"management": "Management",
|
||||
"tokens": "Device Tokens",
|
||||
"organizations": "Organizations"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Entrar",
|
||||
@@ -172,7 +176,20 @@
|
||||
"disk": "Disco",
|
||||
"gpu": "Placa gráfica",
|
||||
"network": "Adaptadores de rede"
|
||||
}
|
||||
},
|
||||
"not_found": "Device not found",
|
||||
"delete_failed": "Failed to delete device",
|
||||
"change_id_failed": "Failed to change device ID",
|
||||
"invalid_id": "Invalid device ID (6-16 characters required)",
|
||||
"invalid_id_format": "Invalid ID format (letters, numbers, dashes, underscores only)",
|
||||
"id_exists": "Device ID already exists",
|
||||
"no_selection": "No devices selected",
|
||||
"filter_type_all": "All Types",
|
||||
"filter_type_rustdesk": "RustDesk",
|
||||
"filter_type_desktop": "Desktop",
|
||||
"filter_type_scada": "SCADA",
|
||||
"filter_type_iot": "IoT",
|
||||
"filter_type_agent": "Agent"
|
||||
},
|
||||
"keys": {
|
||||
"title": "Chaves do servidor",
|
||||
@@ -273,7 +290,20 @@
|
||||
"time": "Hora",
|
||||
"user": "Utilizador",
|
||||
"action": "Ação",
|
||||
"details": "Detalhes"
|
||||
"details": "Detalhes",
|
||||
"action_login": "Login",
|
||||
"action_login_failed": "Login Failed",
|
||||
"action_logout": "Logout",
|
||||
"action_password_change": "Password Changed",
|
||||
"action_2fa_enable": "2FA Enabled",
|
||||
"action_2fa_disable": "2FA Disabled",
|
||||
"action_user_create": "User Created",
|
||||
"action_user_update": "User Updated",
|
||||
"action_user_delete": "User Deleted",
|
||||
"action_settings_update": "Settings Updated",
|
||||
"action_device_ban": "Device Banned",
|
||||
"action_device_unban": "Device Unbanned",
|
||||
"action_device_delete": "Device Deleted"
|
||||
},
|
||||
"generator": {
|
||||
"title": "Gerador de configuração do cliente",
|
||||
@@ -325,14 +355,22 @@
|
||||
"confirm": "Confirmar",
|
||||
"close": "Fechar",
|
||||
"clear": "Limpar",
|
||||
"ok": "OK"
|
||||
"ok": "OK",
|
||||
"remote_viewer": "Remote Viewer"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Agora mesmo",
|
||||
"seconds_ago": "há {count} segundo(s)",
|
||||
"minutes_ago": "há {count} minuto(s)",
|
||||
"hours_ago": "há {count} hora(s)",
|
||||
"days_ago": "há {count} dia(s)"
|
||||
"days_ago": "há {count} dia(s)",
|
||||
"day_mon": "Mon",
|
||||
"day_tue": "Tue",
|
||||
"day_wed": "Wed",
|
||||
"day_thu": "Thu",
|
||||
"day_fri": "Fri",
|
||||
"day_sat": "Sat",
|
||||
"day_sun": "Sun"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestão de utilizadores",
|
||||
@@ -754,7 +792,14 @@
|
||||
"export_json": "Exportar como JSON",
|
||||
"time_range": "Intervalo de tempo",
|
||||
"today": "Hoje",
|
||||
"last_7_days": "Últimos 7 dias"
|
||||
"last_7_days": "Últimos 7 dias",
|
||||
"search": "Search devices...",
|
||||
"date_range": "Date Range",
|
||||
"device_id": "Device ID",
|
||||
"no_activity": "No activity recorded",
|
||||
"reported_at": "Reported At",
|
||||
"detail": "Detail",
|
||||
"no_apps": "No applications"
|
||||
},
|
||||
"automation": {
|
||||
"title": "Automação e alertas",
|
||||
@@ -823,7 +868,8 @@
|
||||
"no_rules": "Nenhuma regra de alarme configurada",
|
||||
"no_alerts": "Nenhum alerta acionado",
|
||||
"no_commands": "Nenhum comando enviado",
|
||||
"no_commands_sent": "Ainda não foram enviados comandos"
|
||||
"no_commands_sent": "Ainda não foram enviados comandos",
|
||||
"id_payload_required": "Device ID and payload are required"
|
||||
},
|
||||
"file_transfer": {
|
||||
"title": "Transferência de ficheiros",
|
||||
@@ -917,7 +963,16 @@
|
||||
"uptime_percent": "% Tempo de atividade",
|
||||
"last_24h": "Últimas 24 horas",
|
||||
"last_7d": "Últimos 7 dias",
|
||||
"last_30d": "Últimos 30 dias"
|
||||
"last_30d": "Últimos 30 dias",
|
||||
"search_targets": "Search targets...",
|
||||
"name": "Name",
|
||||
"tab_targets": "Targets",
|
||||
"tab_tools": "Tools",
|
||||
"avg_latency": "Avg Latency",
|
||||
"type": "Type",
|
||||
"latency": "Latency",
|
||||
"check_history": "Check History",
|
||||
"name_host_required": "Name and host are required"
|
||||
},
|
||||
"dataguard": {
|
||||
"title": "DataGuard — DLP",
|
||||
@@ -967,7 +1022,31 @@
|
||||
"drive_fixed": "Fixo",
|
||||
"drive_network": "Rede",
|
||||
"drive_cdrom": "CD-ROM",
|
||||
"drive_unknown": "Desconhecido"
|
||||
"drive_unknown": "Desconhecido",
|
||||
"status": "Status",
|
||||
"total_policies": "Total Policies",
|
||||
"active_policies": "Active Policies",
|
||||
"violations": "Violations",
|
||||
"blocked": "Blocked",
|
||||
"tab_policies": "Policies",
|
||||
"tab_events": "Events",
|
||||
"search_policies": "Search policies...",
|
||||
"create_policy": "Create Policy",
|
||||
"policy_type": "Policy Type",
|
||||
"scope": "Scope",
|
||||
"events_count": "Events",
|
||||
"search_events": "Search events...",
|
||||
"event_time": "Event Time",
|
||||
"device": "Device",
|
||||
"policy": "Policy",
|
||||
"detail": "Detail",
|
||||
"scope_help": "Comma-separated device IDs, or leave empty for all",
|
||||
"scope_all": "All Devices",
|
||||
"active": "Active",
|
||||
"type_file_type": "File Type",
|
||||
"type_clipboard": "Clipboard",
|
||||
"type_screen_share": "Screen Share",
|
||||
"type_file_transfer": "File Transfer"
|
||||
},
|
||||
"reports": {
|
||||
"title": "Relatórios",
|
||||
@@ -985,7 +1064,7 @@
|
||||
"save_report": "Guardar relatório",
|
||||
"delete_report": "Excluir relatório",
|
||||
"delete_confirm": "Tem a certeza que deseja excluir este relatório guardado?",
|
||||
"no_saved": "Nenhum relatório guardado",
|
||||
"no_saved": "Sem relatórios guardados",
|
||||
"report_title": "Título",
|
||||
"created_by": "Criado por",
|
||||
"created_at": "Criado em",
|
||||
@@ -1028,7 +1107,6 @@
|
||||
"save": "Guardar",
|
||||
"preview": "Pré-visualizar",
|
||||
"download": "Transferir",
|
||||
"no_saved": "Sem relatórios guardados",
|
||||
"delete_saved": "Excluir relatório guardado"
|
||||
},
|
||||
"tenants": {
|
||||
@@ -1073,7 +1151,8 @@
|
||||
"enabled": "Ativado",
|
||||
"max": "Máx",
|
||||
"info": "Informação do inquilino",
|
||||
"status": "Estado"
|
||||
"status": "Estado",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"registrations": {
|
||||
"title": "Registos de dispositivos",
|
||||
@@ -1101,7 +1180,20 @@
|
||||
"reject_reason_placeholder": "Insira o motivo da rejeição...",
|
||||
"rejected_success": "Registo de dispositivo rejeitado",
|
||||
"delete_confirm": "Tem a certeza que deseja excluir este registo?",
|
||||
"deleted_success": "Registo excluído"
|
||||
"deleted_success": "Registo excluído",
|
||||
"enrollment_title": "Device Enrollment",
|
||||
"enrollment_subtitle": "Pending enrollment requests from BetterDesk desktop clients",
|
||||
"enrollment_empty": "No pending enrollment requests",
|
||||
"enrollment_approve": "Approve",
|
||||
"enrollment_reject": "Reject",
|
||||
"enrollment_display_name": "Display Name",
|
||||
"enrollment_display_name_placeholder": "Enter a name for this device...",
|
||||
"enrollment_sync_mode": "Sync Mode",
|
||||
"enrollment_sync_silent": "Silent — minimal telemetry",
|
||||
"enrollment_sync_standard": "Standard — balanced sync",
|
||||
"enrollment_sync_turbo": "Turbo — aggressive sync",
|
||||
"enrollment_approved_success": "Device enrollment approved",
|
||||
"enrollment_rejected_success": "Device enrollment rejected"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
@@ -1213,7 +1305,63 @@
|
||||
"memory_usage": "Utilização da memória",
|
||||
"disk_usage": "Utilização do disco",
|
||||
"hostname": "Nome do host",
|
||||
"system_uptime": "Tempo de atividade do sistema"
|
||||
"system_uptime": "Tempo de atividade do sistema",
|
||||
"loading_widgets": "Loading device widgets...",
|
||||
"load_error": "Failed to load device data",
|
||||
"device_offline_msg": "Device is currently offline. Widget values may be stale.",
|
||||
"no_widgets_desc": "This device has not registered a CDAP manifest with widget definitions.",
|
||||
"clear_log": "Clear log",
|
||||
"confirm_command": "Confirm Command",
|
||||
"select_option": "Select",
|
||||
"cdap_status": "CDAP Status",
|
||||
"cdap_enabled": "CDAP Enabled",
|
||||
"cdap_disabled": "CDAP Disabled",
|
||||
"cdap_devices": "CDAP Devices",
|
||||
"cdap_connections": "Active Connections",
|
||||
"send_command": "Send Command",
|
||||
"devices_title": "CDAP Devices",
|
||||
"devices_subtitle": "Connected devices via Custom Device Application Protocol",
|
||||
"stat_connected": "Connected",
|
||||
"stat_port": "Port",
|
||||
"search_devices": "Search devices...",
|
||||
"no_devices": "No devices connected",
|
||||
"no_devices_desc": "No CDAP devices are currently connected. Devices will appear here once they connect to the gateway.",
|
||||
"gateway_disabled": "CDAP Gateway Disabled",
|
||||
"gateway_disabled_desc": "Enable the CDAP gateway by setting CDAP_ENABLED=true in the server configuration.",
|
||||
"gateway_active": "Active",
|
||||
"toggle_enable": "Enable CDAP",
|
||||
"toggle_disable": "Disable CDAP",
|
||||
"enabled_restart": "CDAP enabled. Server restart required to apply changes.",
|
||||
"disabled_restart": "CDAP disabled. Server restart required to apply changes.",
|
||||
"type_all": "All",
|
||||
"type_iot": "IoT",
|
||||
"type_scada": "SCADA",
|
||||
"type_os_agent": "OS Agent",
|
||||
"type_network": "Network",
|
||||
"type_camera": "Camera",
|
||||
"type_custom": "Custom",
|
||||
"active_alerts": "Active Alerts",
|
||||
"alert_fired": "Alert fired",
|
||||
"alert_cleared": "Alert cleared",
|
||||
"no_alerts": "No active alerts",
|
||||
"connect_terminal": "Connect Terminal",
|
||||
"table_search": "Search...",
|
||||
"just_now": "Just now",
|
||||
"terminal_connecting": "Connecting to device...",
|
||||
"terminal_disconnected": "Disconnected",
|
||||
"terminal_error": "Terminal connection failed",
|
||||
"linked_devices": "Linked Devices",
|
||||
"link_device": "Link Device",
|
||||
"unlink_device": "Unlink",
|
||||
"no_linked_devices": "No linked devices",
|
||||
"unlink_confirm": "Are you sure you want to unlink this device?",
|
||||
"link_prompt": "Enter the Peer ID to link to this device:",
|
||||
"connect_desktop": "Connect Desktop",
|
||||
"connect_video": "Connect Stream",
|
||||
"connect_files": "Browse Files",
|
||||
"upload_file": "Upload",
|
||||
"video_connecting": "Connecting...",
|
||||
"files_connecting": "Connecting..."
|
||||
},
|
||||
"desktop": {
|
||||
"unified_mode": "Modo unificado",
|
||||
@@ -1242,7 +1390,93 @@
|
||||
"connected": "Conectado",
|
||||
"disconnected": "Desconectado",
|
||||
"latency": "Latência",
|
||||
"bandwidth": "Largura de banda"
|
||||
"bandwidth": "Largura de banda",
|
||||
"wp_images": "Imagens",
|
||||
"wp_colors": "Cores sólidas",
|
||||
"wp_custom_color": "Cor personalizada",
|
||||
"wp_fit_style": "Estilo",
|
||||
"wp_fill": "Preencher",
|
||||
"wp_fit": "Ajustar",
|
||||
"wp_stretch": "Esticar",
|
||||
"wp_center": "Centralizar",
|
||||
"switch_mode": "Desktop Mode",
|
||||
"console_mode": "Console Mode",
|
||||
"loading": "Loading...",
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize",
|
||||
"restore": "Restore",
|
||||
"close": "Close",
|
||||
"widgets_mode": "Widgets",
|
||||
"windows_mode": "Windows",
|
||||
"wallpaper": "Wallpaper",
|
||||
"add_widget": "Add Widget",
|
||||
"remove_widget": "Remove Widget",
|
||||
"configure": "Configure",
|
||||
"search_widgets": "Search widgets...",
|
||||
"cat_monitoring": "Monitoring",
|
||||
"cat_devices": "Devices",
|
||||
"cat_tools": "Tools",
|
||||
"cat_general": "General",
|
||||
"notes_placeholder": "Write notes here...",
|
||||
"search_placeholder": "Search devices, pages, settings...",
|
||||
"search_pages": "Pages",
|
||||
"search_devices": "Devices",
|
||||
"search_no_results": "No results",
|
||||
"exit_desktop": "Exit Desktop",
|
||||
"refresh": "Refresh",
|
||||
"widget_clock": "Clock",
|
||||
"widget_device_status": "Device Status",
|
||||
"widget_server_health": "Server Info",
|
||||
"widget_system_stats": "Device Gauges",
|
||||
"widget_device_list": "Device List",
|
||||
"widget_quick_actions": "Quick Actions",
|
||||
"widget_recent_activity": "Recent Activity",
|
||||
"widget_notes": "Notes",
|
||||
"widget_network_monitor": "Network Monitor",
|
||||
"widget_tickets_summary": "Tickets",
|
||||
"widget_iframe": "Web Embed",
|
||||
"widget_cdap_devices": "CDAP Devices",
|
||||
"widget_uptime": "Uptime",
|
||||
"widget_port_status": "Port Status",
|
||||
"widget_device_grid": "Device Grid",
|
||||
"widget_multi_gauge": "Server Gauges",
|
||||
"widget_weekly_chart": "Weekly Activity",
|
||||
"widget_quick_controls": "Quick Controls",
|
||||
"widget_bandwidth": "Bandwidth",
|
||||
"widget_connection_stats": "Connection Stats",
|
||||
"label_online": "Online",
|
||||
"label_offline": "Offline",
|
||||
"label_total": "Total",
|
||||
"label_blocked": "Blocked",
|
||||
"label_banned": "Banned",
|
||||
"label_active": "Active",
|
||||
"label_service": "Service",
|
||||
"label_port": "Port",
|
||||
"label_uptime_prefix": "Uptime:",
|
||||
"label_no_devices": "No devices",
|
||||
"label_no_activity": "No activity",
|
||||
"label_no_targets": "No targets",
|
||||
"label_unavailable": "Unavailable",
|
||||
"label_open": "Open",
|
||||
"label_in_progress": "In Progress",
|
||||
"label_resolved": "Resolved",
|
||||
"label_set_url": "Set URL in config",
|
||||
"label_server_uptime": "Server Uptime",
|
||||
"label_merged_info": "Merged into Server Info widget",
|
||||
"label_with_notes": "With Notes",
|
||||
"label_metric": "Metric",
|
||||
"label_count": "Count",
|
||||
"label_total_devices": "Total Devices",
|
||||
"label_throughput": "Throughput",
|
||||
"label_active_relays": "Active Relays",
|
||||
"label_total_relayed": "Total Relayed",
|
||||
"label_total_bytes": "Total Bytes",
|
||||
"label_filter_devices": "Filter devices...",
|
||||
"label_all": "All",
|
||||
"label_ban": "Ban",
|
||||
"label_unban": "Unban",
|
||||
"label_connect": "Connect",
|
||||
"label_no_cdap_devices": "No CDAP devices"
|
||||
},
|
||||
"tokens": {
|
||||
"title": "Tokens de acesso",
|
||||
@@ -1287,7 +1521,51 @@
|
||||
"revoke_title": "Revogar token",
|
||||
"revoke_confirm": "Tem a certeza que deseja revogar este token? Esta ação não pode ser desfeita.",
|
||||
"revoked": "Token revogado com sucesso",
|
||||
"delete_confirm": "Tem a certeza que deseja excluir este token?"
|
||||
"delete_confirm": "Tem a certeza que deseja excluir este token?",
|
||||
"enrollment_mode": "Enrollment Mode",
|
||||
"mode_open": "Open",
|
||||
"mode_open_desc": "Any device can register without a token",
|
||||
"mode_managed": "Managed",
|
||||
"mode_managed_desc": "Devices need a valid token to register",
|
||||
"mode_locked": "Locked",
|
||||
"mode_locked_desc": "No new device registrations allowed",
|
||||
"total": "Total Tokens",
|
||||
"active": "Active",
|
||||
"used": "Used",
|
||||
"search_placeholder": "Search tokens...",
|
||||
"status_pending": "Pending",
|
||||
"status_active": "Active",
|
||||
"status_used": "Used",
|
||||
"status_revoked": "Revoked",
|
||||
"status_expired": "Expired",
|
||||
"bulk_generate": "Bulk Generate",
|
||||
"edit": "Edit Token",
|
||||
"token": "Token",
|
||||
"status": "Status",
|
||||
"uses": "Uses",
|
||||
"bound_peer": "Bound Peer",
|
||||
"expires": "Expires",
|
||||
"actions": "Actions",
|
||||
"max_uses": "Max Uses",
|
||||
"max_uses_hint": "0 = unlimited",
|
||||
"expires_in": "Expires In",
|
||||
"expires_1h": "1 hour",
|
||||
"expires_24h": "24 hours",
|
||||
"expires_7d": "7 days",
|
||||
"expires_30d": "30 days",
|
||||
"note": "Note",
|
||||
"note_placeholder": "Optional note about this token...",
|
||||
"count": "Count",
|
||||
"count_range": "Number of tokens to generate (1-100)",
|
||||
"name_prefix": "Name Prefix",
|
||||
"generate": "Generate",
|
||||
"token_created": "Token Created",
|
||||
"tokens_generated": "Tokens Generated",
|
||||
"revoked_success": "Token revoked successfully",
|
||||
"never": "Never",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"unlimited": "Unlimited"
|
||||
},
|
||||
"tutorial": {
|
||||
"welcome": "Bem-vindo ao BetterDesk",
|
||||
@@ -1312,7 +1590,73 @@
|
||||
"help_center": "Centro de ajuda",
|
||||
"documentation": "Documentação",
|
||||
"keyboard_shortcuts": "Atalhos de teclado",
|
||||
"report_issue": "Reportar problema"
|
||||
"report_issue": "Reportar problema",
|
||||
"prev": "Previous",
|
||||
"step_of": "{current} of {total}",
|
||||
"console_sidebar_title": "Navigation Sidebar",
|
||||
"console_sidebar_text": "Access all modules from here. Click any icon to navigate to that section. The sidebar adapts to your screen size.",
|
||||
"console_dashboard_title": "Dashboard",
|
||||
"console_dashboard_text": "Your central hub showing server status, online devices, and quick actions. Keep an eye on important metrics at a glance.",
|
||||
"console_devices_title": "Device Management",
|
||||
"console_devices_text": "View, search, and manage all connected devices. Filter by status, assign tags, and configure individual device settings.",
|
||||
"console_settings_title": "Settings",
|
||||
"console_settings_text": "Configure server behavior, security options, branding, and user management. Customize BetterDesk to fit your needs.",
|
||||
"console_desktop_title": "Desktop Mode",
|
||||
"console_desktop_text": "Switch to desktop mode for a windowed workspace with widgets. Perfect for multi-tasking and monitoring dashboards.",
|
||||
"desktop_widgets_title": "Widget Dashboard",
|
||||
"desktop_widgets_text": "Your customizable workspace. Widgets display live information and provide quick access to features. Drag to rearrange.",
|
||||
"desktop_add_widget_title": "Add Widgets",
|
||||
"desktop_add_widget_text": "Click here to browse available widgets. Choose from monitoring displays, device lists, notes, and more.",
|
||||
"desktop_taskbar_title": "Taskbar",
|
||||
"desktop_taskbar_text": "Open apps appear here. Click to focus or minimize windows. The clock shows current time and wallpaper button changes the background.",
|
||||
"desktop_windows_title": "App Windows",
|
||||
"desktop_windows_text": "Apps open in draggable, resizable windows. Minimize, maximize, or close from the title bar. Windows layer above widgets.",
|
||||
"desktop_search_title": "Quick Search",
|
||||
"desktop_search_text": "Search across devices, pages, and settings. Press Ctrl+K anywhere to open search instantly."
|
||||
},
|
||||
"help_request": {
|
||||
"title": "Help Requests",
|
||||
"subtitle": "Manage incoming help requests from desktop clients",
|
||||
"new_request": "New Help Request",
|
||||
"device": "Device",
|
||||
"hostname": "Hostname",
|
||||
"message": "Message",
|
||||
"status": "Status",
|
||||
"pending": "Pending",
|
||||
"accepted": "Accepted",
|
||||
"resolved": "Resolved",
|
||||
"accept": "Accept",
|
||||
"resolve": "Resolve",
|
||||
"no_requests": "No help requests",
|
||||
"notification": "Help request received from",
|
||||
"stats_total": "Total",
|
||||
"filter_all": "All"
|
||||
},
|
||||
"organizations": {
|
||||
"title": "Organizations",
|
||||
"create": "Create Organization",
|
||||
"edit": "Edit Organization",
|
||||
"detail": "Organization Details",
|
||||
"name": "Name",
|
||||
"slug": "Slug (URL-safe)",
|
||||
"logo_url": "Logo URL",
|
||||
"users": "Users",
|
||||
"devices": "Devices",
|
||||
"invitations": "Invitations",
|
||||
"settings": "Settings",
|
||||
"add_user": "Add User",
|
||||
"assign_device": "Assign Device",
|
||||
"create_invitation": "Create Invitation",
|
||||
"delete_confirm": "Delete this organization? This will remove all associated users, devices, and settings.",
|
||||
"no_orgs": "No organizations yet",
|
||||
"no_orgs_hint": "Create your first organization to start managing devices and users.",
|
||||
"connection_policy": "Connection Policy",
|
||||
"allow_file_transfer": "Allow File Transfer",
|
||||
"allow_clipboard": "Allow Clipboard",
|
||||
"max_session_duration": "Max Session Duration (min)",
|
||||
"role_owner": "Owner",
|
||||
"role_admin": "Admin",
|
||||
"role_operator": "Operator",
|
||||
"role_user": "User"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+158
-17
@@ -53,7 +53,8 @@
|
||||
"management": "管理",
|
||||
"toggle_sidebar": "切换侧边栏",
|
||||
"cdap": "CDAP 设备",
|
||||
"tokens": "设备令牌"
|
||||
"tokens": "设备令牌",
|
||||
"organizations": "组织"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
@@ -134,9 +135,9 @@
|
||||
"delete_warning": "您即将永久删除此设备:",
|
||||
"delete_permanent": "此操作无法撤销。与该设备关联的所有数据将从数据库中永久删除。",
|
||||
"delete_success": "设备删除成功",
|
||||
"revoke_option": "吊销设备(断开连接 + 阻止重新注册)",
|
||||
"revoke_hint": "设备将被立即断开连接,并永久阻止重新连接到此服务器。",
|
||||
"revoke_success": "设备已吊销并加入黑名单",
|
||||
"revoke_option": "撤销设备",
|
||||
"revoke_hint": "阻止设备 ID 并断开活动连接",
|
||||
"revoke_success": "设备已成功撤销",
|
||||
"details": "设备详情",
|
||||
"ban_title": "封禁设备",
|
||||
"ban_confirm": "确定要封禁设备 {id} 吗?",
|
||||
@@ -172,9 +173,6 @@
|
||||
"filter_type_agent": "代理",
|
||||
"cdap_connected": "CDAP 已连接",
|
||||
"revoke": "撤销",
|
||||
"revoke_option": "撤销设备",
|
||||
"revoke_hint": "阻止设备 ID 并断开活动连接",
|
||||
"revoke_success": "设备已成功撤销",
|
||||
"tabs": {
|
||||
"info": "信息",
|
||||
"hardware": "硬件",
|
||||
@@ -290,7 +288,20 @@
|
||||
"time": "时间",
|
||||
"user": "用户",
|
||||
"action": "操作",
|
||||
"details": "详情"
|
||||
"details": "详情",
|
||||
"action_login": "Login",
|
||||
"action_login_failed": "Login Failed",
|
||||
"action_logout": "Logout",
|
||||
"action_password_change": "Password Changed",
|
||||
"action_2fa_enable": "2FA Enabled",
|
||||
"action_2fa_disable": "2FA Disabled",
|
||||
"action_user_create": "User Created",
|
||||
"action_user_update": "User Updated",
|
||||
"action_user_delete": "User Deleted",
|
||||
"action_settings_update": "Settings Updated",
|
||||
"action_device_ban": "Device Banned",
|
||||
"action_device_unban": "Device Unbanned",
|
||||
"action_device_delete": "Device Deleted"
|
||||
},
|
||||
"generator": {
|
||||
"title": "客户端配置生成器",
|
||||
@@ -350,7 +361,14 @@
|
||||
"seconds_ago": "{count}秒前",
|
||||
"minutes_ago": "{count}分钟前",
|
||||
"hours_ago": "{count}小时前",
|
||||
"days_ago": "{count}天前"
|
||||
"days_ago": "{count}天前",
|
||||
"day_mon": "Mon",
|
||||
"day_tue": "Tue",
|
||||
"day_wed": "Wed",
|
||||
"day_thu": "Thu",
|
||||
"day_fri": "Fri",
|
||||
"day_sat": "Sat",
|
||||
"day_sun": "Sun"
|
||||
},
|
||||
"users": {
|
||||
"title": "用户管理",
|
||||
@@ -578,8 +596,6 @@
|
||||
"action_native_remote_desc": "原生 JPEG 流 — 无需插件",
|
||||
"action_connect_desktop": "桌面客户端",
|
||||
"action_connect_desktop_desc": "在桌面客户端应用中打开",
|
||||
"action_connect_web": "网页远程",
|
||||
"action_connect_web_desc": "通过浏览器远程桌面连接",
|
||||
"action_change_id_desc": "更改设备注册 ID",
|
||||
"action_ban_desc": "阻止此设备连接",
|
||||
"action_unban_desc": "允许此设备重新连接",
|
||||
@@ -1005,7 +1021,30 @@
|
||||
"drive_fixed": "固定",
|
||||
"drive_network": "网络",
|
||||
"drive_cdrom": "CD-ROM",
|
||||
"drive_unknown": "未知"
|
||||
"drive_unknown": "未知",
|
||||
"total_policies": "Total Policies",
|
||||
"active_policies": "Active Policies",
|
||||
"violations": "Violations",
|
||||
"blocked": "Blocked",
|
||||
"tab_policies": "Policies",
|
||||
"tab_events": "Events",
|
||||
"search_policies": "Search policies...",
|
||||
"create_policy": "Create Policy",
|
||||
"policy_type": "Policy Type",
|
||||
"scope": "Scope",
|
||||
"events_count": "Events",
|
||||
"search_events": "Search events...",
|
||||
"event_time": "Event Time",
|
||||
"device": "Device",
|
||||
"policy": "Policy",
|
||||
"detail": "Detail",
|
||||
"scope_help": "Comma-separated device IDs, or leave empty for all",
|
||||
"scope_all": "All Devices",
|
||||
"active": "Active",
|
||||
"type_file_type": "File Type",
|
||||
"type_clipboard": "Clipboard",
|
||||
"type_screen_share": "Screen Share",
|
||||
"type_file_transfer": "File Transfer"
|
||||
},
|
||||
"reports": {
|
||||
"title": "报表",
|
||||
@@ -1023,7 +1062,7 @@
|
||||
"save_report": "保存报表",
|
||||
"delete_report": "删除报表",
|
||||
"delete_confirm": "确定要删除此已保存的报表吗?",
|
||||
"no_saved": "暂无已保存的报表",
|
||||
"no_saved": "暂无已保存的报告",
|
||||
"report_title": "标题",
|
||||
"created_by": "创建者",
|
||||
"created_at": "创建时间",
|
||||
@@ -1066,7 +1105,6 @@
|
||||
"save": "保存",
|
||||
"preview": "预览",
|
||||
"download": "下载",
|
||||
"no_saved": "暂无已保存的报告",
|
||||
"delete_saved": "删除已保存的报告"
|
||||
},
|
||||
"tenants": {
|
||||
@@ -1111,7 +1149,8 @@
|
||||
"enabled": "已启用",
|
||||
"max": "最大",
|
||||
"info": "租户信息",
|
||||
"status": "状态"
|
||||
"status": "状态",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"registrations": {
|
||||
"title": "设备注册",
|
||||
@@ -1139,7 +1178,20 @@
|
||||
"reject_reason_placeholder": "请输入拒绝原因...",
|
||||
"rejected_success": "设备注册已拒绝",
|
||||
"delete_confirm": "确定要删除此注册记录吗?",
|
||||
"deleted_success": "注册记录已删除"
|
||||
"deleted_success": "注册记录已删除",
|
||||
"enrollment_title": "Device Enrollment",
|
||||
"enrollment_subtitle": "Pending enrollment requests from BetterDesk desktop clients",
|
||||
"enrollment_empty": "No pending enrollment requests",
|
||||
"enrollment_approve": "Approve",
|
||||
"enrollment_reject": "Reject",
|
||||
"enrollment_display_name": "Display Name",
|
||||
"enrollment_display_name_placeholder": "Enter a name for this device...",
|
||||
"enrollment_sync_mode": "Sync Mode",
|
||||
"enrollment_sync_silent": "Silent — minimal telemetry",
|
||||
"enrollment_sync_standard": "Standard — balanced sync",
|
||||
"enrollment_sync_turbo": "Turbo — aggressive sync",
|
||||
"enrollment_approved_success": "Device enrollment approved",
|
||||
"enrollment_rejected_success": "Device enrollment rejected"
|
||||
},
|
||||
"backup": {
|
||||
"title": "备份",
|
||||
@@ -1272,7 +1324,69 @@
|
||||
"search_pages": "页面",
|
||||
"search_devices": "设备",
|
||||
"search_no_results": "无结果",
|
||||
"exit_desktop": "退出桌面"
|
||||
"exit_desktop": "退出桌面",
|
||||
"refresh": "刷新",
|
||||
"wp_images": "图片",
|
||||
"wp_colors": "纯色",
|
||||
"wp_custom_color": "自定义颜色",
|
||||
"wp_fit_style": "样式",
|
||||
"wp_fill": "填充",
|
||||
"wp_fit": "适应",
|
||||
"wp_stretch": "拉伸",
|
||||
"wp_center": "居中",
|
||||
"widget_clock": "Clock",
|
||||
"widget_device_status": "Device Status",
|
||||
"widget_server_health": "Server Info",
|
||||
"widget_system_stats": "Device Gauges",
|
||||
"widget_device_list": "Device List",
|
||||
"widget_quick_actions": "Quick Actions",
|
||||
"widget_recent_activity": "Recent Activity",
|
||||
"widget_notes": "Notes",
|
||||
"widget_network_monitor": "Network Monitor",
|
||||
"widget_tickets_summary": "Tickets",
|
||||
"widget_iframe": "Web Embed",
|
||||
"widget_cdap_devices": "CDAP Devices",
|
||||
"widget_uptime": "Uptime",
|
||||
"widget_port_status": "Port Status",
|
||||
"widget_device_grid": "Device Grid",
|
||||
"widget_multi_gauge": "Server Gauges",
|
||||
"widget_weekly_chart": "Weekly Activity",
|
||||
"widget_quick_controls": "Quick Controls",
|
||||
"widget_bandwidth": "Bandwidth",
|
||||
"widget_connection_stats": "Connection Stats",
|
||||
"label_online": "Online",
|
||||
"label_offline": "Offline",
|
||||
"label_total": "Total",
|
||||
"label_blocked": "Blocked",
|
||||
"label_banned": "Banned",
|
||||
"label_active": "Active",
|
||||
"label_service": "Service",
|
||||
"label_port": "Port",
|
||||
"label_uptime_prefix": "Uptime:",
|
||||
"label_no_devices": "No devices",
|
||||
"label_no_activity": "No activity",
|
||||
"label_no_targets": "No targets",
|
||||
"label_unavailable": "Unavailable",
|
||||
"label_open": "Open",
|
||||
"label_in_progress": "In Progress",
|
||||
"label_resolved": "Resolved",
|
||||
"label_set_url": "Set URL in config",
|
||||
"label_server_uptime": "Server Uptime",
|
||||
"label_merged_info": "Merged into Server Info widget",
|
||||
"label_with_notes": "With Notes",
|
||||
"label_metric": "Metric",
|
||||
"label_count": "Count",
|
||||
"label_total_devices": "Total Devices",
|
||||
"label_throughput": "Throughput",
|
||||
"label_active_relays": "Active Relays",
|
||||
"label_total_relayed": "Total Relayed",
|
||||
"label_total_bytes": "Total Bytes",
|
||||
"label_filter_devices": "Filter devices...",
|
||||
"label_all": "All",
|
||||
"label_ban": "Ban",
|
||||
"label_unban": "Unban",
|
||||
"label_connect": "Connect",
|
||||
"label_no_cdap_devices": "No CDAP devices"
|
||||
},
|
||||
"tokens": {
|
||||
"title": "设备令牌",
|
||||
@@ -1379,5 +1493,32 @@
|
||||
"notification": "收到来自的帮助请求",
|
||||
"stats_total": "总计",
|
||||
"filter_all": "全部"
|
||||
},
|
||||
"organizations": {
|
||||
"title": "组织管理",
|
||||
"create": "创建组织",
|
||||
"edit": "编辑组织",
|
||||
"detail": "组织详情",
|
||||
"name": "名称",
|
||||
"slug": "标识符 (URL)",
|
||||
"logo_url": "Logo 地址",
|
||||
"users": "用户",
|
||||
"devices": "设备",
|
||||
"invitations": "邀请",
|
||||
"settings": "设置",
|
||||
"add_user": "添加用户",
|
||||
"assign_device": "分配设备",
|
||||
"create_invitation": "创建邀请",
|
||||
"delete_confirm": "删除此组织?这将删除所有关联的用户、设备和设置。",
|
||||
"no_orgs": "暂无组织",
|
||||
"no_orgs_hint": "创建第一个组织以开始管理设备和用户。",
|
||||
"connection_policy": "连接策略",
|
||||
"allow_file_transfer": "允许文件传输",
|
||||
"allow_clipboard": "允许剪贴板",
|
||||
"max_session_duration": "最大会话时长 (分钟)",
|
||||
"role_owner": "所有者",
|
||||
"role_admin": "管理员",
|
||||
"role_operator": "操作员",
|
||||
"role_user": "用户"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,62 +8,77 @@ const config = require('../config/config');
|
||||
|
||||
/**
|
||||
* Build CSP connect-src based on HTTPS mode
|
||||
* When HTTPS is enabled, also allow wss:// for future WebSocket connections
|
||||
* Allow WebSocket connections (ws:// or wss:// depending on mode)
|
||||
*/
|
||||
const connectSources = config.httpsEnabled
|
||||
? ["'self'", "wss:"]
|
||||
: ["'self'"];
|
||||
: ["'self'", "ws:"];
|
||||
|
||||
/**
|
||||
* Configure Helmet with appropriate CSP for our app
|
||||
* Security policies adjust automatically based on HTTPS mode
|
||||
* Configure Helmet with hardened CSP
|
||||
* - unsafe-eval: required by protobuf.js codegen (pinned to that library)
|
||||
* - unsafe-inline for scripts: required for EJS inline handlers + page scripts
|
||||
* - unsafe-inline for styles: required for dynamic theming via branding service
|
||||
*/
|
||||
const helmetMiddleware = helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // unsafe-eval required by protobuf.js codegen
|
||||
scriptSrcAttr: ["'unsafe-inline'"], // Allow inline event handlers (onclick etc.)
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
|
||||
scriptSrcAttr: ["'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
mediaSrc: ["'self'", "blob:"], // blob: required by JMuxer MSE video decoding
|
||||
mediaSrc: ["'self'", "blob:"],
|
||||
connectSrc: connectSources,
|
||||
frameSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
childSrc: ["'self'"],
|
||||
workerSrc: ["'self'", "blob:"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'self'"],
|
||||
upgradeInsecureRequests: config.httpsEnabled ? [] : null
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginResourcePolicy: false,
|
||||
crossOriginResourcePolicy: { policy: 'same-origin' },
|
||||
crossOriginOpenerPolicy: config.httpsEnabled ? { policy: 'same-origin' } : false,
|
||||
originAgentCluster: config.httpsEnabled,
|
||||
strictTransportSecurity: config.httpsEnabled
|
||||
? { maxAge: 31536000, includeSubDomains: true, preload: false }
|
||||
: false
|
||||
: false,
|
||||
dnsPrefetchControl: { allow: false },
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' }
|
||||
});
|
||||
|
||||
/**
|
||||
* Custom security headers
|
||||
* Custom security headers beyond what Helmet provides
|
||||
*/
|
||||
function customSecurityHeaders(req, res, next) {
|
||||
// Prevent clickjacking
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
|
||||
// Prevent clickjacking (belt + suspenders with CSP frame-ancestors)
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// XSS Protection (disabled for modern browsers, can cause issues in legacy)
|
||||
|
||||
// XSS Protection (disabled — CSP is the modern replacement)
|
||||
res.setHeader('X-XSS-Protection', '0');
|
||||
|
||||
// Referrer policy
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
// Permissions policy (includes browsing-topics to suppress Chrome warnings)
|
||||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), browsing-topics=()');
|
||||
|
||||
|
||||
// Permissions policy — restrict powerful APIs
|
||||
res.setHeader('Permissions-Policy',
|
||||
'geolocation=(), microphone=(self), camera=(), ' +
|
||||
'browsing-topics=(), payment=(), usb=(), ' +
|
||||
'accelerometer=(), gyroscope=(), magnetometer=()');
|
||||
|
||||
// Prevent cross-site leak via cache timing
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
// Allow static assets to be cached (overridden in express.static options)
|
||||
if (req.path.startsWith('/css/') || req.path.startsWith('/js/') ||
|
||||
req.path.startsWith('/img/') || req.path.startsWith('/fonts/')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
"dev": "node --watch server.js",
|
||||
"i18n:check": "node scripts/i18n-check.js",
|
||||
"i18n:fix": "node scripts/i18n-check.js --fix"
|
||||
},
|
||||
"keywords": [
|
||||
"rustdesk",
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
* Only active on viewports >= 1200px.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Safe area for system UI (OS taskbar, browser chrome). Override via JS if needed. */
|
||||
--desktop-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* ============ Desktop Toggle Button (navbar) ============ */
|
||||
|
||||
.desktop-toggle-btn {
|
||||
@@ -191,12 +196,13 @@ body.desktop-active .desktop-shell {
|
||||
min-width: 420px;
|
||||
min-height: 300px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
border: 1px solid var(--border-color, #30363d);
|
||||
border: 1px solid rgba(48, 54, 61, 0.6);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
pointer-events: all;
|
||||
overflow: hidden;
|
||||
animation: windowOpen 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.desktop-window.closing {
|
||||
@@ -213,8 +219,8 @@ body.desktop-active .desktop-shell {
|
||||
}
|
||||
|
||||
.desktop-window.focused {
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.55), 0 4px 12px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px var(--accent-color, #58a6ff);
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.45), 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
border-color: rgba(88, 166, 255, 0.25);
|
||||
}
|
||||
|
||||
/* ============ Window Title Bar ============ */
|
||||
@@ -222,23 +228,25 @@ body.desktop-active .desktop-shell {
|
||||
.window-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
padding: 0 8px 0 12px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
border-bottom: 1px solid var(--border-color, #30363d);
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
padding: 0 6px 0 12px;
|
||||
background: rgba(22, 27, 34, 0.85);
|
||||
backdrop-filter: blur(12px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(150%);
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.window-titlebar-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -249,7 +257,7 @@ body.desktop-active .desktop-shell {
|
||||
|
||||
.window-titlebar-text {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
overflow: hidden;
|
||||
@@ -265,8 +273,8 @@ body.desktop-active .desktop-shell {
|
||||
}
|
||||
|
||||
.window-ctrl-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -279,11 +287,11 @@ body.desktop-active .desktop-shell {
|
||||
}
|
||||
|
||||
.window-ctrl-btn .material-icons {
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.window-ctrl-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
|
||||
@@ -398,34 +406,16 @@ body.desktop-active .desktop-shell {
|
||||
padding: 0 8px;
|
||||
gap: 4px;
|
||||
animation: taskbarSlideUp 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
transition: transform 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Start button removed in Phase 11.2 — styles kept for backwards compatibility */
|
||||
.taskbar-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.taskbar-start-btn {
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.taskbar-start-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.taskbar-start-btn .material-icons {
|
||||
font-size: 22px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.taskbar-apps {
|
||||
@@ -535,6 +525,66 @@ body.desktop-active .desktop-shell {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Taskbar auto-hide (slides down when no open windows in widgets mode) */
|
||||
.desktop-taskbar.taskbar-hidden {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.desktop-taskbar.taskbar-hidden:hover {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ============ Desktop Context Menu ============ */
|
||||
|
||||
.desktop-context-menu {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
min-width: 180px;
|
||||
background: rgba(13, 17, 23, 0.92);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(48, 54, 61, 0.6);
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
animation: windowOpen 0.15s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ctx-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.ctx-item .material-icons {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
|
||||
.ctx-divider {
|
||||
height: 1px;
|
||||
background: rgba(48, 54, 61, 0.4);
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* ============ Window Animations ============ */
|
||||
|
||||
@keyframes windowOpen {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* BetterDesk Console — Desktop Widget Overrides
|
||||
* Applied only when content is rendered inside desktop mode iframes (embed=1).
|
||||
* Provides desktop-optimized spacing, typography, and layout adjustments.
|
||||
*
|
||||
* This stylesheet is loaded ONLY in embed mode (iframe windows) and overrides
|
||||
* the default web panel styles with desktop-friendly values.
|
||||
*/
|
||||
|
||||
/* ============ CSS Custom Properties (Desktop) ============ */
|
||||
|
||||
:root {
|
||||
--dw-bg: rgba(13, 17, 23, 0.95);
|
||||
--dw-text: #e6edf3;
|
||||
--dw-text-secondary: #8b949e;
|
||||
--dw-accent: #58a6ff;
|
||||
--dw-border: rgba(48, 54, 61, 0.5);
|
||||
--dw-radius: 8px;
|
||||
--dw-spacing: 16px;
|
||||
--dw-font-size: 14px;
|
||||
}
|
||||
|
||||
/* ============ Base Overrides ============ */
|
||||
|
||||
body.embed-mode {
|
||||
background: transparent;
|
||||
font-size: var(--dw-font-size);
|
||||
}
|
||||
|
||||
body.embed-mode .main-content {
|
||||
padding: var(--dw-spacing);
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Remove web-panel elements that don't belong in desktop windows */
|
||||
body.embed-mode .breadcrumb,
|
||||
body.embed-mode .session-bar,
|
||||
body.embed-mode .page-header-actions .desktop-toggle-btn,
|
||||
body.embed-mode .footer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ============ Typography ============ */
|
||||
|
||||
body.embed-mode h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
body.embed-mode h2 {
|
||||
font-size: 1.15rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
body.embed-mode h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ============ Cards & Panels ============ */
|
||||
|
||||
body.embed-mode .card,
|
||||
body.embed-mode .panel,
|
||||
body.embed-mode .stat-card {
|
||||
border-radius: var(--dw-radius);
|
||||
border-color: var(--dw-border);
|
||||
}
|
||||
|
||||
/* ============ Tables ============ */
|
||||
|
||||
body.embed-mode .table-responsive {
|
||||
border-radius: var(--dw-radius);
|
||||
}
|
||||
|
||||
body.embed-mode table th,
|
||||
body.embed-mode table td {
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ============ Buttons ============ */
|
||||
|
||||
body.embed-mode .btn,
|
||||
body.embed-mode button {
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ============ Form Elements ============ */
|
||||
|
||||
body.embed-mode input,
|
||||
body.embed-mode select,
|
||||
body.embed-mode textarea {
|
||||
font-size: var(--dw-font-size);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--dw-radius);
|
||||
}
|
||||
|
||||
/* ============ Spacing Adjustments ============ */
|
||||
|
||||
body.embed-mode .page-header {
|
||||
margin-bottom: var(--dw-spacing);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
body.embed-mode .section,
|
||||
body.embed-mode .card-group {
|
||||
gap: var(--dw-spacing);
|
||||
}
|
||||
|
||||
/* ============ Scrollbar (Desktop-optimized thin) ============ */
|
||||
|
||||
body.embed-mode ::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
body.embed-mode ::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body.embed-mode ::-webkit-scrollbar-thumb {
|
||||
background: rgba(139, 148, 158, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
body.embed-mode ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(139, 148, 158, 0.5);
|
||||
}
|
||||
|
||||
/* ============ Status Bar Area ============ */
|
||||
|
||||
body.embed-mode .page-footer,
|
||||
body.embed-mode .status-bar {
|
||||
font-size: 11px;
|
||||
padding: 6px var(--dw-spacing);
|
||||
border-top: 1px solid var(--dw-border);
|
||||
color: var(--dw-text-secondary);
|
||||
}
|
||||
|
||||
/* ============ Compact Mode for Small Windows ============ */
|
||||
|
||||
@container (max-width: 600px) {
|
||||
body.embed-mode .main-content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
body.embed-mode h1 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
body.embed-mode table th,
|
||||
body.embed-mode table td {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -430,8 +430,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 16px 20px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wallpaper-picker-header h3 {
|
||||
@@ -460,25 +460,183 @@
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
/* ---------- Tabs ---------- */
|
||||
|
||||
.wallpaper-picker-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 20px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wp-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #8b949e;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.wp-tab .material-icons {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.wp-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.wp-tab.active {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
/* ---------- Body / Panels ---------- */
|
||||
|
||||
.wallpaper-picker-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wp-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wp-panel::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.wp-panel::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ---------- Color Panel ---------- */
|
||||
|
||||
.wp-color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.wp-color-swatch {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wp-color-swatch:hover {
|
||||
border-color: rgba(88, 166, 255, 0.5);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.wp-color-swatch.active {
|
||||
border-color: #58a6ff;
|
||||
box-shadow: 0 0 12px rgba(88, 166, 255, 0.3);
|
||||
}
|
||||
|
||||
.wp-color-swatch.active::after {
|
||||
content: 'check';
|
||||
font-family: 'Material Icons';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.wp-custom-color {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.wp-custom-color label {
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wp-custom-color input[type="color"] {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
|
||||
.wallpaper-picker-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wp-fit-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wp-fit-selector label {
|
||||
font-size: 13px;
|
||||
color: #8b949e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wp-fit-selector select {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #e6edf3;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.wp-fit-selector select:hover {
|
||||
border-color: rgba(88, 166, 255, 0.4);
|
||||
}
|
||||
|
||||
.wp-fit-selector select:focus {
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.wallpaper-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.wallpaper-grid::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.wallpaper-grid::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.wallpaper-thumb {
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/* BetterDesk Console — Organizations Page Styles */
|
||||
|
||||
.org-page { padding: 0; }
|
||||
|
||||
.org-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.org-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.org-title h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.org-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
background: var(--bd-accent, #4f6ef7);
|
||||
color: #fff;
|
||||
border-radius: 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Organization cards */
|
||||
.org-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.org-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background: var(--card-bg, #fff);
|
||||
border: 1px solid var(--border-color, #e2e5ea);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.org-card:hover {
|
||||
border-color: var(--bd-accent, #4f6ef7);
|
||||
box-shadow: 0 2px 8px rgba(79, 110, 247, 0.08);
|
||||
}
|
||||
|
||||
.org-card-info { display: flex; align-items: center; gap: 12px; flex: 1; }
|
||||
|
||||
.org-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bd-accent, #4f6ef7);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.org-card-text { display: flex; flex-direction: column; gap: 2px; }
|
||||
.org-card-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
text-decoration: none;
|
||||
}
|
||||
.org-card-name:hover { text-decoration: underline; }
|
||||
.org-card-slug {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.org-card-meta { font-size: 0.8rem; color: var(--text-muted, #9ca3af); }
|
||||
.org-card-actions { display: flex; gap: 4px; }
|
||||
|
||||
/* Organization detail page */
|
||||
.org-detail-page { padding: 0; }
|
||||
|
||||
.org-detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.org-detail-info h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.org-detail-slug {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.org-detail-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* Tabs */
|
||||
.org-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border-color, #e2e5ea);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.org-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.org-tab:hover { color: var(--text-primary, #1a1a2e); }
|
||||
.org-tab.active {
|
||||
color: var(--bd-accent, #4f6ef7);
|
||||
border-bottom-color: var(--bd-accent, #4f6ef7);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
display: inline-flex;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-hover, #eef0f4);
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.org-tab.active .tab-count {
|
||||
background: var(--bd-accent, #4f6ef7);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Section header */
|
||||
.org-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.org-section-header h2 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Role badges */
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.role-owner { background: #f59e0b20; color: #d97706; }
|
||||
.role-admin { background: #ef444420; color: #dc2626; }
|
||||
.role-operator { background: #4f6ef720; color: #4f6ef7; }
|
||||
.role-user { background: #10b98120; color: #059669; }
|
||||
|
||||
/* Settings */
|
||||
.org-settings-form {
|
||||
max-width: 500px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.org-settings-raw {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.org-settings-raw h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: var(--card-bg, #fff);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px 0;
|
||||
}
|
||||
|
||||
.modal-header h3 { margin: 0; font-size: 1.1rem; font-weight: 700; }
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body { padding: 20px 24px; }
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 24px 20px;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* BetterDesk Console — Page Transition Animations
|
||||
* Loaded globally. Provides smooth page enter/exit transitions,
|
||||
* staggered list animations, modal animations, and toast slide-in.
|
||||
*
|
||||
* Respects `prefers-reduced-motion` for accessibility.
|
||||
*/
|
||||
|
||||
/* ---- Reduced Motion Guard ---- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-enter,
|
||||
.page-enter-active,
|
||||
.page-exit,
|
||||
.page-exit-active,
|
||||
.stagger-item,
|
||||
.card-appear,
|
||||
.toast-enter,
|
||||
.modal-enter {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Page Transitions ---- */
|
||||
.page-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.page-enter-active {
|
||||
animation: pageEnter 150ms ease-out forwards;
|
||||
}
|
||||
|
||||
.page-exit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.page-exit-active {
|
||||
animation: pageExit 100ms ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes pageEnter {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pageExit {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
/* Auto-apply page enter on main content area */
|
||||
.main-content > .content-wrapper,
|
||||
.main-content > .page-content,
|
||||
.content-area {
|
||||
animation: pageEnter 200ms ease-out;
|
||||
}
|
||||
|
||||
/* ---- Staggered List Items ---- */
|
||||
.stagger-item {
|
||||
opacity: 0;
|
||||
animation: staggerFadeIn 200ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* Generate stagger delays for first 10 items */
|
||||
.stagger-item:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-item:nth-child(2) { animation-delay: 30ms; }
|
||||
.stagger-item:nth-child(3) { animation-delay: 60ms; }
|
||||
.stagger-item:nth-child(4) { animation-delay: 90ms; }
|
||||
.stagger-item:nth-child(5) { animation-delay: 120ms; }
|
||||
.stagger-item:nth-child(6) { animation-delay: 150ms; }
|
||||
.stagger-item:nth-child(7) { animation-delay: 180ms; }
|
||||
.stagger-item:nth-child(8) { animation-delay: 210ms; }
|
||||
.stagger-item:nth-child(9) { animation-delay: 240ms; }
|
||||
.stagger-item:nth-child(10) { animation-delay: 270ms; }
|
||||
|
||||
@keyframes staggerFadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ---- Card Animations ---- */
|
||||
.card-appear {
|
||||
animation: cardScale 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes cardScale {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* ---- Modal Animations ---- */
|
||||
.modal-backdrop-enter {
|
||||
animation: backdropFade 200ms ease-out forwards;
|
||||
}
|
||||
|
||||
.modal-enter {
|
||||
animation: modalSlideUp 200ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.modal-exit {
|
||||
animation: modalSlideDown 150ms ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes backdropFade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes modalSlideUp {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes modalSlideDown {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(10px) scale(0.98); }
|
||||
}
|
||||
|
||||
/* ---- Toast Notifications ---- */
|
||||
.toast-enter {
|
||||
animation: toastSlideIn 250ms ease-out forwards;
|
||||
}
|
||||
|
||||
.toast-exit {
|
||||
animation: toastSlideOut 200ms ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes toastSlideIn {
|
||||
from { opacity: 0; transform: translateX(100%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes toastSlideOut {
|
||||
from { opacity: 1; transform: translateX(0); }
|
||||
to { opacity: 0; transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* Toast progress bar (auto-dismiss indicator) */
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: var(--accent-blue, #58a6ff);
|
||||
border-radius: 0 0 4px 4px;
|
||||
animation: toastShrink var(--toast-duration, 5s) linear forwards;
|
||||
}
|
||||
|
||||
@keyframes toastShrink {
|
||||
from { width: 100%; }
|
||||
to { width: 0; }
|
||||
}
|
||||
|
||||
/* ---- Skeleton Loading ---- */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary, #21262d) 25%,
|
||||
var(--bg-elevated, #30363d) 50%,
|
||||
var(--bg-tertiary, #21262d) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeletonPulse 1.5s ease-in-out infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
height: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.skeleton-text.short { width: 40%; }
|
||||
.skeleton-text.medium { width: 70%; }
|
||||
.skeleton-text.long { width: 100%; }
|
||||
|
||||
.skeleton-circle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes skeletonPulse {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@@ -39,6 +39,27 @@
|
||||
let isFoldableDevice = false;
|
||||
let devicePosture = 'unknown'; // 'continuous', 'folded', 'folded-over'
|
||||
|
||||
// ============ Window Bounds Persistence ============
|
||||
|
||||
/**
|
||||
* Save window position + size for a given app so it can be restored
|
||||
* when the user reopens the same app in a later session.
|
||||
*/
|
||||
function saveWindowBounds(appId, x, y, width, height) {
|
||||
try {
|
||||
var all = JSON.parse(localStorage.getItem(STORAGE_WINS_KEY) || '{}');
|
||||
all[appId] = { x: x, y: y, w: width, h: height };
|
||||
localStorage.setItem(STORAGE_WINS_KEY, JSON.stringify(all));
|
||||
} catch (_) { /* quota exceeded — ignore */ }
|
||||
}
|
||||
|
||||
function loadWindowBounds(appId) {
|
||||
try {
|
||||
var all = JSON.parse(localStorage.getItem(STORAGE_WINS_KEY) || '{}');
|
||||
return all[appId] || null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
// ============ Apps Definition ============
|
||||
|
||||
function getApps() {
|
||||
@@ -209,17 +230,15 @@
|
||||
consoleBtn.addEventListener('click', function() { deactivate(); });
|
||||
}
|
||||
|
||||
// Taskbar start button
|
||||
var startBtn = document.getElementById('taskbar-start-btn');
|
||||
if (startBtn) {
|
||||
startBtn.addEventListener('click', function() { openStartMenu(); });
|
||||
}
|
||||
|
||||
// Taskbar wallpaper button
|
||||
var wallBtn = document.getElementById('taskbar-wallpaper-btn');
|
||||
if (wallBtn) {
|
||||
wallBtn.addEventListener('click', function() {
|
||||
if (window.DesktopWidgets) window.DesktopWidgets.openWallpaperPicker();
|
||||
// Desktop context menu (right-click on desktop background)
|
||||
var shell = document.getElementById('desktop-shell');
|
||||
if (shell) {
|
||||
shell.addEventListener('contextmenu', function(e) {
|
||||
// Only trigger on desktop background, not on windows/widgets
|
||||
if (e.target.closest('.desktop-window') || e.target.closest('.desktop-taskbar') ||
|
||||
e.target.closest('.desktop-widget') || e.target.closest('.desktop-icon')) return;
|
||||
e.preventDefault();
|
||||
showContextMenu(e.clientX, e.clientY);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -229,13 +248,17 @@
|
||||
|
||||
// Responsive: deactivate if viewport shrinks below breakpoint
|
||||
// and re-clamp windows that may be offscreen after resize
|
||||
window.addEventListener('resize', Utils.debounce(function() {
|
||||
var onResize = Utils.debounce(function() {
|
||||
if (active && window.innerWidth < BREAKPOINT) {
|
||||
deactivate(true);
|
||||
return;
|
||||
}
|
||||
if (active) clampAllWindows();
|
||||
}, 200));
|
||||
}, 200);
|
||||
window.addEventListener('resize', onResize);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', onResize);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Activate / Deactivate ============
|
||||
@@ -417,6 +440,62 @@
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// ============ Desktop Context Menu ============
|
||||
|
||||
function showContextMenu(x, y) {
|
||||
var menu = document.getElementById('desktop-context-menu');
|
||||
if (!menu) return;
|
||||
|
||||
// Position menu, ensuring it stays within viewport
|
||||
var area = getDesktopArea();
|
||||
var mw = 200, mh = 160; // approximate menu size
|
||||
var posX = Math.min(x, area.width - mw);
|
||||
var posY = Math.min(y, area.y + area.height - mh);
|
||||
|
||||
menu.style.left = posX + 'px';
|
||||
menu.style.top = posY + 'px';
|
||||
menu.style.display = 'block';
|
||||
|
||||
function closeCtx(e) {
|
||||
if (!menu.contains(e.target)) {
|
||||
menu.style.display = 'none';
|
||||
document.removeEventListener('click', closeCtx);
|
||||
}
|
||||
}
|
||||
setTimeout(function() {
|
||||
document.addEventListener('click', closeCtx);
|
||||
}, 10);
|
||||
|
||||
// Context menu actions
|
||||
menu.querySelectorAll('.ctx-item').forEach(function(item) {
|
||||
item.onclick = function() {
|
||||
menu.style.display = 'none';
|
||||
var action = item.getAttribute('data-action');
|
||||
if (action === 'wallpaper' && window.DesktopWidgets) {
|
||||
window.DesktopWidgets.openWallpaperPicker();
|
||||
} else if (action === 'refresh') {
|
||||
if (window.DesktopWidgets) window.DesktopWidgets.refreshAll();
|
||||
} else if (action === 'exit') {
|
||||
deactivate();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Taskbar Auto-Hide ============
|
||||
|
||||
function updateTaskbarVisibility() {
|
||||
var taskbar = document.getElementById('desktop-taskbar');
|
||||
if (!taskbar) return;
|
||||
|
||||
// Show taskbar only in windows mode or when there are open windows
|
||||
if (currentMode === 'widgets' && windows.size === 0) {
|
||||
taskbar.classList.add('taskbar-hidden');
|
||||
} else {
|
||||
taskbar.classList.remove('taskbar-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Window Management ============
|
||||
|
||||
function openApp(app) {
|
||||
@@ -441,12 +520,23 @@
|
||||
function createWindow(app) {
|
||||
var id = 'win-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5);
|
||||
|
||||
// Calculate position (cascading)
|
||||
// Try to restore saved position + size for this app
|
||||
var saved = loadWindowBounds(app.id);
|
||||
var area = getDesktopArea();
|
||||
var width = Math.min(960, area.width - 80);
|
||||
var height = Math.min(640, area.height - 80);
|
||||
var x = area.x + 60 + (cascadeIndex * CASCADE_OFFSET) % (area.width - width - 60);
|
||||
var y = area.y + 40 + (cascadeIndex * CASCADE_OFFSET) % (area.height - height - 40);
|
||||
var width, height, x, y;
|
||||
|
||||
if (saved && saved.w > 0 && saved.h > 0) {
|
||||
width = Math.min(saved.w, area.width);
|
||||
height = Math.min(saved.h, area.height);
|
||||
x = Math.max(area.x, Math.min(saved.x, area.x + area.width - 80));
|
||||
y = Math.max(area.y, Math.min(saved.y, area.y + area.height - 32));
|
||||
} else {
|
||||
// Default cascading position
|
||||
width = Math.min(960, area.width - 80);
|
||||
height = Math.min(640, area.height - 80);
|
||||
x = area.x + 60 + (cascadeIndex * CASCADE_OFFSET) % (area.width - width - 60);
|
||||
y = area.y + 40 + (cascadeIndex * CASCADE_OFFSET) % (area.height - height - 40);
|
||||
}
|
||||
cascadeIndex++;
|
||||
|
||||
var win = {
|
||||
@@ -570,6 +660,12 @@
|
||||
}
|
||||
|
||||
function closeWindow(id) {
|
||||
var win = windows.get(id);
|
||||
// Persist position before removing
|
||||
if (win && !win.maximized) {
|
||||
saveWindowBounds(win.appId, win.x, win.y, win.width, win.height);
|
||||
}
|
||||
|
||||
var el = document.getElementById(id);
|
||||
if (!el) {
|
||||
windows.delete(id);
|
||||
@@ -754,15 +850,14 @@
|
||||
var win = windows.get(dragState.winId);
|
||||
if (!win) return;
|
||||
|
||||
var vw = window.innerWidth;
|
||||
var vh = window.innerHeight;
|
||||
var area = getDesktopArea();
|
||||
var newX = dragState.origX + dx;
|
||||
var newY = dragState.origY + dy;
|
||||
|
||||
// Clamp: keep at least 80px of title bar visible horizontally
|
||||
newX = Math.max(-win.width + 80, Math.min(newX, vw - 80));
|
||||
// Clamp: don't drag above viewport or below into taskbar
|
||||
newY = Math.max(0, Math.min(newY, vh - TASKBAR_HEIGHT - 32));
|
||||
newX = Math.max(-win.width + 80, Math.min(newX, area.width - 80));
|
||||
// Clamp: stay within desktop area (above taskbar, below topnav)
|
||||
newY = Math.max(area.y, Math.min(newY, area.y + area.height - 32));
|
||||
|
||||
win.x = newX;
|
||||
win.y = newY;
|
||||
@@ -783,11 +878,10 @@
|
||||
var dir = resizeState.dir;
|
||||
var newX = win.x, newY = win.y;
|
||||
var newW = win.width, newH = win.height;
|
||||
var vh = window.innerHeight;
|
||||
var vw = window.innerWidth;
|
||||
var area = getDesktopArea();
|
||||
|
||||
if (dir.indexOf('e') !== -1) {
|
||||
newW = Math.max(MIN_WIDTH, Math.min(resizeState.origW + dx, vw - newX));
|
||||
newW = Math.max(MIN_WIDTH, Math.min(resizeState.origW + dx, area.width - newX));
|
||||
}
|
||||
if (dir.indexOf('w') !== -1) {
|
||||
var dw = resizeState.origW - dx;
|
||||
@@ -797,7 +891,7 @@
|
||||
}
|
||||
}
|
||||
if (dir.indexOf('s') !== -1) {
|
||||
newH = Math.max(MIN_HEIGHT, Math.min(resizeState.origH + dy, vh - TASKBAR_HEIGHT - newY));
|
||||
newH = Math.max(MIN_HEIGHT, Math.min(resizeState.origH + dy, area.y + area.height - newY));
|
||||
}
|
||||
if (dir === 'n' || dir === 'ne' || dir === 'nw') {
|
||||
var dh = resizeState.origH - dy;
|
||||
@@ -823,6 +917,18 @@
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
if (dragState) {
|
||||
var win = windows.get(dragState.winId);
|
||||
if (win && !win.maximized) {
|
||||
saveWindowBounds(win.appId, win.x, win.y, win.width, win.height);
|
||||
}
|
||||
}
|
||||
if (resizeState) {
|
||||
var win = windows.get(resizeState.winId);
|
||||
if (win && !win.maximized) {
|
||||
saveWindowBounds(win.appId, win.x, win.y, win.width, win.height);
|
||||
}
|
||||
}
|
||||
if (dragState || resizeState) {
|
||||
enableIframePointerEvents();
|
||||
document.body.style.cursor = '';
|
||||
@@ -835,25 +941,42 @@
|
||||
* Re-clamp all windows within viewport bounds (e.g. after browser resize).
|
||||
*/
|
||||
function clampAllWindows() {
|
||||
var vw = window.innerWidth;
|
||||
var vh = window.innerHeight;
|
||||
var area = getDesktopArea();
|
||||
windows.forEach(function(win) {
|
||||
if (win.maximized) return;
|
||||
var changed = false;
|
||||
// Keep at least 80px visible horizontally
|
||||
var clampedX = Math.max(-win.width + 80, Math.min(win.x, vw - 80));
|
||||
var clampedY = Math.max(0, Math.min(win.y, vh - TASKBAR_HEIGHT - 32));
|
||||
var el = document.getElementById(win.id);
|
||||
if (!el) return;
|
||||
|
||||
if (win.maximized) {
|
||||
// Re-maximize to new area bounds
|
||||
win.x = area.x;
|
||||
win.y = area.y;
|
||||
win.width = area.width;
|
||||
win.height = area.height;
|
||||
el.style.left = area.x + 'px';
|
||||
el.style.top = area.y + 'px';
|
||||
el.style.width = area.width + 'px';
|
||||
el.style.height = area.height + 'px';
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep at least 80px visible horizontally, stay within area vertically
|
||||
var clampedX = Math.max(-win.width + 80, Math.min(win.x, area.width - 80));
|
||||
var clampedY = Math.max(area.y, Math.min(win.y, area.y + area.height - 32));
|
||||
if (clampedX !== win.x || clampedY !== win.y) {
|
||||
win.x = clampedX;
|
||||
win.y = clampedY;
|
||||
changed = true;
|
||||
el.style.left = win.x + 'px';
|
||||
el.style.top = win.y + 'px';
|
||||
}
|
||||
if (changed) {
|
||||
var el = document.getElementById(win.id);
|
||||
if (el) {
|
||||
el.style.left = win.x + 'px';
|
||||
el.style.top = win.y + 'px';
|
||||
}
|
||||
|
||||
// Shrink window if it exceeds available area
|
||||
if (win.width > area.width) {
|
||||
win.width = area.width;
|
||||
el.style.width = win.width + 'px';
|
||||
}
|
||||
if (win.y + win.height > area.y + area.height) {
|
||||
win.height = Math.max(MIN_HEIGHT, area.y + area.height - win.y);
|
||||
el.style.height = win.height + 'px';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -930,6 +1053,8 @@
|
||||
|
||||
container.appendChild(btn);
|
||||
});
|
||||
|
||||
updateTaskbarVisibility();
|
||||
}
|
||||
|
||||
function clearTaskbar() {
|
||||
@@ -965,13 +1090,27 @@
|
||||
// ============ Helpers ============
|
||||
|
||||
function getDesktopArea() {
|
||||
// Use visualViewport for accurate available space (excludes on-screen keyboards,
|
||||
// browser chrome, etc.). Falls back to window.innerWidth/Height.
|
||||
var vp = window.visualViewport;
|
||||
var vpWidth = vp ? vp.width : window.innerWidth;
|
||||
var vpHeight = vp ? vp.height : window.innerHeight;
|
||||
|
||||
// In widgets mode, taskbar is hidden — full height minus topnav (42px)
|
||||
var bottomOffset = (currentMode === 'widgets') ? 0 : TASKBAR_HEIGHT;
|
||||
|
||||
// Respect CSS safe-area-inset-bottom (accounts for system UI overlap)
|
||||
var safeBottom = 0;
|
||||
try {
|
||||
var cs = getComputedStyle(document.documentElement);
|
||||
safeBottom = parseInt(cs.getPropertyValue('--desktop-safe-bottom'), 10) || 0;
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
return {
|
||||
x: 0,
|
||||
y: 42,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight - 42 - bottomOffset
|
||||
width: vpWidth,
|
||||
height: vpHeight - 42 - bottomOffset - safeBottom
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -92,53 +92,102 @@
|
||||
removeAddButton();
|
||||
}
|
||||
|
||||
/** Refresh all widget data by restarting their update timers. */
|
||||
function refreshAll() {
|
||||
stopAllTimers();
|
||||
_widgets.forEach(function (w) {
|
||||
startWidgetTimer(w);
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Wallpaper ============
|
||||
|
||||
var STORAGE_WALL_FIT = 'bd_widget_wallpaper_fit';
|
||||
|
||||
function loadWallpaper() {
|
||||
var saved = localStorage.getItem(STORAGE_WALL);
|
||||
setWallpaper(saved || '/wallpapers/1.png');
|
||||
var fit = localStorage.getItem(STORAGE_WALL_FIT) || 'cover';
|
||||
applyWallpaper(saved || '/wallpapers/1.png', fit, false);
|
||||
}
|
||||
|
||||
function setWallpaper(url) {
|
||||
/**
|
||||
* Apply wallpaper URL (or solid: prefix) with optional fit mode.
|
||||
* @param {string} url - Image URL or 'solid:#rrggbb'
|
||||
* @param {string} [fit] - 'cover' | 'contain' | 'stretch' | 'center'
|
||||
* @param {boolean} [animate] - crossfade transition (default true)
|
||||
*/
|
||||
function applyWallpaper(url, fit, animate) {
|
||||
_wallpaperPath = url;
|
||||
fit = fit || 'cover';
|
||||
if (animate === undefined) animate = true;
|
||||
var el = document.querySelector('.desktop-wallpaper');
|
||||
if (!el) return;
|
||||
|
||||
// Preload image before transitioning
|
||||
var isSolid = url.indexOf('solid:') === 0;
|
||||
|
||||
if (isSolid) {
|
||||
var color = url.substring(6);
|
||||
el.style.backgroundImage = 'none';
|
||||
el.style.backgroundColor = color;
|
||||
el.style.backgroundSize = '';
|
||||
el.style.backgroundPosition = '';
|
||||
localStorage.setItem(STORAGE_WALL, url);
|
||||
localStorage.setItem(STORAGE_WALL_FIT, fit);
|
||||
return;
|
||||
}
|
||||
|
||||
var sizeMap = { cover: 'cover', contain: 'contain', stretch: '100% 100%', center: 'auto' };
|
||||
var posMap = { cover: 'center', contain: 'center', stretch: 'center', center: 'center' };
|
||||
var bgSize = sizeMap[fit] || 'cover';
|
||||
var bgPos = posMap[fit] || 'center';
|
||||
|
||||
if (!animate) {
|
||||
el.style.backgroundColor = '';
|
||||
el.style.backgroundImage = 'url("' + url + '")';
|
||||
el.style.backgroundSize = bgSize;
|
||||
el.style.backgroundPosition = bgPos;
|
||||
localStorage.setItem(STORAGE_WALL, url);
|
||||
localStorage.setItem(STORAGE_WALL_FIT, fit);
|
||||
return;
|
||||
}
|
||||
|
||||
var img = new Image();
|
||||
img.onload = function() {
|
||||
// Create new wallpaper layer for crossfade
|
||||
var newLayer = document.createElement('div');
|
||||
newLayer.className = 'desktop-wallpaper-new';
|
||||
newLayer.style.backgroundImage = 'url("' + url + '")';
|
||||
newLayer.style.backgroundSize = 'cover';
|
||||
newLayer.style.backgroundPosition = 'center';
|
||||
newLayer.style.backgroundSize = bgSize;
|
||||
newLayer.style.backgroundPosition = bgPos;
|
||||
el.appendChild(newLayer);
|
||||
|
||||
// Trigger crossfade animation
|
||||
requestAnimationFrame(function() {
|
||||
newLayer.classList.add('fade-in');
|
||||
});
|
||||
|
||||
// After transition, update main layer and remove new layer
|
||||
setTimeout(function() {
|
||||
el.style.backgroundColor = '';
|
||||
el.style.backgroundImage = 'url("' + url + '")';
|
||||
el.style.backgroundSize = 'cover';
|
||||
el.style.backgroundPosition = 'center';
|
||||
if (newLayer.parentElement) {
|
||||
newLayer.remove();
|
||||
}
|
||||
}, 600); // Match CSS transition duration
|
||||
el.style.backgroundSize = bgSize;
|
||||
el.style.backgroundPosition = bgPos;
|
||||
if (newLayer.parentElement) newLayer.remove();
|
||||
}, 600);
|
||||
};
|
||||
img.onerror = function() {
|
||||
// Fallback: set directly without transition
|
||||
el.style.backgroundColor = '';
|
||||
el.style.backgroundImage = 'url("' + url + '")';
|
||||
el.style.backgroundSize = 'cover';
|
||||
el.style.backgroundPosition = 'center';
|
||||
el.style.backgroundSize = bgSize;
|
||||
el.style.backgroundPosition = bgPos;
|
||||
};
|
||||
img.src = url;
|
||||
|
||||
localStorage.setItem(STORAGE_WALL, url);
|
||||
localStorage.setItem(STORAGE_WALL_FIT, fit);
|
||||
}
|
||||
|
||||
/** Legacy wrapper — keeps external API backward-compatible. */
|
||||
function setWallpaper(url) {
|
||||
var fit = localStorage.getItem(STORAGE_WALL_FIT) || 'cover';
|
||||
applyWallpaper(url, fit, true);
|
||||
}
|
||||
|
||||
// ============ Layout Persistence ============
|
||||
@@ -839,33 +888,119 @@
|
||||
if (_wallpicker) return;
|
||||
_wallpicker = true;
|
||||
|
||||
var currentFit = localStorage.getItem(STORAGE_WALL_FIT) || 'cover';
|
||||
var isSolid = _wallpaperPath && _wallpaperPath.indexOf('solid:') === 0;
|
||||
var currentColor = isSolid ? _wallpaperPath.substring(6) : '#1a1a2e';
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'wallpaper-picker-overlay';
|
||||
overlay.id = 'wallpaper-picker-overlay';
|
||||
|
||||
// Predefined solid colors
|
||||
var solidColors = [
|
||||
'#0d1117', '#161b22', '#1a1a2e', '#0f3460',
|
||||
'#16213e', '#1b2838', '#2d3436', '#1e272e',
|
||||
'#2c3e50', '#34495e', '#1c1c1c', '#212121',
|
||||
'#263238', '#37474f', '#102027', '#004d40',
|
||||
'#1b5e20', '#b71c1c', '#4a148c', '#311b92'
|
||||
];
|
||||
|
||||
var html = '<div class="wallpaper-picker">' +
|
||||
'<div class="wallpaper-picker-header">' +
|
||||
'<h3>' + esc(t('desktop.wallpaper')) + '</h3>' +
|
||||
'<button class="wallpaper-picker-close"><span class="material-icons">close</span></button>' +
|
||||
'</div>' +
|
||||
'<div class="wallpaper-grid" id="wallpaper-grid"></div></div>';
|
||||
'<div class="wallpaper-picker-tabs">' +
|
||||
'<button class="wp-tab active" data-tab="images">' +
|
||||
'<span class="material-icons">image</span> ' + esc(t('desktop.wp_images')) +
|
||||
'</button>' +
|
||||
'<button class="wp-tab" data-tab="colors">' +
|
||||
'<span class="material-icons">palette</span> ' + esc(t('desktop.wp_colors')) +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="wallpaper-picker-body">' +
|
||||
'<div class="wp-panel" id="wp-panel-images">' +
|
||||
'<div class="wallpaper-grid" id="wallpaper-grid"></div>' +
|
||||
'</div>' +
|
||||
'<div class="wp-panel" id="wp-panel-colors" style="display:none">' +
|
||||
'<div class="wp-color-grid">' +
|
||||
solidColors.map(function(c) {
|
||||
var sel = (isSolid && currentColor === c) ? ' active' : '';
|
||||
return '<button class="wp-color-swatch' + sel + '" data-color="' + c + '" ' +
|
||||
'style="background:' + c + '" title="' + c + '"></button>';
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'<div class="wp-custom-color">' +
|
||||
'<label>' + esc(t('desktop.wp_custom_color')) + '</label>' +
|
||||
'<input type="color" id="wp-custom-color-input" value="' + esc(currentColor) + '">' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wallpaper-picker-footer">' +
|
||||
'<div class="wp-fit-selector">' +
|
||||
'<label>' + esc(t('desktop.wp_fit_style')) + '</label>' +
|
||||
'<select id="wp-fit-select">' +
|
||||
'<option value="cover"' + (currentFit === 'cover' ? ' selected' : '') + '>' + esc(t('desktop.wp_fill')) + '</option>' +
|
||||
'<option value="contain"' + (currentFit === 'contain' ? ' selected' : '') + '>' + esc(t('desktop.wp_fit')) + '</option>' +
|
||||
'<option value="stretch"' + (currentFit === 'stretch' ? ' selected' : '') + '>' + esc(t('desktop.wp_stretch')) + '</option>' +
|
||||
'<option value="center"' + (currentFit === 'center' ? ' selected' : '') + '>' + esc(t('desktop.wp_center')) + '</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
overlay.innerHTML = html;
|
||||
var shell = document.getElementById('desktop-shell');
|
||||
(shell || document.body).appendChild(overlay);
|
||||
|
||||
// Close handlers
|
||||
overlay.querySelector('.wallpaper-picker-close').addEventListener('click', closeWallpaperPicker);
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) closeWallpaperPicker();
|
||||
});
|
||||
|
||||
var grid = overlay.querySelector('#wallpaper-grid');
|
||||
// Tab switching
|
||||
overlay.querySelectorAll('.wp-tab').forEach(function(tab) {
|
||||
tab.addEventListener('click', function() {
|
||||
overlay.querySelectorAll('.wp-tab').forEach(function(t) { t.classList.remove('active'); });
|
||||
tab.classList.add('active');
|
||||
var target = tab.dataset.tab;
|
||||
overlay.querySelectorAll('.wp-panel').forEach(function(p) { p.style.display = 'none'; });
|
||||
var panel = document.getElementById('wp-panel-' + target);
|
||||
if (panel) panel.style.display = '';
|
||||
});
|
||||
});
|
||||
|
||||
// Build all placeholder divs in a single fragment (lightweight, no images yet)
|
||||
// Fit mode change — apply immediately if a wallpaper is already set
|
||||
var fitSelect = overlay.querySelector('#wp-fit-select');
|
||||
fitSelect.addEventListener('change', function() {
|
||||
if (_wallpaperPath) {
|
||||
applyWallpaper(_wallpaperPath, fitSelect.value, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Solid color swatches
|
||||
overlay.querySelectorAll('.wp-color-swatch').forEach(function(swatch) {
|
||||
swatch.addEventListener('click', function() {
|
||||
overlay.querySelectorAll('.wp-color-swatch.active').forEach(function(a) { a.classList.remove('active'); });
|
||||
swatch.classList.add('active');
|
||||
applyWallpaper('solid:' + swatch.dataset.color, fitSelect.value, false);
|
||||
});
|
||||
});
|
||||
|
||||
// Custom color input
|
||||
var customColor = overlay.querySelector('#wp-custom-color-input');
|
||||
customColor.addEventListener('input', function() {
|
||||
overlay.querySelectorAll('.wp-color-swatch.active').forEach(function(a) { a.classList.remove('active'); });
|
||||
applyWallpaper('solid:' + customColor.value, fitSelect.value, false);
|
||||
});
|
||||
|
||||
// Image grid
|
||||
var grid = overlay.querySelector('#wallpaper-grid');
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = 1; i <= WALLPAPER_COUNT; i++) {
|
||||
var wallPath = '/wallpapers/' + i + '.png';
|
||||
var thumbPath = '/wallpapers/thumbs/' + i + '.webp';
|
||||
var active = (_wallpaperPath === wallPath) ? ' active' : '';
|
||||
var active = (!isSolid && _wallpaperPath === wallPath) ? ' active' : '';
|
||||
var thumb = document.createElement('div');
|
||||
thumb.className = 'wallpaper-thumb' + active;
|
||||
thumb.dataset.path = wallPath;
|
||||
@@ -876,13 +1011,13 @@
|
||||
}
|
||||
grid.appendChild(frag);
|
||||
|
||||
// Single click handler via event delegation (instead of 125 individual listeners)
|
||||
// Single click handler via event delegation
|
||||
grid.addEventListener('click', function (e) {
|
||||
var el = e.target.closest('.wallpaper-thumb');
|
||||
if (!el || !el.dataset.path) return;
|
||||
grid.querySelectorAll('.wallpaper-thumb.active').forEach(function (a) { a.classList.remove('active'); });
|
||||
el.classList.add('active');
|
||||
setWallpaper(el.dataset.path);
|
||||
applyWallpaper(el.dataset.path, fitSelect.value, true);
|
||||
closeWallpaperPicker();
|
||||
});
|
||||
|
||||
@@ -909,7 +1044,6 @@
|
||||
_pickerObserver.observe(el);
|
||||
});
|
||||
} else {
|
||||
// Fallback: native lazy loading (no IntersectionObserver)
|
||||
grid.querySelectorAll('.wallpaper-thumb').forEach(function (el) {
|
||||
var ph = el.querySelector('.wallpaper-thumb-placeholder');
|
||||
if (!ph) return;
|
||||
@@ -922,6 +1056,12 @@
|
||||
el.replaceChild(img, ph);
|
||||
});
|
||||
}
|
||||
|
||||
// If currently on solid color, auto-switch to colors tab
|
||||
if (isSolid) {
|
||||
var colorsTab = overlay.querySelector('.wp-tab[data-tab="colors"]');
|
||||
if (colorsTab) colorsTab.click();
|
||||
}
|
||||
}
|
||||
|
||||
function closeWallpaperPicker() {
|
||||
@@ -1251,7 +1391,8 @@
|
||||
openPicker: openPicker,
|
||||
getWidgets: function () { return _widgets; },
|
||||
removeAddButton: removeAddButton,
|
||||
renderAddButton: renderAddButton
|
||||
renderAddButton: renderAddButton,
|
||||
refreshAll: refreshAll
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* BetterDesk Console — Organization Detail Page JavaScript
|
||||
*
|
||||
* Handles org detail view with tabs: Users, Devices, Invitations, Settings.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
const orgId = document.querySelector('.org-detail-page')?.dataset.orgId;
|
||||
if (!orgId) return;
|
||||
|
||||
const API = `/api/panel/org/${orgId}`;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
async function api(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(API + path, opts);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || 'Request failed');
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function toast(msg, type = 'info') {
|
||||
if (window.showToast) window.showToast(msg, type);
|
||||
else console.log(`[${type}]`, msg);
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '—';
|
||||
return new Date(d).toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Tabs
|
||||
// -----------------------------------------------------------------------
|
||||
document.querySelectorAll('.org-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.org-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.org-tab-content').forEach(c => c.style.display = 'none');
|
||||
tab.classList.add('active');
|
||||
document.getElementById(`tab-${tab.dataset.tab}`).style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Load org header
|
||||
// -----------------------------------------------------------------------
|
||||
async function loadOrgHeader() {
|
||||
try {
|
||||
const org = await api('GET', '');
|
||||
document.getElementById('org-detail-name').textContent = org.name;
|
||||
document.getElementById('org-detail-slug').textContent = org.slug;
|
||||
} catch (err) {
|
||||
toast('Failed to load organization', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Users tab
|
||||
// -----------------------------------------------------------------------
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const data = await api('GET', '/users');
|
||||
const users = data.users || [];
|
||||
document.getElementById('users-count').textContent = users.length;
|
||||
const container = document.getElementById('users-table');
|
||||
|
||||
if (users.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">No users in this organization.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Display Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Last Login</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${users.map(u => `
|
||||
<tr>
|
||||
<td><strong>${escHtml(u.username)}</strong></td>
|
||||
<td>${escHtml(u.display_name)}</td>
|
||||
<td>${escHtml(u.email)}</td>
|
||||
<td><span class="role-badge role-${u.role}">${u.role}</span></td>
|
||||
<td>${formatDate(u.last_login)}</td>
|
||||
<td>
|
||||
<button class="btn btn-icon btn-sm" onclick="orgDetail.deleteUser('${u.id}')" title="Remove">
|
||||
<span class="material-icons">person_remove</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch (err) {
|
||||
toast('Failed to load users', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('add-user-btn')?.addEventListener('click', async () => {
|
||||
const username = prompt('Username:');
|
||||
if (!username) return;
|
||||
const password = prompt('Password:');
|
||||
if (!password) return;
|
||||
const role = prompt('Role (owner/admin/operator/user):', 'user');
|
||||
try {
|
||||
await api('POST', '/users', { username, password, role: role || 'user' });
|
||||
toast('User added', 'success');
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Devices tab
|
||||
// -----------------------------------------------------------------------
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const data = await api('GET', '/devices');
|
||||
const devices = data.devices || [];
|
||||
document.getElementById('devices-count').textContent = devices.length;
|
||||
const container = document.getElementById('devices-table');
|
||||
|
||||
if (devices.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">No devices assigned to this organization.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device ID</th>
|
||||
<th>Department</th>
|
||||
<th>Building</th>
|
||||
<th>Location</th>
|
||||
<th>Assigned User</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${devices.map(d => `
|
||||
<tr>
|
||||
<td><a href="/devices/${d.device_id}">${escHtml(d.device_id)}</a></td>
|
||||
<td>${escHtml(d.department)}</td>
|
||||
<td>${escHtml(d.building)}</td>
|
||||
<td>${escHtml(d.location)}</td>
|
||||
<td>${escHtml(d.assigned_user_id)}</td>
|
||||
<td>
|
||||
<button class="btn btn-icon btn-sm btn-danger" onclick="orgDetail.unassignDevice('${d.device_id}')" title="Unassign">
|
||||
<span class="material-icons">link_off</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch (err) {
|
||||
toast('Failed to load devices', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('assign-device-btn')?.addEventListener('click', async () => {
|
||||
const deviceId = prompt('Device ID to assign:');
|
||||
if (!deviceId) return;
|
||||
const dept = prompt('Department (optional):') || '';
|
||||
const building = prompt('Building (optional):') || '';
|
||||
try {
|
||||
await api('POST', '/devices', { device_id: deviceId, department: dept, building });
|
||||
toast('Device assigned', 'success');
|
||||
loadDevices();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Invitations tab
|
||||
// -----------------------------------------------------------------------
|
||||
async function loadInvitations() {
|
||||
try {
|
||||
const data = await api('GET', '/invitations');
|
||||
const invs = data.invitations || [];
|
||||
document.getElementById('invitations-count').textContent = invs.length;
|
||||
const container = document.getElementById('invitations-table');
|
||||
|
||||
if (invs.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">No invitations.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Token</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Expires</th>
|
||||
<th>Used</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${invs.map(inv => `
|
||||
<tr>
|
||||
<td><code>${escHtml(inv.token?.substring(0, 16) + '...')}</code></td>
|
||||
<td>${escHtml(inv.email)}</td>
|
||||
<td><span class="role-badge role-${inv.role}">${inv.role}</span></td>
|
||||
<td>${formatDate(inv.expires_at)}</td>
|
||||
<td>${inv.used_at ? formatDate(inv.used_at) : '—'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch (err) {
|
||||
toast('Failed to load invitations', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('create-invite-btn')?.addEventListener('click', async () => {
|
||||
const email = prompt('Email (optional):') || '';
|
||||
const role = prompt('Role (user/operator/admin):', 'user') || 'user';
|
||||
try {
|
||||
const inv = await api('POST', '/invite', { email, role, expires_in_hours: 72 });
|
||||
toast('Invitation created! Token: ' + (inv.token || '').substring(0, 16) + '...', 'success');
|
||||
loadInvitations();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Settings tab
|
||||
// -----------------------------------------------------------------------
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const data = await api('GET', '/settings');
|
||||
const settings = data.settings || [];
|
||||
const container = document.getElementById('settings-container');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="org-settings-form">
|
||||
<div class="form-group">
|
||||
<label>Connection Policy</label>
|
||||
<select id="setting-connection-policy" class="form-input">
|
||||
<option value="unattended">Unattended (instant)</option>
|
||||
<option value="attended">Attended (with confirmation)</option>
|
||||
<option value="ask_always">Always ask</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Allow File Transfer</label>
|
||||
<select id="setting-allow-file-transfer" class="form-input">
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Allow Clipboard</label>
|
||||
<select id="setting-allow-clipboard" class="form-input">
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max Session Duration (minutes)</label>
|
||||
<input type="number" id="setting-max-session" class="form-input" value="120" min="0" />
|
||||
</div>
|
||||
<button class="btn btn-primary" id="save-settings-btn">Save Settings</button>
|
||||
</div>
|
||||
<div class="org-settings-raw">
|
||||
<h3>Raw Settings</h3>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Key</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
${settings.map(s => `
|
||||
<tr><td><code>${escHtml(s.key)}</code></td><td>${escHtml(s.value)}</td></tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
// Apply current settings to form
|
||||
settings.forEach(s => {
|
||||
const el = document.getElementById(`setting-${s.key.replace(/_/g, '-')}`);
|
||||
if (el) el.value = s.value;
|
||||
});
|
||||
|
||||
document.getElementById('save-settings-btn')?.addEventListener('click', async () => {
|
||||
const settingsToSave = [
|
||||
{ key: 'connection_policy', value: document.getElementById('setting-connection-policy')?.value || 'unattended' },
|
||||
{ key: 'allow_file_transfer', value: document.getElementById('setting-allow-file-transfer')?.value || 'true' },
|
||||
{ key: 'allow_clipboard', value: document.getElementById('setting-allow-clipboard')?.value || 'true' },
|
||||
{ key: 'max_session_duration_min', value: document.getElementById('setting-max-session')?.value || '120' },
|
||||
];
|
||||
try {
|
||||
for (const s of settingsToSave) {
|
||||
await api('PUT', '/settings', s);
|
||||
}
|
||||
toast('Settings saved', 'success');
|
||||
loadSettings();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
toast('Failed to load settings', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Delete org
|
||||
// -----------------------------------------------------------------------
|
||||
document.getElementById('org-delete-btn')?.addEventListener('click', async () => {
|
||||
if (!confirm('Delete this organization and all associated data?')) return;
|
||||
try {
|
||||
await api('DELETE', '');
|
||||
toast('Organization deleted', 'success');
|
||||
window.location.href = '/organizations';
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Init
|
||||
// -----------------------------------------------------------------------
|
||||
loadOrgHeader();
|
||||
loadUsers();
|
||||
loadDevices();
|
||||
loadInvitations();
|
||||
loadSettings();
|
||||
|
||||
// Expose for inline handlers
|
||||
window.orgDetail = {
|
||||
async deleteUser(uid) {
|
||||
if (!confirm('Remove this user from the organization?')) return;
|
||||
try {
|
||||
await api('DELETE', `/users/${uid}`);
|
||||
toast('User removed', 'success');
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
},
|
||||
async unassignDevice(did) {
|
||||
if (!confirm('Unassign this device from the organization?')) return;
|
||||
try {
|
||||
await api('DELETE', `/devices/${did}`);
|
||||
toast('Device unassigned', 'success');
|
||||
loadDevices();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* BetterDesk Console — Organizations List Page JavaScript
|
||||
*
|
||||
* Handles organization CRUD operations on the /organizations page.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
const API_BASE = '/api/panel/org';
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State
|
||||
// -----------------------------------------------------------------------
|
||||
let organizations = [];
|
||||
let editingId = null;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DOM
|
||||
// -----------------------------------------------------------------------
|
||||
const orgList = document.getElementById('org-list');
|
||||
const orgCount = document.getElementById('org-count');
|
||||
const createBtn = document.getElementById('create-org-btn');
|
||||
const modal = document.getElementById('org-modal');
|
||||
const modalTitle = document.getElementById('org-modal-title');
|
||||
const modalClose = document.getElementById('org-modal-close');
|
||||
const modalCancel = document.getElementById('org-modal-cancel');
|
||||
const modalSave = document.getElementById('org-modal-save');
|
||||
const editIdInput = document.getElementById('org-edit-id');
|
||||
const nameInput = document.getElementById('org-name');
|
||||
const slugInput = document.getElementById('org-slug');
|
||||
const logoUrlInput = document.getElementById('org-logo-url');
|
||||
const loading = document.getElementById('org-loading');
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
async function api(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(API_BASE + path, opts);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || 'Request failed');
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function showToast(msg, type = 'info') {
|
||||
if (window.showToast) window.showToast(msg, type);
|
||||
else console.log(`[${type}]`, msg);
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '—';
|
||||
return new Date(d).toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Render
|
||||
// -----------------------------------------------------------------------
|
||||
function renderOrganizations() {
|
||||
if (loading) loading.style.display = 'none';
|
||||
orgCount.textContent = organizations.length;
|
||||
|
||||
if (organizations.length === 0) {
|
||||
orgList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<span class="material-icons" style="font-size:48px;opacity:0.3;">business</span>
|
||||
<p>No organizations yet</p>
|
||||
<p class="text-muted">Create your first organization to start managing devices and users.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
orgList.innerHTML = organizations.map(org => `
|
||||
<div class="org-card" data-id="${org.id}">
|
||||
<div class="org-card-info">
|
||||
<div class="org-card-icon">
|
||||
<span class="material-icons">business</span>
|
||||
</div>
|
||||
<div class="org-card-text">
|
||||
<a href="/organizations/${org.id}" class="org-card-name">${escHtml(org.name)}</a>
|
||||
<span class="org-card-slug">${escHtml(org.slug)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="org-card-meta">
|
||||
<span class="org-card-date">${formatDate(org.created_at)}</span>
|
||||
</div>
|
||||
<div class="org-card-actions">
|
||||
<button class="btn btn-icon btn-sm" onclick="orgPage.edit('${org.id}')" title="Edit">
|
||||
<span class="material-icons">edit</span>
|
||||
</button>
|
||||
<button class="btn btn-icon btn-sm btn-danger" onclick="orgPage.remove('${org.id}')" title="Delete">
|
||||
<span class="material-icons">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Load
|
||||
// -----------------------------------------------------------------------
|
||||
async function loadOrganizations() {
|
||||
try {
|
||||
const data = await api('GET', '');
|
||||
organizations = data.organizations || [];
|
||||
renderOrganizations();
|
||||
} catch (err) {
|
||||
showToast('Failed to load organizations: ' + err.message, 'error');
|
||||
if (loading) loading.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Modal
|
||||
// -----------------------------------------------------------------------
|
||||
function openModal(org) {
|
||||
editingId = org ? org.id : null;
|
||||
editIdInput.value = editingId || '';
|
||||
modalTitle.textContent = org ? 'Edit Organization' : 'Create Organization';
|
||||
nameInput.value = org ? org.name : '';
|
||||
slugInput.value = org ? org.slug : '';
|
||||
logoUrlInput.value = org ? org.logo_url || '' : '';
|
||||
modal.style.display = 'flex';
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.style.display = 'none';
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
// Auto-generate slug from name
|
||||
nameInput?.addEventListener('input', () => {
|
||||
if (!editingId) {
|
||||
slugInput.value = nameInput.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 64);
|
||||
}
|
||||
});
|
||||
|
||||
async function saveOrg() {
|
||||
const body = {
|
||||
name: nameInput.value.trim(),
|
||||
slug: slugInput.value.trim().toLowerCase(),
|
||||
logo_url: logoUrlInput.value.trim(),
|
||||
};
|
||||
if (!body.name || !body.slug) {
|
||||
showToast('Name and slug are required', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await api('PUT', `/${editingId}`, body);
|
||||
showToast('Organization updated', 'success');
|
||||
} else {
|
||||
await api('POST', '', body);
|
||||
showToast('Organization created', 'success');
|
||||
}
|
||||
closeModal();
|
||||
loadOrganizations();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Delete
|
||||
// -----------------------------------------------------------------------
|
||||
async function removeOrg(id) {
|
||||
if (!confirm('Delete this organization? This will remove all associated users, devices, and settings.')) return;
|
||||
try {
|
||||
await api('DELETE', `/${id}`);
|
||||
showToast('Organization deleted', 'success');
|
||||
loadOrganizations();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Events
|
||||
// -----------------------------------------------------------------------
|
||||
createBtn?.addEventListener('click', () => openModal(null));
|
||||
modalClose?.addEventListener('click', closeModal);
|
||||
modalCancel?.addEventListener('click', closeModal);
|
||||
modalSave?.addEventListener('click', saveOrg);
|
||||
|
||||
modal?.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Init
|
||||
// -----------------------------------------------------------------------
|
||||
loadOrganizations();
|
||||
|
||||
// Expose for inline handlers
|
||||
window.orgPage = {
|
||||
edit(id) {
|
||||
const org = organizations.find(o => o.id === id);
|
||||
if (org) openModal(org);
|
||||
},
|
||||
remove: removeOrg,
|
||||
};
|
||||
})();
|
||||
@@ -31,6 +31,7 @@ const cdapRoutes = require('./cdap.routes');
|
||||
const tokensRoutes = require('./tokens.routes');
|
||||
const pagesRoutes = require('./pages.routes');
|
||||
const desktopRoutes = require('./desktop.routes');
|
||||
const organizationsRoutes = require('./organizations.routes');
|
||||
|
||||
/**
|
||||
* Middleware to require JSON Content-Type for POST/PATCH/PUT requests to API routes.
|
||||
@@ -100,5 +101,6 @@ router.use('/', pagesRoutes); // page routes: /inventor
|
||||
router.use('/', cdapRoutes); // admin-facing: /cdap/devices/:id, /api/cdap/*
|
||||
router.use('/', tokensRoutes); // admin-facing: /tokens, /api/panel/tokens/*
|
||||
router.use('/api/desktop', desktopRoutes); // admin-facing: /api/desktop/layout, /api/desktop/wallpapers
|
||||
router.use('/', organizationsRoutes); // admin-facing: /organizations, /api/panel/org/*
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* BetterDesk Console — Organization Management Routes (v3.0.0)
|
||||
*
|
||||
* Proxies organization CRUD operations to the Go server REST API.
|
||||
* Provides page routes for the web panel and API routes for AJAX calls.
|
||||
*
|
||||
* Page routes:
|
||||
* GET /organizations — Organizations management page
|
||||
* GET /organizations/:id — Organization detail page
|
||||
*
|
||||
* API routes (proxy to Go server /api/org/*):
|
||||
* GET /api/panel/org — List organizations
|
||||
* POST /api/panel/org — Create organization
|
||||
* GET /api/panel/org/:id — Get organization
|
||||
* PUT /api/panel/org/:id — Update organization
|
||||
* DELETE /api/panel/org/:id — Delete organization
|
||||
* GET /api/panel/org/:id/users — List org users
|
||||
* POST /api/panel/org/:id/users — Create org user
|
||||
* PUT /api/panel/org/:id/users/:uid — Update org user
|
||||
* DELETE /api/panel/org/:id/users/:uid — Delete org user
|
||||
* POST /api/panel/org/:id/invite — Create invitation
|
||||
* GET /api/panel/org/:id/invitations — List invitations
|
||||
* POST /api/panel/org/:id/devices — Assign device to org
|
||||
* GET /api/panel/org/:id/devices — List org devices
|
||||
* DELETE /api/panel/org/:id/devices/:did — Unassign device
|
||||
* GET /api/panel/org/:id/settings — List org settings
|
||||
* PUT /api/panel/org/:id/settings — Set org setting
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { apiClient } = require('../services/betterdeskApi');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth middleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.session && req.session.user) return next();
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.session && req.session.user && req.session.user.role === 'admin') return next();
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
return res.redirect('/dashboard');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: proxy to Go server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function goApiProxy(req, res, method, path, body) {
|
||||
try {
|
||||
const opts = { method, url: path };
|
||||
if (body) opts.data = body;
|
||||
const resp = await apiClient(opts);
|
||||
res.status(resp.status).json(resp.data);
|
||||
} catch (err) {
|
||||
const status = err.response?.status || 500;
|
||||
const data = err.response?.data || { error: 'Go server unreachable' };
|
||||
res.status(status).json(data);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/organizations', requireAuth, (req, res) => {
|
||||
res.render('organizations', {
|
||||
title: 'Organizations',
|
||||
user: req.session.user,
|
||||
currentPage: 'organizations',
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/organizations/:id', requireAuth, (req, res) => {
|
||||
res.render('organization-detail', {
|
||||
title: 'Organization Details',
|
||||
user: req.session.user,
|
||||
currentPage: 'organizations',
|
||||
orgId: req.params.id,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API routes (proxy to Go server)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Organizations CRUD
|
||||
router.get('/api/panel/org', requireAuth, (req, res) => goApiProxy(req, res, 'get', '/api/org'));
|
||||
router.post('/api/panel/org', requireAdmin, (req, res) => goApiProxy(req, res, 'post', '/api/org', req.body));
|
||||
router.get('/api/panel/org/:id', requireAuth, (req, res) => goApiProxy(req, res, 'get', `/api/org/${req.params.id}`));
|
||||
router.put('/api/panel/org/:id', requireAdmin, (req, res) => goApiProxy(req, res, 'put', `/api/org/${req.params.id}`, req.body));
|
||||
router.delete('/api/panel/org/:id', requireAdmin, (req, res) => goApiProxy(req, res, 'delete', `/api/org/${req.params.id}`));
|
||||
|
||||
// Org Users
|
||||
router.get('/api/panel/org/:id/users', requireAuth, (req, res) => goApiProxy(req, res, 'get', `/api/org/${req.params.id}/users`));
|
||||
router.post('/api/panel/org/:id/users', requireAdmin, (req, res) => goApiProxy(req, res, 'post', `/api/org/${req.params.id}/users`, req.body));
|
||||
router.put('/api/panel/org/:id/users/:uid', requireAdmin, (req, res) => goApiProxy(req, res, 'put', `/api/org/${req.params.id}/users/${req.params.uid}`, req.body));
|
||||
router.delete('/api/panel/org/:id/users/:uid', requireAdmin, (req, res) => goApiProxy(req, res, 'delete', `/api/org/${req.params.id}/users/${req.params.uid}`));
|
||||
|
||||
// Invitations
|
||||
router.post('/api/panel/org/:id/invite', requireAdmin, (req, res) => goApiProxy(req, res, 'post', `/api/org/${req.params.id}/invite`, req.body));
|
||||
router.get('/api/panel/org/:id/invitations', requireAdmin, (req, res) => goApiProxy(req, res, 'get', `/api/org/${req.params.id}/invitations`));
|
||||
|
||||
// Devices
|
||||
router.post('/api/panel/org/:id/devices', requireAuth, (req, res) => goApiProxy(req, res, 'post', `/api/org/${req.params.id}/devices`, req.body));
|
||||
router.get('/api/panel/org/:id/devices', requireAuth, (req, res) => goApiProxy(req, res, 'get', `/api/org/${req.params.id}/devices`));
|
||||
router.delete('/api/panel/org/:id/devices/:did', requireAuth, (req, res) => goApiProxy(req, res, 'delete', `/api/org/${req.params.id}/devices/${req.params.did}`));
|
||||
|
||||
// Settings
|
||||
router.get('/api/panel/org/:id/settings', requireAuth, (req, res) => goApiProxy(req, res, 'get', `/api/org/${req.params.id}/settings`));
|
||||
router.put('/api/panel/org/:id/settings', requireAdmin, (req, res) => goApiProxy(req, res, 'put', `/api/org/${req.params.id}/settings`, req.body));
|
||||
|
||||
module.exports = router;
|
||||
@@ -204,6 +204,70 @@ router.post('/api/settings/branding/import', requireAuth, requireAdmin, async (r
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/themes - List available theme presets
|
||||
*/
|
||||
router.get('/api/settings/themes', requireAuth, (req, res) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const themesDir = path.join(__dirname, '..', 'themes');
|
||||
const themes = [];
|
||||
|
||||
if (fs.existsSync(themesDir)) {
|
||||
for (const file of fs.readdirSync(themesDir)) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(path.join(themesDir, file), 'utf8'));
|
||||
if (data.type === 'betterdesk-theme' && data.branding) {
|
||||
themes.push({
|
||||
id: file.replace('.json', ''),
|
||||
name: data.branding.appName || file.replace('.json', ''),
|
||||
description: data.branding.appDescription || '',
|
||||
colors: data.branding.colors || {}
|
||||
});
|
||||
}
|
||||
} catch { /* skip invalid files */ }
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: themes });
|
||||
} catch (err) {
|
||||
console.error('List themes error:', err);
|
||||
res.status(500).json({ success: false, error: 'Failed to list themes' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/themes/:id/apply - Apply a built-in theme preset (admin only)
|
||||
*/
|
||||
router.post('/api/settings/themes/:id/apply', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const themeFile = path.join(__dirname, '..', 'themes', req.params.id + '.json');
|
||||
|
||||
if (!fs.existsSync(themeFile)) {
|
||||
return res.status(404).json({ success: false, error: 'Theme not found' });
|
||||
}
|
||||
|
||||
const preset = JSON.parse(fs.readFileSync(themeFile, 'utf8'));
|
||||
const success = await brandingService.importPreset(preset);
|
||||
|
||||
if (!success) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid theme format' });
|
||||
}
|
||||
|
||||
const db = require('../services/database');
|
||||
await db.logAction(req.session?.userId, 'theme_apply', `Applied theme: ${req.params.id}`, req.ip);
|
||||
|
||||
res.json({ success: true, message: `Theme "${req.params.id}" applied` });
|
||||
} catch (err) {
|
||||
console.error('Apply theme error:', err);
|
||||
res.status(500).json({ success: false, error: 'Failed to apply theme' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /css/theme.css - Dynamic CSS theme overrides (no auth required, cached)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* BetterDesk Console — i18n Key Completeness Checker
|
||||
*
|
||||
* Compares all language files against en.json (reference) and reports:
|
||||
* - Missing keys (present in en.json but absent in target)
|
||||
* - Extra keys (present in target but absent in en.json)
|
||||
* - Empty values (key exists but value is empty string)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/i18n-check.js # Check all languages
|
||||
* node scripts/i18n-check.js --fix # Add missing keys with English fallback
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = All languages complete
|
||||
* 1 = Missing or extra keys found
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LANG_DIR = path.join(__dirname, '..', 'lang');
|
||||
const REFERENCE = 'en.json';
|
||||
const FIX_MODE = process.argv.includes('--fix');
|
||||
|
||||
/**
|
||||
* Recursively flatten nested JSON object into dot-notation keys
|
||||
* @param {object} obj
|
||||
* @param {string} prefix
|
||||
* @returns {Map<string, string>}
|
||||
*/
|
||||
function flattenKeys(obj, prefix = '') {
|
||||
const result = new Map();
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
for (const [k, v] of flattenKeys(value, fullKey)) {
|
||||
result.set(k, v);
|
||||
}
|
||||
} else {
|
||||
result.set(fullKey, String(value ?? ''));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a nested key in an object using dot notation
|
||||
* @param {object} obj
|
||||
* @param {string} dotKey
|
||||
* @param {string} value
|
||||
*/
|
||||
function setNestedKey(obj, dotKey, value) {
|
||||
const parts = dotKey.split('.');
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!(parts[i] in current) || typeof current[parts[i]] !== 'object') {
|
||||
current[parts[i]] = {};
|
||||
}
|
||||
current = current[parts[i]];
|
||||
}
|
||||
current[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
const refPath = path.join(LANG_DIR, REFERENCE);
|
||||
if (!fs.existsSync(refPath)) {
|
||||
console.error(`Reference file not found: ${refPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const refData = JSON.parse(fs.readFileSync(refPath, 'utf8'));
|
||||
const refKeys = flattenKeys(refData);
|
||||
const refKeySet = new Set(refKeys.keys());
|
||||
|
||||
console.log(`\n BetterDesk i18n Checker`);
|
||||
console.log(` Reference: ${REFERENCE} (${refKeySet.size} keys)\n`);
|
||||
|
||||
const langFiles = fs.readdirSync(LANG_DIR)
|
||||
.filter(f => f.endsWith('.json') && f !== REFERENCE)
|
||||
.sort();
|
||||
|
||||
let hasErrors = false;
|
||||
const summary = [];
|
||||
|
||||
for (const file of langFiles) {
|
||||
const filePath = path.join(LANG_DIR, file);
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(` ✗ ${file}: Invalid JSON — ${e.message}`);
|
||||
hasErrors = true;
|
||||
summary.push({ file, missing: '?', extra: '?', empty: '?', status: 'PARSE ERROR' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const langKeys = flattenKeys(data);
|
||||
const langKeySet = new Set(langKeys.keys());
|
||||
|
||||
const missing = [...refKeySet].filter(k => !langKeySet.has(k));
|
||||
const extra = [...langKeySet].filter(k => !refKeySet.has(k));
|
||||
const empty = [...langKeySet].filter(k => refKeySet.has(k) && langKeys.get(k) === '');
|
||||
|
||||
const ok = missing.length === 0 && extra.length === 0;
|
||||
const icon = ok ? '✓' : '✗';
|
||||
|
||||
console.log(` ${icon} ${file}: ${langKeySet.size} keys`);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.log(` Missing (${missing.length}):`);
|
||||
for (const k of missing.slice(0, 20)) {
|
||||
console.log(` - ${k}`);
|
||||
}
|
||||
if (missing.length > 20) {
|
||||
console.log(` ... and ${missing.length - 20} more`);
|
||||
}
|
||||
}
|
||||
|
||||
if (extra.length > 0) {
|
||||
console.log(` Extra (${extra.length}):`);
|
||||
for (const k of extra.slice(0, 10)) {
|
||||
console.log(` + ${k}`);
|
||||
}
|
||||
if (extra.length > 10) {
|
||||
console.log(` ... and ${extra.length - 10} more`);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty.length > 0) {
|
||||
console.log(` Empty values (${empty.length}):`);
|
||||
for (const k of empty.slice(0, 10)) {
|
||||
console.log(` ~ ${k}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fix mode: add missing keys with English values
|
||||
if (FIX_MODE && missing.length > 0) {
|
||||
for (const k of missing) {
|
||||
setNestedKey(data, k, refKeys.get(k));
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||
console.log(` → Fixed: added ${missing.length} missing keys with English fallback`);
|
||||
}
|
||||
|
||||
if (!ok) hasErrors = true;
|
||||
|
||||
const coverage = refKeySet.size > 0
|
||||
? Math.round(((refKeySet.size - missing.length) / refKeySet.size) * 100)
|
||||
: 100;
|
||||
|
||||
summary.push({
|
||||
file,
|
||||
missing: missing.length,
|
||||
extra: extra.length,
|
||||
empty: empty.length,
|
||||
coverage: `${coverage}%`,
|
||||
status: ok ? 'OK' : 'INCOMPLETE'
|
||||
});
|
||||
}
|
||||
|
||||
// Summary table
|
||||
console.log('\n ┌──────────────┬─────────┬───────┬───────┬──────────┬──────────┐');
|
||||
console.log(' │ Language │ Missing │ Extra │ Empty │ Coverage │ Status │');
|
||||
console.log(' ├──────────────┼─────────┼───────┼───────┼──────────┼──────────┤');
|
||||
for (const s of summary) {
|
||||
const lang = s.file.padEnd(12);
|
||||
const miss = String(s.missing).padStart(7);
|
||||
const ext = String(s.extra).padStart(5);
|
||||
const emp = String(s.empty).padStart(5);
|
||||
const cov = (s.coverage || '?').padStart(8);
|
||||
const stat = (s.status || '?').padStart(8);
|
||||
console.log(` │ ${lang} │${miss} │${ext} │${emp} │${cov} │${stat} │`);
|
||||
}
|
||||
console.log(' └──────────────┴─────────┴───────┴───────┴──────────┴──────────┘');
|
||||
|
||||
if (hasErrors) {
|
||||
console.log('\n ⚠ Some languages are incomplete. Run with --fix to add missing keys.\n');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n ✓ All languages have 100% key coverage.\n');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"type": "betterdesk-theme",
|
||||
"branding": {
|
||||
"appName": "BetterDesk",
|
||||
"appDescription": "RustDesk Server Management",
|
||||
"logoType": "icon",
|
||||
"logoIcon": "dns",
|
||||
"logoSvg": "",
|
||||
"logoUrl": "",
|
||||
"faviconSvg": "",
|
||||
"colors": {
|
||||
"bgPrimary": "#ffffff",
|
||||
"bgSecondary": "#f6f8fa",
|
||||
"bgTertiary": "#eaeef2",
|
||||
"bgElevated": "#ffffff",
|
||||
"textPrimary": "#1f2328",
|
||||
"textSecondary": "#656d76",
|
||||
"accentBlue": "#0969da",
|
||||
"accentBlueHover": "#0550ae",
|
||||
"accentBlueMuted": "#ddf4ff",
|
||||
"accentGreen": "#1a7f37",
|
||||
"accentGreenHover": "#116329",
|
||||
"accentGreenMuted": "#dafbe1",
|
||||
"accentRed": "#cf222e",
|
||||
"accentRedHover": "#a40e26",
|
||||
"accentRedMuted": "#ffebe9",
|
||||
"accentYellow": "#9a6700",
|
||||
"accentYellowHover": "#7d4e00",
|
||||
"accentYellowMuted": "#fff8c5",
|
||||
"accentPurple": "#8250df",
|
||||
"accentPurpleHover": "#6639ba",
|
||||
"accentPurpleMuted": "#fbefff",
|
||||
"borderPrimary": "#d0d7de",
|
||||
"borderSecondary": "#eaeef2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,13 @@
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" href="/css/main.css?v=<%= cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/theme.css?v=<%= cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/transitions.css?v=<%= cacheVersion %>">
|
||||
<% if (!embed) { %>
|
||||
<link rel="stylesheet" href="/css/desktop-mode.css?v=<%= cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/desktop-widgets.css?v=<%= cacheVersion %>">
|
||||
<link rel="stylesheet" href="/css/tutorial.css?v=<%= cacheVersion %>">
|
||||
<% } else { %>
|
||||
<link rel="stylesheet" href="/css/desktop-widget-overrides.css?v=<%= cacheVersion %>">
|
||||
<% } %>
|
||||
<% if (typeof pageStyles !== 'undefined' && pageStyles.length) { %>
|
||||
<% pageStyles.forEach(style => { %>
|
||||
@@ -56,22 +59,21 @@
|
||||
<div class="desktop-icons" id="desktop-icons"></div>
|
||||
<div class="desktop-windows" id="desktop-windows"></div>
|
||||
<div class="desktop-taskbar" id="desktop-taskbar">
|
||||
<div class="taskbar-start">
|
||||
<button class="taskbar-start-btn" id="taskbar-start-btn" title="<%= appName %>">
|
||||
<span class="material-icons">grid_view</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="taskbar-apps" id="taskbar-apps"></div>
|
||||
<div class="taskbar-right">
|
||||
<button class="taskbar-btn" id="taskbar-wallpaper-btn" title="<%= _('desktop.wallpaper') %>">
|
||||
<span class="material-icons">wallpaper</span>
|
||||
</button>
|
||||
<button class="taskbar-btn taskbar-console-btn" id="taskbar-console-btn" title="<%= _('desktop.exit_desktop') %>">
|
||||
<span class="material-icons">view_sidebar</span>
|
||||
</button>
|
||||
<div class="taskbar-clock" id="taskbar-clock"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Desktop right-click context menu -->
|
||||
<div class="desktop-context-menu" id="desktop-context-menu" style="display:none">
|
||||
<button class="ctx-item" data-action="wallpaper"><span class="material-icons">wallpaper</span><%= _('desktop.wallpaper') %></button>
|
||||
<button class="ctx-item" data-action="refresh"><span class="material-icons">refresh</span><%= _('desktop.refresh') || 'Refresh' %></button>
|
||||
<div class="ctx-divider"></div>
|
||||
<button class="ctx-item" data-action="exit"><span class="material-icons">view_sidebar</span><%= _('desktop.exit_desktop') %></button>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<%- include('layouts/main', {
|
||||
title: _('organizations.detail') || 'Organization Details',
|
||||
pageStyles: ['organizations'],
|
||||
pageScripts: ['organizationDetail'],
|
||||
currentPage: 'organizations',
|
||||
breadcrumb: [
|
||||
{ label: _('nav.organizations') || 'Organizations', href: '/organizations' },
|
||||
{ label: _('organizations.detail') || 'Details' }
|
||||
],
|
||||
body: `
|
||||
<div class="org-detail-page" data-org-id="${orgId}">
|
||||
<!-- Header -->
|
||||
<div class="org-detail-header" id="org-detail-header">
|
||||
<div class="org-detail-info">
|
||||
<h1 id="org-detail-name">Loading...</h1>
|
||||
<span class="org-detail-slug" id="org-detail-slug"></span>
|
||||
</div>
|
||||
<div class="org-detail-actions">
|
||||
<button class="btn btn-secondary btn-sm" id="org-edit-btn">
|
||||
<span class="material-icons">edit</span> ${_('common.edit') || 'Edit'}
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" id="org-delete-btn">
|
||||
<span class="material-icons">delete</span> ${_('common.delete') || 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="org-tabs">
|
||||
<button class="org-tab active" data-tab="users">
|
||||
<span class="material-icons">group</span> ${_('organizations.users') || 'Users'}
|
||||
<span class="tab-count" id="users-count">0</span>
|
||||
</button>
|
||||
<button class="org-tab" data-tab="devices">
|
||||
<span class="material-icons">devices</span> ${_('organizations.devices') || 'Devices'}
|
||||
<span class="tab-count" id="devices-count">0</span>
|
||||
</button>
|
||||
<button class="org-tab" data-tab="invitations">
|
||||
<span class="material-icons">mail</span> ${_('organizations.invitations') || 'Invitations'}
|
||||
<span class="tab-count" id="invitations-count">0</span>
|
||||
</button>
|
||||
<button class="org-tab" data-tab="settings">
|
||||
<span class="material-icons">settings</span> ${_('organizations.settings') || 'Settings'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div class="org-tab-content" id="tab-users">
|
||||
<div class="org-section-header">
|
||||
<h2>${_('organizations.users') || 'Users'}</h2>
|
||||
<button class="btn btn-primary btn-sm" id="add-user-btn">
|
||||
<span class="material-icons">person_add</span> ${_('organizations.add_user') || 'Add User'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="org-table-container" id="users-table"></div>
|
||||
</div>
|
||||
|
||||
<div class="org-tab-content" id="tab-devices" style="display:none;">
|
||||
<div class="org-section-header">
|
||||
<h2>${_('organizations.devices') || 'Devices'}</h2>
|
||||
<button class="btn btn-primary btn-sm" id="assign-device-btn">
|
||||
<span class="material-icons">add_circle</span> ${_('organizations.assign_device') || 'Assign Device'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="org-table-container" id="devices-table"></div>
|
||||
</div>
|
||||
|
||||
<div class="org-tab-content" id="tab-invitations" style="display:none;">
|
||||
<div class="org-section-header">
|
||||
<h2>${_('organizations.invitations') || 'Invitations'}</h2>
|
||||
<button class="btn btn-primary btn-sm" id="create-invite-btn">
|
||||
<span class="material-icons">link</span> ${_('organizations.create_invitation') || 'Create Invitation'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="org-table-container" id="invitations-table"></div>
|
||||
</div>
|
||||
|
||||
<div class="org-tab-content" id="tab-settings" style="display:none;">
|
||||
<div class="org-section-header">
|
||||
<h2>${_('organizations.settings') || 'Settings'}</h2>
|
||||
</div>
|
||||
<div class="org-settings-container" id="settings-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}) %>
|
||||
@@ -0,0 +1,61 @@
|
||||
<%- include('layouts/main', {
|
||||
title: _('nav.organizations') || 'Organizations',
|
||||
pageStyles: ['organizations'],
|
||||
pageScripts: ['organizations'],
|
||||
currentPage: 'organizations',
|
||||
breadcrumb: [{ label: _('nav.organizations') || 'Organizations' }],
|
||||
body: `
|
||||
<div class="org-page">
|
||||
<!-- Header -->
|
||||
<div class="org-header">
|
||||
<div class="org-title">
|
||||
<h1>${_('organizations.title') || 'Organizations'}</h1>
|
||||
<span class="org-count" id="org-count">0</span>
|
||||
</div>
|
||||
<div class="org-actions">
|
||||
<button class="btn btn-primary btn-sm" id="create-org-btn">
|
||||
<span class="material-icons">add</span>
|
||||
<span class="btn-label">${_('organizations.create') || 'Create Organization'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Organization list -->
|
||||
<div class="org-list" id="org-list">
|
||||
<div class="loading-spinner" id="org-loading">
|
||||
<div class="spinner"></div>
|
||||
<p>${_('common.loading') || 'Loading...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit modal -->
|
||||
<div class="modal-overlay" id="org-modal" style="display:none;">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h3 id="org-modal-title">${_('organizations.create') || 'Create Organization'}</h3>
|
||||
<button class="modal-close" id="org-modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="org-edit-id" />
|
||||
<div class="form-group">
|
||||
<label>${_('organizations.name') || 'Name'}</label>
|
||||
<input type="text" id="org-name" class="form-input" placeholder="ACME Corp" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${_('organizations.slug') || 'Slug (URL-safe)'}</label>
|
||||
<input type="text" id="org-slug" class="form-input" placeholder="acme-corp" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${_('organizations.logo_url') || 'Logo URL'}</label>
|
||||
<input type="text" id="org-logo-url" class="form-input" placeholder="https://..." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="org-modal-cancel">${_('common.cancel') || 'Cancel'}</button>
|
||||
<button class="btn btn-primary" id="org-modal-save">${_('common.save') || 'Save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}) %>
|
||||
@@ -111,7 +111,12 @@
|
||||
<span class="material-icons">business</span>
|
||||
<span class="sidebar-link-text"><%= _('nav.tenants') %></span>
|
||||
</a>
|
||||
|
||||
|
||||
<a href="/organizations" class="sidebar-link <%= currentPage === 'organizations' ? 'active' : '' %>">
|
||||
<span class="material-icons">corporate_fare</span>
|
||||
<span class="sidebar-link-text"><%= _('nav.organizations') %></span>
|
||||
</a>
|
||||
|
||||
<a href="/dataguard" class="sidebar-link <%= currentPage === 'dataguard' ? 'active' : '' %>">
|
||||
<span class="material-icons">shield</span>
|
||||
<span class="sidebar-link-text"><%= _('nav.dataguard') %></span>
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
pageScripts: [],
|
||||
body: `
|
||||
|
||||
<!-- WIP Banner -->
|
||||
<div style="position: fixed; top: 0; left: 0; right: 0; z-index: 9999; background: linear-gradient(90deg, #f59e0b, #d97706); color: #fff; text-align: center; padding: 10px 15px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);">
|
||||
🚧 Web Remote Client is under active development and may not function correctly. 🚧
|
||||
<!-- Beta Banner -->
|
||||
<div class="bdv-beta-banner" id="bdv-beta-banner">
|
||||
<span>🔧 Web Remote Client — Beta</span>
|
||||
<button onclick="this.parentElement.style.display='none'" class="bdv-beta-dismiss">×</button>
|
||||
</div>
|
||||
|
||||
<!-- BetterDesk Native Remote Desktop Viewer -->
|
||||
@@ -46,11 +47,28 @@
|
||||
<span class="bdv-toolbar-sep"></span>
|
||||
<span class="bdv-badge" id="bdv-fps-badge">—</span>
|
||||
</div>
|
||||
<div class="bdv-toolbar-center">
|
||||
<!-- Scale mode selector -->
|
||||
<select class="bdv-select" id="bdvScaleMode" title="Scale Mode">
|
||||
<option value="fit" selected>Fit</option>
|
||||
<option value="fill">Fill</option>
|
||||
<option value="1:1">1:1</option>
|
||||
<option value="stretch">Stretch</option>
|
||||
</select>
|
||||
<!-- Monitor selector (hidden until multi-monitor detected) -->
|
||||
<select class="bdv-select bdv-hidden" id="bdvMonitor" title="Monitor"></select>
|
||||
</div>
|
||||
<div class="bdv-toolbar-right">
|
||||
<button class="bdv-btn" id="btnClipboard" title="Clipboard Sync">
|
||||
<span class="material-icons">content_paste</span>
|
||||
</button>
|
||||
<button class="bdv-btn" id="btnSpecialKeys" title="Special Keys">
|
||||
<span class="material-icons">keyboard</span>
|
||||
</button>
|
||||
<button class="bdv-btn" id="btnChat" title="Open Chat">
|
||||
<span class="material-icons">chat</span>
|
||||
</button>
|
||||
<button class="bdv-btn" id="btnFullscreen" title="Fullscreen">
|
||||
<button class="bdv-btn" id="btnFullscreen" title="Fullscreen (F11)">
|
||||
<span class="material-icons">fullscreen</span>
|
||||
</button>
|
||||
<button class="bdv-btn bdv-btn--danger" id="btnStop" title="Stop session">
|
||||
@@ -59,6 +77,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Special Keys dropdown menu -->
|
||||
<div class="bdv-dropdown bdv-hidden" id="bdv-special-keys-menu">
|
||||
<button class="bdv-dropdown-item" data-keys="ctrl+alt+delete">Ctrl+Alt+Del</button>
|
||||
<button class="bdv-dropdown-item" data-keys="meta">Win / Super</button>
|
||||
<button class="bdv-dropdown-item" data-keys="printscreen">Print Screen</button>
|
||||
<button class="bdv-dropdown-item" data-keys="alt+tab">Alt+Tab</button>
|
||||
<button class="bdv-dropdown-item" data-keys="alt+f4">Alt+F4</button>
|
||||
<button class="bdv-dropdown-item" data-keys="ctrl+shift+escape">Task Manager</button>
|
||||
</div>
|
||||
|
||||
<!-- Chat sidebar -->
|
||||
<div class="bdv-chat" id="bdv-chat">
|
||||
<div class="bdv-chat-header">
|
||||
@@ -180,6 +208,25 @@
|
||||
overlay.style.display = 'flex';
|
||||
showActions();
|
||||
break;
|
||||
case 'clipboard':
|
||||
// Receive clipboard text from remote device
|
||||
if (frame.text && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(frame.text).catch(() => {});
|
||||
}
|
||||
break;
|
||||
case 'monitors':
|
||||
// Populate monitor selector when remote reports multiple displays
|
||||
if (frame.list && frame.list.length > 1) {
|
||||
monitorSelect.innerHTML = '';
|
||||
frame.list.forEach((m, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = i;
|
||||
opt.textContent = m.name || ('Monitor ' + (i + 1));
|
||||
monitorSelect.appendChild(opt);
|
||||
});
|
||||
monitorSelect.classList.remove('bdv-hidden');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +336,54 @@
|
||||
icon.textContent = document.fullscreenElement ? 'fullscreen_exit' : 'fullscreen';
|
||||
});
|
||||
|
||||
// F11 shortcut for fullscreen
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'F11') {
|
||||
e.preventDefault();
|
||||
document.getElementById('btnFullscreen').click();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Scale Mode ----
|
||||
const scaleSelect = document.getElementById('bdvScaleMode');
|
||||
scaleSelect.addEventListener('change', () => {
|
||||
sendRemote({ type: 'scale_mode', mode: scaleSelect.value });
|
||||
});
|
||||
|
||||
// ---- Monitor Selector ----
|
||||
const monitorSelect = document.getElementById('bdvMonitor');
|
||||
monitorSelect.addEventListener('change', () => {
|
||||
const idx = parseInt(monitorSelect.value, 10);
|
||||
sendRemote({ type: 'switch_monitor', index: idx });
|
||||
});
|
||||
|
||||
// ---- Special Keys ----
|
||||
const specialKeysMenu = document.getElementById('bdv-special-keys-menu');
|
||||
document.getElementById('btnSpecialKeys').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
specialKeysMenu.classList.toggle('bdv-hidden');
|
||||
};
|
||||
document.addEventListener('click', () => specialKeysMenu.classList.add('bdv-hidden'));
|
||||
specialKeysMenu.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('[data-keys]');
|
||||
if (!item) return;
|
||||
sendRemote({ type: 'special_key', combo: item.dataset.keys });
|
||||
specialKeysMenu.classList.add('bdv-hidden');
|
||||
});
|
||||
|
||||
// ---- Clipboard Sync ----
|
||||
document.getElementById('btnClipboard').onclick = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) sendRemote({ type: 'clipboard', text });
|
||||
} catch {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
sendRemote({ type: 'clipboard_request' });
|
||||
} catch { /* Clipboard API not available */ }
|
||||
}
|
||||
};
|
||||
|
||||
// Toolbar auto-hide
|
||||
let hideTimer;
|
||||
document.getElementById('bdv-container').addEventListener('mousemove', () => {
|
||||
@@ -425,11 +520,57 @@
|
||||
.bdv-toolbar-left, .bdv-toolbar-right {
|
||||
display: flex; align-items: center; gap: 8px; color: #e8e8f0;
|
||||
}
|
||||
.bdv-toolbar-right { margin-left: auto; }
|
||||
.bdv-toolbar-center {
|
||||
display: flex; align-items: center; gap: 8px; margin: 0 auto;
|
||||
}
|
||||
.bdv-toolbar-right { margin-left: 0; }
|
||||
|
||||
.bdv-toolbar-sep { width: 1px; height: 16px; background: #444; }
|
||||
.bdv-badge { background: rgba(255,255,255,.1); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; color: #ccc; }
|
||||
|
||||
.bdv-select {
|
||||
background: rgba(255,255,255,.1);
|
||||
border: 1px solid rgba(255,255,255,.15);
|
||||
border-radius: 4px;
|
||||
color: #e8e8f0;
|
||||
font-size: 0.75rem;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.bdv-select:hover { background: rgba(255,255,255,.2); }
|
||||
.bdv-select option { background: #1a1a2e; color: #e8e8f0; }
|
||||
.bdv-hidden { display: none !important; }
|
||||
|
||||
.bdv-beta-banner {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
|
||||
background: linear-gradient(90deg, #3b82f6, #2563eb);
|
||||
color: #fff; text-align: center; padding: 6px 15px;
|
||||
font-size: 12px; font-weight: 500;
|
||||
display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
}
|
||||
.bdv-beta-dismiss {
|
||||
background: none; border: none; color: rgba(255,255,255,.7);
|
||||
cursor: pointer; font-size: 16px; line-height: 1;
|
||||
}
|
||||
.bdv-beta-dismiss:hover { color: #fff; }
|
||||
|
||||
.bdv-dropdown {
|
||||
position: absolute; top: 52px; right: 200px;
|
||||
background: #1a1a2e; border: 1px solid #2d2d4a;
|
||||
border-radius: 8px; z-index: 25;
|
||||
padding: 4px 0; min-width: 180px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.5);
|
||||
}
|
||||
.bdv-dropdown-item {
|
||||
display: block; width: 100%;
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: #e8e8f0; font-size: 0.85rem;
|
||||
padding: 8px 16px; text-align: left;
|
||||
transition: background .15s;
|
||||
}
|
||||
.bdv-dropdown-item:hover { background: rgba(255,255,255,.1); }
|
||||
|
||||
.bdv-btn {
|
||||
background: none; border: none; cursor: pointer; color: #e8e8f0;
|
||||
padding: 4px; border-radius: 4px;
|
||||
|
||||
Reference in New Issue
Block a user