diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5569b414 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,311 @@ +# ============================================================================= +# NanoKVM Multi-User RBAC Fork – Release Workflow +# +# Wird durch einen Tag im Format v-rbac. ausgelöst, z.B.: +# git tag v2.4.1-rbac.1 +# git push origin v2.4.1-rbac.1 +# +# Baut ein nanokvm_.tar.gz, generiert latest.json und veröffentlicht +# beides als GitHub Release. +# ============================================================================= + +name: Build and Release + +on: + push: + tags: + - 'v*-rbac.*' + workflow_dispatch: + inputs: + tag: + description: 'Manueller Tag, z.B. v2.4.1-rbac.1' + required: true + type: string + +env: + # Auf welchen Fork zeigen die Auto-Updates? Wird in service.go gepatcht. + FORK_REPO: Schattenwelt/NanoKVM + +jobs: + build: + runs-on: ubuntu-22.04 + permissions: + contents: write # nötig für softprops/action-gh-release + + steps: + # ----------------------------------------------------------------------- + # 0. Setup + # ----------------------------------------------------------------------- + - name: Checkout fork + uses: actions/checkout@v4 + + - name: Parse version from tag + id: ver + run: | + if [ -n "${{ inputs.tag }}" ]; then + TAG="${{ inputs.tag }}" + else + TAG="${GITHUB_REF#refs/tags/}" + fi + # TAG: v2.4.1-rbac.1 + VERSION="${TAG#v}" # 2.4.1-rbac.1 + UPSTREAM="${VERSION%%-*}" # 2.4.1 + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "upstream=$UPSTREAM" >> "$GITHUB_OUTPUT" + echo "" + echo "Tag: $TAG" + echo "Version: $VERSION" + echo "Upstream: $UPSTREAM" + + # ----------------------------------------------------------------------- + # 1. Upstream-Paket herunterladen und auspacken + # ----------------------------------------------------------------------- + - name: Download upstream Sipeed package + run: | + UPSTREAM="${{ steps.ver.outputs.upstream }}" + URL="https://github.com/sipeed/NanoKVM/releases/download/${UPSTREAM}/nanokvm_${UPSTREAM}.tar.gz" + echo "Downloading: $URL" + curl -fL -o /tmp/upstream.tar.gz "$URL" + ls -la /tmp/upstream.tar.gz + + - name: Extract upstream package + run: | + mkdir -p _build/upstream + tar -xzf /tmp/upstream.tar.gz -C _build/upstream + + # Top-Level-Ordner finden (z.B. "nanokvm_2.4.1") + DEPLOY_DIR="_build/upstream/$(ls _build/upstream | head -n1)" + echo "DEPLOY_DIR=$DEPLOY_DIR" >> "$GITHUB_ENV" + + # server/ im Original finden (wo NanoKVM-Server-Binary liegt) + ORIG_SERVER_DIR="$(dirname "$(find "$DEPLOY_DIR" -name NanoKVM-Server -type f | head -n1)")" + echo "ORIG_SERVER_DIR=$ORIG_SERVER_DIR" >> "$GITHUB_ENV" + + echo "Deploy dir: $DEPLOY_DIR" + echo "Server dir: $ORIG_SERVER_DIR" + + # ----------------------------------------------------------------------- + # 2. Frontend bauen + # ----------------------------------------------------------------------- + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: web/package-lock.json + + - name: Build frontend + working-directory: web + run: | + if [ -f package-lock.json ]; then + npm ci + else + npm install + fi + npm run build + ls -la dist/ + + # ----------------------------------------------------------------------- + # 3. Go + RISC-V musl Cross-Compiler + # ----------------------------------------------------------------------- + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Install RISC-V musl cross-compiler + run: | + # Fallback-Liste wie in deinem Skript + URLS=( + "https://musl.cc/riscv64-linux-musl-cross.tgz" + "https://more.musl.cc/11.2.1/x86_64-linux-musl/riscv64-linux-musl-cross.tgz" + "https://github.com/dockcross/dockcross/releases/download/20240418-88c04a4/riscv64-linux-musl-cross.tgz" + ) + OK=0 + for URL in "${URLS[@]}"; do + echo "Trying $URL ..." + if wget -q --tries=2 --timeout=60 "$URL" -O /tmp/mc.tgz && [ "$(stat -c%s /tmp/mc.tgz)" -gt 1000000 ]; then + OK=1; break + fi + done + [ "$OK" = "1" ] || { echo "Failed to fetch musl cross-compiler"; exit 1; } + + sudo tar -xf /tmp/mc.tgz -C /opt + GCC_BIN="$(find /opt -name riscv64-linux-musl-gcc -type f | head -n1)" + GCC_DIR="$(dirname "$GCC_BIN")" + + # Wrapper damit "riscv64-musl-gcc" als Name funktioniert (wie dein Skript) + echo '#!/bin/sh' | sudo tee /usr/local/bin/riscv64-musl-gcc > /dev/null + echo "exec $GCC_BIN \"\$@\"" | sudo tee -a /usr/local/bin/riscv64-musl-gcc > /dev/null + sudo chmod +x /usr/local/bin/riscv64-musl-gcc + + # PATH erweitern für nachfolgende Steps + echo "$GCC_DIR" >> "$GITHUB_PATH" + riscv64-musl-gcc --version + + # ----------------------------------------------------------------------- + # 4. Pre-Build: dl_lib aus dem Original holen, service.go umbiegen, + # go.mod patchen + # ----------------------------------------------------------------------- + - name: Copy dl_lib from upstream into source + run: | + cp -r "${ORIG_SERVER_DIR}/dl_lib" server/dl_lib + ls server/dl_lib | head + + - name: Patch service.go (Update-URLs auf den Fork umbiegen) + run: | + FILE=server/service/application/service.go + sed -i \ + -e "s|https://cdn.sipeed.com/nanokvm/preview|https://github.com/${FORK_REPO}/releases/latest/download|g" \ + -e "s|https://cdn.sipeed.com/nanokvm|https://github.com/${FORK_REPO}/releases/latest/download|g" \ + "$FILE" + echo "--- gepatchte URLs ---" + grep -E "StableURL|PreviewURL" "$FILE" + + - name: Patch go.mod (1:1 wie das Build-Skript) + working-directory: server + run: | + # Falls dein Fork das schon committed hat, ist das hier idempotent. + sed -i -E \ + -e 's|^go 1\.[0-9]+|go 1.22|' \ + -e 's|golang\.org/x/net v[0-9.]+|golang.org/x/net v0.33.0|' \ + -e 's|golang\.org/x/sys v[0-9.]+|golang.org/x/sys v0.28.0|' \ + -e 's|golang\.org/x/crypto v[0-9.]+|golang.org/x/crypto v0.31.0|' \ + -e 's|golang\.org/x/text v[0-9.]+|golang.org/x/text v0.21.0|' \ + -e 's|github\.com/pion/dtls/v3 v[0-9.]+|github.com/pion/dtls/v3 v3.0.4|' \ + -e 's|github\.com/pion/webrtc/v4 v[0-9.]+|github.com/pion/webrtc/v4 v4.0.0|' \ + go.mod + rm -f go.sum + echo "--- go.mod (Head) ---" + head -30 go.mod + + # ----------------------------------------------------------------------- + # 5. Server bauen + # ----------------------------------------------------------------------- + - name: Build NanoKVM-Server (riscv64 + musl + CGO) + working-directory: server + env: + GOOS: linux + GOARCH: riscv64 + CGO_ENABLED: '1' + CC: riscv64-musl-gcc + CGO_CFLAGS: '-I./include' + CGO_LDFLAGS: '-L./dl_lib -lkvm -Wl,-rpath,$ORIGIN/dl_lib -Wl,--allow-shlib-undefined' + GOPROXY: 'https://goproxy.io|direct' + GOFLAGS: '-mod=mod' + GONOSUMCHECK: '*' + GOTOOLCHAIN: 'local' + run: | + go mod download || true # nicht fatal, fehlende Pakete werden beim Build nachgezogen + go build -ldflags="-s -w" -o /tmp/NanoKVM-Server . + ls -la /tmp/NanoKVM-Server + file /tmp/NanoKVM-Server + + # ----------------------------------------------------------------------- + # 6. Binary + Frontend einsetzen, S95nanokvm patchen + # ----------------------------------------------------------------------- + - name: Swap binary into upstream package + run: | + cp /tmp/NanoKVM-Server "${ORIG_SERVER_DIR}/NanoKVM-Server" + chmod +x "${ORIG_SERVER_DIR}/NanoKVM-Server" + + - name: Replace web assets (Frontend mit Benutzerverwaltung) + run: | + WEB_DST="${ORIG_SERVER_DIR}/web" + + # sipeed.ico aus dem Original sichern + ICO_BAK="" + if [ -f "$WEB_DST/sipeed.ico" ]; then + cp "$WEB_DST/sipeed.ico" /tmp/sipeed.ico.bak + ICO_BAK=1 + fi + + rm -rf "$WEB_DST" + mkdir -p "$WEB_DST" + cp -r web/dist/* "$WEB_DST/" + + # versehentlich kopierter dist-Unterordner raus + rm -rf "$WEB_DST/dist" + + # sipeed.ico zurück + if [ -n "$ICO_BAK" ]; then + cp /tmp/sipeed.ico.bak "$WEB_DST/sipeed.ico" + fi + + echo "Web-Assets: $(find "$WEB_DST" -type f | wc -l) Dateien" + + - name: Patch S95nanokvm init scripts (LD_LIBRARY_PATH) + run: | + PATCHED=0 + while IFS= read -r script; do + if ! grep -q "LD_LIBRARY_PATH=/tmp/server/dl_lib" "$script"; then + sed -i 's|^ /tmp/server/NanoKVM-Server &| LD_LIBRARY_PATH=/tmp/server/dl_lib /tmp/server/NanoKVM-Server \&|' "$script" + echo "Patched: $script" + PATCHED=$((PATCHED+1)) + fi + done < <(find "$DEPLOY_DIR" -name S95nanokvm -type f) + echo "Insgesamt gepatcht: $PATCHED" + + # ----------------------------------------------------------------------- + # 7. tar.gz packen + # ----------------------------------------------------------------------- + - name: Repackage as nanokvm_.tar.gz + id: pack + run: | + VERSION="${{ steps.ver.outputs.version }}" + PKG_NAME="nanokvm_${VERSION}.tar.gz" + + # innerer Ordnername soll die neue Version tragen + cd _build/upstream + OLD_NAME="$(ls | head -n1)" + mv "$OLD_NAME" "nanokvm_${VERSION}" + tar -czf "/tmp/${PKG_NAME}" "nanokvm_${VERSION}" + + ls -la "/tmp/${PKG_NAME}" + echo "pkg_path=/tmp/${PKG_NAME}" >> "$GITHUB_OUTPUT" + echo "pkg_name=${PKG_NAME}" >> "$GITHUB_OUTPUT" + + # ----------------------------------------------------------------------- + # 8. latest.json für Online-Update generieren + # ----------------------------------------------------------------------- + - name: Generate latest.json + run: | + PKG="${{ steps.pack.outputs.pkg_path }}" + NAME="${{ steps.pack.outputs.pkg_name }}" + VERSION="${{ steps.ver.outputs.version }}" + + SHA=$(openssl dgst -sha512 -binary "$PKG" | base64 -w0) + SIZE=$(stat -c%s "$PKG") + + cat > /tmp/latest.json < ⚠️ **Unofficial Community Fork** +> This is a modified version of [sipeed/NanoKVM](https://github.com/sipeed/NanoKVM), maintained independently by [@Schattenwelt](https://github.com/Schattenwelt). Builds are kept in sync with the upstream firmware — see the [Releases](https://github.com/Schattenwelt/NanoKVM/releases) page for the upstream version each release was built against. +> It is **not** affiliated with, endorsed by, or supported by Sipeed. +> For the official firmware, support, and warranty, please use the [upstream project](https://github.com/sipeed/NanoKVM). +## 🔀 What's different in this fork? + +This fork extends NanoKVM with **multi-user support and role-based access control (RBAC)** on the backend, plus the matching frontend UI for user management. + +### 👥 Three built-in roles + +| Role | Stream | Power / Reset (GPIO) | Terminal & Scripts | HID Paste / Shortcuts | System Config (HDMI/SSH/Reboot/Hostname/TLS) | User Management | +| ------------ | :----: | :------------------: | :----------------: | :-------------------: | :------------------------------------------: | :-------------: | +| **viewer** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **operator** | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| **admin** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | + +Read-only endpoints (info, hardware, mDNS state, hostname, web title, autostart list, etc.) are accessible by **any authenticated user**. + +### 🔐 Other security features + +- **bcrypt** password hashing (no plaintext or simple-hash storage) +- **JWT** session cookies (`nano-kvm-token`) carrying username + role +- **Brute-force protection** on the login endpoint +- **Enable/disable** users without deleting them +- **Last-admin protection**: the last enabled admin account cannot be deleted +- **Self-delete protection**: admins cannot delete their own account +- Internal loopback endpoints (used by `kvm_system` / picoclaw) are gated by a separate loopback token, not JWT + +### 📁 Storage & migration + +- User accounts are stored in `/etc/kvm/accounts.json` (mode `0600`, JSON, bcrypt-hashed passwords). +- On first start, an existing single-user setup in `/etc/kvm/pwd` is **automatically migrated** — the existing user becomes the initial `admin`. The legacy file is removed after migration. +- If no accounts file exists, a default account is created: **username `admin` / password `admin`** (change it immediately on first login). + +### 🌐 Frontend additions + +- New settings pages: `Settings → Users` (list / add / edit / disable / delete) and `Settings → Account` (change own password). +- Login and password pages reused from upstream, extended for the new account model. +- User-management strings are translated for **all 24 supported UI languages**; any locale not explicitly translated falls back to English via i18next. + +## 📥 Installation + +> ⚠️ **This is an update package, not a full SD-card image.** You need a working NanoKVM with the official Sipeed firmware already running. Each fork release is built against a specific upstream version — pick the release that matches (or is closest to) the firmware currently on your device. See the [Releases](https://github.com/Schattenwelt/NanoKVM/releases) page for the compatibility note on each release. + +1. Download the matching `nanokvm_.tar.gz` from the [fork's Releases page](https://github.com/Schattenwelt/NanoKVM/releases). +2. Open the NanoKVM web UI and go to **Settings → Check for updates → Offline update**. +3. Upload the `.tar.gz` and let the device apply it. The NanoKVM service will restart automatically. +4. Log in (existing credentials still work — they have been migrated to the new format). +5. Go to **Settings → Users**, create your accounts, assign roles, and disable the default `admin` if appropriate. + +### Rolling back + +The official firmware images from Sipeed are GPL-3.0 and can be re-flashed (or applied as an offline update) at any time. Always back up `/etc/kvm/accounts.json` if you want to preserve users across reinstalls. + +## 🏗️ Building from source + +All sources of this fork (Go backend + React frontend + RBAC patches) are +in this repository under **GPL-3.0**. For most users, the pre-built +`.tar.gz` on the [Releases](https://github.com/Schattenwelt/NanoKVM/releases) +page is the recommended install path; the section below is for people who +want to rebuild themselves. + +### What needs to happen + +A working build produces a Sipeed offline-update `.tar.gz` containing: + +1. The fork's `NanoKVM-Server` binary, cross-compiled for the device. +2. The fork's `web/` bundle (built with Vite). +3. The unchanged upstream `kvm_system`, init scripts (with + `LD_LIBRARY_PATH=/tmp/server/dl_lib`), and helper utilities. + +### Toolchain + +- Go **1.22** with `GOOS=linux GOARCH=riscv64 CGO_ENABLED=1` +- A RISC-V musl cross-toolchain — e.g. `riscv64-linux-musl-cross` + from [musl.cc](https://musl.cc/) +- The Sipeed `libkvm.so` (extract `server/dl_lib/` from any official + Sipeed offline-update tarball) +- Node **20+** and **pnpm** for the React frontend +- Standard `tar` to repack the result into the Sipeed update format + +### High-level steps + +1. Build the frontend: `cd web && pnpm install && pnpm build` → `web/dist/`. +2. Build the backend against `libkvm.so` using the musl cross-compiler. +3. Take an official Sipeed offline-update `.tar.gz` of the matching + upstream version as a "host package" and replace inside it: + - `server/NanoKVM-Server` with your rebuild + - `server/web/` with `web/dist/` + - `etc/init.d/S95nanokvm` patched to set `LD_LIBRARY_PATH=/tmp/server/dl_lib` +4. Repack as `nanokvm_.tar.gz`. Apply via the device's + **Settings → Check for updates → Offline update**. + +> The exact build orchestration used to produce the official releases of +> this fork is not distributed publicly. If you intend to rebuild and +> are stuck, please open an issue. + +For frontend-only iteration, see [`web/README.md`](web/README.md) — +`pnpm dev` against a NanoKVM device on the LAN. + ## 🌟 What is NanoKVM? NanoKVM is a series of compact, open-source IP-KVM devices based on the LicheeRV Nano (RISC-V). It lets you remotely access and control computers as if you were sitting in front of them, making it useful for servers, embedded systems, and other headless machines. -## 📦 Product Family +## 📦 Compatible Hardware -Choose the NanoKVM model that best fits your deployment: +This fork tracks the upstream NanoKVM firmware and targets the SG2002-based hardware: -- **NanoKVM-Cube Lite:** A barebones kit for DIY users and bulk deployments. -- **NanoKVM-Cube Full:** A ready-to-use kit with a case, accessories, and a pre-flashed system SD card. -- **NanoKVM-PCIe:** A PCIe-bracket form factor for internal chassis mounting. It draws power from the PCIe slot and supports optional Wi-Fi and PoE. -- **[NanoKVM-Pro](https://github.com/sipeed/NanoKVM-Pro):** A higher-performance version with major upgrades: - - **Resolution:** Up to **4K@30fps / 2K@60fps**. - - **Network:** **1Gbps Ethernet + PoE + Wi-Fi 6**, upgraded from 100Mbps Ethernet. - - **Latency:** Hardware-accelerated encoding reduces latency from 100-150ms to **50-100ms**. +- **NanoKVM-Cube Lite / Full** (SG2002, microSD) +- **NanoKVM-PCIe** (SG2002, microSD) -
- NanoKVM Product Family -
+Each release on GitHub notes the upstream firmware version it was built against. + +> **Not in scope:** [NanoKVM-Pro](https://github.com/sipeed/NanoKVM-Pro) (AX630C / ARM) uses a different codebase and is **not** supported by this fork. -> If you are looking for a USB-based KVM solution, check out [NanoKVM-USB](https://github.com/sipeed/NanoKVM-USB). - -## 🛠️ Technical Specifications - -| Feature | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM | -| ------------------ | ------------------------------------- | --------------------------------- | ---------------------------------- | ----------------------------------- | -| Core | AX630C 2xA53 1.2G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G | -| Memory & Storage | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMC | 256M DDR3 + 16G eMMC | -| System | NanoKVM / PiKVM | NanoKVM | GxxKVM | JxxKVM | -| Resolution | 4K@30fps / 2K@60fps | 1080P@60fps | 4K@30fps / 2K@60fps | 1080P@60fps | -| HDMI Loopout | 4K loopout | — | — | — | -| Video Encoding | MJPEG / H.264 / H.265 | MJPEG / H.264 | MJPEG / H.264 | MJPEG / H.264 | -| Audio Transmit | ✓ | — | ✓ | — | -| UEFI / BIOS | ✓ | ✓ | ✓ | ✓ | -| Emulated USB Keyboard & Mouse | ✓ | ✓ | ✓ | ✓ | -| Emulated USB ISO | ✓ | ✓ | ✓ | ✓ | -| IPMI | ✓ | ✓ | ✓ | — | -| Wake-on-LAN | ✓ | ✓ | ✓ | ✓ | -| Web Terminal | ✓ | ✓ | ✓ | ✓ | -| Serial Terminal | 2 channels | 2 channels | — | 1 channel | -| Custom Scripts | ✓ | ✓ | — | — | -| Storage | 32G eMMC 300MB/s | 32G MicroSD 12MB/s | 8G eMMC 120MB/s | 8G eMMC 60MB/s | -| Ethernet | 1000M | 100M | 1000M | 100M | -| PoE | Optional | Optional | — | — | -| Wi-Fi | Optional Wi-Fi 6 | Optional Wi-Fi 6 | — | — | -| ATX Power Control | ✓ | ✓ | Extra $15 | Extra $10 | -| Display | 1.47" 320x172 LCD / 0.96" 128x64 OLED | 0.96" 128x64 OLED | — | 1.68" 280x240 | -| More Features | Sync LED Strip / Smart Assistant | — | — | — | -| Power Consumption | 0.6A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V | -| Power Input | USB-C or PoE | USB-C | USB-C | USB-C | -| Dimensions | 65x65x26mm | 40x36x36mm | 80x60x17.5mm | 60x43x(24~31)mm | +If you are looking for a USB-based KVM solution, check out [NanoKVM-USB](https://github.com/sipeed/NanoKVM-USB). ## 📂 Project Structure ```text -├── kvmapp # APP update package -│ ├── jpg_stream # Legacy support for direct updates from older versions -│ ├── kvm_new_app # Triggers components for kvm_system updates -│ ├── kvm_system # Core KVM application -│ ├── server # Front-end and back-end integration -│ └── system # Essential system components -├── web # NanoKVM Front-end (UI) -├── server # NanoKVM Back-end (Service) -├── support # Auxiliary modules (Image subsystem, status, updates, OLED, HID, etc.) -├── ... +├── server # NanoKVM Back-end (Go) — RBAC-extended +│ ├── service/auth/ # accounts, roles, login, brute-force, password +│ │ ├── account.go # /etc/kvm/accounts.json, bcrypt, legacy migration +│ │ ├── users.go # admin user-management endpoints +│ │ ├── login.go / password.go # session and own-password endpoints +│ │ └── brute_force.go # rate-limited login attempts +│ ├── middleware/jwt.go # CheckToken + RequireRole(...) +│ └── router/ # endpoint groups by required role +├── web # React front-end — extended with: +│ ├── src/pages/desktop/menu/settings/users/ # admin user management UI +│ └── src/pages/desktop/menu/settings/account/ # own account / password UI +├── kvmapp # unchanged from upstream (kvm_system, system, …) +└── support # unchanged from upstream (sg2002 modules) ``` ## 💻 Development -Start with the guide that matches the part of NanoKVM you want to work on: - -- **System support modules:** Build and update the low-level hardware support components in [support/sg2002/README.md](support/sg2002/README.md). -- **Backend service:** Set up, build, and understand the Go service in [server/README.md](server/README.md). -- **Frontend UI:** Develop, lint, and build the React interface in [web/README.md](web/README.md). - -> Backend compilation and runtime validation require the target toolchain or a NanoKVM device. See the module-specific guides above for the latest development workflow. +For module-level guides, see the upstream documentation: -## 🔩 Hardware Platform (NanoKVM Cube/PCIe) +- **Backend service:** [`server/README.md`](server/README.md) +- **Frontend UI:** [`web/README.md`](web/README.md) +- **System support modules:** [`support/sg2002/README.md`](support/sg2002/README.md) -NanoKVM is based on Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html). You can find specifications, schematics, and dimensional drawings in the [download station](https://dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano). +The RBAC additions live entirely in: -The NanoKVM Cube/PCIe hardware is built from these components: +- `server/service/auth/` +- `server/middleware/jwt.go` +- `server/router/*.go` (each router group now uses `RequireRole(...)` where appropriate) +- `web/src/pages/desktop/menu/settings/users/` and `.../account/` -- **NanoKVM Lite:** LicheeRV Nano plus the HDMI-to-CSI board. -- **NanoKVM Full:** NanoKVM Lite plus the NanoKVM-A/B boards and enclosure. -- **HDMI-to-CSI board:** Converts the HDMI input signal. -- **NanoKVM-A board:** Provides OLED, ATX control output through USB-C, auxiliary power, and physical ATX power/reset buttons. -- **NanoKVM-B board:** Connects NanoKVM-A to the host computer's ATX pins for remote power control. - -The NanoKVM image is built with the LicheeRV Nano SDK and MaixCDK. It is intended for NanoKVM hardware and is not a general-purpose KVM software package for other LicheeRV Nano or SG2002 products. If you want to build an HDMI input application on LicheeRV Nano or MaixCam, please contact us for technical support. - -> Note: Of the 256MB memory on SG2002, 158MB is currently allocated to the multimedia subsystem for video capture and processing. +## 🤝 Contributing -- [NanoKVM-A Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf) -- [NanoKVM-B Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_B_30131.pdf) -- [NanoKVM-PCIe Schematic](https://cn.dl.sipeed.com/fileList/KVM/KVM_PCIE/HDK/01_Schematic/SCH_nanoKVM_PCIE_3105D_2025-12-19.pdf) -- [NanoKVM image](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM) +Contributions to this fork are welcome: -
- NanoKVM PCB Pinout -
+1. Fork **this** repository (`Schattenwelt/NanoKVM`). +2. Create a feature branch off `multi-user-rbac`. +3. Commit your changes (please keep PRs small and focused). +4. Push to your branch and open a Pull Request against `multi-user-rbac`. -## 🤝 Contributing +> Changes that are not RBAC-related and could benefit all NanoKVM users are better submitted to the [upstream project](https://github.com/sipeed/NanoKVM) directly. -We welcome contributions. To get started: +## 💬 Community & Support -1. Fork the repository. -2. Create a feature branch. -3. Commit your changes. -4. Push to the branch. -5. Open a Pull Request. +- **Issues with this fork:** [GitHub Issues](https://github.com/Schattenwelt/NanoKVM/issues) +- **Hardware questions / official firmware:** [Sipeed Discord](https://discord.gg/V4sAZ9XWpN), [Upstream FAQ](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/faq.html), or [support@sipeed.com](mailto:support@sipeed.com) -Please keep your pull requests small and focused to facilitate easier review and merging. +> ⚠️ Please **do not** open issues about this fork on the upstream Sipeed repository, and do not contact Sipeed support about problems that only occur with this fork's build. -> 🎁 **Contributors who submit high-quality Pull Requests may receive a NanoKVM Cube, PCIe, or Pro as a token of our appreciation!** +## 🛒 Where to Buy the Hardware -## 🛒 Where to Buy +The hardware itself is unchanged — buy a regular NanoKVM and apply this update on top of the matching stock firmware. - [AliExpress (global, except USA and Russia)](https://www.aliexpress.com/item/1005007369816019.html) - [Taobao](https://item.taobao.com/item.htm?id=811206560480) -- [Preorder for other regions](https://sipeed.com/nanokvm) +- [Sipeed online shop](https://sipeed.com/nanokvm) -## 💬 Community & Support +## 🙏 Credits + +- Original project: **[sipeed/NanoKVM](https://github.com/sipeed/NanoKVM)** — © Sipeed, licensed under GPL-3.0. +- Multi-user RBAC extensions, frontend UI, and build tooling: **[@Schattenwelt](https://github.com/Schattenwelt)** and contributors to this fork. +- Built with the LicheeRV Nano SDK and MaixCDK. -- [Discord](https://discord.gg/V4sAZ9XWpN) -- QQ group: 703230713 -- Email: [support@sipeed.com](mailto:support@sipeed.com) -- [FAQ](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/faq.html) +Trademarks "NanoKVM", "Sipeed", and "LicheeRV" belong to their respective owners. Their use in this README is descriptive only and does not imply endorsement. ## 📜 License -This project is licensed under the GPL-3.0 License. See [LICENSE](LICENSE) for details. +This project is licensed under the **GPL-3.0 License**, the same license as the upstream NanoKVM project. See [LICENSE](LICENSE) for the full text. + +All modifications in this fork are released under the same license. A summary of changes relative to upstream is kept in [CHANGELOG.md](CHANGELOG.md). + +> **No warranty.** This software is provided "as is", without warranty of any kind, to the extent permitted by applicable law. See sections 15 and 16 of the GPL-3.0 for details. diff --git a/server/common/kvm_vision.go b/server/common/kvm_vision.go index 99d3a95b..3a9bbc7c 100644 --- a/server/common/kvm_vision.go +++ b/server/common/kvm_vision.go @@ -1,3 +1,5 @@ +//go:build cgo + package common /* diff --git a/server/common/kvm_vision_stub.go b/server/common/kvm_vision_stub.go new file mode 100644 index 00000000..a545c4e4 --- /dev/null +++ b/server/common/kvm_vision_stub.go @@ -0,0 +1,42 @@ +//go:build !cgo + +package common + +import ( + "sync" + + log "github.com/sirupsen/logrus" +) + +var ( + kvmVision *KvmVision + kvmVisionOnce sync.Once +) + +type KvmVision struct{} + +func GetKvmVision() *KvmVision { + kvmVisionOnce.Do(func() { + kvmVision = &KvmVision{} + log.Debugf("kvm vision stub initialized (no CGO)") + }) + return kvmVision +} + +func (k *KvmVision) ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int) { + return nil, -1 +} + +func (k *KvmVision) ReadH264(width uint16, height uint16, bitRate uint16) (data []byte, result int) { + return nil, -1 +} + +func (k *KvmVision) SetHDMI(enable bool) int { + return 0 +} + +func (k *KvmVision) SetGop(gop uint8) {} + +func (k *KvmVision) SetFrameDetect(frame uint8) {} + +func (k *KvmVision) Close() {} diff --git a/server/middleware/jwt.go b/server/middleware/jwt.go index 0891917b..dceb50c9 100644 --- a/server/middleware/jwt.go +++ b/server/middleware/jwt.go @@ -11,19 +11,51 @@ import ( "NanoKVM-Server/config" ) +// Role constants mirrored here to avoid circular imports. +const ( + RoleAdmin = "admin" + RoleOperator = "operator" + RoleViewer = "viewer" +) + type Token struct { Username string `json:"username"` + Role string `json:"role"` jwt.RegisteredClaims } +// CheckToken allows any authenticated user. func CheckToken() gin.HandlerFunc { return func(c *gin.Context) { - if allowByToken(c) { - c.Next() + token, ok := parseTokenFromContext(c) + if !ok { + abortUnauthorized(c) return } + // Store username and role for downstream handlers. + c.Set("username", token.Username) + c.Set("role", token.Role) + c.Next() + } +} - abortUnauthorized(c) +// RequireRole returns a middleware that only allows users with one of the given roles. +func RequireRole(roles ...string) gin.HandlerFunc { + allowed := make(map[string]bool, len(roles)) + for _, r := range roles { + allowed[r] = true + } + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists { + abortForbidden(c) + return + } + if !allowed[role.(string)] { + abortForbidden(c) + return + } + c.Next() } } @@ -33,36 +65,43 @@ func CheckLoopbackInternalToken() gin.HandlerFunc { c.Next() return } - abortUnauthorized(c) } } func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc { return func(c *gin.Context) { - if allowByToken(c) || allowByLoopbackInternalToken(c.Request) { + token, ok := parseTokenFromContext(c) + if ok { + c.Set("username", token.Username) + c.Set("role", token.Role) + c.Next() + return + } + if allowByLoopbackInternalToken(c.Request) { c.Next() return } - abortUnauthorized(c) } } -func allowByToken(c *gin.Context) bool { +func parseTokenFromContext(c *gin.Context) (*Token, bool) { conf := config.GetInstance() - if conf.Authentication == "disable" { - return true + c.Set("username", "admin") + c.Set("role", RoleAdmin) + return &Token{Username: "admin", Role: RoleAdmin}, true } - cookie, err := c.Cookie("nano-kvm-token") if err != nil { - return false + return nil, false } - - _, err = ParseJWT(cookie) - return err == nil + token, err := ParseJWT(cookie) + if err != nil { + return nil, false + } + return token, true } func abortUnauthorized(c *gin.Context) { @@ -70,26 +109,27 @@ func abortUnauthorized(c *gin.Context) { c.Abort() } -func GenerateJWT(username string) (string, error) { - conf := config.GetInstance() +func abortForbidden(c *gin.Context) { + c.JSON(http.StatusForbidden, "forbidden: insufficient permissions") + c.Abort() +} +func GenerateJWT(username, role string) (string, error) { + conf := config.GetInstance() expireDuration := time.Duration(conf.JWT.RefreshTokenDuration) * time.Second - claims := Token{ Username: username, + Role: role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(expireDuration)), }, } - t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return t.SignedString([]byte(conf.JWT.SecretKey)) } func ParseJWT(jwtToken string) (*Token, error) { conf := config.GetInstance() - t, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (interface{}, error) { return []byte(conf.JWT.SecretKey), nil }) @@ -97,10 +137,8 @@ func ParseJWT(jwtToken string) (*Token, error) { log.Debugf("parse jwt error: %s", err) return nil, err } - if claims, ok := t.Claims.(*Token); ok && t.Valid { return claims, nil - } else { - return nil, err } + return nil, err } diff --git a/server/proto/auth.go b/server/proto/auth.go index dcf92cff..828799b4 100644 --- a/server/proto/auth.go +++ b/server/proto/auth.go @@ -11,6 +11,7 @@ type LoginRsp struct { type GetAccountRsp struct { Username string `json:"username"` + Role string `json:"role"` } type ChangePasswordReq struct { @@ -21,3 +22,26 @@ type ChangePasswordReq struct { type IsPasswordUpdatedRsp struct { IsUpdated bool `json:"isUpdated"` } + +// --- Multi-user management --- + +type UserInfo struct { + Username string `json:"username"` + Role string `json:"role"` + Enabled bool `json:"enabled"` +} + +type ListUsersRsp struct { + Users []UserInfo `json:"users"` +} + +type CreateUserReq struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` + Role string `json:"role" validate:"required"` +} + +type UpdateUserReq struct { + Role string `json:"role"` + Enabled *bool `json:"enabled"` +} diff --git a/server/router/auth.go b/server/router/auth.go index aa237fa0..8b0acda3 100644 --- a/server/router/auth.go +++ b/server/router/auth.go @@ -10,12 +10,27 @@ import ( func authRouter(r *gin.Engine) { service := auth.NewService() - r.POST("/api/auth/login", service.Login) // login + // Public – no token required + r.POST("/api/auth/login", service.Login) + // Any authenticated user api := r.Group("/api").Use(middleware.CheckToken()) + api.GET("/auth/password", service.IsPasswordUpdated) + api.POST("/auth/password", service.ChangePassword) + api.GET("/auth/account", service.GetAccount) + api.POST("/auth/logout", service.Logout) - api.GET("/auth/password", service.IsPasswordUpdated) // is password updated - api.GET("/auth/account", service.GetAccount) // get account - api.POST("/auth/password", service.ChangePassword) // change password - api.POST("/auth/logout", service.Logout) // logout + // Any authenticated user may change their own password; + // admin may change any user's password (enforced inside handler). + api.POST("/auth/users/:username/password", service.ChangeUserPassword) + + // Admin-only: full user management + adminAPI := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(middleware.RoleAdmin), + ) + adminAPI.GET("/auth/users", service.ListUsers) + adminAPI.POST("/auth/users", service.CreateUser) + adminAPI.PUT("/auth/users/:username", service.UpdateUser) + adminAPI.DELETE("/auth/users/:username", service.DeleteUser) } diff --git a/server/router/hid.go b/server/router/hid.go index 665a57da..1a6aab68 100644 --- a/server/router/hid.go +++ b/server/router/hid.go @@ -15,21 +15,33 @@ func HIDLoopbackHTTPAllowedPaths() []string { func hidRouter(r *gin.Engine) { service := hid.NewService() - api := r.Group("/api").Use(middleware.CheckToken()) - localAPI := r.Group("/api/internal").Use(middleware.CheckLoopbackInternalToken()) - api.POST("/hid/paste", service.Paste) // paste + // Operator and admin may send inputs (paste, shortcuts, read/use keyboard) + opAPI := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(middleware.RoleAdmin, middleware.RoleOperator), + ) + + opAPI.POST("/hid/paste", service.Paste) // paste - api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts - api.POST("/hid/shortcut", service.AddShortcut) // add shortcut - api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut + opAPI.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts + opAPI.POST("/hid/shortcut", service.AddShortcut) // add shortcut + opAPI.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut - api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key - api.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key + opAPI.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // get shortcut leader key + opAPI.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key - api.GET("/hid/mode", service.GetHidMode) // get hid mode - api.POST("/hid/mode", service.SetHidMode) // set hid mode - api.POST("/hid/reset", service.ResetHid) // reset hid + opAPI.GET("/hid/mode", service.GetHidMode) // get hid mode + // Admin only: HID hardware configuration + adminAPI := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(middleware.RoleAdmin), + ) + adminAPI.POST("/hid/mode", service.SetHidMode) // set hid mode + adminAPI.POST("/hid/reset", service.ResetHid) // reset hid + + // Internal loopback (for kvm_system / picoclaw): no JWT, only loopback token + localAPI := r.Group("/api/internal").Use(middleware.CheckLoopbackInternalToken()) localAPI.POST("/usb/recover", service.RecoverUSB) } diff --git a/server/router/network.go b/server/router/network.go index 9aeb4b6a..f148d730 100644 --- a/server/router/network.go +++ b/server/router/network.go @@ -10,20 +10,28 @@ import ( func networkRouter(r *gin.Engine) { service := network.NewService() + // Unauthenticated endpoints (only meaningful in AP/setup mode) r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode) r.POST("/api/network/wifi/verify", service.VerifyApLogin) // verify ap login - api := r.Group("/api").Use(middleware.CheckToken()) - - api.POST("/network/wol", service.WakeOnLAN) // wake on lan - api.GET("/network/wol/mac", service.GetMac) // get mac list - api.DELETE("/network/wol/mac", service.DeleteMac) // delete mac - api.POST("/network/wol/mac/name", service.SetMacName) // set mac name - - api.GET("/network/wifi", service.GetWifi) // get Wi-Fi information - api.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi - api.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi - - api.GET("/network/dns", service.GetDNS) // get DNS configuration - api.POST("/network/dns", service.SetDNS) // set DNS configuration + // Operator and admin: read network state, perform Wake-on-LAN + opAPI := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(middleware.RoleAdmin, middleware.RoleOperator), + ) + opAPI.POST("/network/wol", service.WakeOnLAN) // wake on lan + opAPI.GET("/network/wol/mac", service.GetMac) // get mac list + opAPI.GET("/network/wifi", service.GetWifi) // get Wi-Fi information + opAPI.GET("/network/dns", service.GetDNS) // get DNS configuration + + // Admin only: network configuration + adminAPI := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(middleware.RoleAdmin), + ) + adminAPI.DELETE("/network/wol/mac", service.DeleteMac) // delete mac + adminAPI.POST("/network/wol/mac/name", service.SetMacName) // set mac name + adminAPI.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi + adminAPI.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi + adminAPI.POST("/network/dns", service.SetDNS) // set DNS configuration } diff --git a/server/router/vm.go b/server/router/vm.go index f41b6b6e..1ffa2924 100644 --- a/server/router/vm.go +++ b/server/router/vm.go @@ -10,63 +10,58 @@ import ( func vmRouter(r *gin.Engine) { service := vm.NewService() - api := r.Group("/api").Use(middleware.CheckToken()) - - api.GET("/vm/info", service.GetInfo) // get device information - api.GET("/vm/hardware", service.GetHardware) // get hardware version - - api.POST("/vm/gpio", service.SetGpio) // update gpio - api.GET("/vm/gpio", service.GetGpio) // get gpio - api.POST("/vm/screen", service.SetScreen) // update screen - - api.GET("/vm/terminal", service.Terminal) // web terminal - - api.GET("/vm/script", service.GetScripts) // get script - api.POST("/vm/script/upload", service.UploadScript) // upload script - api.POST("/vm/script/run", service.RunScript) // run script - api.DELETE("/vm/script", service.DeleteScript) // delete script - - api.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device - api.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device - - api.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit - api.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit - - api.GET("/vm/oled", service.GetOLED) // get OLED configuration - api.POST("/vm/oled", service.SetOLED) // set OLED configuration - - // Only supported by PCIe version - api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state - api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi - api.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi - api.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi - - api.GET("/vm/ssh", service.GetSSHState) // get SSH state - api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH - api.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH - - api.GET("/vm/swap", service.GetSwap) // get swap file size - api.POST("/vm/swap", service.SetSwap) // set swap file size - - api.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler - api.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) // set mouse jiggler - - api.GET("/vm/hostname", service.GetHostname) // Get Hostname - api.POST("/vm/hostname", service.SetHostname) // Set Hostname - - api.GET("/vm/web-title", service.GetWebTitle) // Get web title - api.POST("/vm/web-title", service.SetWebTitle) // Set web title - - api.GET("/vm/mdns", service.GetMdnsState) // get mDNS state - api.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS - api.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS - - api.POST("/vm/tls", service.SetTls) // enable/disable TLS - - api.GET("/vm/autostart", service.GetAutostart) // get autostart list - api.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content - api.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script - api.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script - - api.POST("/vm/system/reboot", service.Reboot) // reboot system + // All authenticated users (viewer, operator, admin) may read basic info + anyAPI := r.Group("/api").Use(middleware.CheckToken()) + anyAPI.GET("/vm/info", service.GetInfo) + anyAPI.GET("/vm/hardware", service.GetHardware) + anyAPI.GET("/vm/gpio", service.GetGpio) + anyAPI.GET("/vm/device/virtual", service.GetVirtualDevice) + anyAPI.GET("/vm/memory/limit", service.GetMemoryLimit) + anyAPI.GET("/vm/oled", service.GetOLED) + anyAPI.GET("/vm/hdmi", service.GetHdmiState) + anyAPI.GET("/vm/ssh", service.GetSSHState) + anyAPI.GET("/vm/swap", service.GetSwap) + anyAPI.GET("/vm/mouse-jiggler", service.GetMouseJiggler) + anyAPI.GET("/vm/hostname", service.GetHostname) + anyAPI.GET("/vm/web-title", service.GetWebTitle) + anyAPI.GET("/vm/mdns", service.GetMdnsState) + anyAPI.GET("/vm/autostart", service.GetAutostart) + anyAPI.GET("/vm/autostart/:name", service.GetAutostartContent) + + // Operator and admin may interact with the machine + opAPI := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(middleware.RoleAdmin, middleware.RoleOperator), + ) + opAPI.POST("/vm/gpio", service.SetGpio) // power/reset buttons + opAPI.GET("/vm/terminal", service.Terminal) // web terminal + opAPI.GET("/vm/script", service.GetScripts) + opAPI.POST("/vm/script/run", service.RunScript) + opAPI.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) + + // Admin only: system configuration + adminAPI := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(middleware.RoleAdmin), + ) + adminAPI.POST("/vm/screen", service.SetScreen) + adminAPI.POST("/vm/script/upload", service.UploadScript) + adminAPI.DELETE("/vm/script", service.DeleteScript) + adminAPI.POST("/vm/device/virtual", service.UpdateVirtualDevice) + adminAPI.POST("/vm/memory/limit", service.SetMemoryLimit) + adminAPI.POST("/vm/oled", service.SetOLED) + adminAPI.POST("/vm/hdmi/reset", service.ResetHdmi) + adminAPI.POST("/vm/hdmi/enable", service.EnableHdmi) + adminAPI.POST("/vm/hdmi/disable", service.DisableHdmi) + adminAPI.POST("/vm/ssh/enable", service.EnableSSH) + adminAPI.POST("/vm/ssh/disable", service.DisableSSH) + adminAPI.POST("/vm/swap", service.SetSwap) + adminAPI.POST("/vm/hostname", service.SetHostname) + adminAPI.POST("/vm/web-title", service.SetWebTitle) + adminAPI.POST("/vm/mdns/enable", service.EnableMdns) + adminAPI.POST("/vm/mdns/disable", service.DisableMdns) + adminAPI.POST("/vm/tls", service.SetTls) + adminAPI.DELETE("/vm/autostart/:name", service.DeleteAutostart) + adminAPI.POST("/vm/autostart/:name", service.UploadAutostart) + adminAPI.POST("/vm/system/reboot", service.Reboot) } diff --git a/server/service/application/service.go b/server/service/application/service.go index 03bbf88b..07107a8b 100644 --- a/server/service/application/service.go +++ b/server/service/application/service.go @@ -1,8 +1,14 @@ package application const ( - StableURL = "https://cdn.sipeed.com/nanokvm" - PreviewURL = "https://cdn.sipeed.com/nanokvm/preview" + // Update-Channel: Schattenwelt/NanoKVM Fork (Multiuser). + // Erwartete Assets im jeweiligen GitHub-Release: + // - latest.json + // - nanokvm_.tar.gz + // "latest" = neuestes "Latest"-markiertes Release im Fork. + // "preview" = Release mit Tag "preview" im Fork. + StableURL = "https://github.com/Schattenwelt/NanoKVM/releases/latest/download" + PreviewURL = "https://github.com/Schattenwelt/NanoKVM/releases/download/preview" AppDir = "/kvmapp" BackupDir = "/root/old" diff --git a/server/service/auth/account.go b/server/service/auth/account.go index 0305965e..76c7f063 100644 --- a/server/service/auth/account.go +++ b/server/service/auth/account.go @@ -1,7 +1,6 @@ package auth import ( - "NanoKVM-Server/utils" "encoding/json" "errors" "os" @@ -9,105 +8,248 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/crypto/bcrypt" + + "NanoKVM-Server/utils" ) -const AccountFile = "/etc/kvm/pwd" +const AccountFile = "/etc/kvm/accounts.json" +const LegacyAccountFile = "/etc/kvm/pwd" +// Role defines the permission level of a user. +type Role string + +const ( + RoleAdmin Role = "admin" // Full access including user management + RoleOperator Role = "operator" // KVM control: stream, keyboard, mouse, GPIO + RoleViewer Role = "viewer" // View-only: stream access +) + +// Account represents a single user. type Account struct { Username string `json:"username"` - Password string `json:"password"` // should be named HashedPassword for clarity + Password string `json:"password"` // bcrypt hash + Role Role `json:"role"` + Enabled bool `json:"enabled"` } -func GetAccount() (*Account, error) { - if _, err := os.Stat(AccountFile); err != nil { - if errors.Is(err, os.ErrNotExist) { - return getDefaultAccount(), nil - } - return nil, err +// legacyAccount mirrors the old single-user format for migration. +type legacyAccount struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// GetAccounts returns all accounts, migrating from legacy format if needed. +func GetAccounts() ([]Account, error) { + if _, err := os.Stat(AccountFile); err == nil { + return readAccountsFile() + } + if _, err := os.Stat(LegacyAccountFile); err == nil { + return migrateLegacyAccount() } + return []Account{defaultAdminAccount()}, nil +} - content, err := os.ReadFile(AccountFile) +// GetAccountByUsername returns a specific account or an error if not found. +func GetAccountByUsername(username string) (*Account, error) { + accounts, err := GetAccounts() if err != nil { return nil, err } - - var account Account - if err = json.Unmarshal(content, &account); err != nil { - log.Errorf("unmarshal account failed: %s", err) - return nil, err + for _, a := range accounts { + if a.Username == username { + acc := a + return &acc, nil + } } - - return &account, nil + return nil, errors.New("user not found") } -func SetAccount(username string, hashedPassword string) error { - account, err := json.Marshal(&Account{ - Username: username, - Password: hashedPassword, - }) +// SaveAccounts writes the full account list to disk. +func SaveAccounts(accounts []Account) error { + data, err := json.MarshalIndent(accounts, "", " ") if err != nil { - log.Errorf("failed to marshal account information to json: %s", err) return err } + if err = os.MkdirAll(filepath.Dir(AccountFile), 0o755); err != nil { + return err + } + return os.WriteFile(AccountFile, data, 0o600) +} - err = os.MkdirAll(filepath.Dir(AccountFile), 0o644) +// AddAccount appends a new user. Returns error if username exists. +func AddAccount(username, plainPassword string, role Role) error { + accounts, err := GetAccounts() if err != nil { - log.Errorf("create directory %s failed: %s", AccountFile, err) return err } - - err = os.WriteFile(AccountFile, account, 0o644) + for _, a := range accounts { + if a.Username == username { + return errors.New("username already exists") + } + } + hashed, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost) if err != nil { - log.Errorf("write password failed: %s", err) return err } - - return nil + accounts = append(accounts, Account{ + Username: username, + Password: string(hashed), + Role: role, + Enabled: true, + }) + return SaveAccounts(accounts) } -func CompareAccount(username string, plainPassword string) bool { - account, err := GetAccount() +// UpdateAccountPassword changes a user's password (expects bcrypt hash). +func UpdateAccountPassword(username, hashedPassword string) error { + accounts, err := GetAccounts() if err != nil { - return false + return err + } + for i, a := range accounts { + if a.Username == username { + accounts[i].Password = hashedPassword + return SaveAccounts(accounts) + } } + return errors.New("user not found") +} - if username != account.Username { - return false +// UpdateAccountRole changes a user's role. +func UpdateAccountRole(username string, role Role) error { + accounts, err := GetAccounts() + if err != nil { + return err } + for i, a := range accounts { + if a.Username == username { + accounts[i].Role = role + return SaveAccounts(accounts) + } + } + return errors.New("user not found") +} - hashedPassword, err := utils.DecodeDecrypt(plainPassword) - if err != nil || hashedPassword == "" { - return false +// SetAccountEnabled enables or disables a user account. +func SetAccountEnabled(username string, enabled bool) error { + accounts, err := GetAccounts() + if err != nil { + return err } + for i, a := range accounts { + if a.Username == username { + accounts[i].Enabled = enabled + return SaveAccounts(accounts) + } + } + return errors.New("user not found") +} - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(hashedPassword)) +// DeleteAccount removes a user. The last admin account cannot be deleted. +func DeleteAccount(username string) error { + accounts, err := GetAccounts() if err != nil { - // Compatible with old versions - accountHashedPassword, _ := utils.DecodeDecrypt(account.Password) - if accountHashedPassword == hashedPassword { - return true + return err + } + var target *Account + for _, a := range accounts { + if a.Username == username { + acc := a + target = &acc + break } + } + if target == nil { + return errors.New("user not found") + } + if target.Role == RoleAdmin { + adminCount := 0 + for _, a := range accounts { + if a.Role == RoleAdmin && a.Enabled { + adminCount++ + } + } + if adminCount <= 1 { + return errors.New("cannot delete the last admin account") + } + } + filtered := make([]Account, 0, len(accounts)-1) + for _, a := range accounts { + if a.Username != username { + filtered = append(filtered, a) + } + } + return SaveAccounts(filtered) +} - return false +// CompareAccount checks credentials and returns the account on success. +func CompareAccount(username, plainPassword string) (*Account, bool) { + account, err := GetAccountByUsername(username) + if err != nil || account == nil || !account.Enabled { + return nil, false } + decoded, err := utils.DecodeDecrypt(plainPassword) + if err != nil || decoded == "" { + return nil, false + } + if err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(decoded)); err != nil { + // Compatibility with old plain-hashed storage + oldHash, _ := utils.DecodeDecrypt(account.Password) + if oldHash != decoded { + return nil, false + } + } + return account, true +} - return true +// IsValidRole checks whether a role string is valid. +func IsValidRole(r Role) bool { + return r == RoleAdmin || r == RoleOperator || r == RoleViewer } -func DelAccount() error { - if err := os.Remove(AccountFile); err != nil { - log.Errorf("failed to delete password: %s", err) - return err +func readAccountsFile() ([]Account, error) { + data, err := os.ReadFile(AccountFile) + if err != nil { + return nil, err } - - return nil + var accounts []Account + if err = json.Unmarshal(data, &accounts); err != nil { + log.Errorf("failed to unmarshal accounts: %s", err) + return nil, err + } + return accounts, nil } -func getDefaultAccount() *Account { - hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) +func migrateLegacyAccount() ([]Account, error) { + data, err := os.ReadFile(LegacyAccountFile) + if err != nil { + return nil, err + } + var legacy legacyAccount + if err = json.Unmarshal(data, &legacy); err != nil { + log.Errorf("failed to unmarshal legacy account: %s", err) + return []Account{defaultAdminAccount()}, nil + } + account := Account{ + Username: legacy.Username, + Password: legacy.Password, + Role: RoleAdmin, + Enabled: true, + } + accounts := []Account{account} + if saveErr := SaveAccounts(accounts); saveErr == nil { + _ = os.Remove(LegacyAccountFile) + log.Infof("migrated legacy account '%s' to multi-user format", legacy.Username) + } + return accounts, nil +} - return &Account{ +func defaultAdminAccount() Account { + hashed, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) + return Account{ Username: "admin", - Password: string(hashedPassword), + Password: string(hashed), + Role: RoleAdmin, + Enabled: true, } } diff --git a/server/service/auth/login.go b/server/service/auth/login.go index 9e566d66..48344cc8 100644 --- a/server/service/auth/login.go +++ b/server/service/auth/login.go @@ -15,12 +15,9 @@ func (s *Service) Login(c *gin.Context) { var req proto.LoginReq var rsp proto.Response - // authentication disabled conf := config.GetInstance() if conf.Authentication == "disable" { - rsp.OkRspWithData(c, &proto.LoginRsp{ - Token: "disabled", - }) + rsp.OkRspWithData(c, &proto.LoginRsp{Token: "disabled"}) return } @@ -37,41 +34,35 @@ func (s *Service) Login(c *gin.Context) { return } - if ok := CompareAccount(req.Username, req.Password); !ok { + account, ok := CompareAccount(req.Username, req.Password) + if !ok { time.Sleep(2 * time.Second) - if locked, code, msg := RecordLoginFailure(clientIP); locked { rsp.ErrRsp(c, code, msg) return } - rsp.ErrRsp(c, -2, "invalid username or password") return } ClearLoginAttempt(clientIP) - token, err := middleware.GenerateJWT(req.Username) + token, err := middleware.GenerateJWT(account.Username, string(account.Role)) if err != nil { time.Sleep(1 * time.Second) rsp.ErrRsp(c, -3, "generate token failed") return } - rsp.OkRspWithData(c, &proto.LoginRsp{ - Token: token, - }) - - log.Debugf("login success, username: %s", req.Username) + rsp.OkRspWithData(c, &proto.LoginRsp{Token: token}) + log.Debugf("login success, username: %s, role: %s", account.Username, account.Role) } func (s *Service) Logout(c *gin.Context) { conf := config.GetInstance() - if conf.JWT.RevokeTokensOnLogout { config.RegenerateSecretKey() } - var rsp proto.Response rsp.OkRsp(c) } @@ -79,14 +70,12 @@ func (s *Service) Logout(c *gin.Context) { func (s *Service) GetAccount(c *gin.Context) { var rsp proto.Response - account, err := GetAccount() - if err != nil { - rsp.ErrRsp(c, -1, "get account failed") - return - } + username, _ := c.Get("username") + role, _ := c.Get("role") rsp.OkRspWithData(c, &proto.GetAccountRsp{ - Username: account.Username, + Username: username.(string), + Role: role.(string), }) log.Debugf("get account successful") } diff --git a/server/service/auth/password.go b/server/service/auth/password.go index 22c60161..c9461b99 100644 --- a/server/service/auth/password.go +++ b/server/service/auth/password.go @@ -1,19 +1,22 @@ package auth import ( - "NanoKVM-Server/proto" - "NanoKVM-Server/utils" "errors" "io" "os" "os/exec" "time" + "NanoKVM-Server/proto" + "NanoKVM-Server/utils" + "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" "golang.org/x/crypto/bcrypt" ) +// ChangePassword allows a user to change their own password (legacy endpoint). +// If the request contains no username, the current logged-in user is used. func (s *Service) ChangePassword(c *gin.Context) { var req proto.ChangePasswordReq var rsp proto.Response @@ -23,57 +26,61 @@ func (s *Service) ChangePassword(c *gin.Context) { return } + selfUsername, _ := c.Get("username") + selfRole, _ := c.Get("role") + + // If no username given, default to the current user. + if req.Username == "" { + req.Username = selfUsername.(string) + } + + // Only admins may change other accounts; others can only change their own. + if selfRole.(string) != "admin" && selfUsername.(string) != req.Username { + rsp.ErrRsp(c, -2, "permission denied") + return + } + password, err := utils.DecodeDecrypt(req.Password) if err != nil || password == "" { - rsp.ErrRsp(c, -2, "invalid password") + rsp.ErrRsp(c, -3, "invalid password") return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { - rsp.ErrRsp(c, -3, "failed to hash password") + rsp.ErrRsp(c, -4, "failed to hash password") return } - if err = SetAccount(req.Username, string(hashedPassword)); err != nil { - rsp.ErrRsp(c, -4, "failed to save password") + if err = UpdateAccountPassword(req.Username, string(hashedPassword)); err != nil { + rsp.ErrRsp(c, -5, "failed to save password") return } - // change root password - err = changeRootPassword(password) - if err != nil { - _ = DelAccount() - rsp.ErrRsp(c, -5, "failed to change password") - return + // Only change the root system password when the admin changes their own password. + if req.Username == "admin" { + if err = changeRootPassword(password); err != nil { + log.Warnf("failed to change root password: %s", err) + } } rsp.OkRsp(c) - log.Debugf("change password success, username: %s", req.Username) + log.Debugf("password changed for user: %s", req.Username) } +// IsPasswordUpdated reports whether the admin password has been changed from the default. func (s *Service) IsPasswordUpdated(c *gin.Context) { var rsp proto.Response - if _, err := os.Stat(AccountFile); err != nil { - rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{ - IsUpdated: false, - }) - return - } - - account, err := GetAccount() - if err != nil || account == nil { - rsp.ErrRsp(c, -1, "failed to get password") + account, err := GetAccountByUsername("admin") + if err != nil { + rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{IsUpdated: false}) return } - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte("admin")) - + checkErr := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte("admin")) rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{ - // If the hash is not valid, still assume it's not updated - // The error we want to see is password and hash not matching - IsUpdated: errors.Is(err, bcrypt.ErrMismatchedHashAndPassword), + IsUpdated: errors.Is(checkErr, bcrypt.ErrMismatchedHashAndPassword), }) } @@ -83,42 +90,28 @@ func changeRootPassword(password string) error { log.Errorf("failed to change root password: %s", err) return err } - - log.Debugf("change root password successful.") + log.Debugf("root password changed successfully") return nil } func passwd(password string) error { cmd := exec.Command("passwd", "root") - stdin, err := cmd.StdinPipe() if err != nil { return err } - defer func() { - _ = stdin.Close() - }() - + defer func() { _ = stdin.Close() }() cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - if err = cmd.Start(); err != nil { return err } - if _, err = io.WriteString(stdin, password+"\n"); err != nil { return err } - time.Sleep(100 * time.Millisecond) - if _, err = io.WriteString(stdin, password+"\n"); err != nil { return err } - - if err = cmd.Wait(); err != nil { - return err - } - - return nil + return cmd.Wait() } diff --git a/server/service/auth/users.go b/server/service/auth/users.go new file mode 100644 index 00000000..e252d457 --- /dev/null +++ b/server/service/auth/users.go @@ -0,0 +1,176 @@ +package auth + +import ( + "NanoKVM-Server/proto" + "NanoKVM-Server/utils" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/bcrypt" +) + +// ListUsers returns all user accounts (passwords excluded). +func (s *Service) ListUsers(c *gin.Context) { + var rsp proto.Response + + accounts, err := GetAccounts() + if err != nil { + rsp.ErrRsp(c, -1, "failed to load users") + return + } + + users := make([]proto.UserInfo, 0, len(accounts)) + for _, a := range accounts { + users = append(users, proto.UserInfo{ + Username: a.Username, + Role: string(a.Role), + Enabled: a.Enabled, + }) + } + + rsp.OkRspWithData(c, &proto.ListUsersRsp{Users: users}) +} + +// CreateUser adds a new user (admin only). +func (s *Service) CreateUser(c *gin.Context) { + var req proto.CreateUserReq + var rsp proto.Response + + if err := proto.ParseFormRequest(c, &req); err != nil { + rsp.ErrRsp(c, -1, "invalid parameters") + return + } + + role := Role(req.Role) + if !IsValidRole(role) { + rsp.ErrRsp(c, -2, "invalid role; must be admin, operator, or viewer") + return + } + + password, err := utils.DecodeDecrypt(req.Password) + if err != nil || password == "" { + rsp.ErrRsp(c, -3, "invalid password") + return + } + + if err = AddAccount(req.Username, password, role); err != nil { + rsp.ErrRsp(c, -4, err.Error()) + return + } + + rsp.OkRsp(c) + log.Infof("user created: %s (role: %s)", req.Username, role) +} + +// UpdateUser changes a user's role or enabled status (admin only). +func (s *Service) UpdateUser(c *gin.Context) { + var req proto.UpdateUserReq + var rsp proto.Response + + username := c.Param("username") + if username == "" { + rsp.ErrRsp(c, -1, "username is required") + return + } + + if err := proto.ParseFormRequest(c, &req); err != nil { + rsp.ErrRsp(c, -2, "invalid parameters") + return + } + + if req.Role != "" { + role := Role(req.Role) + if !IsValidRole(role) { + rsp.ErrRsp(c, -3, "invalid role; must be admin, operator, or viewer") + return + } + if err := UpdateAccountRole(username, role); err != nil { + rsp.ErrRsp(c, -4, err.Error()) + return + } + log.Infof("user role updated: %s -> %s", username, role) + } + + if req.Enabled != nil { + if err := SetAccountEnabled(username, *req.Enabled); err != nil { + rsp.ErrRsp(c, -5, err.Error()) + return + } + log.Infof("user enabled state updated: %s -> %v", username, *req.Enabled) + } + + rsp.OkRsp(c) +} + +// DeleteUser removes a user account (admin only). +func (s *Service) DeleteUser(c *gin.Context) { + var rsp proto.Response + + username := c.Param("username") + if username == "" { + rsp.ErrRsp(c, -1, "username is required") + return + } + + // Prevent an admin from deleting themselves. + selfUsername, _ := c.Get("username") + if selfUsername.(string) == username { + rsp.ErrRsp(c, -2, "cannot delete your own account") + return + } + + if err := DeleteAccount(username); err != nil { + rsp.ErrRsp(c, -3, err.Error()) + return + } + + rsp.OkRsp(c) + log.Infof("user deleted: %s", username) +} + +// ChangeUserPassword allows an admin to set any user's password, +// or a user to change their own password. +func (s *Service) ChangeUserPassword(c *gin.Context) { + var req proto.ChangePasswordReq + var rsp proto.Response + + username := c.Param("username") + if username == "" { + rsp.ErrRsp(c, -1, "username is required") + return + } + + selfUsername, _ := c.Get("username") + selfRole, _ := c.Get("role") + + // Only admins may change other users' passwords. + if selfRole.(string) != "admin" && selfUsername.(string) != username { + rsp.ErrRsp(c, -2, "permission denied") + return + } + + if err := proto.ParseFormRequest(c, &req); err != nil { + rsp.ErrRsp(c, -3, "invalid parameters") + return + } + + password, err := utils.DecodeDecrypt(req.Password) + if err != nil || password == "" { + rsp.ErrRsp(c, -4, "invalid password") + return + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + rsp.ErrRsp(c, -5, "failed to hash password") + return + } + + if err = UpdateAccountPassword(username, string(hashed)); err != nil { + rsp.ErrRsp(c, -6, err.Error()) + return + } + + rsp.OkRsp(c) + log.Infof("password changed for user: %s", username) +} diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 3657ce4c..132b4efd 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -1,10 +1,7 @@ import { http } from '@/lib/http'; export function login(username: string, password: string) { - const data = { - username, - password - }; + const data = { username, password }; return http.post('/api/auth/login', data); } @@ -17,13 +14,31 @@ export function getAccount() { } export function changePassword(username: string, password: string) { - const data = { - username, - password - }; + const data = { username, password }; return http.post('/api/auth/password', data); } export function isPasswordUpdated() { return http.get('/api/auth/password'); } + +// Multi-user management +export function listUsers() { + return http.get('/api/auth/users'); +} + +export function createUser(username: string, password: string, role: string) { + return http.post('/api/auth/users', { username, password, role }); +} + +export function updateUser(username: string, data: { role?: string; enabled?: boolean }) { + return http.put(`/api/auth/users/${username}`, data); +} + +export function deleteUser(username: string) { + return http.delete(`/api/auth/users/${username}`); +} + +export function changeUserPassword(username: string, password: string) { + return http.post(`/api/auth/users/${username}/password`, { username, password }); +} diff --git a/web/src/hooks/useRole.ts b/web/src/hooks/useRole.ts new file mode 100644 index 00000000..1affd41d --- /dev/null +++ b/web/src/hooks/useRole.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; +import * as api from '@/api/auth.ts'; + +export type Role = 'admin' | 'operator' | 'viewer' | 'loading'; + +export function useRole() { + const [role, setRole] = useState('loading'); + + useEffect(() => { + api.getAccount().then((rsp: any) => { + if (rsp.code === 0 && rsp.data?.role) { + setRole(rsp.data.role as Role); + } else { + // Fallback: alles anzeigen wenn Rolle nicht ermittelt werden kann + setRole('admin'); + } + }).catch(() => { + setRole('admin'); + }); + }, []); + + // Während Ladezeit alles anzeigen + const loaded = role !== 'loading'; + + return { + role, + isAdmin: !loaded || role === 'admin', + isOperator: !loaded || role === 'admin' || role === 'operator', + isViewer: loaded && role === 'viewer', + }; +} diff --git a/web/src/i18n/locales/ca.ts b/web/src/i18n/locales/ca.ts index 57b1a072..28820c92 100644 --- a/web/src/i18n/locales/ca.ts +++ b/web/src/i18n/locales/ca.ts @@ -474,6 +474,38 @@ const ca = { updateFailed: 'Error en actualitzar. Torna-ho a intentar.' } }, + users: { + title: 'Gestió d\'usuaris', + addUser: 'Afegir usuari', + colUsername: 'Nom d\'usuari', + colRole: 'Rol', + colEnabled: 'Actiu', + colActions: 'Accions', + rolesTitle: 'Visió general dels rols', + roleAdmin: 'Accés complet + gestió d\'usuaris', + roleOperator: 'Ús del KVM: stream, teclat, ratolí, botons d\'engegada', + roleViewer: 'Només visualització de l\'stream', + changePassword: 'Canviar contrasenya', + newPassword: 'Nova contrasenya', + confirmPassword: 'Confirmar contrasenya', + pwdMismatch: 'Les contrasenyes no coincideixen', + pwdSuccess: 'Contrasenya canviada correctament', + pwdFailed: 'No s\'ha pogut canviar la contrasenya', + password: 'Contrasenya', + delete: 'Eliminar', + deleteConfirm: 'Segur que voleu eliminar aquest usuari?', + createSuccess: 'Usuari creat', + createFailed: 'No s\'ha pogut crear l\'usuari', + deleteSuccess: 'Usuari eliminat', + deleteFailed: 'No s\'ha pogut eliminar l\'usuari', + updateSuccess: 'Actualitzat', + updateFailed: 'Actualització fallida', + loadFailed: 'No s\'han pogut carregar els usuaris', + usernameRequired: 'Introduïu el nom d\'usuari', + passwordRequired: 'Introduïu la contrasenya', + okBtn: 'D\'acord', + cancelBtn: 'Cancel·lar' + }, account: { title: 'Compte', webAccount: 'Nom del compte web', diff --git a/web/src/i18n/locales/cz.ts b/web/src/i18n/locales/cz.ts index 269ffcaa..4bf5f631 100644 --- a/web/src/i18n/locales/cz.ts +++ b/web/src/i18n/locales/cz.ts @@ -479,6 +479,38 @@ const cz = { updateFailed: 'Aktualizace se nezdařila. Zkuste to prosím znovu.' } }, + users: { + title: 'Správa uživatelů', + addUser: 'Přidat uživatele', + colUsername: 'Uživatelské jméno', + colRole: 'Role', + colEnabled: 'Aktivní', + colActions: 'Akce', + rolesTitle: 'Přehled rolí', + roleAdmin: 'Plný přístup + správa uživatelů', + roleOperator: 'Použití KVM: stream, klávesnice, myš, tlačítka napájení', + roleViewer: 'Pouze zobrazení streamu', + changePassword: 'Změnit heslo', + newPassword: 'Nové heslo', + confirmPassword: 'Potvrďte heslo', + pwdMismatch: 'Hesla se neshodují', + pwdSuccess: 'Heslo úspěšně změněno', + pwdFailed: 'Nepodařilo se změnit heslo', + password: 'Heslo', + delete: 'Smazat', + deleteConfirm: 'Opravdu chcete smazat tohoto uživatele?', + createSuccess: 'Uživatel vytvořen', + createFailed: 'Vytvoření uživatele se nezdařilo', + deleteSuccess: 'Uživatel smazán', + deleteFailed: 'Smazání se nezdařilo', + updateSuccess: 'Aktualizováno', + updateFailed: 'Aktualizace se nezdařila', + loadFailed: 'Načtení uživatelů se nezdařilo', + usernameRequired: 'Zadejte uživatelské jméno', + passwordRequired: 'Zadejte heslo', + okBtn: 'OK', + cancelBtn: 'Zrušit' + }, account: { title: 'Účet', webAccount: 'Název webového účtu', diff --git a/web/src/i18n/locales/da.ts b/web/src/i18n/locales/da.ts index fb2a52b5..0aff78e6 100644 --- a/web/src/i18n/locales/da.ts +++ b/web/src/i18n/locales/da.ts @@ -478,6 +478,38 @@ const da = { updateFailed: 'Opdatering fejlede. Prøv igen.' } }, + users: { + title: 'Brugeradministration', + addUser: 'Tilføj bruger', + colUsername: 'Brugernavn', + colRole: 'Rolle', + colEnabled: 'Aktiv', + colActions: 'Handlinger', + rolesTitle: 'Rolleoversigt', + roleAdmin: 'Fuld adgang + brugeradministration', + roleOperator: 'KVM-brug: stream, tastatur, mus, tænd-knapper', + roleViewer: 'Kun stream-visning', + changePassword: 'Skift adgangskode', + newPassword: 'Ny adgangskode', + confirmPassword: 'Bekræft adgangskode', + pwdMismatch: 'Adgangskoder stemmer ikke overens', + pwdSuccess: 'Adgangskode ændret', + pwdFailed: 'Kunne ikke ændre adgangskode', + password: 'Adgangskode', + delete: 'Slet', + deleteConfirm: 'Er du sikker på at du vil slette denne bruger?', + createSuccess: 'Bruger oprettet', + createFailed: 'Kunne ikke oprette bruger', + deleteSuccess: 'Bruger slettet', + deleteFailed: 'Sletning mislykkedes', + updateSuccess: 'Opdateret', + updateFailed: 'Opdatering mislykkedes', + loadFailed: 'Kunne ikke indlæse brugere', + usernameRequired: 'Indtast brugernavn', + passwordRequired: 'Indtast adgangskode', + okBtn: 'OK', + cancelBtn: 'Annuller' + }, account: { title: 'Konto', webAccount: 'Navn på webkonto', diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index 8cbaf2b2..1e4c028f 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -484,6 +484,38 @@ const de = { updateFailed: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.' } }, + users: { + title: 'Benutzerverwaltung', + addUser: 'Benutzer hinzufügen', + colUsername: 'Benutzername', + colRole: 'Rolle', + colEnabled: 'Aktiv', + colActions: 'Aktionen', + rolesTitle: 'Rollen-Übersicht', + roleAdmin: 'Vollzugriff + Benutzerverwaltung', + roleOperator: 'KVM nutzen: Stream, Tastatur, Maus, Power-Buttons', + roleViewer: 'Nur Stream anschauen', + changePassword: 'Passwort ändern', + newPassword: 'Neues Passwort', + confirmPassword: 'Passwort bestätigen', + pwdMismatch: 'Passwörter stimmen nicht überein', + pwdSuccess: 'Passwort erfolgreich geändert', + pwdFailed: 'Passwort ändern fehlgeschlagen', + password: 'Passwort', + delete: 'Löschen', + deleteConfirm: 'Benutzer wirklich löschen?', + createSuccess: 'Benutzer erstellt', + createFailed: 'Erstellen fehlgeschlagen', + deleteSuccess: 'Benutzer gelöscht', + deleteFailed: 'Löschen fehlgeschlagen', + updateSuccess: 'Aktualisiert', + updateFailed: 'Aktualisierung fehlgeschlagen', + loadFailed: 'Benutzer laden fehlgeschlagen', + usernameRequired: 'Benutzername eingeben', + passwordRequired: 'Passwort eingeben', + okBtn: 'OK', + cancelBtn: 'Abbrechen' + }, account: { title: 'Konto', webAccount: 'Web Konto Name', diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 7148c5a9..ffb8f59f 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -476,6 +476,38 @@ const en = { updateFailed: 'Update failed. Please retry.' } }, + users: { + title: 'User Management', + addUser: 'Add User', + colUsername: 'Username', + colRole: 'Role', + colEnabled: 'Enabled', + colActions: 'Actions', + rolesTitle: 'Role Overview', + roleAdmin: 'Full access + user management', + roleOperator: 'KVM control: stream, keyboard, mouse, power buttons', + roleViewer: 'View-only: watch stream', + changePassword: 'Change Password', + newPassword: 'New Password', + confirmPassword: 'Confirm Password', + pwdMismatch: 'Passwords do not match', + pwdSuccess: 'Password changed successfully', + pwdFailed: 'Failed to change password', + password: 'Password', + delete: 'Delete', + deleteConfirm: 'Are you sure you want to delete this user?', + createSuccess: 'User created', + createFailed: 'Failed to create user', + deleteSuccess: 'User deleted', + deleteFailed: 'Failed to delete user', + updateSuccess: 'Updated', + updateFailed: 'Update failed', + loadFailed: 'Failed to load users', + usernameRequired: 'Please enter a username', + passwordRequired: 'Please enter a password', + okBtn: 'OK', + cancelBtn: 'Cancel' + }, account: { title: 'Account', webAccount: 'Web Account Name', diff --git a/web/src/i18n/locales/es.ts b/web/src/i18n/locales/es.ts index 8f789dd6..ad8aca20 100644 --- a/web/src/i18n/locales/es.ts +++ b/web/src/i18n/locales/es.ts @@ -482,6 +482,38 @@ const es = { updateFailed: 'La actualización falló. Por favor, inténtalo de nuevo.' } }, + users: { + title: 'Gestión de usuarios', + addUser: 'Agregar usuario', + colUsername: 'Nombre de usuario', + colRole: 'Rol', + colEnabled: 'Activo', + colActions: 'Acciones', + rolesTitle: 'Resumen de roles', + roleAdmin: 'Acceso completo + gestión de usuarios', + roleOperator: 'Uso del KVM: stream, teclado, ratón, botones de encendido', + roleViewer: 'Solo visualización del stream', + changePassword: 'Cambiar contraseña', + newPassword: 'Nueva contraseña', + confirmPassword: 'Confirmar contraseña', + pwdMismatch: 'Las contraseñas no coinciden', + pwdSuccess: 'Contraseña cambiada con éxito', + pwdFailed: 'Error al cambiar la contraseña', + password: 'Contraseña', + delete: 'Eliminar', + deleteConfirm: '¿Está seguro de que desea eliminar este usuario?', + createSuccess: 'Usuario creado', + createFailed: 'Error al crear el usuario', + deleteSuccess: 'Usuario eliminado', + deleteFailed: 'Error al eliminar', + updateSuccess: 'Actualizado', + updateFailed: 'Error de actualización', + loadFailed: 'Error al cargar usuarios', + usernameRequired: 'Ingrese el nombre de usuario', + passwordRequired: 'Ingrese la contraseña', + okBtn: 'Aceptar', + cancelBtn: 'Cancelar' + }, account: { title: 'Cuenta', webAccount: 'Nombre de la cuenta web', diff --git a/web/src/i18n/locales/fr.ts b/web/src/i18n/locales/fr.ts index d46989ea..ec06ebb3 100644 --- a/web/src/i18n/locales/fr.ts +++ b/web/src/i18n/locales/fr.ts @@ -484,6 +484,38 @@ const fr = { updateFailed: 'Mise à jour échouée. Veuillez réessayer.' } }, + users: { + title: 'Gestion des utilisateurs', + addUser: 'Ajouter un utilisateur', + colUsername: 'Nom d\'utilisateur', + colRole: 'Rôle', + colEnabled: 'Actif', + colActions: 'Actions', + rolesTitle: 'Aperçu des rôles', + roleAdmin: 'Accès complet + gestion des utilisateurs', + roleOperator: 'Utiliser le KVM : flux, clavier, souris, boutons d\'alimentation', + roleViewer: 'Visualisation du flux uniquement', + changePassword: 'Changer le mot de passe', + newPassword: 'Nouveau mot de passe', + confirmPassword: 'Confirmer le mot de passe', + pwdMismatch: 'Les mots de passe ne correspondent pas', + pwdSuccess: 'Mot de passe modifié avec succès', + pwdFailed: 'Échec du changement de mot de passe', + password: 'Mot de passe', + delete: 'Supprimer', + deleteConfirm: 'Êtes-vous sûr de vouloir supprimer cet utilisateur ?', + createSuccess: 'Utilisateur créé', + createFailed: 'Échec de la création', + deleteSuccess: 'Utilisateur supprimé', + deleteFailed: 'Échec de la suppression', + updateSuccess: 'Mis à jour', + updateFailed: 'Échec de la mise à jour', + loadFailed: 'Échec du chargement des utilisateurs', + usernameRequired: 'Saisissez le nom d\'utilisateur', + passwordRequired: 'Saisissez le mot de passe', + okBtn: 'OK', + cancelBtn: 'Annuler' + }, account: { title: 'Compte', webAccount: 'Nom du compte Web', diff --git a/web/src/i18n/locales/hu.ts b/web/src/i18n/locales/hu.ts index 2d20380a..9431362d 100644 --- a/web/src/i18n/locales/hu.ts +++ b/web/src/i18n/locales/hu.ts @@ -481,6 +481,38 @@ const hu = { updateFailed: 'Frissítés sikertelen. Kérem, próbálja újra.' } }, + users: { + title: 'Felhasználókezelés', + addUser: 'Felhasználó hozzáadása', + colUsername: 'Felhasználónév', + colRole: 'Szerepkör', + colEnabled: 'Aktív', + colActions: 'Műveletek', + rolesTitle: 'Szerepkörök áttekintése', + roleAdmin: 'Teljes hozzáférés + felhasználókezelés', + roleOperator: 'KVM használata: stream, billentyűzet, egér, bekapcsoló gombok', + roleViewer: 'Csak stream megtekintés', + changePassword: 'Jelszó módosítása', + newPassword: 'Új jelszó', + confirmPassword: 'Jelszó megerősítése', + pwdMismatch: 'A jelszavak nem egyeznek', + pwdSuccess: 'Jelszó sikeresen módosítva', + pwdFailed: 'Jelszó módosítása sikertelen', + password: 'Jelszó', + delete: 'Törlés', + deleteConfirm: 'Biztosan törölni szeretné ezt a felhasználót?', + createSuccess: 'Felhasználó létrehozva', + createFailed: 'Létrehozás sikertelen', + deleteSuccess: 'Felhasználó törölve', + deleteFailed: 'Törlés sikertelen', + updateSuccess: 'Frissítve', + updateFailed: 'Frissítés sikertelen', + loadFailed: 'Felhasználók betöltése sikertelen', + usernameRequired: 'Adjon meg felhasználónevet', + passwordRequired: 'Adjon meg jelszót', + okBtn: 'OK', + cancelBtn: 'Mégse' + }, account: { title: 'Fiók', webAccount: 'Webes fiók neve', diff --git a/web/src/i18n/locales/id.ts b/web/src/i18n/locales/id.ts index 7c37c4a1..659f8df9 100644 --- a/web/src/i18n/locales/id.ts +++ b/web/src/i18n/locales/id.ts @@ -478,6 +478,38 @@ const id = { updateFailed: 'Gagal memperbarui, tolong coba lagi.' } }, + users: { + title: 'Manajemen Pengguna', + addUser: 'Tambah Pengguna', + colUsername: 'Nama Pengguna', + colRole: 'Peran', + colEnabled: 'Aktif', + colActions: 'Tindakan', + rolesTitle: 'Ikhtisar Peran', + roleAdmin: 'Akses penuh + manajemen pengguna', + roleOperator: 'Penggunaan KVM: stream, keyboard, mouse, tombol daya', + roleViewer: 'Hanya melihat stream', + changePassword: 'Ubah Kata Sandi', + newPassword: 'Kata Sandi Baru', + confirmPassword: 'Konfirmasi Kata Sandi', + pwdMismatch: 'Kata sandi tidak cocok', + pwdSuccess: 'Kata sandi berhasil diubah', + pwdFailed: 'Gagal mengubah kata sandi', + password: 'Kata Sandi', + delete: 'Hapus', + deleteConfirm: 'Anda yakin ingin menghapus pengguna ini?', + createSuccess: 'Pengguna dibuat', + createFailed: 'Gagal membuat pengguna', + deleteSuccess: 'Pengguna dihapus', + deleteFailed: 'Gagal menghapus', + updateSuccess: 'Diperbarui', + updateFailed: 'Pembaruan gagal', + loadFailed: 'Gagal memuat pengguna', + usernameRequired: 'Masukkan nama pengguna', + passwordRequired: 'Masukkan kata sandi', + okBtn: 'OK', + cancelBtn: 'Batal' + }, account: { title: 'Akun', webAccount: 'Nama akun web', diff --git a/web/src/i18n/locales/it.ts b/web/src/i18n/locales/it.ts index 8df8546f..67097fbc 100644 --- a/web/src/i18n/locales/it.ts +++ b/web/src/i18n/locales/it.ts @@ -482,6 +482,38 @@ const it = { updateFailed: 'Aggiornamento fallito. Riprova.' } }, + users: { + title: 'Gestione utenti', + addUser: 'Aggiungi utente', + colUsername: 'Nome utente', + colRole: 'Ruolo', + colEnabled: 'Attivo', + colActions: 'Azioni', + rolesTitle: 'Panoramica dei ruoli', + roleAdmin: 'Accesso completo + gestione utenti', + roleOperator: 'Uso KVM: stream, tastiera, mouse, pulsanti di accensione', + roleViewer: 'Solo visualizzazione stream', + changePassword: 'Cambia password', + newPassword: 'Nuova password', + confirmPassword: 'Conferma password', + pwdMismatch: 'Le password non corrispondono', + pwdSuccess: 'Password modificata con successo', + pwdFailed: 'Impossibile cambiare la password', + password: 'Password', + delete: 'Elimina', + deleteConfirm: 'Sei sicuro di voler eliminare questo utente?', + createSuccess: 'Utente creato', + createFailed: 'Creazione fallita', + deleteSuccess: 'Utente eliminato', + deleteFailed: 'Eliminazione fallita', + updateSuccess: 'Aggiornato', + updateFailed: 'Aggiornamento fallito', + loadFailed: 'Caricamento utenti fallito', + usernameRequired: 'Inserisci il nome utente', + passwordRequired: 'Inserisci la password', + okBtn: 'OK', + cancelBtn: 'Annulla' + }, account: { title: 'Account', webAccount: 'Nome account web', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index 91b642aa..0f35f863 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -481,6 +481,38 @@ const ja = { updateFailed: 'アップデートに失敗しました。もう一度お試しください。' } }, + users: { + title: 'ユーザー管理', + addUser: 'ユーザーを追加', + colUsername: 'ユーザー名', + colRole: 'ロール', + colEnabled: '有効', + colActions: '操作', + rolesTitle: 'ロール概要', + roleAdmin: 'フルアクセス + ユーザー管理', + roleOperator: 'KVM使用: ストリーム、キーボード、マウス、電源ボタン', + roleViewer: 'ストリーム閲覧のみ', + changePassword: 'パスワード変更', + newPassword: '新しいパスワード', + confirmPassword: 'パスワード確認', + pwdMismatch: 'パスワードが一致しません', + pwdSuccess: 'パスワードが変更されました', + pwdFailed: 'パスワードの変更に失敗しました', + password: 'パスワード', + delete: '削除', + deleteConfirm: 'このユーザーを削除しますか?', + createSuccess: 'ユーザーが作成されました', + createFailed: '作成に失敗しました', + deleteSuccess: 'ユーザーが削除されました', + deleteFailed: '削除に失敗しました', + updateSuccess: '更新しました', + updateFailed: '更新に失敗しました', + loadFailed: 'ユーザーの読み込みに失敗しました', + usernameRequired: 'ユーザー名を入力してください', + passwordRequired: 'パスワードを入力してください', + okBtn: 'OK', + cancelBtn: 'キャンセル' + }, account: { title: 'アカウント', webAccount: 'ウェブアカウント名', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 035272c1..ffb8f9f8 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -472,6 +472,38 @@ const ko = { updateFailed: '업데이트에 실패했습니다. 재시도하세요.' } }, + users: { + title: '사용자 관리', + addUser: '사용자 추가', + colUsername: '사용자 이름', + colRole: '역할', + colEnabled: '활성', + colActions: '작업', + rolesTitle: '역할 개요', + roleAdmin: '전체 액세스 + 사용자 관리', + roleOperator: 'KVM 사용: 스트림, 키보드, 마우스, 전원 버튼', + roleViewer: '스트림 보기 전용', + changePassword: '비밀번호 변경', + newPassword: '새 비밀번호', + confirmPassword: '비밀번호 확인', + pwdMismatch: '비밀번호가 일치하지 않습니다', + pwdSuccess: '비밀번호가 변경되었습니다', + pwdFailed: '비밀번호 변경에 실패했습니다', + password: '비밀번호', + delete: '삭제', + deleteConfirm: '이 사용자를 삭제하시겠습니까?', + createSuccess: '사용자가 생성되었습니다', + createFailed: '생성에 실패했습니다', + deleteSuccess: '사용자가 삭제되었습니다', + deleteFailed: '삭제에 실패했습니다', + updateSuccess: '업데이트됨', + updateFailed: '업데이트 실패', + loadFailed: '사용자 로드 실패', + usernameRequired: '사용자 이름을 입력하세요', + passwordRequired: '비밀번호를 입력하세요', + okBtn: '확인', + cancelBtn: '취소' + }, account: { title: '계정', webAccount: '웹 계정', diff --git a/web/src/i18n/locales/nb.ts b/web/src/i18n/locales/nb.ts index 5178a7b7..dbea44ff 100644 --- a/web/src/i18n/locales/nb.ts +++ b/web/src/i18n/locales/nb.ts @@ -478,6 +478,38 @@ const nb = { updateFailed: 'En feil oppstod under oppdatering. Vennligst forsøk igjen.' } }, + users: { + title: 'Brukerhåndtering', + addUser: 'Legg til bruker', + colUsername: 'Brukernavn', + colRole: 'Rolle', + colEnabled: 'Aktiv', + colActions: 'Handlinger', + rolesTitle: 'Rolleoversikt', + roleAdmin: 'Full tilgang + brukerhåndtering', + roleOperator: 'KVM-bruk: stream, tastatur, mus, av/på-knapper', + roleViewer: 'Kun stream-visning', + changePassword: 'Endre passord', + newPassword: 'Nytt passord', + confirmPassword: 'Bekreft passord', + pwdMismatch: 'Passordene stemmer ikke', + pwdSuccess: 'Passord endret', + pwdFailed: 'Kunne ikke endre passord', + password: 'Passord', + delete: 'Slett', + deleteConfirm: 'Er du sikker på at du vil slette denne brukeren?', + createSuccess: 'Bruker opprettet', + createFailed: 'Kunne ikke opprette bruker', + deleteSuccess: 'Bruker slettet', + deleteFailed: 'Sletting mislyktes', + updateSuccess: 'Oppdatert', + updateFailed: 'Oppdatering mislyktes', + loadFailed: 'Kunne ikke laste brukere', + usernameRequired: 'Skriv inn brukernavn', + passwordRequired: 'Skriv inn passord', + okBtn: 'OK', + cancelBtn: 'Avbryt' + }, account: { title: 'Konto', webAccount: 'Navn på webkonto', diff --git a/web/src/i18n/locales/nl.ts b/web/src/i18n/locales/nl.ts index b7d0e1f9..990af388 100644 --- a/web/src/i18n/locales/nl.ts +++ b/web/src/i18n/locales/nl.ts @@ -482,6 +482,38 @@ const nl = { updateFailed: 'Update mislukt. Probeer het opnieuw.' } }, + users: { + title: 'Gebruikersbeheer', + addUser: 'Gebruiker toevoegen', + colUsername: 'Gebruikersnaam', + colRole: 'Rol', + colEnabled: 'Actief', + colActions: 'Acties', + rolesTitle: 'Rollenoverzicht', + roleAdmin: 'Volledige toegang + gebruikersbeheer', + roleOperator: 'KVM-gebruik: stream, toetsenbord, muis, aan/uit-knoppen', + roleViewer: 'Alleen stream bekijken', + changePassword: 'Wachtwoord wijzigen', + newPassword: 'Nieuw wachtwoord', + confirmPassword: 'Wachtwoord bevestigen', + pwdMismatch: 'Wachtwoorden komen niet overeen', + pwdSuccess: 'Wachtwoord gewijzigd', + pwdFailed: 'Wachtwoord wijzigen mislukt', + password: 'Wachtwoord', + delete: 'Verwijderen', + deleteConfirm: 'Weet u zeker dat u deze gebruiker wilt verwijderen?', + createSuccess: 'Gebruiker aangemaakt', + createFailed: 'Aanmaken mislukt', + deleteSuccess: 'Gebruiker verwijderd', + deleteFailed: 'Verwijderen mislukt', + updateSuccess: 'Bijgewerkt', + updateFailed: 'Bijwerken mislukt', + loadFailed: 'Gebruikers laden mislukt', + usernameRequired: 'Gebruikersnaam invoeren', + passwordRequired: 'Wachtwoord invoeren', + okBtn: 'OK', + cancelBtn: 'Annuleren' + }, account: { title: 'Account', webAccount: 'Web Account Naam', diff --git a/web/src/i18n/locales/pl.ts b/web/src/i18n/locales/pl.ts index be69332b..e9ffc786 100644 --- a/web/src/i18n/locales/pl.ts +++ b/web/src/i18n/locales/pl.ts @@ -481,6 +481,38 @@ const pl = { updateFailed: 'Aktualizacja nie powiodła się. Spróbuj ponownie.' } }, + users: { + title: 'Zarządzanie użytkownikami', + addUser: 'Dodaj użytkownika', + colUsername: 'Nazwa użytkownika', + colRole: 'Rola', + colEnabled: 'Aktywny', + colActions: 'Akcje', + rolesTitle: 'Przegląd ról', + roleAdmin: 'Pełny dostęp + zarządzanie użytkownikami', + roleOperator: 'Korzystanie z KVM: stream, klawiatura, mysz, przyciski zasilania', + roleViewer: 'Tylko podgląd streamu', + changePassword: 'Zmień hasło', + newPassword: 'Nowe hasło', + confirmPassword: 'Potwierdź hasło', + pwdMismatch: 'Hasła nie pasują do siebie', + pwdSuccess: 'Hasło zostało zmienione', + pwdFailed: 'Nie udało się zmienić hasła', + password: 'Hasło', + delete: 'Usuń', + deleteConfirm: 'Czy na pewno chcesz usunąć tego użytkownika?', + createSuccess: 'Użytkownik utworzony', + createFailed: 'Utworzenie nie powiodło się', + deleteSuccess: 'Użytkownik usunięty', + deleteFailed: 'Usunięcie nie powiodło się', + updateSuccess: 'Zaktualizowano', + updateFailed: 'Aktualizacja nie powiodła się', + loadFailed: 'Nie udało się wczytać użytkowników', + usernameRequired: 'Wprowadź nazwę użytkownika', + passwordRequired: 'Wprowadź hasło', + okBtn: 'OK', + cancelBtn: 'Anuluj' + }, account: { title: 'Konto', webAccount: 'Nazwa konta web', diff --git a/web/src/i18n/locales/pt_br.ts b/web/src/i18n/locales/pt_br.ts index e6fd78b3..9cbde247 100644 --- a/web/src/i18n/locales/pt_br.ts +++ b/web/src/i18n/locales/pt_br.ts @@ -479,6 +479,38 @@ const pt_br = { updateFailed: 'Falha na atualização. Por favor, tente novamente.' } }, + users: { + title: 'Gerenciamento de usuários', + addUser: 'Adicionar usuário', + colUsername: 'Nome de usuário', + colRole: 'Função', + colEnabled: 'Ativo', + colActions: 'Ações', + rolesTitle: 'Visão geral das funções', + roleAdmin: 'Acesso total + gerenciamento de usuários', + roleOperator: 'Uso do KVM: stream, teclado, mouse, botões de energia', + roleViewer: 'Apenas visualização do stream', + changePassword: 'Alterar senha', + newPassword: 'Nova senha', + confirmPassword: 'Confirmar senha', + pwdMismatch: 'As senhas não coincidem', + pwdSuccess: 'Senha alterada com sucesso', + pwdFailed: 'Falha ao alterar a senha', + password: 'Senha', + delete: 'Excluir', + deleteConfirm: 'Tem certeza que deseja excluir este usuário?', + createSuccess: 'Usuário criado', + createFailed: 'Falha ao criar usuário', + deleteSuccess: 'Usuário excluído', + deleteFailed: 'Falha ao excluir', + updateSuccess: 'Atualizado', + updateFailed: 'Falha na atualização', + loadFailed: 'Falha ao carregar usuários', + usernameRequired: 'Digite o nome de usuário', + passwordRequired: 'Digite a senha', + okBtn: 'OK', + cancelBtn: 'Cancelar' + }, account: { title: 'Conta', webAccount: 'Nome da Conta Web', diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index 9b79faa3..59546850 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -480,6 +480,38 @@ const ru = { updateFailed: 'Обновление не удалось. Пожалуйста, попробуйте еще раз.' } }, + users: { + title: 'Управление пользователями', + addUser: 'Добавить пользователя', + colUsername: 'Имя пользователя', + colRole: 'Роль', + colEnabled: 'Активен', + colActions: 'Действия', + rolesTitle: 'Обзор ролей', + roleAdmin: 'Полный доступ + управление пользователями', + roleOperator: 'Использование KVM: поток, клавиатура, мышь, кнопки питания', + roleViewer: 'Только просмотр потока', + changePassword: 'Сменить пароль', + newPassword: 'Новый пароль', + confirmPassword: 'Подтвердите пароль', + pwdMismatch: 'Пароли не совпадают', + pwdSuccess: 'Пароль успешно изменен', + pwdFailed: 'Не удалось изменить пароль', + password: 'Пароль', + delete: 'Удалить', + deleteConfirm: 'Вы уверены, что хотите удалить этого пользователя?', + createSuccess: 'Пользователь создан', + createFailed: 'Не удалось создать', + deleteSuccess: 'Пользователь удален', + deleteFailed: 'Не удалось удалить', + updateSuccess: 'Обновлено', + updateFailed: 'Ошибка обновления', + loadFailed: 'Не удалось загрузить пользователей', + usernameRequired: 'Введите имя пользователя', + passwordRequired: 'Введите пароль', + okBtn: 'OK', + cancelBtn: 'Отмена' + }, account: { title: 'Аккаунт', webAccount: 'Имя веб-аккаунта', diff --git a/web/src/i18n/locales/se.ts b/web/src/i18n/locales/se.ts index 7662d627..210a7946 100644 --- a/web/src/i18n/locales/se.ts +++ b/web/src/i18n/locales/se.ts @@ -474,6 +474,38 @@ const se = { updateFailed: 'Uppdatering misslyckades. Försök igen.' } }, + users: { + title: 'Användarhantering', + addUser: 'Lägg till användare', + colUsername: 'Användarnamn', + colRole: 'Roll', + colEnabled: 'Aktiv', + colActions: 'Åtgärder', + rolesTitle: 'Rollöversikt', + roleAdmin: 'Full åtkomst + användarhantering', + roleOperator: 'KVM-användning: ström, tangentbord, mus, strömbrytare', + roleViewer: 'Endast strömvisning', + changePassword: 'Ändra lösenord', + newPassword: 'Nytt lösenord', + confirmPassword: 'Bekräfta lösenord', + pwdMismatch: 'Lösenorden matchar inte', + pwdSuccess: 'Lösenord ändrat', + pwdFailed: 'Det gick inte att ändra lösenordet', + password: 'Lösenord', + delete: 'Ta bort', + deleteConfirm: 'Är du säker på att du vill ta bort denna användare?', + createSuccess: 'Användare skapad', + createFailed: 'Kunde inte skapa användare', + deleteSuccess: 'Användare borttagen', + deleteFailed: 'Borttagning misslyckades', + updateSuccess: 'Uppdaterad', + updateFailed: 'Uppdatering misslyckades', + loadFailed: 'Det gick inte att läsa in användare', + usernameRequired: 'Ange användarnamn', + passwordRequired: 'Ange lösenord', + okBtn: 'OK', + cancelBtn: 'Avbryt' + }, account: { title: 'Konto', webAccount: 'Webbkonto-namn', diff --git a/web/src/i18n/locales/th.ts b/web/src/i18n/locales/th.ts index bf2d5a35..c11ceb03 100644 --- a/web/src/i18n/locales/th.ts +++ b/web/src/i18n/locales/th.ts @@ -471,6 +471,38 @@ const th = { updateFailed: 'การอัปเดตล้มเหลว กรุณาลองใหม่' } }, + users: { + title: 'การจัดการผู้ใช้', + addUser: 'เพิ่มผู้ใช้', + colUsername: 'ชื่อผู้ใช้', + colRole: 'บทบาท', + colEnabled: 'เปิดใช้งาน', + colActions: 'การดำเนินการ', + rolesTitle: 'ภาพรวมบทบาท', + roleAdmin: 'เข้าถึงเต็มรูปแบบ + การจัดการผู้ใช้', + roleOperator: 'การใช้ KVM: สตรีม คีย์บอร์ด เมาส์ ปุ่มเปิด/ปิด', + roleViewer: 'ดูสตรีมเท่านั้น', + changePassword: 'เปลี่ยนรหัสผ่าน', + newPassword: 'รหัสผ่านใหม่', + confirmPassword: 'ยืนยันรหัสผ่าน', + pwdMismatch: 'รหัสผ่านไม่ตรงกัน', + pwdSuccess: 'เปลี่ยนรหัสผ่านสำเร็จ', + pwdFailed: 'ไม่สามารถเปลี่ยนรหัสผ่านได้', + password: 'รหัสผ่าน', + delete: 'ลบ', + deleteConfirm: 'คุณแน่ใจหรือไม่ว่าต้องการลบผู้ใช้นี้?', + createSuccess: 'สร้างผู้ใช้แล้ว', + createFailed: 'การสร้างล้มเหลว', + deleteSuccess: 'ลบผู้ใช้แล้ว', + deleteFailed: 'การลบล้มเหลว', + updateSuccess: 'อัปเดตแล้ว', + updateFailed: 'การอัปเดตล้มเหลว', + loadFailed: 'โหลดผู้ใช้ไม่สำเร็จ', + usernameRequired: 'กรุณากรอกชื่อผู้ใช้', + passwordRequired: 'กรุณากรอกรหัสผ่าน', + okBtn: 'ตกลง', + cancelBtn: 'ยกเลิก' + }, account: { title: 'บัญชี', webAccount: 'ชื่อผู้ใช้', diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts index fcdf3ea3..69465e3f 100644 --- a/web/src/i18n/locales/tr.ts +++ b/web/src/i18n/locales/tr.ts @@ -478,6 +478,38 @@ const tr = { updateFailed: 'Güncelleme başarısız oldu. Lütfen tekrar deneyin.' } }, + users: { + title: 'Kullanıcı Yönetimi', + addUser: 'Kullanıcı Ekle', + colUsername: 'Kullanıcı Adı', + colRole: 'Rol', + colEnabled: 'Aktif', + colActions: 'Eylemler', + rolesTitle: 'Roller Genel Bakış', + roleAdmin: 'Tam erişim + kullanıcı yönetimi', + roleOperator: 'KVM kullanımı: yayın, klavye, fare, güç düğmeleri', + roleViewer: 'Yalnızca yayın görüntüleme', + changePassword: 'Şifre Değiştir', + newPassword: 'Yeni Şifre', + confirmPassword: 'Şifreyi Onayla', + pwdMismatch: 'Şifreler eşleşmiyor', + pwdSuccess: 'Şifre başarıyla değiştirildi', + pwdFailed: 'Şifre değiştirilemedi', + password: 'Şifre', + delete: 'Sil', + deleteConfirm: 'Bu kullanıcıyı silmek istediğinizden emin misiniz?', + createSuccess: 'Kullanıcı oluşturuldu', + createFailed: 'Oluşturma başarısız', + deleteSuccess: 'Kullanıcı silindi', + deleteFailed: 'Silme başarısız', + updateSuccess: 'Güncellendi', + updateFailed: 'Güncelleme başarısız', + loadFailed: 'Kullanıcılar yüklenemedi', + usernameRequired: 'Kullanıcı adı girin', + passwordRequired: 'Şifre girin', + okBtn: 'Tamam', + cancelBtn: 'İptal' + }, account: { title: 'Hesap', webAccount: 'Web Hesap Adı', diff --git a/web/src/i18n/locales/uk.ts b/web/src/i18n/locales/uk.ts index 1187a4d4..1e7835ae 100644 --- a/web/src/i18n/locales/uk.ts +++ b/web/src/i18n/locales/uk.ts @@ -479,6 +479,38 @@ const uk = { updateFailed: 'Оновлення не вдалося. Будь ласка, спробуйте ще раз.' } }, + users: { + title: 'Керування користувачами', + addUser: 'Додати користувача', + colUsername: 'Ім\'я користувача', + colRole: 'Роль', + colEnabled: 'Активний', + colActions: 'Дії', + rolesTitle: 'Огляд ролей', + roleAdmin: 'Повний доступ + керування користувачами', + roleOperator: 'Використання KVM: потік, клавіатура, миша, кнопки живлення', + roleViewer: 'Лише перегляд потоку', + changePassword: 'Змінити пароль', + newPassword: 'Новий пароль', + confirmPassword: 'Підтвердити пароль', + pwdMismatch: 'Паролі не збігаються', + pwdSuccess: 'Пароль успішно змінено', + pwdFailed: 'Не вдалося змінити пароль', + password: 'Пароль', + delete: 'Видалити', + deleteConfirm: 'Ви впевнені, що хочете видалити цього користувача?', + createSuccess: 'Користувача створено', + createFailed: 'Помилка створення', + deleteSuccess: 'Користувача видалено', + deleteFailed: 'Помилка видалення', + updateSuccess: 'Оновлено', + updateFailed: 'Помилка оновлення', + loadFailed: 'Не вдалося завантажити користувачів', + usernameRequired: 'Введіть ім\'я користувача', + passwordRequired: 'Введіть пароль', + okBtn: 'OK', + cancelBtn: 'Скасувати' + }, account: { title: 'Обліковий запис', webAccount: "Ім'я облыкового запису у веб-інтерфейсі", diff --git a/web/src/i18n/locales/vi.ts b/web/src/i18n/locales/vi.ts index 873531d2..c492c8d0 100644 --- a/web/src/i18n/locales/vi.ts +++ b/web/src/i18n/locales/vi.ts @@ -477,6 +477,38 @@ const vi = { updateFailed: 'Cập nhật thất bại. Vui lòng thử lại.' } }, + users: { + title: 'Quản lý người dùng', + addUser: 'Thêm người dùng', + colUsername: 'Tên người dùng', + colRole: 'Vai trò', + colEnabled: 'Hoạt động', + colActions: 'Hành động', + rolesTitle: 'Tổng quan vai trò', + roleAdmin: 'Truy cập đầy đủ + quản lý người dùng', + roleOperator: 'Sử dụng KVM: luồng, bàn phím, chuột, nút nguồn', + roleViewer: 'Chỉ xem luồng', + changePassword: 'Đổi mật khẩu', + newPassword: 'Mật khẩu mới', + confirmPassword: 'Xác nhận mật khẩu', + pwdMismatch: 'Mật khẩu không khớp', + pwdSuccess: 'Đổi mật khẩu thành công', + pwdFailed: 'Không thể đổi mật khẩu', + password: 'Mật khẩu', + delete: 'Xóa', + deleteConfirm: 'Bạn có chắc muốn xóa người dùng này?', + createSuccess: 'Đã tạo người dùng', + createFailed: 'Tạo thất bại', + deleteSuccess: 'Đã xóa người dùng', + deleteFailed: 'Xóa thất bại', + updateSuccess: 'Đã cập nhật', + updateFailed: 'Cập nhật thất bại', + loadFailed: 'Tải người dùng thất bại', + usernameRequired: 'Nhập tên người dùng', + passwordRequired: 'Nhập mật khẩu', + okBtn: 'OK', + cancelBtn: 'Hủy' + }, account: { title: 'Tài khoản', webAccount: 'Tên tài khoản web', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index f11277be..81a3677b 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -466,6 +466,38 @@ const zh = { updateFailed: '更新失败,请重试' } }, + users: { + title: '用户管理', + addUser: '添加用户', + colUsername: '用户名', + colRole: '角色', + colEnabled: '启用', + colActions: '操作', + rolesTitle: '角色概览', + roleAdmin: '完全访问 + 用户管理', + roleOperator: 'KVM 使用:流、键盘、鼠标、电源按钮', + roleViewer: '仅查看流', + changePassword: '修改密码', + newPassword: '新密码', + confirmPassword: '确认密码', + pwdMismatch: '密码不匹配', + pwdSuccess: '密码修改成功', + pwdFailed: '修改密码失败', + password: '密码', + delete: '删除', + deleteConfirm: '确定要删除此用户吗?', + createSuccess: '用户已创建', + createFailed: '创建失败', + deleteSuccess: '用户已删除', + deleteFailed: '删除失败', + updateSuccess: '已更新', + updateFailed: '更新失败', + loadFailed: '加载用户失败', + usernameRequired: '请输入用户名', + passwordRequired: '请输入密码', + okBtn: '确定', + cancelBtn: '取消' + }, account: { title: '帐号', webAccount: '网页帐号', diff --git a/web/src/i18n/locales/zh_tw.ts b/web/src/i18n/locales/zh_tw.ts index e2aa1bf5..0d736472 100644 --- a/web/src/i18n/locales/zh_tw.ts +++ b/web/src/i18n/locales/zh_tw.ts @@ -466,6 +466,38 @@ const zh_tw = { updateFailed: '更新失敗,請重試' } }, + users: { + title: '使用者管理', + addUser: '新增使用者', + colUsername: '使用者名稱', + colRole: '角色', + colEnabled: '啟用', + colActions: '操作', + rolesTitle: '角色概覽', + roleAdmin: '完全存取 + 使用者管理', + roleOperator: 'KVM 使用:串流、鍵盤、滑鼠、電源按鈕', + roleViewer: '僅檢視串流', + changePassword: '變更密碼', + newPassword: '新密碼', + confirmPassword: '確認密碼', + pwdMismatch: '密碼不相符', + pwdSuccess: '密碼變更成功', + pwdFailed: '變更密碼失敗', + password: '密碼', + delete: '刪除', + deleteConfirm: '確定要刪除此使用者嗎?', + createSuccess: '使用者已建立', + createFailed: '建立失敗', + deleteSuccess: '使用者已刪除', + deleteFailed: '刪除失敗', + updateSuccess: '已更新', + updateFailed: '更新失敗', + loadFailed: '載入使用者失敗', + usernameRequired: '請輸入使用者名稱', + passwordRequired: '請輸入密碼', + okBtn: '確定', + cancelBtn: '取消' + }, account: { title: '帳號', webAccount: '網頁帳號', diff --git a/web/src/lib/http.ts b/web/src/lib/http.ts index 38802d9e..0002efac 100644 --- a/web/src/lib/http.ts +++ b/web/src/lib/http.ts @@ -67,6 +67,15 @@ class Http { }); } + public put(url: string, data?: any, config?: AxiosRequestConfig): Promise { + return this.instance.request({ + method: 'put', + url, + data, + ...config + }); + } + public delete(url: string, data?: any): Promise { return this.instance.request({ method: 'delete', diff --git a/web/src/pages/auth/password/index.tsx b/web/src/pages/auth/password/index.tsx index a75457a5..117db4af 100644 --- a/web/src/pages/auth/password/index.tsx +++ b/web/src/pages/auth/password/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { LockOutlined, UserOutlined } from '@ant-design/icons'; +import { LockOutlined } from '@ant-design/icons'; import { Button, Card, Form, Input } from 'antd'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -25,20 +25,16 @@ export const Password = () => { setMsg(t('auth.differentPassword')); return; } - if (!validateString(values.username)) { - setMsg(t('auth.illegalUsername')); - return; - } if (!validateString(values.password)) { setMsg('auth.illegalPassword'); return; } - const username = values.username; + // initial password change is always for "admin" const password = encrypt(values.password); api - .changePassword(username, password) + .changePassword('admin', password) .then((rsp: any) => { if (rsp.code !== 0) { setMsg(t('auth.error')); @@ -74,13 +70,6 @@ export const Password = () => { initialValues={{ remember: true }} onFinish={changePassword} > - - } placeholder={t('auth.placeholderUsername')} /> - - { const nodeRef = useRef(null); - const menuDisabledItems = useAtomValue(menuDisabledItemsAtom); + const { isOperator, isAdmin } = useRole(); const { isInitialized, @@ -66,12 +67,10 @@ export const Menu = () => { onMouseLeave={() => handleHovered(false)} onBlur={() => handleHovered(false)} > - {/* Trigger area for auto-show when hidden */} {isMenuExpanded && (
)} - {/* Menubar */}
{ + {/* Screen immer sichtbar (nur Anzeige) */} - - + + {/* Tastatur & Maus: nur operator+ */} + {isOperator && } + {isOperator && } + + {/* Image/Download: alle */} {isEnabled('image') && } {isEnabled('download') && } - {isEnabled('terminal') && } - {isEnabled('script') &&