feat: organizations, desktop mode, i18n (ja/ko/ru), security docs, client i18n integration

This commit is contained in:
UNITRONIX
2026-03-25 20:27:04 +01:00
parent 3e3f6748db
commit 95f2beb744
50 changed files with 12692 additions and 342 deletions
+78
View File
@@ -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
+207
View File
@@ -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
+128
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
+659
View File
@@ -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",
})
}
+19
View File
@@ -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)
+99
View File
@@ -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)
}
+60
View File
@@ -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 {
+366
View File
@@ -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()
}
+62
View File
@@ -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 {
+487
View File
@@ -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
View File
@@ -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*
+66
View File
@@ -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.*
+64
View File
@@ -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
+85
View File
@@ -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 |
+106
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "用户"
}
}
+37 -22
View File
@@ -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();
}
+3 -1
View File
@@ -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",
+89 -39
View File
@@ -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;
}
}
+172 -14
View File
@@ -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;
+255
View File
@@ -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;
}
+196
View File
@@ -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; }
}
+182 -43
View File
@@ -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
};
}
+166 -25
View File
@@ -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
};
})();
+382
View File
@@ -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');
}
},
};
})();
+222
View File
@@ -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,
};
})();
+2
View File
@@ -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;
+124
View File
@@ -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;
+64
View File
@@ -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)
*/
+184
View File
@@ -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);
}
+38
View File
@@ -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"
}
}
}
+10 -8
View File
@@ -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>
<% } %>
+86
View File
@@ -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>
`
}) %>
+61
View File
@@ -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">&times;</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>
`
}) %>
+6 -1
View File
@@ -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>
+146 -5
View File
@@ -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">&times;</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;