mirror of
https://github.com/gl-inet/glkvm-cloud.git
synced 2026-10-04 12:41:42 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57da780653 | |||
| 07609164ef | |||
| c6c57e450c | |||
| 428d296881 | |||
| 34c3bb4e68 | |||
| 3b41b0b41d | |||
| bf96173777 | |||
| 2abb1cff9a | |||
| dce404e593 | |||
| f291671d9d | |||
| e98d46850b | |||
| b871dda40c | |||
| 8f21589239 | |||
| cbe724f425 | |||
| 53b7ee4e9e | |||
| 6e85756350 | |||
| c973b41a36 | |||
| e4d5e0baec | |||
| 5c7ff5531b | |||
| fbc269c63a | |||
| 9459b4709c | |||
| f5a8a6ddb6 | |||
| 53b95b71e6 | |||
| f49525ee9c | |||
| 40a1d23ea2 | |||
| 80112c7978 | |||
| b943a18959 | |||
| ea37a171e9 | |||
| 689793d47f | |||
| 55854afba8 | |||
| c8c67d5f0e | |||
| 66566e74a8 | |||
| 30a132cb4e | |||
| 52117f044b | |||
| 884f537ae8 | |||
| 807bab3e2a | |||
| a1823096cc | |||
| b1aa8e3cae | |||
| 3d75a87140 | |||
| d237847f0d | |||
| 59021535b7 | |||
| 133f344713 | |||
| 7fd9c39ffb | |||
| 96936ecdd8 | |||
| a44c9a4833 |
Vendored
+1
@@ -3,6 +3,7 @@
|
||||
"ddns",
|
||||
"glkvm",
|
||||
"repassword",
|
||||
"sortablejs",
|
||||
"webrtc"
|
||||
]
|
||||
}
|
||||
@@ -15,7 +15,7 @@ IMAGE_TAG ?= build
|
||||
GOARCH ?= $(shell go env GOARCH)
|
||||
|
||||
# ---------------- Commands ----------------
|
||||
.PHONY: all ui debug-local \
|
||||
.PHONY: all ui debug-local debug-dev-server \
|
||||
build-linux-amd64 build-linux-arm64 build-linux-all \
|
||||
docker-buildx docker-buildx-full
|
||||
|
||||
@@ -72,3 +72,19 @@ debug-local: build-linux-amd64 docker-buildx
|
||||
scp glkvmcloudbuild.tar $(DEBUG_HOST):$(DEBUG_PATH)
|
||||
ssh $(DEBUG_HOST) "docker load < $(DEBUG_PATH)"
|
||||
ssh $(DEBUG_HOST) "cd /root/glkvm_cloud && docker-compose down && docker-compose up -d"
|
||||
|
||||
# ---------------- Dev server debug ----------------
|
||||
# Set DEBUG_DEV_SERVER_IP via environment variable, e.g.:
|
||||
# export DEBUG_DEV_SERVER_IP=1.2.3.4
|
||||
# make debug-dev-server
|
||||
DEBUG_DEV_SERVER_IP ?= $(error DEBUG_DEV_SERVER_IP is not set)
|
||||
DEBUG_DEV_SERVER_USER ?= ubuntu
|
||||
DEBUG_DEV_SERVER_HOST = $(DEBUG_DEV_SERVER_USER)@$(DEBUG_DEV_SERVER_IP)
|
||||
DEBUG_DEV_SERVER_PATH ?= /home/$(DEBUG_DEV_SERVER_USER)/glkvmcloudbuild.tar
|
||||
DEBUG_DEV_SERVER_DIR ?= /home/$(DEBUG_DEV_SERVER_USER)/glkvm_cloud
|
||||
debug-dev-server: build-linux-amd64 docker-buildx
|
||||
docker save $(IMAGE_NAME):$(IMAGE_TAG) -o glkvmcloudbuild.tar
|
||||
ssh $(DEBUG_DEV_SERVER_HOST) "rm -f $(DEBUG_DEV_SERVER_PATH)"
|
||||
scp glkvmcloudbuild.tar $(DEBUG_DEV_SERVER_HOST):$(DEBUG_DEV_SERVER_PATH)
|
||||
ssh $(DEBUG_DEV_SERVER_HOST) "sudo docker load < $(DEBUG_DEV_SERVER_PATH)"
|
||||
ssh $(DEBUG_DEV_SERVER_HOST) "cd $(DEBUG_DEV_SERVER_DIR) && sudo docker-compose down && sudo docker-compose up -d"
|
||||
|
||||
@@ -18,7 +18,7 @@ Self-Deployed Lightweight Cloud is a lightweight KVM remote cloud platform tailo
|
||||
- **Enterprise Authentication** - Supports both **LDAP** and **OIDC** login methods for enterprise users.
|
||||
|
||||
- **Deployment & Platform Compatibility** - Supports both **internal network** and **public internet** deployments on **x86_64** and **arm64** platforms
|
||||
- **HTTP/HTTPS Web Proxy Support** - Supports onboarding OpenWrt, ImmortalWrt, Raspberry Pi, Linux VPS, macOS, and Windows hosts into self-hosted GLKVM Cloud for centralized management, and using them as HTTP/HTTPS web proxy nodes for NAT traversal access
|
||||
- **HTTP/HTTPS Web Proxy Support** - Supports onboarding embedded devices such as OpenWrt, Raspberry Pi, and other Linux-based hosts into self-hosted GLKVM Cloud for centralized management and NAT traversal access
|
||||
|
||||
## Self-Hosting Guide
|
||||
|
||||
@@ -45,7 +45,7 @@ The following mainstream operating systems have been tested and verified
|
||||
| Network Bandwidth | ≥ 3 Mbps |
|
||||
| KVM Device Firmware | ≥ v1.5.0 |
|
||||
|
||||
#### 🔐 Cloud Security Group Settings
|
||||
#### Cloud Security Group Settings
|
||||
|
||||
If your server provider uses a **cloud security group** (e.g., AWS, Aliyun, etc.), please make sure the following ports are **open**:
|
||||
|
||||
@@ -60,7 +60,7 @@ If your server provider uses a **cloud security group** (e.g., AWS, Aliyun, etc.
|
||||
These ports will be **used by GLKVM Cloud**. Please ensure **no other applications or services** on your server are binding to these ports, otherwise the lightweight cloud platform may fail to start properly.
|
||||
|
||||
------
|
||||
### 📦 Installation
|
||||
### Installation
|
||||
|
||||
We provide **two** ways to install GLKVM Cloud:
|
||||
|
||||
@@ -82,7 +82,7 @@ Run **as root**:
|
||||
>
|
||||
> **Platform:** supports both **x86_64 (amd64)** and **arm64 (AArch64)**.
|
||||
|
||||
### 🌐 Platform Access
|
||||
### Platform Access
|
||||
|
||||
Once the installation is complete, the installer will print the platform URL and admin login credentials in the console. You can access the platform via:
|
||||
|
||||
@@ -93,7 +93,7 @@ https://<your_server_public_ip>
|
||||
⚠️ **Note**: Accessing via an IP address will trigger a **browser certificate warning**.
|
||||
To remove the warning, configure your own domain and a valid SSL certificate.
|
||||
|
||||
### 🔑 Web UI Login Credentials
|
||||
### Web UI Login Credentials
|
||||
|
||||
At the end of the installation script, the console will display the Web UI administrator username and password (for example):
|
||||
|
||||
@@ -144,7 +144,7 @@ you can **skip** configuring a custom domain and SSL certificate, and still acce
|
||||
|
||||
For production use, or if you need to **access multiple KVM devices via subdomains**, it is **strongly recommended** to configure your own **wildcard SSL certificate** (see below).
|
||||
|
||||
#### 🌐 Add DNS Records
|
||||
#### Add DNS Records
|
||||
|
||||
To enable full domain-based access, configure the following DNS records for your domain:
|
||||
|
||||
@@ -157,7 +157,7 @@ To enable full domain-based access, configure the following DNS records for your
|
||||
└────────────┴──────┴────────────────────┴─────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 🔧 Using a Custom SSL Certificate
|
||||
#### Using a Custom SSL Certificate
|
||||
|
||||
To avoid browser warnings, replace the default certificates with your own **wildcard SSL certificate**
|
||||
that supports both:
|
||||
@@ -176,51 +176,9 @@ Replace the following files in:
|
||||
|
||||
⚠️ **Make sure the filenames remain unchanged.**
|
||||
|
||||
#### 🔐 LDAP Authentication Configuration (Optional)
|
||||
#### Restart Services After Configuration Changes
|
||||
|
||||
GLKVM Cloud supports LDAP authentication for enterprise environments, allowing you to integrate with existing directory services like Active Directory, OpenLDAP, or FreeIPA.
|
||||
|
||||
**Key Features:**
|
||||
- **Dual Authentication Mode**: Support both LDAP and traditional password authentication simultaneously
|
||||
- **Group-based Authorization**: Restrict access to specific LDAP groups
|
||||
- **User-based Authorization**: Allow access for specific users only
|
||||
- **TLS/SSL Support**: Secure LDAP connections with encryption
|
||||
- **Multiple LDAP Systems**: Compatible with Active Directory, OpenLDAP, FreeIPA, and generic LDAP servers
|
||||
|
||||
**Configuration:**
|
||||
For detailed LDAP configuration options and setup instructions, see the [Docker Compose README](docker-compose/README.md).
|
||||
|
||||
**Note**: When LDAP is enabled, users can choose between:
|
||||
- **LDAP Authentication**: Enter username and password for directory service authentication
|
||||
- **Legacy Authentication**: Leave username empty and use the web management password
|
||||
|
||||
#### 🔐 OIDC Authentication Configuration (Optional)
|
||||
|
||||
GLKVM Cloud provides full support for **OIDC (OpenID Connect)** authentication, allowing seamless integration with modern identity providers such as **Google, Auth0, Authing** and any other standard-compliant OIDC provider.
|
||||
|
||||
**Key Features**
|
||||
|
||||
- **Modern Authentication**
|
||||
Secure sign-in through any OIDC provider supporting Authorization Code Flow.
|
||||
- **Email / Username / Group Whitelisting**
|
||||
Restrict access based on:
|
||||
- Email or domain (e.g. *@example.com*)
|
||||
- Stable user ID (*sub*)
|
||||
- Username (*preferred_username* or *name*)
|
||||
- Groups attribute
|
||||
- **Full OpenID Connect Compliance**
|
||||
Supports issuer validation, token signature verification, and nonce protection.
|
||||
- **Flexible Provider Support**
|
||||
Works with public clouds (Google, Azure AD, Auth0, Okta) and self-hosted solutions.
|
||||
|
||||
**Configuration**
|
||||
|
||||
For detailed OIDC configuration options and setup instructions, see the
|
||||
**[Docker Compose README](docker-compose/README.md)**.
|
||||
|
||||
#### 🔄 Restart Services After Configuration Changes
|
||||
|
||||
After replacing certificates or updating LDAP configuration, restart the GLKVM Cloud services to apply the changes:
|
||||
After replacing certificates, restart the GLKVM Cloud services to apply the changes:
|
||||
|
||||
```bash
|
||||
cd ~/glkvm_cloud
|
||||
@@ -233,7 +191,7 @@ Or, on systems with the Docker CLI plugin:
|
||||
docker compose down && docker compose up -d
|
||||
```
|
||||
|
||||
### Domain-Based Access Example
|
||||
### Domain-Based Access Example
|
||||
|
||||
Once everything is configured, you can access the platform via your domain:
|
||||
|
||||
|
||||
+10
-48
@@ -19,7 +19,7 @@
|
||||
* **企业级认证** - 同时支持 **LDAP** 和 **OIDC** 登录方式,适用于企业用户。
|
||||
|
||||
- **部署与平台兼容性** - 同时支持 **内网部署** 和 **公网部署**,并兼容 **x86_64** 与 **arm64** 平台
|
||||
- **HTTP/HTTPS Web代理功能支持** - 支持 OpenWrt、ImmortalWrt、树莓派、Linux VPS、macOS、Windows 等主机接入自部署 GLKVM Cloud 进行统一管理,并可作为 HTTP/HTTPS Web 代理节点实现内网穿透访问
|
||||
- **HTTP/HTTPS Web 代理支持** - 支持 OpenWrt、树莓派等嵌入式设备及 Linux 主机接入自部署 GLKVM Cloud,实现统一管理与内网穿透访问
|
||||
|
||||
## 自部署指南
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
| 网络带宽 | ≥ 3 Mbps |
|
||||
| KVM 固件版本 | ≥ v1.5.0 |
|
||||
|
||||
#### 🔐 云安全组端口要求
|
||||
#### 云安全组端口要求
|
||||
|
||||
如果你的服务器提供商(如 AWS、阿里云等)启用了 **云安全组**,请确保以下端口已开放:
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
⚠️ **重要提示**:
|
||||
这些端口将被 **GLKVM 轻量云** 占用,请确保服务器上没有其他程序占用这些端口,否则平台可能无法正常启动。
|
||||
|
||||
## 📦 安装
|
||||
## 安装
|
||||
|
||||
我们提供 **两种** 安装 GLKVM Cloud 的方式:
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
> 平台支持: 同时支持 x86_64(amd64) 与 arm64(AArch64) 平台。
|
||||
|
||||
|
||||
### 🌐 平台访问
|
||||
### 平台访问
|
||||
|
||||
安装完成后,安装脚本会在控制台输出平台访问地址和管理员登录信息。你可以通过以下方式访问平台:
|
||||
|
||||
@@ -94,7 +94,7 @@ https://<你的服务器公网IP>
|
||||
⚠️ **提示**:通过 IP 访问时,浏览器会提示 **证书不受信任**。
|
||||
如需消除该提示,建议配置 **自定义域名 + 有效 SSL 证书**。
|
||||
|
||||
### 🔑 Web UI 登录信息
|
||||
### Web UI 登录信息
|
||||
|
||||
安装脚本运行结束后,安装控制台会显示 Web UI 管理员用户名和密码(示例):
|
||||
|
||||
@@ -142,7 +142,7 @@ https://<你的服务器公网IP>
|
||||
但在 **生产环境**,或需要通过 **子域名同时访问多台 KVM 设备** 的情况下,
|
||||
强烈建议配置 **通配符 SSL 证书**(见下文)。
|
||||
|
||||
#### 🌐 添加 DNS 记录
|
||||
#### 添加 DNS 记录
|
||||
|
||||
如果需要完整的域名访问,请在域名解析中添加以下记录:
|
||||
|
||||
@@ -157,7 +157,7 @@ https://<你的服务器公网IP>
|
||||
|
||||
---
|
||||
|
||||
#### 🔧 使用自定义 SSL 证书
|
||||
#### 使用自定义 SSL 证书
|
||||
|
||||
如果要消除浏览器证书警告,请使用支持以下域名的 **通配符 SSL 证书**:
|
||||
|
||||
@@ -173,47 +173,9 @@ https://<你的服务器公网IP>
|
||||
* `glkvm.cer`
|
||||
* `glkvm.key`
|
||||
|
||||
#### 🔐 LDAP 身份认证配置(可选)
|
||||
#### 配置更改后重启服务
|
||||
|
||||
GLKVM 轻量云支持 LDAP 身份认证,适用于企业环境,可以与现有的目录服务(如 Active Directory、OpenLDAP 或 FreeIPA)集成。
|
||||
|
||||
**主要功能:**
|
||||
|
||||
- **双重认证模式**:同时支持 LDAP 和传统密码认证
|
||||
- **基于组的授权**:限制特定 LDAP 组访问
|
||||
- **基于用户的授权**:仅允许特定用户访问
|
||||
- **TLS/SSL 支持**:加密 LDAP 连接
|
||||
- **多 LDAP 系统支持**:兼容 Active Directory、OpenLDAP、FreeIPA 和通用 LDAP 服务器
|
||||
|
||||
**配置方法:**
|
||||
详细的 LDAP 配置选项和设置说明,请参见 [Docker Compose README](docker-compose/README.md)。
|
||||
|
||||
**注意**:启用 LDAP 后,用户可以选择:
|
||||
- **LDAP 认证**:输入用户名和密码进行目录服务认证
|
||||
- **传统认证**:留空用户名并使用 Web 管理密码
|
||||
|
||||
#### 🔐 OIDC 登录认证配置(可选)
|
||||
|
||||
GLKVM Cloud 完整支持 **OIDC(OpenID Connect)** 登录认证,可无缝集成现代身份提供商,例如 **Google、Auth0、Authing**,以及任何符合 OIDC 标准的认证服务。
|
||||
|
||||
**主要功能**
|
||||
|
||||
- **现代化认证方式**
|
||||
支持使用任意支持 Authorization Code Flow 的 OIDC 身份提供商进行安全登录。
|
||||
- **邮箱 / 用户名 / 用户组白名单控制**
|
||||
可根据以下信息限制用户访问:
|
||||
- 邮箱或域名(如 *@example.com*)
|
||||
- 用户 ID(*sub*)
|
||||
- 用户名(*preferred_username* 或 *name*)
|
||||
- 用户组
|
||||
- **完全符合 OpenID Connect 标准**
|
||||
支持 Issuer 校验、ID Token 签名验证、Nonce 防重放保护等安全机制。
|
||||
- **高度灵活的提供商支持**
|
||||
兼容各类公共云 IdP(Google、Azure AD、Auth0、Okta 等)以及自建身份服务(Keycloak、Authentik、Dex 等)。
|
||||
|
||||
#### 🔄 配置更改后重启服务
|
||||
|
||||
替换证书或更新 LDAP 配置后,需要重启 GLKVM 轻量云服务以应用更改:
|
||||
替换证书后,需要重启 GLKVM 轻量云服务以应用更改:
|
||||
|
||||
```bash
|
||||
cd ~/glkvm_cloud
|
||||
@@ -226,7 +188,7 @@ docker-compose down && docker-compose up -d
|
||||
docker compose down && docker compose up -d
|
||||
```
|
||||
|
||||
### 🌍 域名访问示例
|
||||
### 域名访问示例
|
||||
|
||||
配置完成后,你可以通过以下方式访问平台:
|
||||
|
||||
|
||||
@@ -22,6 +22,21 @@ COTURN_IMAGE=coturn/coturn:edge-alpine-arm64v8
|
||||
# https://github.com/gl-inet/glkvm-cloud/blob/main/docker-compose/nginx-reverse-proxy-example.conf
|
||||
REVERSE_PROXY_ENABLED=false
|
||||
|
||||
# =====================================================
|
||||
# Selfhost WebUI URL (Optional)
|
||||
# =====================================================
|
||||
# The full URL (including scheme) of the self-hosted cloud WebUI.
|
||||
# This URL is written to the KVM device as /etc/kvmd/user/selfhost-cloud.json
|
||||
# so firmware can read it and create a navigation link in the device's web page.
|
||||
#
|
||||
# If left empty, the URL is automatically derived from the browser's current
|
||||
# address when copying the installation script.
|
||||
#
|
||||
# Examples:
|
||||
# SELFHOST_WEBUI_URL=https://kvm.example.com
|
||||
# SELFHOST_WEBUI_URL=https://192.168.1.100
|
||||
SELFHOST_WEBUI_URL=
|
||||
|
||||
# =====================================================
|
||||
# Device Remote Access Domain (Reverse Proxy Mode Only)
|
||||
# =====================================================
|
||||
@@ -102,6 +117,13 @@ LDAP_USER_FILTER=(uid=%s)
|
||||
LDAP_ALLOWED_GROUPS=admins,operators
|
||||
LDAP_ALLOWED_USERS=user1,user2
|
||||
|
||||
# LDAP admin group: users in these groups are assigned the "admin" role.
|
||||
# Comma-separated list of group CNs. Leave empty to default all LDAP users to "user" role.
|
||||
LDAP_ADMIN_GROUP=
|
||||
# LDAP admin users: these usernames are directly assigned the "admin" role.
|
||||
# Comma-separated list of usernames. Leave empty to skip user-based admin assignment.
|
||||
LDAP_ADMIN_USERS=
|
||||
|
||||
# OIDC Authentication (Optional, generic OIDC provider)
|
||||
OIDC_ENABLED=false
|
||||
OIDC_ISSUER=
|
||||
@@ -126,3 +148,10 @@ OIDC_ALLOWED_SUBS=
|
||||
OIDC_ALLOWED_USERNAMES=
|
||||
# Groups whitelist (e.g. admin, devops)
|
||||
OIDC_ALLOWED_GROUPS=
|
||||
|
||||
# OIDC admin group: users in these groups are assigned the "admin" role.
|
||||
# Comma-separated list of group names. Leave empty to default all OIDC users to "user" role.
|
||||
OIDC_ADMIN_GROUP=
|
||||
# OIDC admin users: these users are directly assigned the "admin" role.
|
||||
# Comma-separated list matching preferred_username or email. Leave empty to skip user-based admin assignment.
|
||||
OIDC_ADMIN_USERS=
|
||||
|
||||
@@ -22,6 +22,22 @@ COTURN_IMAGE=coturn/coturn:edge-alpine
|
||||
# https://github.com/gl-inet/glkvm-cloud/blob/main/docker-compose/nginx-reverse-proxy-example.conf
|
||||
REVERSE_PROXY_ENABLED=false
|
||||
|
||||
# =====================================================
|
||||
# Selfhost WebUI URL (Optional)
|
||||
# =====================================================
|
||||
# The full URL (including scheme) of the self-hosted cloud WebUI.
|
||||
# This URL is written to the KVM device as /etc/kvmd/user/selfhost-cloud.json
|
||||
# so firmware can read it and create a navigation link in the device's web page.
|
||||
#
|
||||
# If left empty, the URL is automatically derived from the browser's current
|
||||
# address when copying the installation script.
|
||||
#
|
||||
# Examples:
|
||||
# SELFHOST_WEBUI_URL=https://kvm.example.com
|
||||
# SELFHOST_WEBUI_URL=https://192.168.1.100
|
||||
SELFHOST_WEBUI_URL=
|
||||
|
||||
|
||||
# =====================================================
|
||||
# Device Remote Access Domain (Reverse Proxy Mode Only)
|
||||
# =====================================================
|
||||
@@ -63,7 +79,7 @@ DEVICE_ENDPOINT_HOST=
|
||||
# - Leave empty to disable domain restriction (allow access via any domain)
|
||||
WEB_UI_HOST=
|
||||
|
||||
GLKVM access IP seen by devices/users.
|
||||
# GLKVM access IP seen by devices/users.
|
||||
# Leave empty to auto-detect at container start.
|
||||
GLKVM_ACCESS_IP=
|
||||
|
||||
@@ -101,6 +117,13 @@ LDAP_USER_FILTER=(uid=%s)
|
||||
LDAP_ALLOWED_GROUPS=admins,operators
|
||||
LDAP_ALLOWED_USERS=user1,user2
|
||||
|
||||
# LDAP admin group: users in these groups are assigned the "admin" role.
|
||||
# Comma-separated list of group CNs. Leave empty to default all LDAP users to "user" role.
|
||||
LDAP_ADMIN_GROUP=
|
||||
# LDAP admin users: these usernames are directly assigned the "admin" role.
|
||||
# Comma-separated list of usernames. Leave empty to skip user-based admin assignment.
|
||||
LDAP_ADMIN_USERS=
|
||||
|
||||
# OIDC Authentication (Optional, generic OIDC provider)
|
||||
OIDC_ENABLED=false
|
||||
OIDC_ISSUER=
|
||||
@@ -125,3 +148,10 @@ OIDC_ALLOWED_SUBS=
|
||||
OIDC_ALLOWED_USERNAMES=
|
||||
# Groups whitelist (e.g. admin, devops)
|
||||
OIDC_ALLOWED_GROUPS=
|
||||
|
||||
# OIDC admin group: users in these groups are assigned the "admin" role.
|
||||
# Comma-separated list of group names. Leave empty to default all OIDC users to "user" role.
|
||||
OIDC_ADMIN_GROUP=
|
||||
# OIDC admin users: these users are directly assigned the "admin" role.
|
||||
# Comma-separated list matching preferred_username or email. Leave empty to skip user-based admin assignment.
|
||||
OIDC_ADMIN_USERS=
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "2.0"
|
||||
|
||||
services:
|
||||
rttys:
|
||||
image: ${GLKVM_IMAGE:-glzhitong/glkvm-cloud:latest}
|
||||
@@ -35,6 +33,8 @@ services:
|
||||
LDAP_USER_FILTER: ${LDAP_USER_FILTER:-(uid=%s)}
|
||||
LDAP_ALLOWED_GROUPS: ${LDAP_ALLOWED_GROUPS:-}
|
||||
LDAP_ALLOWED_USERS: ${LDAP_ALLOWED_USERS:-}
|
||||
LDAP_ADMIN_GROUP: ${LDAP_ADMIN_GROUP:-}
|
||||
LDAP_ADMIN_USERS: ${LDAP_ADMIN_USERS:-}
|
||||
|
||||
# ---- OIDC Authentication ----
|
||||
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
||||
@@ -50,6 +50,11 @@ services:
|
||||
OIDC_ALLOWED_SUBS: ${OIDC_ALLOWED_SUBS:-}
|
||||
OIDC_ALLOWED_USERNAMES: ${OIDC_ALLOWED_USERNAMES:-}
|
||||
OIDC_ALLOWED_GROUPS: ${OIDC_ALLOWED_GROUPS:-}
|
||||
OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP:-}
|
||||
OIDC_ADMIN_USERS: ${OIDC_ADMIN_USERS:-}
|
||||
|
||||
# ---- Selfhost WebUI URL ----
|
||||
SELFHOST_WEBUI_URL: ${SELFHOST_WEBUI_URL:-}
|
||||
|
||||
# ---- Reverse Proxy ----
|
||||
REVERSE_PROXY_ENABLED: ${REVERSE_PROXY_ENABLED:-false}
|
||||
|
||||
@@ -66,9 +66,11 @@ case "$1" in
|
||||
LDAP_ENABLED LDAP_SERVER LDAP_PORT LDAP_USE_TLS \
|
||||
LDAP_BIND_DN LDAP_BIND_PASSWORD LDAP_BASE_DN \
|
||||
LDAP_USER_FILTER LDAP_ALLOWED_GROUPS LDAP_ALLOWED_USERS \
|
||||
LDAP_ADMIN_GROUP LDAP_ADMIN_USERS \
|
||||
OIDC_ENABLED OIDC_CLIENT_ID OIDC_AUTH_URL OIDC_TOKEN_URL \
|
||||
OIDC_REDIRECT_URL OIDC_CLIENT_SECRET OIDC_SCOPES OIDC_ALLOWED_USERS OIDC_ISSUER \
|
||||
OIDC_ALLOWED_SUBS OIDC_ALLOWED_USERNAMES OIDC_ALLOWED_GROUPS
|
||||
OIDC_ALLOWED_SUBS OIDC_ALLOWED_USERNAMES OIDC_ALLOWED_GROUPS \
|
||||
OIDC_ADMIN_GROUP OIDC_ADMIN_USERS
|
||||
|
||||
exec rttys -c /home/rttys.conf
|
||||
;;
|
||||
|
||||
@@ -29,6 +29,8 @@ ldap-base-dn: {{LDAP_BASE_DN}}
|
||||
ldap-user-filter: {{LDAP_USER_FILTER}}
|
||||
ldap-allowed-groups: {{LDAP_ALLOWED_GROUPS}}
|
||||
ldap-allowed-users: {{LDAP_ALLOWED_USERS}}
|
||||
ldap-admin-group: {{LDAP_ADMIN_GROUP}}
|
||||
ldap-admin-users: {{LDAP_ADMIN_USERS}}
|
||||
|
||||
# OIDC Authentication (generic OIDC provider)
|
||||
oidc-enabled: {{OIDC_ENABLED}}
|
||||
@@ -50,3 +52,5 @@ oidc-generic-allowed-users: {{OIDC_ALLOWED_USERS}}
|
||||
oidc-generic-allowed-subs: {{OIDC_ALLOWED_SUBS}}
|
||||
oidc-generic-allowed-usernames: {{OIDC_ALLOWED_USERNAMES}}
|
||||
oidc-generic-allowed-groups: {{OIDC_ALLOWED_GROUPS}}
|
||||
oidc-admin-group: {{OIDC_ADMIN_GROUP}}
|
||||
oidc-admin-users: {{OIDC_ADMIN_USERS}}
|
||||
|
||||
@@ -25,6 +25,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/sonic v1.13.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
@@ -48,6 +49,7 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
|
||||
@@ -2,6 +2,8 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
@@ -115,6 +117,8 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
|
||||
@@ -20,3 +20,29 @@ type Device struct {
|
||||
Status Status
|
||||
LastSeenAt *int64
|
||||
}
|
||||
|
||||
// ListItem is a device plus its joined device-group name, returned by the
|
||||
// server-side paginated list query so callers don't need a second lookup.
|
||||
type ListItem struct {
|
||||
Device
|
||||
GroupName string
|
||||
}
|
||||
|
||||
// ListQuery describes a server-side filtered/sorted/paginated device listing.
|
||||
// Filtering, ordering (incl. online-first) and pagination all happen in SQL so
|
||||
// large fleets don't require loading every row into memory per request.
|
||||
type ListQuery struct {
|
||||
// RestrictGroups limits results to devices whose device_group_id is in this
|
||||
// set. nil means no restriction (admin: all devices, including ungrouped).
|
||||
RestrictGroups []int64
|
||||
// Search matches ddns/mac/ip/description as a case-insensitive substring.
|
||||
// Callers pass it already lowercased and with ':' stripped (to match the
|
||||
// colon-less MAC stored in the DB).
|
||||
Search string
|
||||
Unassigned bool // only devices with no device group
|
||||
Status string // "" = any; otherwise online|offline|disabled
|
||||
SortBy string // id|ip|mac|ddns|description|connectedTime|deviceGroupName
|
||||
Order string // "asc" (default) or "desc"
|
||||
Page int // 1-based
|
||||
PageSize int // 0 => no limit (return all matching rows)
|
||||
}
|
||||
|
||||
@@ -5,4 +5,7 @@ import "context"
|
||||
type Repository interface {
|
||||
ListAll(ctx context.Context) ([]Device, error)
|
||||
ListByDeviceGroupIDs(ctx context.Context, groupIDs []int64) ([]Device, error)
|
||||
// ListPaged returns one page of devices (with joined group name) plus the
|
||||
// total count of matching rows, doing all filtering/sorting/pagination in SQL.
|
||||
ListPaged(ctx context.Context, q ListQuery) ([]ListItem, int64, error)
|
||||
}
|
||||
|
||||
@@ -34,3 +34,9 @@ func (s *Service) ListVisible(ctx context.Context, role identity.Role, userID in
|
||||
func (s *Service) ListByDeviceGroupIDs(ctx context.Context, groupIDs []int64) ([]Device, error) {
|
||||
return s.repo.ListByDeviceGroupIDs(ctx, groupIDs)
|
||||
}
|
||||
|
||||
// ListPaged returns one filtered/sorted page plus the total matching count,
|
||||
// pushing all the work to SQL (see Repository.ListPaged).
|
||||
func (s *Service) ListPaged(ctx context.Context, q ListQuery) ([]ListItem, int64, error) {
|
||||
return s.repo.ListPaged(ctx, q)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package devicelog
|
||||
|
||||
// EventType identifies the kind of device event being logged.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventDeviceOnline EventType = "device_online"
|
||||
EventDeviceOffline EventType = "device_offline"
|
||||
EventRemoteSSH EventType = "remote_ssh"
|
||||
EventRemoteWeb EventType = "remote_web"
|
||||
EventRemoteControl EventType = "remote_control"
|
||||
)
|
||||
|
||||
// IsSession reports whether the event represents a long-running session
|
||||
// (SSH / Web / Control) for which we track both started_at and ended_at.
|
||||
func (e EventType) IsSession() bool {
|
||||
return e == EventRemoteSSH || e == EventRemoteWeb || e == EventRemoteControl
|
||||
}
|
||||
|
||||
// Log is a single device event row.
|
||||
//
|
||||
// For point events (online/offline) EndedAt is always 0.
|
||||
// For session events (SSH/Web) CreatedAt is the session start and EndedAt
|
||||
// is the session end (0 while still active).
|
||||
type Log struct {
|
||||
ID int64
|
||||
DeviceID string
|
||||
DeviceMac string
|
||||
EventType EventType
|
||||
ActorUserID int64
|
||||
ActorName string
|
||||
ClientIP string
|
||||
Detail string // JSON-encoded extra fields
|
||||
CreatedAt int64
|
||||
EndedAt int64
|
||||
}
|
||||
|
||||
// Query holds the filter parameters for listing logs.
|
||||
type Query struct {
|
||||
Mac string // substring match (LIKE %mac%)
|
||||
EventTypes []EventType // empty = no filter
|
||||
From int64 // unix seconds, 0 = no lower bound
|
||||
To int64 // unix seconds, 0 = no upper bound
|
||||
Page int // 1-based
|
||||
PageSize int
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package devicelog
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, l *Log) (int64, error)
|
||||
UpdateEndedAt(ctx context.Context, id int64, ts int64) error
|
||||
List(ctx context.Context, q Query) (items []Log, total int64, err error)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// Package devicelog records four kinds of device events: online, offline,
|
||||
// remote SSH session and remote Web session. Records are queryable by
|
||||
// MAC, event type and time range.
|
||||
//
|
||||
// The service deliberately swallows errors so that logging never blocks the
|
||||
// device runtime — failures are reported via the standard logger.
|
||||
package devicelog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// startupGraceWindow is how long after the service boots we silently drop
|
||||
// device online/offline events. The goal is to avoid the reconnect storm
|
||||
// that happens right after a server restart from filling the log with
|
||||
// noise. SSH/Web events are user-initiated and never suppressed.
|
||||
const startupGraceWindow = 60 * time.Second
|
||||
|
||||
// onOffDebounceWindow coalesces rapid online/offline flapping in the audit
|
||||
// log: a second event of the same kind for the same device within this window
|
||||
// is dropped. Only the historical event log is affected; the live device
|
||||
// status (devices.status) is updated on a separate path and stays accurate.
|
||||
const onOffDebounceWindow = 30 * time.Second
|
||||
|
||||
// Detail field length cap to keep rows bounded against malicious input.
|
||||
const maxDetailLen = 2000
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
startupTime time.Time
|
||||
|
||||
// lastOnOff tracks the last time an online/offline event was accepted for
|
||||
// a given device+type, keyed by deviceID+"|"+eventType, for flap damping.
|
||||
onOffMu sync.Mutex
|
||||
lastOnOff map[string]int64
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
startupTime: time.Now(),
|
||||
lastOnOff: make(map[string]int64),
|
||||
}
|
||||
}
|
||||
|
||||
// allowOnOff reports whether an online/offline event for the device should be
|
||||
// recorded now, updating the last-seen timestamp when it returns true. It
|
||||
// drops a same-kind event that arrives within onOffDebounceWindow.
|
||||
func (s *Service) allowOnOff(deviceID string, evt EventType) bool {
|
||||
key := deviceID + "|" + string(evt)
|
||||
now := time.Now().Unix()
|
||||
window := int64(onOffDebounceWindow / time.Second)
|
||||
|
||||
s.onOffMu.Lock()
|
||||
defer s.onOffMu.Unlock()
|
||||
if last, ok := s.lastOnOff[key]; ok && now-last < window {
|
||||
return false
|
||||
}
|
||||
s.lastOnOff[key] = now
|
||||
return true
|
||||
}
|
||||
|
||||
// normalizeMac strips colons and lowercases, matching the format in the
|
||||
// devices table so that MAC-based searches work correctly.
|
||||
func normalizeMac(mac string) string {
|
||||
return strings.ReplaceAll(strings.ToLower(mac), ":", "")
|
||||
}
|
||||
|
||||
// inGracePeriod reports whether we are still inside the post-startup quiet
|
||||
// window during which device on/off events are dropped.
|
||||
func (s *Service) inGracePeriod() bool {
|
||||
return time.Since(s.startupTime) < startupGraceWindow
|
||||
}
|
||||
|
||||
// RecordDeviceOnline records a device-online event. Dropped during the
|
||||
// startup grace window.
|
||||
func (s *Service) RecordDeviceOnline(ctx context.Context, deviceID, mac, ip string) {
|
||||
if s == nil || s.repo == nil {
|
||||
return
|
||||
}
|
||||
if s.inGracePeriod() {
|
||||
return
|
||||
}
|
||||
if !s.allowOnOff(deviceID, EventDeviceOnline) {
|
||||
return
|
||||
}
|
||||
if _, err := s.repo.Create(ctx, &Log{
|
||||
DeviceID: deviceID,
|
||||
DeviceMac: normalizeMac(mac),
|
||||
EventType: EventDeviceOnline,
|
||||
ClientIP: ip,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}); err != nil {
|
||||
log.Warn().Err(err).Str("device", deviceID).Msg("devicelog: record online failed")
|
||||
}
|
||||
}
|
||||
|
||||
// RecordDeviceOffline records a device-offline event. Dropped during the
|
||||
// startup grace window.
|
||||
func (s *Service) RecordDeviceOffline(ctx context.Context, deviceID, mac, ip string) {
|
||||
if s == nil || s.repo == nil {
|
||||
return
|
||||
}
|
||||
if s.inGracePeriod() {
|
||||
return
|
||||
}
|
||||
if !s.allowOnOff(deviceID, EventDeviceOffline) {
|
||||
return
|
||||
}
|
||||
if _, err := s.repo.Create(ctx, &Log{
|
||||
DeviceID: deviceID,
|
||||
DeviceMac: normalizeMac(mac),
|
||||
EventType: EventDeviceOffline,
|
||||
ClientIP: ip,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}); err != nil {
|
||||
log.Warn().Err(err).Str("device", deviceID).Msg("devicelog: record offline failed")
|
||||
}
|
||||
}
|
||||
|
||||
// StartRemoteSSHSession records the start of an SSH session and returns
|
||||
// the row ID so the caller can later mark it ended via EndSession.
|
||||
// Returns 0 if recording failed.
|
||||
func (s *Service) StartRemoteSSHSession(ctx context.Context, deviceID, mac string, userID int64, userName, ip string) int64 {
|
||||
if s == nil || s.repo == nil {
|
||||
return 0
|
||||
}
|
||||
id, err := s.repo.Create(ctx, &Log{
|
||||
DeviceID: deviceID,
|
||||
DeviceMac: normalizeMac(mac),
|
||||
EventType: EventRemoteSSH,
|
||||
ActorUserID: userID,
|
||||
ActorName: userName,
|
||||
ClientIP: ip,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("device", deviceID).Msg("devicelog: start ssh session failed")
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// StartRemoteWebSession records the start of a web-proxy session.
|
||||
// addr/proto are stored as a small JSON detail blob.
|
||||
func (s *Service) StartRemoteWebSession(ctx context.Context, deviceID, mac string, userID int64, userName, ip, addr, proto string) int64 {
|
||||
if s == nil || s.repo == nil {
|
||||
return 0
|
||||
}
|
||||
detail := encodeDetail(map[string]string{"addr": addr, "proto": proto})
|
||||
id, err := s.repo.Create(ctx, &Log{
|
||||
DeviceID: deviceID,
|
||||
DeviceMac: normalizeMac(mac),
|
||||
EventType: EventRemoteWeb,
|
||||
ActorUserID: userID,
|
||||
ActorName: userName,
|
||||
ClientIP: ip,
|
||||
Detail: detail,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("device", deviceID).Msg("devicelog: start web session failed")
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// StartRemoteControlSession records the start of a remote-control session
|
||||
// (KVM web UI). No detail blob is stored for this event type.
|
||||
func (s *Service) StartRemoteControlSession(ctx context.Context, deviceID, mac string, userID int64, userName, ip string) int64 {
|
||||
if s == nil || s.repo == nil {
|
||||
return 0
|
||||
}
|
||||
id, err := s.repo.Create(ctx, &Log{
|
||||
DeviceID: deviceID,
|
||||
DeviceMac: normalizeMac(mac),
|
||||
EventType: EventRemoteControl,
|
||||
ActorUserID: userID,
|
||||
ActorName: userName,
|
||||
ClientIP: ip,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("device", deviceID).Msg("devicelog: start control session failed")
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// EndSession stamps ended_at on a session row. Safe to call with id == 0
|
||||
// (no-op) so callers can write `defer logSvc.EndSession(ctx, id)` without
|
||||
// branching on whether the start succeeded.
|
||||
func (s *Service) EndSession(ctx context.Context, id int64) {
|
||||
if s == nil || s.repo == nil || id <= 0 {
|
||||
return
|
||||
}
|
||||
if err := s.repo.UpdateEndedAt(ctx, id, time.Now().Unix()); err != nil {
|
||||
log.Warn().Err(err).Int64("id", id).Msg("devicelog: end session failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Query lists logs matching the filter. Page/PageSize are normalized:
|
||||
// page defaults to 1, pageSize is clamped to [1, 200].
|
||||
func (s *Service) Query(ctx context.Context, q Query) ([]Log, int64, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = 20
|
||||
}
|
||||
if q.PageSize > 200 {
|
||||
q.PageSize = 200
|
||||
}
|
||||
return s.repo.List(ctx, q)
|
||||
}
|
||||
|
||||
func encodeDetail(m map[string]string) string {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(b) > maxDetailLen {
|
||||
return string(b[:maxDetailLen])
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SendEmail delivers an HTML email via the given SMTP config.
|
||||
func SendEmail(cfg *SMTPConfig, to []string, subject, htmlBody string) error {
|
||||
if cfg == nil || cfg.Host == "" || len(to) == 0 {
|
||||
return fmt.Errorf("invalid smtp config or empty recipients")
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
from := cfg.FromEmail
|
||||
if from == "" {
|
||||
from = cfg.Username
|
||||
}
|
||||
|
||||
msg := buildMIME(from, to, subject, htmlBody)
|
||||
|
||||
switch strings.ToLower(cfg.Encryption) {
|
||||
case "tls":
|
||||
return sendTLS(addr, cfg, from, to, msg)
|
||||
case "starttls":
|
||||
return sendSTARTTLS(addr, cfg, from, to, msg)
|
||||
default:
|
||||
return sendPlain(addr, cfg, from, to, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func buildMIME(from string, to []string, subject, htmlBody string) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString("From: " + from + "\r\n")
|
||||
b.WriteString("To: " + strings.Join(to, ",") + "\r\n")
|
||||
b.WriteString("Subject: " + subject + "\r\n")
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||||
b.WriteString("Date: " + time.Now().UTC().Format(time.RFC1123Z) + "\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(htmlBody)
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func authOrNil(cfg *SMTPConfig) smtp.Auth {
|
||||
if cfg.Username == "" && cfg.Password == "" {
|
||||
return nil
|
||||
}
|
||||
return smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||
}
|
||||
|
||||
// sendTLS connects via implicit TLS (port 465 typical).
|
||||
func sendTLS(addr string, cfg *SMTPConfig, from string, to []string, msg []byte) error {
|
||||
tlsCfg := &tls.Config{ServerName: cfg.Host}
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
c, err := smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
return smtpSend(c, cfg, from, to, msg)
|
||||
}
|
||||
|
||||
// sendSTARTTLS connects plain then upgrades (port 587 typical).
|
||||
func sendSTARTTLS(addr string, cfg *SMTPConfig, from string, to []string, msg []byte) error {
|
||||
c, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp dial: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
|
||||
return fmt.Errorf("starttls: %w", err)
|
||||
}
|
||||
|
||||
return smtpSend(c, cfg, from, to, msg)
|
||||
}
|
||||
|
||||
// sendPlain sends without encryption.
|
||||
func sendPlain(addr string, cfg *SMTPConfig, from string, to []string, msg []byte) error {
|
||||
auth := authOrNil(cfg)
|
||||
return smtp.SendMail(addr, auth, from, to, msg)
|
||||
}
|
||||
|
||||
func smtpSend(c *smtp.Client, cfg *SMTPConfig, from string, to []string, msg []byte) error {
|
||||
if auth := authOrNil(cfg); auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("auth: %w", err)
|
||||
}
|
||||
}
|
||||
if err := c.Mail(from); err != nil {
|
||||
return fmt.Errorf("mail from: %w", err)
|
||||
}
|
||||
for _, addr := range to {
|
||||
if err := c.Rcpt(addr); err != nil {
|
||||
return fmt.Errorf("rcpt %s: %w", addr, err)
|
||||
}
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("data: %w", err)
|
||||
}
|
||||
if _, err = w.Write(msg); err != nil {
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("close data: %w", err)
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// RenderNotificationEmail produces a simple HTML email body.
|
||||
func RenderNotificationEmail(title string, fields []EmailField) string {
|
||||
var rows strings.Builder
|
||||
for _, f := range fields {
|
||||
rows.WriteString(fmt.Sprintf(
|
||||
`<tr><td style="padding:8px 12px;color:#666;width:140px;border-bottom:1px solid #f0f0f0;">%s</td>`+
|
||||
`<td style="padding:8px 12px;color:#333;border-bottom:1px solid #f0f0f0;">%s</td></tr>`,
|
||||
f.Label, f.Value))
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"></head>
|
||||
<body style="font-family:Arial,sans-serif;background:#f5f5f5;padding:20px;margin:0;">
|
||||
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;">
|
||||
<div style="background:#1890ff;padding:20px 24px;">
|
||||
<h2 style="color:#fff;margin:0;font-size:18px;">🔔 GLKVM Cloud Notification</h2>
|
||||
</div>
|
||||
<div style="padding:24px;">
|
||||
<p style="color:#333;font-size:16px;font-weight:bold;margin:0 0 16px;">%s</p>
|
||||
<table style="width:100%%;border-collapse:collapse;">%s</table>
|
||||
</div>
|
||||
<div style="padding:16px 24px;border-top:1px solid #f0f0f0;">
|
||||
<p style="color:#999;font-size:12px;margin:0;">This is an automated notification from GLKVM Cloud. Please do not reply.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`, title, rows.String())
|
||||
}
|
||||
|
||||
// EmailField is a label/value pair for the email template.
|
||||
type EmailField struct {
|
||||
Label string
|
||||
Value string
|
||||
}
|
||||
|
||||
// RenderTestEmail produces a test email body.
|
||||
func RenderTestEmail() (subject, body string) {
|
||||
subject = "[GLKVM Cloud] Test Notification"
|
||||
body = RenderNotificationEmail("SMTP Configuration Test", []EmailField{
|
||||
{Label: "Status", Value: "✅ Success"},
|
||||
{Label: "Message", Value: "Your SMTP settings are configured correctly. You will receive notifications at this email address."},
|
||||
{Label: "Time", Value: time.Now().UTC().Format("2006-01-02 15:04:05 UTC")},
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package notification
|
||||
|
||||
// SMTPConfig holds mail server settings. Only one row exists (singleton).
|
||||
type SMTPConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
Encryption string `json:"encryption"` // "none", "tls", "starttls"
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// NotifyRules controls which event categories trigger email notifications.
|
||||
type NotifyRules struct {
|
||||
DeviceOnline bool `json:"deviceOnline"`
|
||||
DeviceOffline bool `json:"deviceOffline"`
|
||||
RemoteAccess bool `json:"remoteAccess"` // SSH + Web + Control
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Recipient is a notification email address.
|
||||
type Recipient struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package notification
|
||||
|
||||
import "context"
|
||||
|
||||
// Repository persists notification configuration.
|
||||
type Repository interface {
|
||||
GetSMTPConfig(ctx context.Context) (*SMTPConfig, error)
|
||||
SaveSMTPConfig(ctx context.Context, cfg *SMTPConfig) error
|
||||
|
||||
GetNotifyRules(ctx context.Context) (*NotifyRules, error)
|
||||
SaveNotifyRules(ctx context.Context, rules *NotifyRules) error
|
||||
|
||||
ListRecipients(ctx context.Context) ([]Recipient, error)
|
||||
AddRecipient(ctx context.Context, email string) (*Recipient, error)
|
||||
RemoveRecipient(ctx context.Context, id int64) error
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Package notification provides email notification for device events.
|
||||
// The service swallows errors so notifications never block the device runtime.
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// startupGraceWindow mirrors the device-log grace period: device online/
|
||||
// offline emails are suppressed during this window to avoid a reconnect
|
||||
// storm flooding inboxes after a server restart.
|
||||
const startupGraceWindow = 60 * time.Second
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
startupTime time.Time
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo, startupTime: time.Now()}
|
||||
}
|
||||
|
||||
func (s *Service) inGracePeriod() bool {
|
||||
return time.Since(s.startupTime) < startupGraceWindow
|
||||
}
|
||||
|
||||
// ─── SMTP config ────────────────────────────────────────────────
|
||||
|
||||
func (s *Service) GetSMTPConfig(ctx context.Context) (*SMTPConfig, error) {
|
||||
return s.repo.GetSMTPConfig(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) SaveSMTPConfig(ctx context.Context, cfg *SMTPConfig) error {
|
||||
cfg.UpdatedAt = time.Now().Unix()
|
||||
return s.repo.SaveSMTPConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
func (s *Service) TestSMTP(ctx context.Context, email string) error {
|
||||
cfg, err := s.repo.GetSMTPConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load smtp config: %w", err)
|
||||
}
|
||||
if cfg.Host == "" {
|
||||
return fmt.Errorf("SMTP is not configured")
|
||||
}
|
||||
subj, body := RenderTestEmail()
|
||||
return SendEmail(cfg, []string{email}, subj, body)
|
||||
}
|
||||
|
||||
// ─── Notification rules ─────────────────────────────────────────
|
||||
|
||||
func (s *Service) GetNotifyRules(ctx context.Context) (*NotifyRules, error) {
|
||||
return s.repo.GetNotifyRules(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) SaveNotifyRules(ctx context.Context, rules *NotifyRules) error {
|
||||
rules.UpdatedAt = time.Now().Unix()
|
||||
return s.repo.SaveNotifyRules(ctx, rules)
|
||||
}
|
||||
|
||||
// ─── Recipients ─────────────────────────────────────────────────
|
||||
|
||||
func (s *Service) ListRecipients(ctx context.Context) ([]Recipient, error) {
|
||||
return s.repo.ListRecipients(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) AddRecipient(ctx context.Context, email string) (*Recipient, error) {
|
||||
return s.repo.AddRecipient(ctx, email)
|
||||
}
|
||||
|
||||
func (s *Service) RemoveRecipient(ctx context.Context, id int64) error {
|
||||
return s.repo.RemoveRecipient(ctx, id)
|
||||
}
|
||||
|
||||
// ─── Event triggers (called from device runtime) ────────────────
|
||||
|
||||
// NotifyDeviceOnline sends a device-online notification if enabled.
|
||||
// Suppressed during the startup grace window.
|
||||
func (s *Service) NotifyDeviceOnline(deviceID, mac string) {
|
||||
if s.inGracePeriod() {
|
||||
return
|
||||
}
|
||||
s.sendEventNotification("deviceOnline", "[GLKVM Cloud] Device Online", "Device Online", []EmailField{
|
||||
{Label: "Event", Value: "Device Online"},
|
||||
{Label: "Device ID", Value: deviceID},
|
||||
{Label: "MAC Address", Value: mac},
|
||||
{Label: "Time", Value: time.Now().UTC().Format("2006-01-02 15:04:05 UTC")},
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyDeviceOffline sends a device-offline notification if enabled.
|
||||
// Suppressed during the startup grace window.
|
||||
func (s *Service) NotifyDeviceOffline(deviceID, mac string) {
|
||||
if s.inGracePeriod() {
|
||||
return
|
||||
}
|
||||
s.sendEventNotification("deviceOffline", "[GLKVM Cloud] Device Offline", "Device Offline", []EmailField{
|
||||
{Label: "Event", Value: "Device Offline"},
|
||||
{Label: "Device ID", Value: deviceID},
|
||||
{Label: "MAC Address", Value: mac},
|
||||
{Label: "Time", Value: time.Now().UTC().Format("2006-01-02 15:04:05 UTC")},
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyRemoteAccess sends a remote-access notification if enabled.
|
||||
func (s *Service) NotifyRemoteAccess(accessType, deviceID, mac, actor, clientIP string) {
|
||||
s.sendEventNotification("remoteAccess", "[GLKVM Cloud] Remote Access: "+accessType, "Remote Access Detected", []EmailField{
|
||||
{Label: "Access Type", Value: accessType},
|
||||
{Label: "Device ID", Value: deviceID},
|
||||
{Label: "MAC Address", Value: mac},
|
||||
{Label: "Actor", Value: actor},
|
||||
{Label: "Client IP", Value: clientIP},
|
||||
{Label: "Time", Value: time.Now().UTC().Format("2006-01-02 15:04:05 UTC")},
|
||||
})
|
||||
}
|
||||
|
||||
// sendEventNotification is the common helper: check rules → load recipients → send emails.
|
||||
func (s *Service) sendEventNotification(ruleField, subject, title string, fields []EmailField) {
|
||||
if s == nil || s.repo == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
cfg, err := s.repo.GetSMTPConfig(ctx)
|
||||
if err != nil || cfg == nil || !cfg.Enabled || cfg.Host == "" {
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := s.repo.GetNotifyRules(ctx)
|
||||
if err != nil || rules == nil {
|
||||
return
|
||||
}
|
||||
if !s.ruleEnabled(rules, ruleField) {
|
||||
return
|
||||
}
|
||||
|
||||
recipients, err := s.repo.ListRecipients(ctx)
|
||||
if err != nil || len(recipients) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
to := make([]string, 0, len(recipients))
|
||||
for _, r := range recipients {
|
||||
to = append(to, r.Email)
|
||||
}
|
||||
|
||||
body := RenderNotificationEmail(title, fields)
|
||||
if err := SendEmail(cfg, to, subject, body); err != nil {
|
||||
log.Warn().Err(err).Str("subject", subject).Msg("notification: send email failed")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) ruleEnabled(rules *NotifyRules, field string) bool {
|
||||
switch field {
|
||||
case "deviceOnline":
|
||||
return rules.DeviceOnline
|
||||
case "deviceOffline":
|
||||
return rules.DeviceOffline
|
||||
case "remoteAccess":
|
||||
return rules.RemoteAccess
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ const (
|
||||
UserWrite Key = "user.write"
|
||||
|
||||
RelationWrite Key = "relation.write"
|
||||
|
||||
DeviceLogRead Key = "device_log.read"
|
||||
|
||||
NotificationRead Key = "notification.read"
|
||||
NotificationWrite Key = "notification.write"
|
||||
)
|
||||
|
||||
func DefaultKeysForRole(role identity.Role) []Key {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package trusteddevice
|
||||
|
||||
type Device struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
Token string
|
||||
DeviceName string
|
||||
IP string
|
||||
CreatedAt int64
|
||||
LastUsedAt int64
|
||||
ExpiresAt int64
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package trusteddevice
|
||||
|
||||
import "context"
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, d *Device) (int64, error)
|
||||
FindByToken(ctx context.Context, token string) (*Device, error)
|
||||
ListByUserID(ctx context.Context, userID int64) ([]Device, error)
|
||||
Delete(ctx context.Context, id, userID int64) error
|
||||
DeleteByUserID(ctx context.Context, userID int64) error
|
||||
TouchLastUsed(ctx context.Context, id int64, ts int64) error
|
||||
DeleteExpired(ctx context.Context, before int64) error
|
||||
}
|
||||
@@ -20,4 +20,10 @@ type User struct {
|
||||
Role identity.Role
|
||||
Status Status
|
||||
IsSystem bool
|
||||
AuthProvider string // "local", "oidc", "ldap"
|
||||
ExternalSub string // OIDC sub claim / LDAP user DN
|
||||
LastLoginAt *int64 // unix seconds, nil if never
|
||||
TotpSecret string // base32 secret; "" when 2FA not enabled
|
||||
TotpEnabled bool
|
||||
CreatedAt int64 // unix seconds
|
||||
}
|
||||
|
||||
@@ -5,10 +5,16 @@ import "context"
|
||||
type Repository interface {
|
||||
FindByID(ctx context.Context, id int64) (*User, error)
|
||||
FindByUsername(ctx context.Context, username string) (*User, error)
|
||||
FindByExternalID(ctx context.Context, provider, externalSub string) (*User, error)
|
||||
FindSystemAdmin(ctx context.Context) (*User, error)
|
||||
|
||||
Create(ctx context.Context, u *User) (int64, error)
|
||||
Update(ctx context.Context, u *User) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
List(ctx context.Context) ([]User, error)
|
||||
|
||||
// Partial updates
|
||||
UpdateLastLoginAt(ctx context.Context, id int64, ts int64) error
|
||||
UpdateDescription(ctx context.Context, id int64, description string) error
|
||||
UpdateTotp(ctx context.Context, id int64, secret string, enabled bool) error
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ package user
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"rttys/internal/domain/identity"
|
||||
"strconv"
|
||||
|
||||
"rttys/internal/domain/identity"
|
||||
"rttys/internal/pkg/password"
|
||||
)
|
||||
|
||||
@@ -108,3 +109,105 @@ func (s *Service) UpdateUser(ctx context.Context, id int64, username, descriptio
|
||||
func (s *Service) DeleteUser(ctx context.Context, id int64) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateDescription persists a new description (a.k.a. display name) for a user.
|
||||
func (s *Service) UpdateDescription(ctx context.Context, id int64, description string) error {
|
||||
return s.repo.UpdateDescription(ctx, id, description)
|
||||
}
|
||||
|
||||
// SetTotp toggles 2FA for a user. Pass enabled=false and secret="" to disable.
|
||||
func (s *Service) SetTotp(ctx context.Context, id int64, secret string, enabled bool) error {
|
||||
return s.repo.UpdateTotp(ctx, id, secret, enabled)
|
||||
}
|
||||
|
||||
// TouchLastLogin records a fresh last_login_at timestamp.
|
||||
func (s *Service) TouchLastLogin(ctx context.Context, id int64, ts int64) error {
|
||||
return s.repo.UpdateLastLoginAt(ctx, id, ts)
|
||||
}
|
||||
|
||||
// FindOrCreateExternalUser looks up a user by (provider, externalSub).
|
||||
// If found, it updates email/description and returns the user.
|
||||
// If not found, it creates a new user with the given role and status=active.
|
||||
//
|
||||
// role is determined by the caller based on admin-group/admin-users membership
|
||||
// and is only applied at user creation time. Existing users keep their current role.
|
||||
func (s *Service) FindOrCreateExternalUser(ctx context.Context, provider, externalSub, preferredUsername, email, displayName string, role identity.Role) (*User, error) {
|
||||
u, err := s.repo.FindByExternalID(ctx, provider, externalSub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u != nil {
|
||||
// Update email and display name on each login (IdP may change them).
|
||||
changed := false
|
||||
if email != "" && u.Email != email {
|
||||
u.Email = email
|
||||
changed = true
|
||||
}
|
||||
if displayName != "" && u.Description != displayName {
|
||||
u.Description = displayName
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
_ = s.repo.Update(ctx, u)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// --- Create new user ---
|
||||
username := s.pickUniqueUsername(ctx, preferredUsername, email, provider)
|
||||
|
||||
newUser := &User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Description: displayName,
|
||||
PasswordHash: "", // external users never authenticate via password
|
||||
Role: role,
|
||||
Status: StatusActive,
|
||||
AuthProvider: provider,
|
||||
ExternalSub: externalSub,
|
||||
}
|
||||
id, err := s.repo.Create(ctx, newUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newUser.ID = id
|
||||
return newUser, nil
|
||||
}
|
||||
|
||||
// pickUniqueUsername tries candidate usernames until one doesn't conflict.
|
||||
func (s *Service) pickUniqueUsername(ctx context.Context, preferredUsername, email, provider string) string {
|
||||
candidates := make([]string, 0, 4)
|
||||
if preferredUsername != "" {
|
||||
candidates = append(candidates, preferredUsername)
|
||||
}
|
||||
if email != "" && email != preferredUsername {
|
||||
candidates = append(candidates, email)
|
||||
}
|
||||
// Fallback with provider suffix
|
||||
if preferredUsername != "" {
|
||||
candidates = append(candidates, preferredUsername+"_"+provider)
|
||||
}
|
||||
if email != "" {
|
||||
candidates = append(candidates, email+"_"+provider)
|
||||
}
|
||||
// Last resort
|
||||
if len(candidates) == 0 {
|
||||
candidates = append(candidates, provider+"_user")
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
existing, _ := s.repo.FindByUsername(ctx, c)
|
||||
if existing == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
// All candidates taken — append a numeric suffix
|
||||
base := candidates[0] + "_" + provider
|
||||
for i := 2; ; i++ {
|
||||
name := base + "_" + strconv.Itoa(i)
|
||||
existing, _ := s.repo.FindByUsername(ctx, name)
|
||||
if existing == nil {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package dto
|
||||
|
||||
type LoginReq struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
AuthMethod string `json:"authMethod,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
AuthMethod string `json:"authMethod,omitempty"`
|
||||
TotpCode string `json:"totpCode,omitempty"`
|
||||
RememberDevice bool `json:"rememberDevice,omitempty"`
|
||||
}
|
||||
|
||||
type LoginResp struct {
|
||||
Token string `json:"token"`
|
||||
Token string `json:"token,omitempty"`
|
||||
TwoFactorRequired bool `json:"twoFactorRequired,omitempty"`
|
||||
}
|
||||
|
||||
type LogoutResp struct{}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package dto
|
||||
|
||||
type DeviceEventLog struct {
|
||||
ID int64 `json:"id"`
|
||||
DeviceMac string `json:"deviceMac"`
|
||||
EventType string `json:"eventType"`
|
||||
ActorName string `json:"actorName"`
|
||||
ClientIP string `json:"clientIp"`
|
||||
Detail string `json:"detail"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
EndedAt int64 `json:"endedAt"`
|
||||
}
|
||||
|
||||
type ListDeviceEventLogsResp struct {
|
||||
Items []DeviceEventLog `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package dto
|
||||
|
||||
type MeUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
}
|
||||
|
||||
type MeResp struct {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package dto
|
||||
|
||||
// ─── SMTP Config ────────────────────────────────────────────────
|
||||
|
||||
type SMTPConfigReq struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
Encryption string `json:"encryption"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type SMTPConfigResp struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
Encryption string `json:"encryption"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ─── SMTP Test ──────────────────────────────────────────────────
|
||||
|
||||
type SMTPTestReq struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
}
|
||||
|
||||
// ─── Notify Rules ───────────────────────────────────────────────
|
||||
|
||||
type NotifyRulesReq struct {
|
||||
DeviceOnline bool `json:"deviceOnline"`
|
||||
DeviceOffline bool `json:"deviceOffline"`
|
||||
RemoteAccess bool `json:"remoteAccess"`
|
||||
}
|
||||
|
||||
type NotifyRulesResp struct {
|
||||
DeviceOnline bool `json:"deviceOnline"`
|
||||
DeviceOffline bool `json:"deviceOffline"`
|
||||
RemoteAccess bool `json:"remoteAccess"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ─── Recipients ─────────────────────────────────────────────────
|
||||
|
||||
type AddRecipientReq struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
}
|
||||
|
||||
type RecipientResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ListRecipientsResp struct {
|
||||
Items []RecipientResp `json:"items"`
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package dto
|
||||
|
||||
// ---- profile ----
|
||||
|
||||
type PersonalProfileResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
RegistrationTime int64 `json:"registrationTime"`
|
||||
LastLoginTime *int64 `json:"lastLoginTime"`
|
||||
TotpEnabled bool `json:"totpEnabled"`
|
||||
}
|
||||
|
||||
type UpdatePersonalProfileReq struct {
|
||||
DisplayName *string `json:"displayName"`
|
||||
}
|
||||
|
||||
// ---- 2fa ----
|
||||
|
||||
type Setup2faResp struct {
|
||||
Secret string `json:"secret"`
|
||||
OtpauthURL string `json:"otpauthUrl"`
|
||||
}
|
||||
|
||||
type Enable2faReq struct {
|
||||
Secret string `json:"secret"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type Disable2faReq struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// ---- trusted devices ----
|
||||
|
||||
type TrustedDevice struct {
|
||||
ID int64 `json:"id"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
IP string `json:"ip"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
LastUsedAt int64 `json:"lastUsedAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type ListTrustedDevicesResp struct {
|
||||
Items []TrustedDevice `json:"items"`
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type User struct {
|
||||
Description string `json:"description"`
|
||||
Role string `json:"role"`
|
||||
IsSystem bool `json:"isSystem"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
UserGroupList []UserGroupRef `json:"userGroupList"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"rttys/internal/domain/identity"
|
||||
"rttys/internal/domain/trusteddevice"
|
||||
"rttys/internal/pkg/ldap"
|
||||
"rttys/internal/pkg/totp"
|
||||
"rttys/internal/pkg/useragent"
|
||||
"rttys/xconfig"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rttys/internal/domain/user"
|
||||
"rttys/internal/http/dto"
|
||||
@@ -12,15 +17,26 @@ import (
|
||||
"rttys/internal/store/memory"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
trustedDeviceCookieName = "td"
|
||||
trustedDeviceTTL = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
userSvc *user.Service
|
||||
sessionStore *memory.SessionStore
|
||||
userSvc *user.Service
|
||||
sessionStore *memory.SessionStore
|
||||
trustedDeviceRepo trusteddevice.Repository
|
||||
}
|
||||
|
||||
func NewAuthHandler(userSvc *user.Service, sessionStore *memory.SessionStore) *AuthHandler {
|
||||
return &AuthHandler{userSvc: userSvc, sessionStore: sessionStore}
|
||||
func NewAuthHandler(userSvc *user.Service, sessionStore *memory.SessionStore, tdRepo trusteddevice.Repository) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
userSvc: userSvc,
|
||||
sessionStore: sessionStore,
|
||||
trustedDeviceRepo: tdRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/login
|
||||
@@ -40,7 +56,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
// ---- LDAP ----
|
||||
authMethod := req.AuthMethod
|
||||
if authMethod == "ldap" {
|
||||
ok, errorType := ldap.AuthenticateUserWithError(cfg, req.Username, req.Password, authMethod)
|
||||
ok, errorType, userDN, isAdmin := ldap.AuthenticateUserWithError(cfg, req.Username, req.Password, authMethod)
|
||||
if !ok {
|
||||
if errorType == "authorization" {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "User not authorized", nil))
|
||||
@@ -49,18 +65,47 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
}
|
||||
return
|
||||
}
|
||||
sysAdmin, err := h.userSvc.GetSystemAdmin(c.Request.Context())
|
||||
role := identity.RoleUser
|
||||
if isAdmin {
|
||||
role = identity.RoleAdmin
|
||||
}
|
||||
ldapUser, err := h.userSvc.FindOrCreateExternalUser(c.Request.Context(), "ldap", userDN, req.Username, "", req.Username, role)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "System admin not found", nil))
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to create LDAP user", nil))
|
||||
return
|
||||
}
|
||||
userID = sysAdmin.ID
|
||||
log.Info().
|
||||
Str("username", req.Username).
|
||||
Str("userDN", userDN).
|
||||
Str("role", string(role)).
|
||||
Int64("userID", ldapUser.ID).
|
||||
Msg("LDAP user login completed")
|
||||
userID = ldapUser.ID
|
||||
} else {
|
||||
u, err := h.userSvc.Authenticate(c.Request.Context(), req.Username, req.Password)
|
||||
if err != nil || u == nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "Authentication failed", nil))
|
||||
return
|
||||
}
|
||||
|
||||
// Local user with 2FA enabled: enforce TOTP unless a valid trusted-device cookie is present.
|
||||
if u.TotpEnabled && u.TotpSecret != "" {
|
||||
if !h.trustedDeviceCookieValid(c, u.ID) {
|
||||
if strings.TrimSpace(req.TotpCode) == "" {
|
||||
dto.Write(c, dto.Ok(traceID, dto.LoginResp{TwoFactorRequired: true}))
|
||||
return
|
||||
}
|
||||
if !totp.Verify(u.TotpSecret, strings.TrimSpace(req.TotpCode)) {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "Invalid verification code", nil))
|
||||
return
|
||||
}
|
||||
// Optionally remember this device.
|
||||
if req.RememberDevice {
|
||||
h.issueTrustedDevice(c, u.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userID = u.ID
|
||||
}
|
||||
|
||||
@@ -72,11 +117,85 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
|
||||
h.sessionStore.Create(sid, userID)
|
||||
|
||||
// Best-effort: refresh last_login_at. Failure here should not block login.
|
||||
_ = h.userSvc.TouchLastLogin(c.Request.Context(), userID, time.Now().Unix())
|
||||
|
||||
dto.Write(c, dto.Ok(traceID, dto.LoginResp{
|
||||
Token: sid,
|
||||
}))
|
||||
}
|
||||
|
||||
// trustedDeviceCookieValid returns true if the request carries a non-expired
|
||||
// trusted-device cookie that maps to the given user. It also refreshes
|
||||
// last_used_at as a side effect.
|
||||
func (h *AuthHandler) trustedDeviceCookieValid(c *gin.Context, userID int64) bool {
|
||||
if h.trustedDeviceRepo == nil {
|
||||
return false
|
||||
}
|
||||
token, err := c.Cookie(trustedDeviceCookieName)
|
||||
if err != nil || strings.TrimSpace(token) == "" {
|
||||
return false
|
||||
}
|
||||
dev, err := h.trustedDeviceRepo.FindByToken(c.Request.Context(), strings.TrimSpace(token))
|
||||
if err != nil || dev == nil {
|
||||
return false
|
||||
}
|
||||
if dev.UserID != userID {
|
||||
return false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if dev.ExpiresAt < now {
|
||||
_ = h.trustedDeviceRepo.Delete(c.Request.Context(), dev.ID, dev.UserID)
|
||||
return false
|
||||
}
|
||||
_ = h.trustedDeviceRepo.TouchLastUsed(c.Request.Context(), dev.ID, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// issueTrustedDevice creates a new trusted-device record and writes the token cookie.
|
||||
func (h *AuthHandler) issueTrustedDevice(c *gin.Context, userID int64) {
|
||||
if h.trustedDeviceRepo == nil {
|
||||
return
|
||||
}
|
||||
token, err := randtoken.New()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("trusted device: generate token failed")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
dev := &trusteddevice.Device{
|
||||
UserID: userID,
|
||||
Token: token,
|
||||
DeviceName: trimToLen(useragent.Friendly(c.Request.UserAgent()), 200),
|
||||
IP: clientIP(c),
|
||||
CreatedAt: now.Unix(),
|
||||
LastUsedAt: now.Unix(),
|
||||
ExpiresAt: now.Add(trustedDeviceTTL).Unix(),
|
||||
}
|
||||
if _, err := h.trustedDeviceRepo.Create(c.Request.Context(), dev); err != nil {
|
||||
log.Warn().Err(err).Msg("trusted device: create failed")
|
||||
return
|
||||
}
|
||||
c.SetCookie(trustedDeviceCookieName, token, int(trustedDeviceTTL.Seconds()), "/", "", false, true)
|
||||
}
|
||||
|
||||
func clientIP(c *gin.Context) string {
|
||||
if c == nil || c.Request == nil {
|
||||
return ""
|
||||
}
|
||||
if ip := c.ClientIP(); ip != "" {
|
||||
return ip
|
||||
}
|
||||
return c.Request.RemoteAddr
|
||||
}
|
||||
|
||||
func trimToLen(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
// POST /api/logout
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
|
||||
@@ -3,7 +3,6 @@ package handler
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -53,17 +52,19 @@ func (h *DeviceHandler) ListDevices(c *gin.Context) {
|
||||
}
|
||||
|
||||
isAdmin := p.Role == identity.RoleAdmin
|
||||
var items []device.Device
|
||||
|
||||
emptyResp := func() {
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListDevicesResp{
|
||||
Items: []dto.Device{}, Page: 1, PageSize: 0, Total: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
// Resolve visibility into a group-id restriction. nil = no restriction
|
||||
// (admin sees all devices, including ungrouped).
|
||||
var restrictGroups []int64
|
||||
if isAdmin {
|
||||
var err error
|
||||
if filterGroupID != nil {
|
||||
items, err = h.devSvc.ListByDeviceGroupIDs(c.Request.Context(), []int64{*filterGroupID})
|
||||
} else {
|
||||
items, err = h.devSvc.ListVisible(c.Request.Context(), p.Role, p.UserID)
|
||||
}
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
restrictGroups = []int64{*filterGroupID}
|
||||
}
|
||||
} else {
|
||||
if h.groupRepo == nil {
|
||||
@@ -72,12 +73,7 @@ func (h *DeviceHandler) ListDevices(c *gin.Context) {
|
||||
}
|
||||
dgIDs, err := h.groupRepo.ListDeviceGroupIDsByUser(c.Request.Context(), p.UserID)
|
||||
if err != nil || len(dgIDs) == 0 {
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListDevicesResp{
|
||||
Items: []dto.Device{},
|
||||
Page: 1,
|
||||
PageSize: 0,
|
||||
Total: 0,
|
||||
}))
|
||||
emptyResp()
|
||||
return
|
||||
}
|
||||
if filterGroupID != nil {
|
||||
@@ -89,56 +85,56 @@ func (h *DeviceHandler) ListDevices(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListDevicesResp{
|
||||
Items: []dto.Device{},
|
||||
Page: 1,
|
||||
PageSize: 0,
|
||||
Total: 0,
|
||||
}))
|
||||
emptyResp()
|
||||
return
|
||||
}
|
||||
dgIDs = []int64{*filterGroupID}
|
||||
}
|
||||
items, err = h.devSvc.ListByDeviceGroupIDs(c.Request.Context(), dgIDs)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
}
|
||||
restrictGroups = dgIDs
|
||||
}
|
||||
|
||||
groupNameByID := map[int64]string{}
|
||||
if h.groupRepo != nil {
|
||||
groups, err := h.groupRepo.ListDeviceGroupsVisibleToUser(c.Request.Context(), p.UserID, isAdmin)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
}
|
||||
for _, g := range groups {
|
||||
groupNameByID[g.ID] = g.Name
|
||||
}
|
||||
// Pagination params: pageSize omitted => return all (backward compatible).
|
||||
page := 1
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(c.Query("page"))); err == nil && v > 0 {
|
||||
page = v
|
||||
}
|
||||
pageSize := 0
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(c.Query("pageSize"))); err == nil && v > 0 {
|
||||
pageSize = v
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
oi := items[i].Status == device.StatusOnline
|
||||
oj := items[j].Status == device.StatusOnline
|
||||
if oi != oj {
|
||||
return oi
|
||||
}
|
||||
return items[i].Ddns < items[j].Ddns
|
||||
// Status filter: only accept known values, ignore anything else.
|
||||
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||||
switch status {
|
||||
case "online", "offline", "disabled":
|
||||
default:
|
||||
status = ""
|
||||
}
|
||||
|
||||
// All filtering/sorting/pagination is pushed to SQL. The search ':' is
|
||||
// stripped to match the colon-less MAC stored in the DB, mirroring the
|
||||
// previous client-side search behavior.
|
||||
items, total, err := h.devSvc.ListPaged(c.Request.Context(), device.ListQuery{
|
||||
RestrictGroups: restrictGroups,
|
||||
Search: strings.ToLower(strings.ReplaceAll(strings.TrimSpace(c.Query("q")), ":", "")),
|
||||
Unassigned: strings.EqualFold(strings.TrimSpace(c.Query("unassigned")), "true"),
|
||||
Status: status,
|
||||
SortBy: strings.TrimSpace(c.Query("sortBy")),
|
||||
Order: strings.TrimSpace(c.Query("order")),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.Device, 0, len(items))
|
||||
for _, d := range items {
|
||||
var groupName string
|
||||
if d.DeviceGroupID != nil {
|
||||
groupName = groupNameByID[*d.DeviceGroupID]
|
||||
}
|
||||
|
||||
var connectedTime int64
|
||||
if d.LastSeenAt != nil {
|
||||
connectedTime = *d.LastSeenAt
|
||||
}
|
||||
|
||||
out = append(out, dto.Device{
|
||||
ID: d.ID,
|
||||
Ddns: d.Ddns,
|
||||
@@ -149,15 +145,19 @@ func (h *DeviceHandler) ListDevices(c *gin.Context) {
|
||||
Description: d.Description,
|
||||
Client: d.Client,
|
||||
DeviceGroupID: d.DeviceGroupID,
|
||||
DeviceGroupName: groupName,
|
||||
DeviceGroupName: d.GroupName,
|
||||
})
|
||||
}
|
||||
|
||||
respPageSize := int(total)
|
||||
if pageSize > 0 {
|
||||
respPageSize = pageSize
|
||||
}
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListDevicesResp{
|
||||
Items: out,
|
||||
Page: 1,
|
||||
PageSize: len(out),
|
||||
Total: len(out),
|
||||
Page: page,
|
||||
PageSize: respPageSize,
|
||||
Total: int(total),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"rttys/internal/domain/devicelog"
|
||||
"rttys/internal/http/dto"
|
||||
"rttys/internal/http/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DeviceLogHandler exposes /api/device-event-logs for admins.
|
||||
type DeviceLogHandler struct {
|
||||
svc *devicelog.Service
|
||||
}
|
||||
|
||||
func NewDeviceLogHandler(svc *devicelog.Service) *DeviceLogHandler {
|
||||
return &DeviceLogHandler{svc: svc}
|
||||
}
|
||||
|
||||
// GET /api/device-event-logs?mac=&types=device_online,remote_ssh&from=&to=&page=1&pageSize=20
|
||||
func (h *DeviceLogHandler) List(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
|
||||
if h.svc == nil {
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListDeviceEventLogsResp{
|
||||
Items: []dto.DeviceEventLog{}, Total: 0, Page: 1, PageSize: 20,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
q := devicelog.Query{
|
||||
Mac: strings.TrimSpace(c.Query("mac")),
|
||||
EventTypes: parseEventTypes(c.Query("types")),
|
||||
From: parseInt64(c.Query("from")),
|
||||
To: parseInt64(c.Query("to")),
|
||||
Page: parseInt(c.Query("page")),
|
||||
PageSize: parseInt(c.Query("pageSize")),
|
||||
}
|
||||
|
||||
rows, total, err := h.svc.Query(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.DeviceEventLog, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, dto.DeviceEventLog{
|
||||
ID: r.ID,
|
||||
DeviceMac: r.DeviceMac,
|
||||
EventType: string(r.EventType),
|
||||
ActorName: r.ActorName,
|
||||
ClientIP: r.ClientIP,
|
||||
Detail: r.Detail,
|
||||
CreatedAt: r.CreatedAt,
|
||||
EndedAt: r.EndedAt,
|
||||
})
|
||||
}
|
||||
|
||||
page := q.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := q.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListDeviceEventLogsResp{
|
||||
Items: out,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}))
|
||||
}
|
||||
|
||||
func parseEventTypes(raw string) []devicelog.EventType {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]devicelog.EventType, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
switch devicelog.EventType(p) {
|
||||
case devicelog.EventDeviceOnline,
|
||||
devicelog.EventDeviceOffline,
|
||||
devicelog.EventRemoteSSH,
|
||||
devicelog.EventRemoteWeb,
|
||||
devicelog.EventRemoteControl:
|
||||
out = append(out, devicelog.EventType(p))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseInt64(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.ParseInt(s, 10, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
func parseInt(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.Atoi(s)
|
||||
return v
|
||||
}
|
||||
@@ -18,10 +18,11 @@ func (h *MeHandler) GetMe(c *gin.Context) {
|
||||
|
||||
dto.Write(c, dto.Ok(traceID, dto.MeResp{
|
||||
User: dto.MeUser{
|
||||
ID: p.UserID,
|
||||
Username: p.Username,
|
||||
DisplayName: p.DisplayName,
|
||||
Role: string(p.Role),
|
||||
ID: p.UserID,
|
||||
Username: p.Username,
|
||||
DisplayName: p.DisplayName,
|
||||
Role: string(p.Role),
|
||||
AuthProvider: p.AuthProvider,
|
||||
},
|
||||
Permissions: p.PermissionKeys,
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"rttys/internal/domain/notification"
|
||||
"rttys/internal/http/dto"
|
||||
"rttys/internal/http/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type NotificationHandler struct {
|
||||
svc *notification.Service
|
||||
}
|
||||
|
||||
func NewNotificationHandler(svc *notification.Service) *NotificationHandler {
|
||||
return &NotificationHandler{svc: svc}
|
||||
}
|
||||
|
||||
// GET /api/notification/smtp
|
||||
func (h *NotificationHandler) GetSMTPConfig(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
cfg, err := h.svc.GetSMTPConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to load SMTP config", nil))
|
||||
return
|
||||
}
|
||||
dto.Write(c, dto.Ok(traceID, dto.SMTPConfigResp{
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
FromEmail: cfg.FromEmail,
|
||||
Encryption: cfg.Encryption,
|
||||
Enabled: cfg.Enabled,
|
||||
UpdatedAt: cfg.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// PUT /api/notification/smtp
|
||||
func (h *NotificationHandler) SaveSMTPConfig(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
var req dto.SMTPConfigReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, err.Error(), nil))
|
||||
return
|
||||
}
|
||||
cfg := ¬ification.SMTPConfig{
|
||||
Host: req.Host,
|
||||
Port: req.Port,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
FromEmail: req.FromEmail,
|
||||
Encryption: req.Encryption,
|
||||
Enabled: req.Enabled,
|
||||
}
|
||||
if err := h.svc.SaveSMTPConfig(c.Request.Context(), cfg); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to save SMTP config", nil))
|
||||
return
|
||||
}
|
||||
dto.Write(c, dto.Ok(traceID, dto.SMTPConfigResp{
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
FromEmail: cfg.FromEmail,
|
||||
Encryption: cfg.Encryption,
|
||||
Enabled: cfg.Enabled,
|
||||
UpdatedAt: cfg.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// POST /api/notification/smtp/test
|
||||
func (h *NotificationHandler) TestSMTP(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
var req dto.SMTPTestReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, err.Error(), nil))
|
||||
return
|
||||
}
|
||||
if err := h.svc.TestSMTP(c.Request.Context(), req.Email); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, err.Error(), nil))
|
||||
return
|
||||
}
|
||||
dto.Write(c, dto.Ok(traceID, gin.H{"message": "Test email sent successfully"}))
|
||||
}
|
||||
|
||||
// GET /api/notification/rules
|
||||
func (h *NotificationHandler) GetNotifyRules(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
rules, err := h.svc.GetNotifyRules(c.Request.Context())
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to load rules", nil))
|
||||
return
|
||||
}
|
||||
dto.Write(c, dto.Ok(traceID, dto.NotifyRulesResp{
|
||||
DeviceOnline: rules.DeviceOnline,
|
||||
DeviceOffline: rules.DeviceOffline,
|
||||
RemoteAccess: rules.RemoteAccess,
|
||||
UpdatedAt: rules.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// PUT /api/notification/rules
|
||||
func (h *NotificationHandler) SaveNotifyRules(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
var req dto.NotifyRulesReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, err.Error(), nil))
|
||||
return
|
||||
}
|
||||
rules := ¬ification.NotifyRules{
|
||||
DeviceOnline: req.DeviceOnline,
|
||||
DeviceOffline: req.DeviceOffline,
|
||||
RemoteAccess: req.RemoteAccess,
|
||||
}
|
||||
if err := h.svc.SaveNotifyRules(c.Request.Context(), rules); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to save rules", nil))
|
||||
return
|
||||
}
|
||||
dto.Write(c, dto.Ok(traceID, dto.NotifyRulesResp{
|
||||
DeviceOnline: rules.DeviceOnline,
|
||||
DeviceOffline: rules.DeviceOffline,
|
||||
RemoteAccess: rules.RemoteAccess,
|
||||
UpdatedAt: rules.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// GET /api/notification/recipients
|
||||
func (h *NotificationHandler) ListRecipients(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
list, err := h.svc.ListRecipients(c.Request.Context())
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to list recipients", nil))
|
||||
return
|
||||
}
|
||||
items := make([]dto.RecipientResp, 0, len(list))
|
||||
for _, r := range list {
|
||||
items = append(items, dto.RecipientResp{
|
||||
ID: r.ID,
|
||||
Email: r.Email,
|
||||
CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListRecipientsResp{Items: items}))
|
||||
}
|
||||
|
||||
// POST /api/notification/recipients
|
||||
func (h *NotificationHandler) AddRecipient(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
var req dto.AddRecipientReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, err.Error(), nil))
|
||||
return
|
||||
}
|
||||
r, err := h.svc.AddRecipient(c.Request.Context(), req.Email)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to add recipient", nil))
|
||||
return
|
||||
}
|
||||
dto.Write(c, dto.Ok(traceID, dto.RecipientResp{
|
||||
ID: r.ID,
|
||||
Email: r.Email,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// DELETE /api/notification/recipients/:id
|
||||
func (h *NotificationHandler) RemoveRecipient(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
id := parseInt64(c.Param("id"))
|
||||
if id <= 0 {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid ID", nil))
|
||||
return
|
||||
}
|
||||
if err := h.svc.RemoveRecipient(c.Request.Context(), id); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to remove recipient", nil))
|
||||
return
|
||||
}
|
||||
dto.Write(c, dto.Ok(traceID, gin.H{"message": "Recipient removed"}))
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rttys/internal/domain/trusteddevice"
|
||||
"rttys/internal/domain/user"
|
||||
"rttys/internal/http/dto"
|
||||
"rttys/internal/http/middleware"
|
||||
"rttys/internal/pkg/totp"
|
||||
"rttys/internal/pkg/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PersonalHandler exposes /api/me/profile and /api/me/2fa/* endpoints
|
||||
// for the logged-in user to view and edit their own account.
|
||||
type PersonalHandler struct {
|
||||
userSvc *user.Service
|
||||
trustedDeviceSvc trusteddevice.Repository
|
||||
issuer string
|
||||
}
|
||||
|
||||
func NewPersonalHandler(userSvc *user.Service, tdRepo trusteddevice.Repository, issuer string) *PersonalHandler {
|
||||
if issuer == "" {
|
||||
issuer = "GLKVM Cloud"
|
||||
}
|
||||
return &PersonalHandler{userSvc: userSvc, trustedDeviceSvc: tdRepo, issuer: issuer}
|
||||
}
|
||||
|
||||
// GET /api/me/profile
|
||||
func (h *PersonalHandler) GetProfile(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
p := middleware.MustPrincipal(c)
|
||||
|
||||
u, err := h.userSvc.FindByID(c.Request.Context(), p.UserID)
|
||||
if err != nil || u == nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "User not found", nil))
|
||||
return
|
||||
}
|
||||
|
||||
dto.Write(c, dto.Ok(traceID, dto.PersonalProfileResp{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
DisplayName: u.Description,
|
||||
Email: u.Email,
|
||||
Role: string(u.Role),
|
||||
AuthProvider: normalizedAuthProvider(u.AuthProvider),
|
||||
RegistrationTime: u.CreatedAt,
|
||||
LastLoginTime: u.LastLoginAt,
|
||||
TotpEnabled: u.TotpEnabled,
|
||||
}))
|
||||
}
|
||||
|
||||
// PUT /api/me/profile
|
||||
func (h *PersonalHandler) UpdateProfile(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
p := middleware.MustPrincipal(c)
|
||||
|
||||
var req dto.UpdatePersonalProfileReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
|
||||
return
|
||||
}
|
||||
|
||||
if req.DisplayName != nil {
|
||||
desc := strings.TrimSpace(*req.DisplayName)
|
||||
if len(desc) > 200 {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeValidationFailed, "Display name too long", nil))
|
||||
return
|
||||
}
|
||||
if err := h.userSvc.UpdateDescription(c.Request.Context(), p.UserID, desc); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dto.Write(c, dto.Ok(traceID, struct{}{}))
|
||||
}
|
||||
|
||||
// POST /api/me/2fa/setup
|
||||
//
|
||||
// Generates a fresh TOTP secret and otpauth URL. The secret is NOT persisted
|
||||
// until the client confirms by calling /api/me/2fa/enable with a valid code.
|
||||
func (h *PersonalHandler) Setup2fa(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
p := middleware.MustPrincipal(c)
|
||||
|
||||
if !isLocalAuthProvider(p.AuthProvider) {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "2FA is managed by your identity provider", nil))
|
||||
return
|
||||
}
|
||||
|
||||
secret, url, err := totp.GenerateSecret(h.issuer, p.Username)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Failed to generate secret", nil))
|
||||
return
|
||||
}
|
||||
|
||||
dto.Write(c, dto.Ok(traceID, dto.Setup2faResp{Secret: secret, OtpauthURL: url}))
|
||||
}
|
||||
|
||||
// POST /api/me/2fa/enable
|
||||
func (h *PersonalHandler) Enable2fa(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
p := middleware.MustPrincipal(c)
|
||||
|
||||
if !isLocalAuthProvider(p.AuthProvider) {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "2FA is managed by your identity provider", nil))
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.Enable2faReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Secret == "" || req.Code == "" {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
|
||||
return
|
||||
}
|
||||
|
||||
if !totp.Verify(req.Secret, req.Code) {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeValidationFailed, "Invalid verification code", nil))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.userSvc.SetTotp(c.Request.Context(), p.UserID, req.Secret, true); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
}
|
||||
|
||||
dto.Write(c, dto.Ok(traceID, struct{}{}))
|
||||
}
|
||||
|
||||
// POST /api/me/2fa/disable
|
||||
//
|
||||
// Requires a current valid TOTP code. After disabling, all trusted-device
|
||||
// records for this user are revoked.
|
||||
func (h *PersonalHandler) Disable2fa(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
p := middleware.MustPrincipal(c)
|
||||
|
||||
if !isLocalAuthProvider(p.AuthProvider) {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeForbidden, "2FA is managed by your identity provider", nil))
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.Disable2faReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Code == "" {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.userSvc.FindByID(c.Request.Context(), p.UserID)
|
||||
if err != nil || u == nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "User not found", nil))
|
||||
return
|
||||
}
|
||||
if !u.TotpEnabled || u.TotpSecret == "" {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeValidationFailed, "2FA is not enabled", nil))
|
||||
return
|
||||
}
|
||||
if !totp.Verify(u.TotpSecret, req.Code) {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeValidationFailed, "Invalid verification code", nil))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.userSvc.SetTotp(c.Request.Context(), p.UserID, "", false); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
}
|
||||
if h.trustedDeviceSvc != nil {
|
||||
_ = h.trustedDeviceSvc.DeleteByUserID(c.Request.Context(), p.UserID)
|
||||
}
|
||||
|
||||
dto.Write(c, dto.Ok(traceID, struct{}{}))
|
||||
}
|
||||
|
||||
// GET /api/me/2fa/trusted-devices
|
||||
func (h *PersonalHandler) ListTrustedDevices(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
p := middleware.MustPrincipal(c)
|
||||
|
||||
if h.trustedDeviceSvc == nil {
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListTrustedDevicesResp{Items: []dto.TrustedDevice{}}))
|
||||
return
|
||||
}
|
||||
|
||||
// Lazy-clean expired records so the list never shows stale entries.
|
||||
_ = h.trustedDeviceSvc.DeleteExpired(c.Request.Context(), time.Now().Unix())
|
||||
|
||||
rows, err := h.trustedDeviceSvc.ListByUserID(c.Request.Context(), p.UserID)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]dto.TrustedDevice, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
// Re-parse the stored device name on every read so legacy records
|
||||
// (which contain the raw User-Agent header) get rendered with the same
|
||||
// short label as freshly-issued ones — no DB migration needed.
|
||||
out = append(out, dto.TrustedDevice{
|
||||
ID: r.ID,
|
||||
DeviceName: useragent.Friendly(r.DeviceName),
|
||||
IP: r.IP,
|
||||
CreatedAt: r.CreatedAt,
|
||||
LastUsedAt: r.LastUsedAt,
|
||||
ExpiresAt: r.ExpiresAt,
|
||||
})
|
||||
}
|
||||
dto.Write(c, dto.Ok(traceID, dto.ListTrustedDevicesResp{Items: out}))
|
||||
}
|
||||
|
||||
// DELETE /api/me/2fa/trusted-devices/:id
|
||||
func (h *PersonalHandler) RevokeTrustedDevice(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
p := middleware.MustPrincipal(c)
|
||||
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInvalidArgument, "Invalid argument", nil))
|
||||
return
|
||||
}
|
||||
|
||||
if h.trustedDeviceSvc != nil {
|
||||
if err := h.trustedDeviceSvc.Delete(c.Request.Context(), id, p.UserID); err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeInternalError, "Internal error", nil))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dto.Write(c, dto.Ok(traceID, struct{}{}))
|
||||
}
|
||||
|
||||
func isLocalAuthProvider(p string) bool {
|
||||
return p == "" || p == "local"
|
||||
}
|
||||
|
||||
func normalizedAuthProvider(p string) string {
|
||||
if p == "" {
|
||||
return "local"
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -88,6 +88,7 @@ func (h *UserHandler) ListUsers(c *gin.Context) {
|
||||
Username: u.Username,
|
||||
Description: u.Description,
|
||||
IsSystem: u.IsSystem,
|
||||
AuthProvider: u.AuthProvider,
|
||||
UserGroupList: groups,
|
||||
})
|
||||
}
|
||||
@@ -161,6 +162,30 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
p := middleware.MustPrincipal(c)
|
||||
|
||||
target, err := h.userSvc.FindByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "Not found", nil))
|
||||
return
|
||||
}
|
||||
if target.IsSystem {
|
||||
req.Username = nil
|
||||
req.Role = nil
|
||||
req.Password = nil
|
||||
req.Repassword = nil
|
||||
}
|
||||
// Users cannot change their own role
|
||||
if id == p.UserID {
|
||||
req.Role = nil
|
||||
}
|
||||
// External users (OIDC/LDAP): username and password are managed by the IdP
|
||||
if target.AuthProvider != "" && target.AuthProvider != "local" {
|
||||
req.Username = nil
|
||||
req.Password = nil
|
||||
req.Repassword = nil
|
||||
}
|
||||
|
||||
if err := h.userSvc.UpdateUser(c.Request.Context(), id, req.Username, req.Description, req.Password, req.Role, nil); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "Not found", nil))
|
||||
|
||||
@@ -20,6 +20,7 @@ type Principal struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role identity.Role `json:"role"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
PermissionKeys []string `json:"permissions"`
|
||||
}
|
||||
|
||||
@@ -82,11 +83,17 @@ func Auth(sessionStore *memory.SessionStore, userSvc *user.Service, permSvc *per
|
||||
displayName = u.Username
|
||||
}
|
||||
|
||||
authProvider := u.AuthProvider
|
||||
if authProvider == "" {
|
||||
authProvider = "local"
|
||||
}
|
||||
|
||||
c.Set(PrincipalKey, Principal{
|
||||
UserID: u.ID,
|
||||
Username: u.Username,
|
||||
DisplayName: displayName,
|
||||
Role: u.Role,
|
||||
AuthProvider: authProvider,
|
||||
PermissionKeys: perms,
|
||||
})
|
||||
|
||||
|
||||
+59
-24
@@ -4,6 +4,8 @@ import (
|
||||
"net"
|
||||
|
||||
"rttys/internal/domain/device"
|
||||
"rttys/internal/domain/devicelog"
|
||||
"rttys/internal/domain/notification"
|
||||
"rttys/internal/domain/permission"
|
||||
"rttys/internal/domain/user"
|
||||
"rttys/internal/http/dto"
|
||||
@@ -18,14 +20,17 @@ import (
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
UserSvc *user.Service
|
||||
PermSvc *permission.Service
|
||||
DevSvc *device.Service
|
||||
GroupRepo *sqlite.GroupRepo
|
||||
SessionStore *memory.SessionStore
|
||||
RelationsRepo *sqlite.RelationsRepo
|
||||
Cfg *xconfig.Config
|
||||
CloudVersion string
|
||||
UserSvc *user.Service
|
||||
PermSvc *permission.Service
|
||||
DevSvc *device.Service
|
||||
GroupRepo *sqlite.GroupRepo
|
||||
SessionStore *memory.SessionStore
|
||||
RelationsRepo *sqlite.RelationsRepo
|
||||
TrustedDeviceRepo *sqlite.TrustedDeviceRepo
|
||||
DeviceLogSvc *devicelog.Service
|
||||
NotificationSvc *notification.Service
|
||||
Cfg *xconfig.Config
|
||||
CloudVersion string
|
||||
}
|
||||
|
||||
func RegisterAPIRoutes(r *gin.Engine, d Deps) {
|
||||
@@ -34,7 +39,7 @@ func RegisterAPIRoutes(r *gin.Engine, d Deps) {
|
||||
cfg = xconfig.Must()
|
||||
}
|
||||
|
||||
authH := handler.NewAuthHandler(d.UserSvc, d.SessionStore)
|
||||
authH := handler.NewAuthHandler(d.UserSvc, d.SessionStore, d.TrustedDeviceRepo)
|
||||
meH := handler.NewMeHandler()
|
||||
devH := handler.NewDeviceHandler(d.DevSvc, d.GroupRepo, d.RelationsRepo)
|
||||
dgH := handler.NewDeviceGroupHandler(d.GroupRepo, d.RelationsRepo)
|
||||
@@ -42,6 +47,9 @@ func RegisterAPIRoutes(r *gin.Engine, d Deps) {
|
||||
relH := handler.NewRelationsHandler(d.RelationsRepo)
|
||||
|
||||
userH := handler.NewUserHandler(d.UserSvc, d.GroupRepo, d.RelationsRepo, d.SessionStore)
|
||||
personalH := handler.NewPersonalHandler(d.UserSvc, d.TrustedDeviceRepo, "GLKVM Cloud")
|
||||
devLogH := handler.NewDeviceLogHandler(d.DeviceLogSvc)
|
||||
notifH := handler.NewNotificationHandler(d.NotificationSvc)
|
||||
|
||||
// public
|
||||
r.GET("/auth-config", func(c *gin.Context) {
|
||||
@@ -75,19 +83,25 @@ func RegisterAPIRoutes(r *gin.Engine, d Deps) {
|
||||
}
|
||||
|
||||
chosen := hostname
|
||||
// -------- Reverse proxy mode: force IP ----------
|
||||
if cfg.ReverseProxyEnabled {
|
||||
// Reverse proxy mode: always use configured WebRTC IP
|
||||
if strings.TrimSpace(cfg.WebrtcIP) != "" {
|
||||
chosen = strings.TrimSpace(cfg.WebrtcIP)
|
||||
// GLKVM_ACCESS_IP (cfg.WebrtcIP) takes priority whenever it is set,
|
||||
// regardless of how the web UI was reached (IP or domain/localhost)
|
||||
// and regardless of reverse-proxy mode. Only fall back to the current
|
||||
// web host when it is left empty (auto-detect).
|
||||
if v := strings.TrimSpace(cfg.WebrtcIP); v != "" {
|
||||
chosen = v
|
||||
}
|
||||
|
||||
// Determine selfhost WebUI URL
|
||||
webUIURL := strings.TrimSpace(cfg.SelfhostWebUIURL)
|
||||
if webUIURL == "" {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
} else {
|
||||
// -------- 3) Original behavior (unchanged) ----------
|
||||
// 1) If hostname is domain, keep it
|
||||
// 2) If hostname is IP and cfg.WebrtcIP is set, use cfg.WebrtcIP
|
||||
if isIP(hostname) && cfg.WebrtcIP != "" {
|
||||
chosen = cfg.WebrtcIP
|
||||
if fwdProto := c.GetHeader("X-Forwarded-Proto"); fwdProto != "" {
|
||||
scheme = fwdProto
|
||||
}
|
||||
webUIURL = scheme + "://" + c.Request.Host
|
||||
}
|
||||
|
||||
data := scriptInfoResp{
|
||||
@@ -98,6 +112,7 @@ func RegisterAPIRoutes(r *gin.Engine, d Deps) {
|
||||
WebrtcPort: cfg.WebrtcPort,
|
||||
WebrtcUsername: cfg.WebrtcUsername,
|
||||
WebrtcPassword: cfg.WebrtcPassword,
|
||||
WebUIURL: webUIURL,
|
||||
}
|
||||
|
||||
dto.Write(c, dto.Ok(traceID, data))
|
||||
@@ -109,6 +124,15 @@ func RegisterAPIRoutes(r *gin.Engine, d Deps) {
|
||||
// me
|
||||
api.GET("/me", middleware.Require(permission.MeRead), meH.GetMe)
|
||||
|
||||
// personal center
|
||||
api.GET("/me/profile", middleware.Require(permission.MeRead), personalH.GetProfile)
|
||||
api.PUT("/me/profile", middleware.Require(permission.MeRead), personalH.UpdateProfile)
|
||||
api.POST("/me/2fa/setup", middleware.Require(permission.MeRead), personalH.Setup2fa)
|
||||
api.POST("/me/2fa/enable", middleware.Require(permission.MeRead), personalH.Enable2fa)
|
||||
api.POST("/me/2fa/disable", middleware.Require(permission.MeRead), personalH.Disable2fa)
|
||||
api.GET("/me/2fa/trusted-devices", middleware.Require(permission.MeRead), personalH.ListTrustedDevices)
|
||||
api.DELETE("/me/2fa/trusted-devices/:id", middleware.Require(permission.MeRead), personalH.RevokeTrustedDevice)
|
||||
|
||||
// device scope list
|
||||
api.GET("/devices", middleware.Require(permission.DeviceRead), devH.ListDevices)
|
||||
api.POST("/devices/move-to-device-group", middleware.Require(permission.DeviceGroupWrite), devH.MoveToDeviceGroup)
|
||||
@@ -137,6 +161,20 @@ func RegisterAPIRoutes(r *gin.Engine, d Deps) {
|
||||
api.POST("/device-groups/:id/devices", middleware.Require(permission.DeviceGroupWrite), dgH.AddDevices)
|
||||
api.DELETE("/device-groups/:id/devices", middleware.Require(permission.DeviceGroupWrite), dgH.RemoveDevices)
|
||||
|
||||
// device event logs (admin only)
|
||||
api.GET("/device-event-logs", middleware.Require(permission.DeviceLogRead), devLogH.List)
|
||||
|
||||
// notification settings (admin only)
|
||||
notifGroup := api.Group("/notification")
|
||||
notifGroup.GET("/smtp", middleware.Require(permission.NotificationRead), notifH.GetSMTPConfig)
|
||||
notifGroup.PUT("/smtp", middleware.Require(permission.NotificationWrite), notifH.SaveSMTPConfig)
|
||||
notifGroup.POST("/smtp/test", middleware.Require(permission.NotificationWrite), notifH.TestSMTP)
|
||||
notifGroup.GET("/rules", middleware.Require(permission.NotificationRead), notifH.GetNotifyRules)
|
||||
notifGroup.PUT("/rules", middleware.Require(permission.NotificationWrite), notifH.SaveNotifyRules)
|
||||
notifGroup.GET("/recipients", middleware.Require(permission.NotificationRead), notifH.ListRecipients)
|
||||
notifGroup.POST("/recipients", middleware.Require(permission.NotificationWrite), notifH.AddRecipient)
|
||||
notifGroup.DELETE("/recipients/:id", middleware.Require(permission.NotificationWrite), notifH.RemoveRecipient)
|
||||
|
||||
// Relations (cover / set)
|
||||
api.PUT("/users/:id/user-groups", middleware.Require(permission.UserWrite), relH.SetUserGroups)
|
||||
api.PUT("/user-groups/:id/device-groups", middleware.Require(permission.UserGroupWrite), relH.SetUserGroupDeviceGroups)
|
||||
@@ -158,8 +196,5 @@ type scriptInfoResp struct {
|
||||
WebrtcPort string `json:"webrtcPort"`
|
||||
WebrtcUsername string `json:"webrtcUsername"`
|
||||
WebrtcPassword string `json:"webrtcPassword"`
|
||||
}
|
||||
|
||||
func isIP(addr string) bool {
|
||||
return net.ParseIP(addr) != nil
|
||||
WebUIURL string `json:"webUIURL"`
|
||||
}
|
||||
|
||||
@@ -35,3 +35,8 @@ func MarkDeviceOffline(deviceID string) error {
|
||||
repo := sqlite.MustContainer().DeviceMeta
|
||||
return repo.MarkOffline(context.Background(), deviceID)
|
||||
}
|
||||
|
||||
func MarkDeviceOnline(deviceID string) error {
|
||||
repo := sqlite.MustContainer().DeviceMeta
|
||||
return repo.MarkOnline(context.Background(), deviceID)
|
||||
}
|
||||
|
||||
+72
-22
@@ -31,59 +31,71 @@ func NewLDAPAuthenticator(config *xconfig.Config) *LDAPAuthenticator {
|
||||
}
|
||||
|
||||
// 执行用户LDAP认证 (Perform LDAP authentication for a user)
|
||||
func (l *LDAPAuthenticator) Authenticate(username, password string) (bool, error) {
|
||||
// Returns (success, userDN, isAdmin, error). userDN is the distinguished name of the authenticated user.
|
||||
func (l *LDAPAuthenticator) Authenticate(username, password string) (bool, string, bool, error) {
|
||||
if !l.config.LdapEnabled {
|
||||
return false, fmt.Errorf("LDAP authentication is disabled")
|
||||
return false, "", false, fmt.Errorf("LDAP authentication is disabled")
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
return false, fmt.Errorf("username and password are required")
|
||||
return false, "", false, fmt.Errorf("username and password are required")
|
||||
}
|
||||
|
||||
// 连接到LDAP服务器 (Connect to LDAP server)
|
||||
conn, err := l.connect()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to connect to LDAP server: %v", err)
|
||||
return false, "", false, fmt.Errorf("failed to connect to LDAP server: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 使用服务账户进行绑定和搜索 (Use service account for binding and searching)
|
||||
if l.config.LdapBindDN == "" || l.config.LdapBindPassword == "" {
|
||||
return false, fmt.Errorf("service account credentials are required for LDAP authentication - BindDN empty: %v, BindPassword empty: %v", l.config.LdapBindDN == "", l.config.LdapBindPassword == "")
|
||||
return false, "", false, fmt.Errorf("service account credentials are required for LDAP authentication - BindDN empty: %v, BindPassword empty: %v", l.config.LdapBindDN == "", l.config.LdapBindPassword == "")
|
||||
}
|
||||
|
||||
err = conn.Bind(l.config.LdapBindDN, l.config.LdapBindPassword)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("service account bind failed: %v", err)
|
||||
return false, "", false, fmt.Errorf("service account bind failed: %v", err)
|
||||
} // 使用服务账户搜索用户 (Use service account to search for user)
|
||||
userDN, err := l.findUserDN(conn, username)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("user search failed: %v", err)
|
||||
return false, "", false, fmt.Errorf("user search failed: %v", err)
|
||||
}
|
||||
|
||||
// 找到用户,现在用用户凭证验证密码 (Found user, now validate password with user credentials)
|
||||
err = conn.Bind(userDN, password)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("password validation failed: %v", err)
|
||||
return false, "", false, fmt.Errorf("password validation failed: %v", err)
|
||||
}
|
||||
|
||||
// 重新绑定为服务账户以进行授权检查 (Rebind as service account for authorization check)
|
||||
err = conn.Bind(l.config.LdapBindDN, l.config.LdapBindPassword)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to rebind as service account for authorization: %v", err)
|
||||
return false, "", false, fmt.Errorf("failed to rebind as service account for authorization: %v", err)
|
||||
}
|
||||
|
||||
// 检查用户授权 (Check user authorization)
|
||||
authorized, err := l.checkAuthorization(conn, userDN, username)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("authorization check failed: %v", err)
|
||||
return false, "", false, fmt.Errorf("authorization check failed: %v", err)
|
||||
}
|
||||
|
||||
if !authorized {
|
||||
return false, fmt.Errorf("user not authorized")
|
||||
return false, "", false, fmt.Errorf("user not authorized")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
// 检查用户是否为管理员 (Check if user is admin by group or username)
|
||||
isAdmin := l.checkIsAdmin(conn, userDN, username)
|
||||
|
||||
log.Info().
|
||||
Str("username", username).
|
||||
Str("userDN", userDN).
|
||||
Str("adminGroup", l.config.LdapAdminGroup).
|
||||
Str("adminUsers", l.config.LdapAdminUsers).
|
||||
Bool("isAdmin", isAdmin).
|
||||
Msg("LDAP authentication successful")
|
||||
|
||||
return true, userDN, isAdmin, nil
|
||||
}
|
||||
|
||||
// 建立到LDAP服务器的连接 (Establish connection to LDAP server)
|
||||
@@ -342,35 +354,73 @@ func (l *LDAPAuthenticator) findActualUserDN(conn *ldap.Conn, username string) (
|
||||
return sr.Entries[0].DN, nil
|
||||
}
|
||||
|
||||
// checkIsAdmin checks whether the authenticated user should be assigned the admin role,
|
||||
// by matching against LdapAdminUsers (username list) OR LdapAdminGroup (group membership).
|
||||
func (l *LDAPAuthenticator) checkIsAdmin(conn *ldap.Conn, userDN, username string) bool {
|
||||
// 1) Check admin users list
|
||||
adminUsers := strings.TrimSpace(l.config.LdapAdminUsers)
|
||||
if adminUsers != "" {
|
||||
users := strings.Split(adminUsers, ",")
|
||||
for _, u := range users {
|
||||
if strings.TrimSpace(u) == username {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Check admin group membership
|
||||
adminGroups := strings.TrimSpace(l.config.LdapAdminGroup)
|
||||
if adminGroups != "" {
|
||||
groups := strings.Split(adminGroups, ",")
|
||||
for _, group := range groups {
|
||||
group = strings.TrimSpace(group)
|
||||
if group == "" {
|
||||
continue
|
||||
}
|
||||
isMember, err := l.isGroupMember(conn, userDN, username, group)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("Error checking admin group membership for %s in %s: %v", username, group, err)
|
||||
continue
|
||||
}
|
||||
if isMember {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 执行用户认证,支持LDAP和传统密码认证 (Perform user authentication with LDAP and legacy password support)
|
||||
func AuthenticateUser(cfg *xconfig.Config, username, password, authMethod string) bool {
|
||||
success, _ := AuthenticateUserWithError(cfg, username, password, authMethod)
|
||||
success, _, _, _ := AuthenticateUserWithError(cfg, username, password, authMethod)
|
||||
return success
|
||||
}
|
||||
|
||||
// 执行用户认证并返回错误类型,支持LDAP和传统密码认证 (Perform user authentication with error type, supporting LDAP and legacy password authentication)
|
||||
func AuthenticateUserWithError(cfg *xconfig.Config, username, password, authMethod string) (bool, string) {
|
||||
// AuthenticateUserWithError performs authentication and returns (success, errorType, userDN, isAdmin).
|
||||
// userDN and isAdmin are only populated for successful LDAP authentication.
|
||||
func AuthenticateUserWithError(cfg *xconfig.Config, username, password, authMethod string) (bool, string, string, bool) {
|
||||
// 处理LDAP认证 (Handle LDAP authentication)
|
||||
if cfg.LdapEnabled && authMethod == "ldap" && username != "" {
|
||||
ldapAuth := NewLDAPAuthenticator(cfg)
|
||||
success, err := ldapAuth.Authenticate(username, password)
|
||||
success, userDN, isAdmin, err := ldapAuth.Authenticate(username, password)
|
||||
if err != nil {
|
||||
log.Error().Msgf("LDAP authentication error: %v", err)
|
||||
// 检查错误类型以区分认证和授权错误 (Check error type to distinguish between authentication and authorization errors)
|
||||
if strings.Contains(err.Error(), "user not authorized") {
|
||||
return false, "authorization"
|
||||
return false, "authorization", "", false
|
||||
}
|
||||
return false, "authentication"
|
||||
return false, "authentication", "", false
|
||||
}
|
||||
return success, ""
|
||||
return success, "", userDN, isAdmin
|
||||
}
|
||||
|
||||
if authMethod == "legacy" || authMethod == "" {
|
||||
if cfg.Password == password {
|
||||
return true, ""
|
||||
return true, "", "", false
|
||||
}
|
||||
return false, "authentication"
|
||||
return false, "authentication", "", false
|
||||
}
|
||||
|
||||
return false, "authentication"
|
||||
return false, "authentication", "", false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package totp wraps github.com/pquerna/otp/totp for the cloud server.
|
||||
//
|
||||
// We use TOTP (RFC 6238) for two-factor authentication. Secrets are stored
|
||||
// base32-encoded in the database and verified with ±1 step (30s) skew to
|
||||
// tolerate clock drift between server and client.
|
||||
package totp
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
// GenerateSecret creates a fresh TOTP secret for the given account.
|
||||
// Returns the base32 secret and the otpauth:// URL ready for QR encoding.
|
||||
func GenerateSecret(issuer, accountName string) (secret string, otpauthURL string, err error) {
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: issuer,
|
||||
AccountName: accountName,
|
||||
Period: 30,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return key.Secret(), key.URL(), nil
|
||||
}
|
||||
|
||||
// Verify checks a 6-digit code against the secret with ±1 step skew.
|
||||
func Verify(secret, code string) bool {
|
||||
if secret == "" || code == "" {
|
||||
return false
|
||||
}
|
||||
valid, err := totp.ValidateCustom(code, secret, time.Now(), totp.ValidateOpts{
|
||||
Period: 30,
|
||||
Skew: 1,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return valid
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package useragent provides a tiny, dependency-free helper that turns a raw
|
||||
// HTTP User-Agent header into a short human-readable label such as
|
||||
// "Chrome 146 · Windows" or "Safari 17 · iOS 17". It only recognises the
|
||||
// browsers / OSes that we actually display in the trusted-device list — for
|
||||
// anything unknown it falls back to a truncated copy of the original UA.
|
||||
package useragent
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Friendly turns a raw User-Agent header into a short human-readable label.
|
||||
// Returns "Unknown" for an empty input.
|
||||
func Friendly(ua string) string {
|
||||
ua = strings.TrimSpace(ua)
|
||||
if ua == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
browser := parseBrowser(ua)
|
||||
os := parseOS(ua)
|
||||
|
||||
switch {
|
||||
case browser != "" && os != "":
|
||||
return browser + " · " + os
|
||||
case browser != "":
|
||||
return browser
|
||||
case os != "":
|
||||
return os
|
||||
}
|
||||
|
||||
// Fallback: truncated raw UA so we never lose information entirely.
|
||||
if len(ua) > 80 {
|
||||
return ua[:80] + "…"
|
||||
}
|
||||
return ua
|
||||
}
|
||||
|
||||
// --- browser detection ----------------------------------------------------
|
||||
|
||||
// Order matters: Edge / Opera / Brave embed "Chrome" in their UA, so they must
|
||||
// be checked first. Likewise Chrome embeds "Safari", so Safari is last.
|
||||
var browserPatterns = []struct {
|
||||
name string
|
||||
re *regexp.Regexp
|
||||
}{
|
||||
{"Edge", regexp.MustCompile(`Edg(?:e|A|iOS)?/(\d+)`)},
|
||||
{"Opera", regexp.MustCompile(`(?:OPR|Opera)/(\d+)`)},
|
||||
{"Vivaldi", regexp.MustCompile(`Vivaldi/(\d+)`)},
|
||||
{"Firefox", regexp.MustCompile(`Firefox/(\d+)`)},
|
||||
{"Chrome", regexp.MustCompile(`(?:Chrome|CriOS)/(\d+)`)},
|
||||
{"Safari", regexp.MustCompile(`Version/(\d+)[\d.]*\s+.*Safari/`)},
|
||||
}
|
||||
|
||||
func parseBrowser(ua string) string {
|
||||
for _, p := range browserPatterns {
|
||||
m := p.re.FindStringSubmatch(ua)
|
||||
if len(m) >= 2 {
|
||||
return p.name + " " + m[1]
|
||||
}
|
||||
}
|
||||
if strings.Contains(ua, "Safari/") {
|
||||
return "Safari"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- OS detection ---------------------------------------------------------
|
||||
|
||||
var (
|
||||
reAndroid = regexp.MustCompile(`Android (\d+)`)
|
||||
reIOS = regexp.MustCompile(`(?:iPhone OS|CPU OS) (\d+)`)
|
||||
reMac = regexp.MustCompile(`Mac OS X (\d+)[._](\d+)`)
|
||||
)
|
||||
|
||||
func parseOS(ua string) string {
|
||||
switch {
|
||||
case strings.Contains(ua, "Windows NT"):
|
||||
// Windows NT 10.0 covers Windows 10 and 11; Microsoft never bumped the
|
||||
// NT version, so we can't tell them apart from the UA alone.
|
||||
return "Windows"
|
||||
case strings.Contains(ua, "Android"):
|
||||
if m := reAndroid.FindStringSubmatch(ua); len(m) >= 2 {
|
||||
return "Android " + m[1]
|
||||
}
|
||||
return "Android"
|
||||
case strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad"):
|
||||
if m := reIOS.FindStringSubmatch(ua); len(m) >= 2 {
|
||||
return "iOS " + m[1]
|
||||
}
|
||||
return "iOS"
|
||||
case strings.Contains(ua, "Mac OS X"):
|
||||
if m := reMac.FindStringSubmatch(ua); len(m) >= 3 {
|
||||
major := m[1]
|
||||
minor := m[2]
|
||||
// Mac OS X 10.x is "macOS", 11+ is also "macOS" but with the major
|
||||
// number directly.
|
||||
if major == "10" {
|
||||
return "macOS"
|
||||
}
|
||||
return "macOS " + major + "." + minor
|
||||
}
|
||||
return "macOS"
|
||||
case strings.Contains(ua, "CrOS"):
|
||||
return "ChromeOS"
|
||||
case strings.Contains(ua, "Linux"):
|
||||
return "Linux"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package useragent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFriendly(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ua string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
ua: "",
|
||||
want: "Unknown",
|
||||
},
|
||||
{
|
||||
name: "chrome on windows 10",
|
||||
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
||||
want: "Chrome 146 · Windows",
|
||||
},
|
||||
{
|
||||
name: "edge on windows",
|
||||
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0",
|
||||
want: "Edge 130 · Windows",
|
||||
},
|
||||
{
|
||||
name: "firefox on linux",
|
||||
ua: "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0",
|
||||
want: "Firefox 120 · Linux",
|
||||
},
|
||||
{
|
||||
name: "safari on macOS",
|
||||
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
|
||||
want: "Safari 17 · macOS",
|
||||
},
|
||||
{
|
||||
name: "chrome on android 13",
|
||||
ua: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
||||
want: "Chrome 120 · Android 13",
|
||||
},
|
||||
{
|
||||
name: "safari on iphone 17",
|
||||
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
want: "Safari 17 · iOS 17",
|
||||
},
|
||||
{
|
||||
name: "opera",
|
||||
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0",
|
||||
want: "Opera 106 · Windows",
|
||||
},
|
||||
{
|
||||
name: "unknown UA",
|
||||
ua: "curl/8.0.1",
|
||||
want: "curl/8.0.1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := Friendly(tc.ua)
|
||||
if got != tc.want {
|
||||
t.Errorf("Friendly(%q) = %q, want %q", tc.ua, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
// Package qa contains repeatable end-to-end / API regression tests that run
|
||||
// against a LIVE glkvm-cloud deployment (not unit tests). They are gated by the
|
||||
// QA_TARGET env var, so a normal `go test ./...` skips them.
|
||||
//
|
||||
// Run (example, against the CN box):
|
||||
//
|
||||
// QA_TARGET=https://106.55.158.199 \
|
||||
// QA_USER=admin QA_PASS='<password>' \
|
||||
// QA_DEV_ADDR=106.55.158.199:5912 QA_DEV_TOKEN='<rtty token>' \
|
||||
// QA_REAL_DEVID=zh71fb1 QA_REAL_MAC=9483c4b71fb1 \
|
||||
// go test ./internal/qa -run TestE2E -v
|
||||
//
|
||||
// Assertions are invariant-based (don't depend on exact fixture counts), so the
|
||||
// suite can be re-run against any environment. L2 device-protocol tests register
|
||||
// their own throwaway devices (ddns prefix "qae2e") and clean them up via the API.
|
||||
package qa
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- config from env ----------
|
||||
|
||||
type config struct {
|
||||
target string // user API base, e.g. https://106.55.158.199
|
||||
user string
|
||||
pass string
|
||||
devAddr string // device port host:port (TLS), e.g. 106.55.158.199:5912
|
||||
devToken string // rtty registration token
|
||||
realID string // an already-online real device id (optional)
|
||||
realMAC string // its colon-less MAC (optional)
|
||||
}
|
||||
|
||||
func loadConfig(t *testing.T) config {
|
||||
c := config{
|
||||
target: os.Getenv("QA_TARGET"),
|
||||
user: os.Getenv("QA_USER"),
|
||||
pass: os.Getenv("QA_PASS"),
|
||||
devAddr: os.Getenv("QA_DEV_ADDR"),
|
||||
devToken: os.Getenv("QA_DEV_TOKEN"),
|
||||
realID: os.Getenv("QA_REAL_DEVID"),
|
||||
realMAC: os.Getenv("QA_REAL_MAC"),
|
||||
}
|
||||
if c.target == "" {
|
||||
t.Skip("QA_TARGET not set; skipping live e2e suite")
|
||||
}
|
||||
if c.user == "" {
|
||||
c.user = "admin"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ---------- HTTP client ----------
|
||||
|
||||
type client struct {
|
||||
cfg config
|
||||
http *http.Client
|
||||
token string
|
||||
}
|
||||
|
||||
func newClient(cfg config) *client {
|
||||
return &client{
|
||||
cfg: cfg,
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type apiEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func (c *client) do(method, path string, body any, auth bool) (int, apiEnvelope, error) {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.cfg.target+path, rdr)
|
||||
if err != nil {
|
||||
return 0, apiEnvelope{}, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if auth && c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return 0, apiEnvelope{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
var env apiEnvelope
|
||||
_ = json.Unmarshal(raw, &env)
|
||||
return resp.StatusCode, env, nil
|
||||
}
|
||||
|
||||
func (c *client) login(t *testing.T) {
|
||||
t.Helper()
|
||||
_, env, err := c.do("POST", "/api/login", map[string]string{
|
||||
"username": c.cfg.user, "password": c.cfg.pass,
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("login request failed: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("login failed: code=%s msg=%s", env.Code, env.Message)
|
||||
}
|
||||
var d struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &d); err != nil || d.Token == "" {
|
||||
t.Fatalf("login returned no token: %s", string(env.Data))
|
||||
}
|
||||
c.token = d.Token
|
||||
}
|
||||
|
||||
// ---------- device-list helpers ----------
|
||||
|
||||
type devItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Ddns string `json:"ddns"`
|
||||
Mac string `json:"mac"`
|
||||
IP string `json:"ip"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
ConnectedTime int64 `json:"connectedTime"`
|
||||
DeviceGroupID *int64 `json:"deviceGroupId"`
|
||||
DeviceGroupName string `json:"deviceGroupName"`
|
||||
}
|
||||
|
||||
type devList struct {
|
||||
Items []devItem `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
func (c *client) listDevices(t *testing.T, query string) devList {
|
||||
t.Helper()
|
||||
path := "/api/devices"
|
||||
if query != "" {
|
||||
path += "?" + query
|
||||
}
|
||||
st, env, err := c.do("GET", path, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("list devices: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("list devices not ok: http=%d code=%s", st, env.Code)
|
||||
}
|
||||
var d devList
|
||||
if err := json.Unmarshal(env.Data, &d); err != nil {
|
||||
t.Fatalf("decode device list: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ---------- rtty register helper (device protocol over TLS :5912) ----------
|
||||
|
||||
const (
|
||||
regAttrDevid = 1
|
||||
regAttrDesc = 2 // MAC carried here
|
||||
regAttrToken = 3
|
||||
)
|
||||
|
||||
func tlv(typ byte, v []byte) []byte {
|
||||
b := []byte{typ, 0, 0}
|
||||
binary.BigEndian.PutUint16(b[1:], uint16(len(v)))
|
||||
return append(b, v...)
|
||||
}
|
||||
func frame(typ byte, payload []byte) []byte {
|
||||
b := []byte{typ, 0, 0}
|
||||
binary.BigEndian.PutUint16(b[1:], uint16(len(payload)))
|
||||
return append(b, payload...)
|
||||
}
|
||||
|
||||
// registerDevice opens a TLS connection to the device port and performs an rtty
|
||||
// registration. Returns the live connection (caller must Close) and the server's
|
||||
// register response code (0 = accepted, non-zero = rejected).
|
||||
func registerDevice(addr, devid, mac, token string) (net.Conn, byte, error) {
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
payload := []byte{5} // proto 5 (TLV)
|
||||
payload = append(payload, tlv(regAttrDevid, []byte(devid))...)
|
||||
payload = append(payload, tlv(regAttrDesc, []byte(mac))...)
|
||||
if token != "" {
|
||||
payload = append(payload, tlv(regAttrToken, []byte(token))...)
|
||||
}
|
||||
if _, err := conn.Write(frame(0 /*register*/, payload)); err != nil {
|
||||
conn.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(8 * time.Second))
|
||||
head := make([]byte, 3)
|
||||
if _, err := io.ReadFull(conn, head); err != nil {
|
||||
conn.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
blen := binary.BigEndian.Uint16(head[1:])
|
||||
body := make([]byte, blen)
|
||||
io.ReadFull(conn, body)
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
var code byte
|
||||
if len(body) > 0 {
|
||||
code = body[0]
|
||||
}
|
||||
return conn, code, nil
|
||||
}
|
||||
|
||||
// cleanupQADevices deletes every device whose ddns starts with the qa prefix.
|
||||
func (c *client) cleanupQADevices(t *testing.T) {
|
||||
t.Helper()
|
||||
list := c.listDevices(t, "q=qae2e&pageSize=500")
|
||||
for _, it := range list.Items {
|
||||
if strings.HasPrefix(it.Ddns, "qae2e") {
|
||||
c.do("DELETE", fmt.Sprintf("/api/devices/%d", it.ID), nil, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the suite ----------
|
||||
|
||||
func TestE2E(t *testing.T) {
|
||||
cfg := loadConfig(t)
|
||||
c := newClient(cfg)
|
||||
|
||||
// ===== A. Auth =====
|
||||
t.Run("A_auth", func(t *testing.T) {
|
||||
// A5 public auth-config
|
||||
st, env, err := c.do("GET", "/auth-config", nil, false)
|
||||
if err != nil || !env.OK {
|
||||
t.Fatalf("A5 auth-config not ok: http=%d err=%v", st, err)
|
||||
}
|
||||
// A3 unauthenticated device list rejected
|
||||
c.token = ""
|
||||
_, env, _ = c.do("GET", "/api/devices?pageSize=1", nil, true)
|
||||
if env.OK {
|
||||
t.Errorf("A3 expected auth required without token, got ok")
|
||||
}
|
||||
// A2 wrong password rejected
|
||||
_, env, _ = c.do("POST", "/api/login", map[string]string{"username": cfg.user, "password": "definitely-wrong-xyz"}, false)
|
||||
if env.OK {
|
||||
t.Errorf("A2 expected login failure with wrong password")
|
||||
}
|
||||
})
|
||||
|
||||
if cfg.pass == "" {
|
||||
t.Skip("QA_PASS not set; skipping authenticated tests")
|
||||
}
|
||||
c.login(t)
|
||||
|
||||
// ===== B. Pagination (invariants) =====
|
||||
t.Run("B_pagination", func(t *testing.T) {
|
||||
p1 := c.listDevices(t, "page=1&pageSize=10")
|
||||
if len(p1.Items) > 10 {
|
||||
t.Errorf("B1 page items %d > pageSize 10", len(p1.Items))
|
||||
}
|
||||
if p1.Total < len(p1.Items) {
|
||||
t.Errorf("B1 total %d < page items %d", p1.Total, len(p1.Items))
|
||||
}
|
||||
if p1.Total <= 10 {
|
||||
t.Skip("fewer than 11 devices; pagination cross-page checks skipped")
|
||||
}
|
||||
p2 := c.listDevices(t, "page=2&pageSize=10")
|
||||
if len(p2.Items) > 0 && len(p1.Items) > 0 && p2.Items[0].ID == p1.Items[0].ID {
|
||||
t.Errorf("B2 page2 first item equals page1 first item")
|
||||
}
|
||||
// B3 last page size = remainder
|
||||
size := 10
|
||||
pages := (p1.Total + size - 1) / size
|
||||
last := c.listDevices(t, fmt.Sprintf("page=%d&pageSize=%d", pages, size))
|
||||
wantLast := p1.Total - (pages-1)*size
|
||||
if last.Total == p1.Total && len(last.Items) != wantLast {
|
||||
t.Errorf("B3 last page items=%d want=%d (total=%d)", len(last.Items), wantLast, p1.Total)
|
||||
}
|
||||
// B4 out-of-range page → empty, total unchanged
|
||||
oob := c.listDevices(t, fmt.Sprintf("page=%d&pageSize=%d", pages+50, size))
|
||||
if len(oob.Items) != 0 {
|
||||
t.Errorf("B4 out-of-range page returned %d items", len(oob.Items))
|
||||
}
|
||||
if oob.Total != p1.Total {
|
||||
t.Errorf("B4 total changed on oob page: %d vs %d", oob.Total, p1.Total)
|
||||
}
|
||||
})
|
||||
|
||||
// ===== C. Search (invariants) =====
|
||||
t.Run("C_search", func(t *testing.T) {
|
||||
// C5 no-match → 0
|
||||
none := c.listDevices(t, "q=zzz_no_such_device_zzz")
|
||||
if none.Total != 0 || len(none.Items) != 0 {
|
||||
t.Errorf("C5 non-matching search returned total=%d", none.Total)
|
||||
}
|
||||
// pick a real device to search for
|
||||
base := c.listDevices(t, "page=1&pageSize=1")
|
||||
if len(base.Items) == 0 {
|
||||
t.Skip("no devices to exercise search")
|
||||
}
|
||||
d := base.Items[0]
|
||||
// C1 search by ddns substring → contains it
|
||||
got := c.listDevices(t, "q="+d.Ddns)
|
||||
if !containsDdns(got.Items, d.Ddns) {
|
||||
t.Errorf("C1 search by ddns %q did not return it", d.Ddns)
|
||||
}
|
||||
// C2 search by MAC with colons → still matches (colon-strip logic)
|
||||
if d.Mac != "" {
|
||||
withColons := insertColons(d.Mac)
|
||||
gotMac := c.listDevices(t, "q="+withColons)
|
||||
if !containsDdns(gotMac.Items, d.Ddns) {
|
||||
t.Errorf("C2 search by colon-MAC %q did not return device %q", withColons, d.Ddns)
|
||||
}
|
||||
}
|
||||
// C4 case-insensitive
|
||||
gotUpper := c.listDevices(t, "q="+strings.ToUpper(d.Ddns))
|
||||
if !containsDdns(gotUpper.Items, d.Ddns) {
|
||||
t.Errorf("C4 uppercase search did not match")
|
||||
}
|
||||
})
|
||||
|
||||
// ===== D. Sort (invariants) =====
|
||||
t.Run("D_sort", func(t *testing.T) {
|
||||
for _, field := range []string{"ddns", "mac", "ip"} {
|
||||
asc := c.listDevices(t, "sortBy="+field+"&order=asc&pageSize=50")
|
||||
// online-first must always hold
|
||||
if !onlineFirst(asc.Items) {
|
||||
t.Errorf("D7 online-first violated when sorting by %s", field)
|
||||
}
|
||||
// within the same online-bucket, the field is ordered
|
||||
if !fieldOrderedWithinBucket(asc.Items, field, true) {
|
||||
t.Errorf("D sort by %s asc not ordered within status bucket", field)
|
||||
}
|
||||
desc := c.listDevices(t, "sortBy="+field+"&order=desc&pageSize=50")
|
||||
if !fieldOrderedWithinBucket(desc.Items, field, false) {
|
||||
t.Errorf("D sort by %s desc not ordered within status bucket", field)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ===== E. Status filter (new feature) =====
|
||||
t.Run("E_status_filter", func(t *testing.T) {
|
||||
on := c.listDevices(t, "status=online&pageSize=50")
|
||||
for _, it := range on.Items {
|
||||
if it.Status != "online" {
|
||||
t.Errorf("E1 status=online returned a %s device (%s)", it.Status, it.Ddns)
|
||||
}
|
||||
}
|
||||
off := c.listDevices(t, "status=offline&pageSize=50")
|
||||
for _, it := range off.Items {
|
||||
if it.Status != "offline" {
|
||||
t.Errorf("E2 status=offline returned a %s device (%s)", it.Status, it.Ddns)
|
||||
}
|
||||
}
|
||||
all := c.listDevices(t, "pageSize=1")
|
||||
// E4 invalid status ignored → behaves like all
|
||||
bad := c.listDevices(t, "status=foobar&pageSize=1")
|
||||
if bad.Total != all.Total {
|
||||
t.Errorf("E4 invalid status changed total: %d vs %d", bad.Total, all.Total)
|
||||
}
|
||||
// E6 online+offline totals reconcile with all (allowing live drift)
|
||||
if on.Total+off.Total > all.Total {
|
||||
t.Errorf("E6 online(%d)+offline(%d) > all(%d)", on.Total, off.Total, all.Total)
|
||||
}
|
||||
})
|
||||
|
||||
// ===== G. Device protocol (L2) =====
|
||||
if cfg.devAddr != "" {
|
||||
t.Run("G_device_protocol", func(t *testing.T) {
|
||||
c.cleanupQADevices(t)
|
||||
defer c.cleanupQADevices(t)
|
||||
|
||||
devid := "qae2e_ok"
|
||||
mac := "02ffqae20001"
|
||||
// G2 empty MAC → rejected
|
||||
if conn, code, err := registerDevice(cfg.devAddr, "qae2e_nomac", "", cfg.devToken); err == nil {
|
||||
conn.Close()
|
||||
if code == 0 {
|
||||
t.Errorf("G2 empty-MAC registration was accepted (code 0)")
|
||||
}
|
||||
}
|
||||
// G3 bad token → rejected (only meaningful if server enforces a token)
|
||||
if cfg.devToken != "" {
|
||||
if conn, code, err := registerDevice(cfg.devAddr, "qae2e_badtok", "02ffqae29999", "wrong-token-xyz"); err == nil {
|
||||
conn.Close()
|
||||
if code == 0 {
|
||||
t.Errorf("G3 bad-token registration was accepted (code 0)")
|
||||
}
|
||||
}
|
||||
}
|
||||
// G1 valid registration → device appears online with correct MAC
|
||||
conn, code, err := registerDevice(cfg.devAddr, devid, mac, cfg.devToken)
|
||||
if err != nil {
|
||||
t.Fatalf("G1 register failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if code != 0 {
|
||||
t.Fatalf("G1 valid registration rejected, code=%d", code)
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
got := c.listDevices(t, "q="+devid)
|
||||
var found *devItem
|
||||
for i := range got.Items {
|
||||
if got.Items[i].Ddns == devid {
|
||||
found = &got.Items[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("G1 registered device %q not in list", devid)
|
||||
}
|
||||
if found.Status != "online" {
|
||||
t.Errorf("G1 device status=%q want online", found.Status)
|
||||
}
|
||||
if found.Mac != mac {
|
||||
t.Errorf("G1 device mac=%q want %q", found.Mac, mac)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===== L. Real device E2E (L3) =====
|
||||
if cfg.realID != "" {
|
||||
t.Run("L_real_device", func(t *testing.T) {
|
||||
got := c.listDevices(t, "q="+cfg.realID)
|
||||
var found *devItem
|
||||
for i := range got.Items {
|
||||
if got.Items[i].Ddns == cfg.realID {
|
||||
found = &got.Items[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("L1 real device %q not found in list", cfg.realID)
|
||||
}
|
||||
if found.Status != "online" {
|
||||
t.Errorf("L1 real device %q status=%q want online (is the KVM connected?)", cfg.realID, found.Status)
|
||||
}
|
||||
if cfg.realMAC != "" && found.Mac != cfg.realMAC {
|
||||
t.Errorf("L1 real device mac=%q want %q", found.Mac, cfg.realMAC)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- assertion helpers ----------
|
||||
|
||||
func containsDdns(items []devItem, ddns string) bool {
|
||||
for _, it := range items {
|
||||
if it.Ddns == ddns {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func insertColons(mac string) string {
|
||||
if len(mac) != 12 {
|
||||
return mac
|
||||
}
|
||||
var p []string
|
||||
for i := 0; i < 12; i += 2 {
|
||||
p = append(p, mac[i:i+2])
|
||||
}
|
||||
return strings.Join(p, ":")
|
||||
}
|
||||
|
||||
func onlineFirst(items []devItem) bool {
|
||||
seenOffline := false
|
||||
for _, it := range items {
|
||||
if it.Status == "online" && seenOffline {
|
||||
return false
|
||||
}
|
||||
if it.Status != "online" {
|
||||
seenOffline = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fieldVal(it devItem, field string) string {
|
||||
switch field {
|
||||
case "ddns":
|
||||
return it.Ddns
|
||||
case "mac":
|
||||
return it.Mac
|
||||
case "ip":
|
||||
return it.IP
|
||||
case "description":
|
||||
return it.Description
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fieldOrderedWithinBucket checks the field is sorted within each status bucket
|
||||
// (online block, then offline block), matching the server's online-first rule.
|
||||
func fieldOrderedWithinBucket(items []devItem, field string, asc bool) bool {
|
||||
buckets := map[string][]string{}
|
||||
order := []string{}
|
||||
for _, it := range items {
|
||||
if _, ok := buckets[it.Status]; !ok {
|
||||
order = append(order, it.Status)
|
||||
}
|
||||
buckets[it.Status] = append(buckets[it.Status], fieldVal(it, field))
|
||||
}
|
||||
for _, st := range order {
|
||||
vals := buckets[st]
|
||||
sorted := make([]string, len(vals))
|
||||
copy(sorted, vals)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
if asc {
|
||||
return sorted[i] < sorted[j]
|
||||
}
|
||||
return sorted[i] > sorted[j]
|
||||
})
|
||||
for i := range vals {
|
||||
if vals[i] != sorted[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// F / H / I — groups & RBAC, event logs, CRUD. Modular, self-contained tests.
|
||||
// ============================================================================
|
||||
|
||||
type evtItem struct {
|
||||
DeviceMac string `json:"deviceMac"`
|
||||
EventType string `json:"eventType"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ---- generic request helpers ----
|
||||
|
||||
func (c *client) post(t *testing.T, path string, body any) apiEnvelope {
|
||||
t.Helper()
|
||||
_, env, err := c.do("POST", path, body, true)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", path, err)
|
||||
}
|
||||
return env
|
||||
}
|
||||
func (c *client) put(t *testing.T, path string, body any) apiEnvelope {
|
||||
t.Helper()
|
||||
_, env, err := c.do("PUT", path, body, true)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT %s: %v", path, err)
|
||||
}
|
||||
return env
|
||||
}
|
||||
func (c *client) del(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
c.do("DELETE", path, nil, true)
|
||||
}
|
||||
|
||||
func loginClient(t *testing.T, cfg config, user, pass string) *client {
|
||||
c := newClient(cfg)
|
||||
c.cfg.user, c.cfg.pass = user, pass
|
||||
_, env, err := c.do("POST", "/api/login", map[string]string{"username": user, "password": pass}, false)
|
||||
if err != nil || !env.OK {
|
||||
return nil
|
||||
}
|
||||
var d struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
json.Unmarshal(env.Data, &d)
|
||||
if d.Token == "" {
|
||||
return nil
|
||||
}
|
||||
c.token = d.Token
|
||||
return c
|
||||
}
|
||||
|
||||
// ---- fixture helpers ----
|
||||
|
||||
func (c *client) createDeviceGroup(t *testing.T, name string) int64 {
|
||||
t.Helper()
|
||||
env := c.post(t, "/api/device-groups", map[string]any{"name": name})
|
||||
if !env.OK {
|
||||
t.Fatalf("create device-group %q: %s", name, env.Code)
|
||||
}
|
||||
var d struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
json.Unmarshal(env.Data, &d)
|
||||
if d.ID == 0 {
|
||||
t.Fatalf("create device-group returned no id")
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
func (c *client) createUserGroup(t *testing.T, name string) int64 {
|
||||
t.Helper()
|
||||
env := c.post(t, "/api/user-groups", map[string]any{"name": name})
|
||||
if !env.OK {
|
||||
t.Fatalf("create user-group %q: %s", name, env.Code)
|
||||
}
|
||||
var d struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
json.Unmarshal(env.Data, &d)
|
||||
return d.ID
|
||||
}
|
||||
func (c *client) linkUGtoDG(t *testing.T, ugID int64, dgIDs []int64) {
|
||||
t.Helper()
|
||||
env := c.put(t, fmt.Sprintf("/api/user-groups/%d/device-groups", ugID), map[string]any{"deviceGroupIds": dgIDs})
|
||||
if !env.OK {
|
||||
t.Fatalf("link ug %d -> dg: %s", ugID, env.Code)
|
||||
}
|
||||
}
|
||||
func (c *client) createNormalUser(t *testing.T, username, pass string, ugIDs []int64) {
|
||||
t.Helper()
|
||||
env := c.post(t, "/api/users", map[string]any{
|
||||
"role": "user", "username": username, "password": pass, "repassword": pass, "userGroupIds": ugIDs,
|
||||
})
|
||||
if !env.OK {
|
||||
t.Fatalf("create user %q: %s", username, env.Code)
|
||||
}
|
||||
}
|
||||
func (c *client) findUserID(t *testing.T, username string) int64 {
|
||||
t.Helper()
|
||||
_, env, _ := c.do("GET", "/api/users", nil, true)
|
||||
var d struct {
|
||||
Items []struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
} `json:"items"`
|
||||
}
|
||||
json.Unmarshal(env.Data, &d)
|
||||
for _, u := range d.Items {
|
||||
if u.Username == username {
|
||||
return u.ID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func (c *client) assignDevicesToGroup(t *testing.T, dgID int64, devIDs []int64) {
|
||||
t.Helper()
|
||||
env := c.put(t, fmt.Sprintf("/api/device-groups/%d/devices", dgID), map[string]any{"deviceIds": devIDs})
|
||||
if !env.OK {
|
||||
t.Fatalf("assign devices to dg %d: %s", dgID, env.Code)
|
||||
}
|
||||
}
|
||||
func (c *client) me(t *testing.T) []string {
|
||||
t.Helper()
|
||||
_, env, _ := c.do("GET", "/api/me", nil, true)
|
||||
var d struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
json.Unmarshal(env.Data, &d)
|
||||
return d.Permissions
|
||||
}
|
||||
func (c *client) listEventLogs(t *testing.T, mac, types string) []evtItem {
|
||||
t.Helper()
|
||||
_, env, _ := c.do("GET", fmt.Sprintf("/api/device-event-logs?mac=%s&types=%s&pageSize=50", mac, types), nil, true)
|
||||
var d struct {
|
||||
Items []evtItem `json:"items"`
|
||||
}
|
||||
json.Unmarshal(env.Data, &d)
|
||||
return d.Items
|
||||
}
|
||||
|
||||
// registerQAOnline registers a qa device and returns the live conn + its DB id.
|
||||
func (c *client) registerQAOnline(t *testing.T, devid, mac string) (net.Conn, int64) {
|
||||
t.Helper()
|
||||
conn, code, err := registerDevice(c.cfg.devAddr, devid, mac, c.cfg.devToken)
|
||||
if err != nil {
|
||||
t.Fatalf("register %s: %v", devid, err)
|
||||
}
|
||||
if code != 0 {
|
||||
conn.Close()
|
||||
t.Fatalf("register %s rejected, code=%d", devid, code)
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
got := c.listDevices(t, "q="+devid)
|
||||
for _, it := range got.Items {
|
||||
if it.Ddns == devid {
|
||||
return conn, it.ID
|
||||
}
|
||||
}
|
||||
conn.Close()
|
||||
t.Fatalf("registered device %s not found in list", devid)
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
func findItem(items []devItem, ddns string) *devItem {
|
||||
for i := range items {
|
||||
if items[i].Ddns == ddns {
|
||||
return &items[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func contains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func anyEvent(items []evtItem, mac, typ string) bool {
|
||||
for _, e := range items {
|
||||
if e.DeviceMac == mac && e.EventType == typ {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ===== F. Group filter + RBAC visibility =====
|
||||
func TestE2E_GroupsRBAC(t *testing.T) {
|
||||
cfg := loadConfig(t)
|
||||
if cfg.pass == "" {
|
||||
t.Skip("QA_PASS required")
|
||||
}
|
||||
c := newClient(cfg)
|
||||
c.login(t)
|
||||
|
||||
t.Run("F2_unassigned", func(t *testing.T) {
|
||||
un := c.listDevices(t, "unassigned=true&pageSize=50")
|
||||
for _, it := range un.Items {
|
||||
if it.DeviceGroupID != nil {
|
||||
t.Errorf("F2 unassigned=true returned grouped device %s", it.Ddns)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if cfg.devAddr == "" {
|
||||
t.Skip("QA_DEV_ADDR required for RBAC fixture")
|
||||
}
|
||||
t.Run("F3_visibility_and_perm_enforcement", func(t *testing.T) {
|
||||
c.cleanupQADevices(t)
|
||||
defer c.cleanupQADevices(t)
|
||||
dgID := c.createDeviceGroup(t, "qae2e_dg")
|
||||
ugID := c.createUserGroup(t, "qae2e_ug")
|
||||
c.linkUGtoDG(t, ugID, []int64{dgID})
|
||||
uname, upass := "qae2e_user", "Qae2ePass123!"
|
||||
c.createNormalUser(t, uname, upass, []int64{ugID})
|
||||
defer func() {
|
||||
if uid := c.findUserID(t, uname); uid > 0 {
|
||||
c.del(t, fmt.Sprintf("/api/users/%d", uid))
|
||||
}
|
||||
c.del(t, fmt.Sprintf("/api/user-groups/%d", ugID))
|
||||
c.del(t, fmt.Sprintf("/api/device-groups/%d", dgID))
|
||||
}()
|
||||
|
||||
conn, devID := c.registerQAOnline(t, "qae2e_vis", "02ffqaevis01")
|
||||
defer conn.Close()
|
||||
c.assignDevicesToGroup(t, dgID, []int64{devID})
|
||||
|
||||
nu := loginClient(t, cfg, uname, upass)
|
||||
if nu == nil {
|
||||
t.Fatal("normal user login failed")
|
||||
}
|
||||
vl := nu.listDevices(t, "pageSize=100")
|
||||
// F3: the user sees their group device, and ONLY devices in their group.
|
||||
if findItem(vl.Items, "qae2e_vis") == nil {
|
||||
t.Errorf("F3 normal user cannot see their own group's device")
|
||||
}
|
||||
for _, it := range vl.Items {
|
||||
if it.DeviceGroupID == nil || *it.DeviceGroupID != dgID {
|
||||
t.Errorf("F3 normal user saw out-of-scope device %s (group=%v)", it.Ddns, it.DeviceGroupID)
|
||||
}
|
||||
}
|
||||
// Permission enforcement must be consistent with declared permissions.
|
||||
perms := nu.me(t)
|
||||
hasWrite := contains(perms, "device.write")
|
||||
_, env, _ := nu.do("DELETE", fmt.Sprintf("/api/devices/%d", devID), nil, true)
|
||||
denied := !env.OK
|
||||
if hasWrite && denied {
|
||||
t.Errorf("RBAC inconsistent: user HAS device.write but delete was denied")
|
||||
}
|
||||
if !hasWrite && !denied {
|
||||
t.Errorf("RBAC inconsistent: user LACKS device.write but delete succeeded")
|
||||
}
|
||||
t.Logf("normal user perms=%v device.write=%v deleteDenied=%v", perms, hasWrite, denied)
|
||||
})
|
||||
}
|
||||
|
||||
// ===== H. Event logs =====
|
||||
func TestE2E_EventLogs(t *testing.T) {
|
||||
cfg := loadConfig(t)
|
||||
if cfg.pass == "" || cfg.devAddr == "" {
|
||||
t.Skip("QA_PASS + QA_DEV_ADDR required")
|
||||
}
|
||||
c := newClient(cfg)
|
||||
c.login(t)
|
||||
c.cleanupQADevices(t)
|
||||
defer c.cleanupQADevices(t)
|
||||
|
||||
devid, mac := "qae2e_evt", "02ffqaeevt01"
|
||||
conn, _ := c.registerQAOnline(t, devid, mac)
|
||||
|
||||
t.Run("H1_online_event", func(t *testing.T) {
|
||||
// poll a few seconds for the online event
|
||||
for i := 0; i < 6; i++ {
|
||||
if anyEvent(c.listEventLogs(t, mac, "device_online"), mac, "device_online") {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
t.Errorf("H1 no device_online event recorded for %s (mac %s)", devid, mac)
|
||||
})
|
||||
|
||||
t.Run("H2_offline_event", func(t *testing.T) {
|
||||
conn.Close() // clean disconnect -> server should mark offline + log
|
||||
for i := 0; i < 12; i++ {
|
||||
if anyEvent(c.listEventLogs(t, mac, "device_offline"), mac, "device_offline") {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
t.Errorf("H2 no device_offline event within 12s after disconnect")
|
||||
})
|
||||
}
|
||||
|
||||
// ===== I. Device CRUD (admin) =====
|
||||
func TestE2E_CRUD(t *testing.T) {
|
||||
cfg := loadConfig(t)
|
||||
if cfg.pass == "" || cfg.devAddr == "" {
|
||||
t.Skip("QA_PASS + QA_DEV_ADDR required")
|
||||
}
|
||||
c := newClient(cfg)
|
||||
c.login(t)
|
||||
c.cleanupQADevices(t)
|
||||
defer c.cleanupQADevices(t)
|
||||
|
||||
dgID := c.createDeviceGroup(t, "qae2e_crud_dg")
|
||||
defer c.del(t, fmt.Sprintf("/api/device-groups/%d", dgID))
|
||||
|
||||
conn, devID := c.registerQAOnline(t, "qae2e_crud", "02ffqaecrud1")
|
||||
|
||||
t.Run("I1_update_description", func(t *testing.T) {
|
||||
env := c.put(t, fmt.Sprintf("/api/devices/%d", devID), map[string]string{"description": "qa-updated-desc"})
|
||||
if !env.OK {
|
||||
t.Fatalf("I1 update failed: %s", env.Code)
|
||||
}
|
||||
d := findItem(c.listDevices(t, "q=qae2e_crud").Items, "qae2e_crud")
|
||||
if d == nil || d.Description != "qa-updated-desc" {
|
||||
t.Errorf("I1 description not updated, got %+v", d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("I3_move_to_group", func(t *testing.T) {
|
||||
env := c.post(t, "/api/devices/move-to-device-group", map[string]any{"groupId": dgID, "deviceIds": []int64{devID}})
|
||||
if !env.OK {
|
||||
t.Fatalf("I3 move failed: %s", env.Code)
|
||||
}
|
||||
d := findItem(c.listDevices(t, "q=qae2e_crud").Items, "qae2e_crud")
|
||||
if d == nil || d.DeviceGroupID == nil || *d.DeviceGroupID != dgID {
|
||||
t.Errorf("I3 device not in group %d, got %+v", dgID, d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("I2_delete", func(t *testing.T) {
|
||||
conn.Close()
|
||||
c.del(t, fmt.Sprintf("/api/devices/%d", devID))
|
||||
if findItem(c.listDevices(t, "q=qae2e_crud").Items, "qae2e_crud") != nil {
|
||||
t.Errorf("I2 device still present after delete")
|
||||
}
|
||||
})
|
||||
}
|
||||
+74
-22
@@ -33,6 +33,8 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
"rttys/internal/domain/device"
|
||||
"rttys/internal/domain/devicelog"
|
||||
"rttys/internal/domain/notification"
|
||||
"rttys/internal/domain/permission"
|
||||
"rttys/internal/domain/user"
|
||||
httpx "rttys/internal/http"
|
||||
@@ -53,9 +55,11 @@ import (
|
||||
)
|
||||
|
||||
type AppContainer struct {
|
||||
DB *sqlite.AppDB
|
||||
DeviceMetaRepo *sqlite.DeviceMetaRepo
|
||||
UserSvc *user.Service
|
||||
DB *sqlite.AppDB
|
||||
DeviceMetaRepo *sqlite.DeviceMetaRepo
|
||||
UserSvc *user.Service
|
||||
DeviceLogSvc *devicelog.Service
|
||||
NotificationSvc *notification.Service
|
||||
}
|
||||
|
||||
var sessionStore *memory.SessionStore
|
||||
@@ -67,10 +71,13 @@ func InitAppContainer(r *gin.Engine) (*AppContainer, error) {
|
||||
cfg := xconfig.Must()
|
||||
// --- DB ---
|
||||
appDB, err := sqlite.Open(ctx, sqlite.Options{
|
||||
DSN: defaultDBPath,
|
||||
MaxOpenConns: 1,
|
||||
MaxIdleConns: 1,
|
||||
LogSQL: true,
|
||||
DSN: defaultDBPath,
|
||||
// WAL (set via DSN pragmas) lets reads run concurrently with the single
|
||||
// writer, so a wider pool serves API reads without blocking on device
|
||||
// registration writes.
|
||||
MaxOpenConns: 8,
|
||||
MaxIdleConns: 8,
|
||||
LogSQL: false,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("open sqlite failed")
|
||||
@@ -89,9 +96,14 @@ func InitAppContainer(r *gin.Engine) (*AppContainer, error) {
|
||||
groupRepo := sqlite.NewGroupRepo(appDB.Gorm())
|
||||
deviceRepo := sqlite.NewDeviceRepo(appDB.Gorm())
|
||||
relationsRepo := sqlite.NewRelationsRepo(appDB.Gorm())
|
||||
trustedDeviceRepo := sqlite.NewTrustedDeviceRepo(appDB.Gorm())
|
||||
deviceLogRepo := sqlite.NewDeviceLogRepo(appDB.Gorm())
|
||||
notificationRepo := sqlite.NewNotificationRepo(appDB.Gorm())
|
||||
|
||||
userSvc := user.NewService(userRepo)
|
||||
devSvc := device.NewService(deviceRepo, groupRepo)
|
||||
deviceLogSvc := devicelog.NewService(deviceLogRepo)
|
||||
notificationSvc := notification.NewService(notificationRepo)
|
||||
|
||||
permRepo := memory.NewPermissionRepo() // permissions stay in-memory
|
||||
permSvc := permission.NewService(permRepo)
|
||||
@@ -99,20 +111,25 @@ func InitAppContainer(r *gin.Engine) (*AppContainer, error) {
|
||||
sessionStore = memory.NewSessionStore(cfg.AuthSessionTTL)
|
||||
|
||||
httpx.RegisterAPIRoutes(r, httpx.Deps{
|
||||
UserSvc: userSvc,
|
||||
PermSvc: permSvc,
|
||||
DevSvc: devSvc,
|
||||
GroupRepo: groupRepo,
|
||||
SessionStore: sessionStore,
|
||||
RelationsRepo: relationsRepo,
|
||||
Cfg: cfg,
|
||||
CloudVersion: KVMCloudVersion,
|
||||
UserSvc: userSvc,
|
||||
PermSvc: permSvc,
|
||||
DevSvc: devSvc,
|
||||
GroupRepo: groupRepo,
|
||||
SessionStore: sessionStore,
|
||||
RelationsRepo: relationsRepo,
|
||||
TrustedDeviceRepo: trustedDeviceRepo,
|
||||
DeviceLogSvc: deviceLogSvc,
|
||||
NotificationSvc: notificationSvc,
|
||||
Cfg: cfg,
|
||||
CloudVersion: KVMCloudVersion,
|
||||
})
|
||||
|
||||
c := &AppContainer{
|
||||
DB: appDB,
|
||||
DeviceMetaRepo: deviceMetaRepo,
|
||||
UserSvc: userSvc,
|
||||
DB: appDB,
|
||||
DeviceMetaRepo: deviceMetaRepo,
|
||||
UserSvc: userSvc,
|
||||
DeviceLogSvc: deviceLogSvc,
|
||||
NotificationSvc: notificationSvc,
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -138,14 +155,16 @@ func ensureAdminUser(ctx context.Context, db *gorm.DB, adminName, plainPassword
|
||||
}
|
||||
|
||||
// Upsert: create the admin user if not exists, or update password/role/status.
|
||||
// On conflict, also set description to 'System Administrator' if it is currently empty.
|
||||
return db.WithContext(ctx).Exec(
|
||||
`INSERT INTO users (username, description, password_hash, role, status, is_system)
|
||||
VALUES (?, 'Admin', ?, 'admin', 'active', 1)
|
||||
VALUES (?, 'System Administrator', ?, 'admin', 'active', 1)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
password_hash=excluded.password_hash,
|
||||
role='admin',
|
||||
status='active',
|
||||
is_system=1`,
|
||||
is_system=1,
|
||||
description=CASE WHEN (description IS NULL OR description = '') THEN 'System Administrator' ELSE description END`,
|
||||
adminName, hash,
|
||||
).Error
|
||||
}
|
||||
@@ -251,8 +270,11 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
}
|
||||
defer container.DB.Close()
|
||||
sqlite.SetContainer(&sqlite.Container{
|
||||
Gorm: container.DB.Gorm(),
|
||||
DeviceMeta: sqlite.NewDeviceMetaRepo(container.DB.Gorm()),
|
||||
Gorm: container.DB.Gorm(),
|
||||
DeviceMeta: sqlite.NewDeviceMetaRepo(container.DB.Gorm()),
|
||||
DeviceLogSvc: container.DeviceLogSvc,
|
||||
UserSvc: container.UserSvc,
|
||||
NotificationSvc: container.NotificationSvc,
|
||||
})
|
||||
|
||||
// ===== 添加OIDC路由 =====
|
||||
@@ -403,3 +425,33 @@ func httpAuth(cfg *xconfig.Config, c *gin.Context) bool {
|
||||
_, ok := sessionStore.Get(sid)
|
||||
return ok
|
||||
}
|
||||
|
||||
// principalFromCtx best-effort extracts the logged-in user (id + username)
|
||||
// from the request, used for tagging device-event logs. Returns (0, "")
|
||||
// when no session can be resolved. Never blocks the calling path.
|
||||
func principalFromCtx(c *gin.Context) (int64, string) {
|
||||
if c == nil || c.Request == nil || sessionStore == nil {
|
||||
return 0, ""
|
||||
}
|
||||
sid, err := c.Cookie("sid")
|
||||
if err != nil {
|
||||
return 0, ""
|
||||
}
|
||||
sid = strings.TrimSpace(sid)
|
||||
if sid == "" {
|
||||
return 0, ""
|
||||
}
|
||||
sess, ok := sessionStore.Get(sid)
|
||||
if !ok {
|
||||
return 0, ""
|
||||
}
|
||||
cont := sqlite.TryContainer()
|
||||
if cont == nil || cont.UserSvc == nil {
|
||||
return sess.UserID, ""
|
||||
}
|
||||
u, err := cont.UserSvc.FindByID(c.Request.Context(), sess.UserID)
|
||||
if err != nil || u == nil {
|
||||
return sess.UserID, ""
|
||||
}
|
||||
return sess.UserID, u.Username
|
||||
}
|
||||
|
||||
+81
-15
@@ -35,8 +35,10 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"rttys/internal/legacy"
|
||||
"rttys/internal/store/sqlite"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"rttys/utils"
|
||||
@@ -80,6 +82,11 @@ type Device struct {
|
||||
close sync.Once
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// registered becomes true only after the device passes registration
|
||||
// (token + MAC checks) and is added to the server. Close() consults it so
|
||||
// that failed registrations never emit an unpaired device-offline event.
|
||||
registered atomic.Bool
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -126,6 +133,7 @@ const (
|
||||
devRegErrInvalidToken
|
||||
devRegErrHookFailed
|
||||
devRegErrIdConflicting
|
||||
devRegErrEmptyMac
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -143,6 +151,7 @@ var DevRegErrMsg = map[byte]string{
|
||||
devRegErrInvalidToken: "Invalid token",
|
||||
devRegErrHookFailed: "Hook failed",
|
||||
devRegErrIdConflicting: "ID conflict",
|
||||
devRegErrEmptyMac: "Empty MAC",
|
||||
}
|
||||
|
||||
var DeviceMsgHandlers = map[byte]func(*Device, []byte) error{
|
||||
@@ -247,19 +256,29 @@ func handleDeviceConnection(srv *RttyServer, conn net.Conn) {
|
||||
log.Info().Msgf("device '%s' registered, group '%s' proto %d, heartbeat %v, remoteIP '%s'",
|
||||
dev.id, dev.group, dev.proto, dev.heartbeat, deviceRemoteIP)
|
||||
|
||||
// 2. Load existing metadata by device_id
|
||||
description := ""
|
||||
meta, err := legacy.GetDeviceMetaByDeviceID(dev.id)
|
||||
if err == nil && meta != nil {
|
||||
description = meta.Description
|
||||
}
|
||||
if err := legacy.SaveOrUpdateDeviceMeta(
|
||||
dev.id,
|
||||
dev.desc, // device register mac info with desc filed
|
||||
description,
|
||||
deviceRemoteIP,
|
||||
); err != nil {
|
||||
return
|
||||
// 2. Persist device metadata. A known device reconnecting with unchanged
|
||||
// identity (same MAC + IP) only needs to be flipped back online with a
|
||||
// fresh last_seen — avoid rewriting every metadata column. This removes most
|
||||
// of the per-reconnect write amplification that drives restart/reconnect
|
||||
// storms. New devices, or ones whose MAC/IP changed, get the full upsert.
|
||||
meta, _ := legacy.GetDeviceMetaByDeviceID(dev.id)
|
||||
if meta != nil && meta.Mac == utils.NormalizeMac(dev.desc) && meta.IP == deviceRemoteIP {
|
||||
if err := legacy.MarkDeviceOnline(dev.id); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
description := ""
|
||||
if meta != nil {
|
||||
description = meta.Description
|
||||
}
|
||||
if err := legacy.SaveOrUpdateDeviceMeta(
|
||||
dev.id,
|
||||
dev.desc, // device register mac info with desc filed
|
||||
description,
|
||||
deviceRemoteIP,
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -376,8 +395,18 @@ func (dev *Device) Close(srv *RttyServer) {
|
||||
dev.close.Do(func() {
|
||||
log.Error().Msgf("device '%s' disconnected", dev.id)
|
||||
srv.DelDevice(dev)
|
||||
if dev.id != "" {
|
||||
// Only emit an offline event for devices that actually came online.
|
||||
// A connection that failed registration (bad token, empty MAC, proto
|
||||
// too low, hook failure, id conflict) never recorded an online event,
|
||||
// so recording offline here would produce orphan rows on every retry.
|
||||
if dev.id != "" && dev.registered.Load() {
|
||||
_ = legacy.MarkDeviceOffline(dev.id)
|
||||
if c := sqlite.TryContainer(); c != nil && c.DeviceLogSvc != nil {
|
||||
c.DeviceLogSvc.RecordDeviceOffline(context.Background(), dev.id, dev.desc, "")
|
||||
if c.NotificationSvc != nil {
|
||||
c.NotificationSvc.NotifyDeviceOffline(dev.id, dev.desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
dev.cancel()
|
||||
dev.conn.Close()
|
||||
@@ -438,12 +467,20 @@ func (dev *Device) Register(srv *RttyServer) byte {
|
||||
return devRegErrHookFailed
|
||||
}
|
||||
|
||||
log.Info().Msgf("cfg.Token:%s,dev.token:%s", cfg.Token, dev.token)
|
||||
if cfg.Token != "" && dev.token != cfg.Token {
|
||||
log.Error().Msgf("invalid token for device '%s'", dev.id)
|
||||
return devRegErrInvalidToken
|
||||
}
|
||||
|
||||
// Reject an empty MAC (carried in the register description field). An empty
|
||||
// value would collide with the devices.mac UNIQUE constraint as soon as a
|
||||
// second device registers the same way, so refuse it here instead of
|
||||
// letting the DB upsert fail repeatedly on every reconnect.
|
||||
if strings.TrimSpace(dev.desc) == "" {
|
||||
log.Error().Msgf("empty MAC for device '%s'", dev.id)
|
||||
return devRegErrEmptyMac
|
||||
}
|
||||
|
||||
devHookUrl := cfg.DevHookUrl
|
||||
if devHookUrl != "" {
|
||||
cli := &http.Client{
|
||||
@@ -469,6 +506,16 @@ func (dev *Device) Register(srv *RttyServer) byte {
|
||||
return devRegErrIdConflicting
|
||||
}
|
||||
|
||||
// Mark as fully registered so Close() will emit a paired offline event.
|
||||
dev.registered.Store(true)
|
||||
|
||||
if c := sqlite.TryContainer(); c != nil && c.DeviceLogSvc != nil {
|
||||
c.DeviceLogSvc.RecordDeviceOnline(context.Background(), dev.id, dev.desc, "")
|
||||
if c.NotificationSvc != nil {
|
||||
c.NotificationSvc.NotifyDeviceOnline(dev.id, dev.desc)
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -478,6 +525,25 @@ func (dev *Device) setClientInfo(data []byte) {
|
||||
dev.clientInfoMu.Unlock()
|
||||
}
|
||||
|
||||
// ClientType returns the "client" value from the device's client info JSON
|
||||
// (e.g. "rtty-go"). Returns "" if not available or not parseable.
|
||||
func (dev *Device) ClientType() string {
|
||||
dev.clientInfoMu.RLock()
|
||||
raw := make([]byte, len(dev.clientInfo))
|
||||
copy(raw, dev.clientInfo)
|
||||
dev.clientInfoMu.RUnlock()
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var info struct {
|
||||
Client string `json:"client"`
|
||||
}
|
||||
if err := jsoniter.Unmarshal(raw, &info); err != nil {
|
||||
return ""
|
||||
}
|
||||
return info.Client
|
||||
}
|
||||
|
||||
func handleDeviceInfoMsg(dev *Device, data []byte) error {
|
||||
if len(data) == 0 {
|
||||
log.Warn().Msgf("device '%s' sent empty client info", dev.id)
|
||||
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"time"
|
||||
|
||||
"rttys/internal/proxy"
|
||||
"rttys/internal/store/sqlite"
|
||||
"rttys/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -56,6 +57,7 @@ type HttpProxySession struct {
|
||||
group string
|
||||
destaddr string
|
||||
https bool
|
||||
logID int64 // device-event-log row id (0 if not recorded)
|
||||
}
|
||||
|
||||
var httpProxySessions = sync.Map{}
|
||||
@@ -71,6 +73,19 @@ func (ses *HttpProxySession) String() string {
|
||||
ses.devid, ses.group, ses.destaddr, ses.https)
|
||||
}
|
||||
|
||||
// endWebSessionLog stamps ended_at on the web-session log row, if any.
|
||||
// Safe to call on a session whose log was never recorded (logID == 0).
|
||||
func endWebSessionLog(ses *HttpProxySession) {
|
||||
if ses == nil || ses.logID == 0 {
|
||||
return
|
||||
}
|
||||
cont := sqlite.TryContainer()
|
||||
if cont == nil || cont.DeviceLogSvc == nil {
|
||||
return
|
||||
}
|
||||
cont.DeviceLogSvc.EndSession(context.Background(), ses.logID)
|
||||
}
|
||||
|
||||
func (srv *RttyServer) ListenHttpProxy() {
|
||||
cfg := &srv.cfg
|
||||
|
||||
@@ -127,6 +142,7 @@ func httpProxySessionsClean() {
|
||||
ses := value.(*HttpProxySession)
|
||||
if time.Now().Unix() > ses.expire.Load() {
|
||||
log.Debug().Msgf("Http proxy session '%s' expired", key)
|
||||
endWebSessionLog(ses)
|
||||
ses.cancel()
|
||||
httpProxySessions.Delete(key)
|
||||
}
|
||||
@@ -329,6 +345,7 @@ func httpProxyRedirect(srv *RttyServer, c *gin.Context, group string) {
|
||||
if err == nil {
|
||||
if v, loaded := httpProxySessions.LoadAndDelete(sid); loaded {
|
||||
s := v.(*HttpProxySession)
|
||||
endWebSessionLog(s)
|
||||
s.cancel()
|
||||
log.Debug().Msgf(`del old httpProxySession "%s" for device "%s"`, sid, devid)
|
||||
}
|
||||
@@ -346,6 +363,23 @@ func httpProxyRedirect(srv *RttyServer, c *gin.Context, group string) {
|
||||
destaddr: addr,
|
||||
https: proto == "https",
|
||||
}
|
||||
if cont := sqlite.TryContainer(); cont != nil && cont.DeviceLogSvc != nil {
|
||||
actorID, actorName := principalFromCtx(c)
|
||||
if dev.ClientType() != "rtty-go" {
|
||||
// Non-rtty-go clients use the KVM control UI → remote_control
|
||||
ses.logID = cont.DeviceLogSvc.StartRemoteControlSession(
|
||||
c.Request.Context(), devid, dev.desc, actorID, actorName, c.ClientIP())
|
||||
if cont.NotificationSvc != nil {
|
||||
cont.NotificationSvc.NotifyRemoteAccess("Remote Control", devid, dev.desc, actorName, c.ClientIP())
|
||||
}
|
||||
} else {
|
||||
ses.logID = cont.DeviceLogSvc.StartRemoteWebSession(
|
||||
c.Request.Context(), devid, dev.desc, actorID, actorName, c.ClientIP(), addr, proto)
|
||||
if cont.NotificationSvc != nil {
|
||||
cont.NotificationSvc.NotifyRemoteAccess("Remote Web", devid, dev.desc, actorName, c.ClientIP())
|
||||
}
|
||||
}
|
||||
}
|
||||
ses.Expire()
|
||||
httpProxySessions.Store(sid, ses)
|
||||
|
||||
|
||||
+44
-5
@@ -13,6 +13,7 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"rttys/internal/domain/identity"
|
||||
"rttys/internal/domain/user"
|
||||
"rttys/internal/pkg/randtoken"
|
||||
"rttys/xconfig"
|
||||
@@ -232,21 +233,59 @@ func oidcCallbackHandler(cfg *xconfig.Config, userSvc *user.Service) gin.Handler
|
||||
return
|
||||
}
|
||||
|
||||
// ==== Create application session (new session_store, same as LDAP) ====
|
||||
sid, err := randtoken.New() // randtoken.New()
|
||||
// ==== Create application session ====
|
||||
sid, err := randtoken.New()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create session token")
|
||||
c.Redirect(http.StatusFound, "/?error=internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
sysAdmin, err := userSvc.GetSystemAdmin(c.Request.Context())
|
||||
preferredUsername, _ := claims["preferred_username"].(string)
|
||||
|
||||
// Determine role based on admin group / admin users
|
||||
role := identity.RoleUser
|
||||
hasAdminRule := len(cfg.OIDCAdminGroup) > 0 || len(cfg.OIDCAdminUsers) > 0
|
||||
if hasAdminRule {
|
||||
// Check admin users list (match preferred_username or email)
|
||||
if len(cfg.OIDCAdminUsers) > 0 {
|
||||
if contains(cfg.OIDCAdminUsers, preferredUsername) || contains(cfg.OIDCAdminUsers, userEmail) {
|
||||
role = identity.RoleAdmin
|
||||
}
|
||||
}
|
||||
// Check admin group membership
|
||||
if role != identity.RoleAdmin && len(cfg.OIDCAdminGroup) > 0 {
|
||||
groups := extractStringSlice(claims["groups"])
|
||||
if intersects(groups, cfg.OIDCAdminGroup) {
|
||||
role = identity.RoleAdmin
|
||||
}
|
||||
}
|
||||
log.Info().
|
||||
Str("sub", sub).
|
||||
Str("email", userEmail).
|
||||
Str("name", userName).
|
||||
Str("preferredUsername", preferredUsername).
|
||||
Strs("userGroups", extractStringSlice(claims["groups"])).
|
||||
Strs("adminGroup", cfg.OIDCAdminGroup).
|
||||
Strs("adminUsers", cfg.OIDCAdminUsers).
|
||||
Str("role", string(role)).
|
||||
Msg("OIDC admin role check")
|
||||
}
|
||||
|
||||
oidcUser, err := userSvc.FindOrCreateExternalUser(c.Request.Context(), "oidc", sub, preferredUsername, userEmail, userName, role)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to find system admin user")
|
||||
log.Error().Err(err).Msg("Failed to find or create OIDC user")
|
||||
c.Redirect(http.StatusFound, "/?error=internal_error")
|
||||
return
|
||||
}
|
||||
sessionStore.Create(sid, sysAdmin.ID)
|
||||
log.Info().
|
||||
Str("sub", sub).
|
||||
Str("email", userEmail).
|
||||
Str("preferredUsername", preferredUsername).
|
||||
Str("role", string(role)).
|
||||
Int64("userID", oidcUser.ID).
|
||||
Msg("OIDC user login completed")
|
||||
sessionStore.Create(sid, oidcUser.ID)
|
||||
|
||||
c.SetCookie("sid", sid, 0, "/", "", cfg.SslCert != "", false)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"rttys/internal/store/sqlite"
|
||||
"rttys/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -121,6 +122,22 @@ func handleUserConnection(srv *RttyServer, c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var sshLogID int64
|
||||
if cont := sqlite.TryContainer(); cont != nil && cont.DeviceLogSvc != nil {
|
||||
actorID, actorName := principalFromCtx(c)
|
||||
sshLogID = cont.DeviceLogSvc.StartRemoteSSHSession(c.Request.Context(), dev.id, dev.desc, actorID, actorName, c.ClientIP())
|
||||
if cont.NotificationSvc != nil {
|
||||
cont.NotificationSvc.NotifyRemoteAccess("SSH", dev.id, dev.desc, actorName, c.ClientIP())
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if sshLogID > 0 {
|
||||
if cont := sqlite.TryContainer(); cont != nil && cont.DeviceLogSvc != nil {
|
||||
cont.DeviceLogSvc.EndSession(context.Background(), sshLogID)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
msgType, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package server
|
||||
|
||||
const RttysVersion = "5.2.0"
|
||||
const KVMCloudVersion = "v2.0.0"
|
||||
const KVMCloudVersion = "v2.7.0"
|
||||
|
||||
var (
|
||||
GitCommit = ""
|
||||
BuildTime = ""
|
||||
GitCommit = ""
|
||||
BuildTime = ""
|
||||
)
|
||||
|
||||
@@ -24,6 +24,8 @@ func NewPermissionRepo() *PermissionRepo {
|
||||
permission.UserGroupRead, permission.UserGroupWrite,
|
||||
permission.UserRead, permission.UserWrite,
|
||||
permission.RelationWrite,
|
||||
permission.DeviceLogRead,
|
||||
permission.NotificationRead, permission.NotificationWrite,
|
||||
},
|
||||
identity.RoleUser: {
|
||||
permission.MeRead, permission.AuthWrite,
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rttys/internal/domain/devicelog"
|
||||
"rttys/internal/domain/notification"
|
||||
"rttys/internal/domain/user"
|
||||
)
|
||||
|
||||
type Container struct {
|
||||
Gorm *gorm.DB
|
||||
DeviceMeta *DeviceMetaRepo
|
||||
Gorm *gorm.DB
|
||||
DeviceMeta *DeviceMetaRepo
|
||||
DeviceLogSvc *devicelog.Service
|
||||
UserSvc *user.Service
|
||||
NotificationSvc *notification.Service
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -29,3 +37,13 @@ func MustContainer() *Container {
|
||||
}
|
||||
return gContainer
|
||||
}
|
||||
|
||||
// TryContainer returns the global container if it has been initialized,
|
||||
// or nil otherwise. Use this from code paths (e.g. device runtime) that
|
||||
// may execute before InitAppContainer has finished — calling MustContainer
|
||||
// there would panic on early connections.
|
||||
func TryContainer() *Container {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return gContainer
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rttys/internal/domain/device"
|
||||
)
|
||||
|
||||
// Mirrors the OLD in-memory ordering logic from the device list handler, used
|
||||
// as the oracle to prove the SQL ListPaged produces identical ordering.
|
||||
func oracleOrder(items []device.Device, groupName map[int64]string, sortBy, order string) []string {
|
||||
asc := !strings.EqualFold(order, "desc")
|
||||
idx := make([]int, len(items))
|
||||
for i := range idx {
|
||||
idx[i] = i
|
||||
}
|
||||
// items are assumed pre-ordered by id ASC (as the repo loaded them)
|
||||
sort.SliceStable(idx, func(a, b int) bool {
|
||||
i, j := items[idx[a]], items[idx[b]]
|
||||
oi := i.Status == device.StatusOnline
|
||||
oj := j.Status == device.StatusOnline
|
||||
if oi != oj {
|
||||
return oi
|
||||
}
|
||||
var cmp int
|
||||
switch sortBy {
|
||||
case "id":
|
||||
switch {
|
||||
case i.ID < j.ID:
|
||||
cmp = -1
|
||||
case i.ID > j.ID:
|
||||
cmp = 1
|
||||
}
|
||||
case "ip":
|
||||
cmp = strings.Compare(i.IP, j.IP)
|
||||
case "mac":
|
||||
cmp = strings.Compare(i.Mac, j.Mac)
|
||||
case "connectedTime":
|
||||
var ti, tj int64
|
||||
if i.LastSeenAt != nil {
|
||||
ti = *i.LastSeenAt
|
||||
}
|
||||
if j.LastSeenAt != nil {
|
||||
tj = *j.LastSeenAt
|
||||
}
|
||||
switch {
|
||||
case ti < tj:
|
||||
cmp = -1
|
||||
case ti > tj:
|
||||
cmp = 1
|
||||
}
|
||||
case "description":
|
||||
cmp = strings.Compare(i.Description, j.Description)
|
||||
case "ddns":
|
||||
cmp = strings.Compare(i.Ddns, j.Ddns)
|
||||
case "deviceGroupName":
|
||||
var gi, gj string
|
||||
if i.DeviceGroupID != nil {
|
||||
gi = groupName[*i.DeviceGroupID]
|
||||
}
|
||||
if j.DeviceGroupID != nil {
|
||||
gj = groupName[*j.DeviceGroupID]
|
||||
}
|
||||
cmp = strings.Compare(gi, gj)
|
||||
default:
|
||||
cmp = strings.Compare(i.Ddns, j.Ddns)
|
||||
}
|
||||
if cmp == 0 {
|
||||
return false
|
||||
}
|
||||
if asc {
|
||||
return cmp < 0
|
||||
}
|
||||
return cmp > 0
|
||||
})
|
||||
out := make([]string, len(idx))
|
||||
for k, ix := range idx {
|
||||
out[k] = items[ix].Ddns
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestListPagedMatchesLegacyOrdering(t *testing.T) {
|
||||
dsn := filepath.Join(t.TempDir(), "lp.db")
|
||||
db, err := Open(context.Background(), Options{DSN: dsn, MaxOpenConns: 4, MaxIdleConns: 4})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
g := db.Gorm()
|
||||
|
||||
// minimal tables (only what ListPaged reads)
|
||||
g.Exec(`CREATE TABLE device_groups (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`)
|
||||
g.Exec(`CREATE TABLE devices (
|
||||
id INTEGER PRIMARY KEY, ddns TEXT, mac TEXT, name TEXT DEFAULT '', description TEXT DEFAULT '',
|
||||
ip TEXT DEFAULT '', client TEXT DEFAULT '', device_group_id INTEGER, status TEXT, last_seen_at INTEGER)`)
|
||||
g.Exec(`INSERT INTO device_groups(id,name) VALUES (1,'Beta'),(2,'alpha'),(3,'Zeta')`)
|
||||
|
||||
// Fixtures: mixed status, null/non-null group, null/value last_seen, ties,
|
||||
// case differences, ungrouped devices.
|
||||
rows := []struct {
|
||||
id int64
|
||||
ddns string
|
||||
mac string
|
||||
ip string
|
||||
desc string
|
||||
group interface{}
|
||||
status string
|
||||
lastSee interface{}
|
||||
}{
|
||||
{1, "bbb001", "aa11", "10.0.0.5", "Camera", 1, "online", 1000},
|
||||
{2, "aaa002", "bb22", "10.0.0.2", "router", nil, "offline", nil},
|
||||
{3, "ccc003", "cc33", "10.0.0.9", "camera", 2, "online", 2000},
|
||||
{4, "aaa001", "dd44", "10.0.0.2", "router", 3, "online", 1000}, // ties ip & lastseen with others
|
||||
{5, "ddd005", "ee55", "10.0.0.1", "", nil, "offline", 500},
|
||||
{6, "eee006", "ff66", "10.0.0.7", "Zebra", 1, "online", nil},
|
||||
{7, "fff007", "0011", "10.0.0.3", "alpha", 2, "offline", 3000},
|
||||
{8, "ggg008", "1122", "10.0.0.8", "Beta", nil, "online", 2000}, // ungrouped, online, ties lastseen
|
||||
}
|
||||
for _, r := range rows {
|
||||
g.Exec(`INSERT INTO devices(id,ddns,mac,ip,description,device_group_id,status,last_seen_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)`, r.id, r.ddns, r.mac, r.ip, r.desc, r.group, r.status, r.lastSee)
|
||||
}
|
||||
|
||||
// Build the oracle input: all devices ordered by id ASC, plus group names.
|
||||
groupName := map[int64]string{1: "Beta", 2: "alpha", 3: "Zeta"}
|
||||
var legacyItems []device.Device
|
||||
for _, r := range rows {
|
||||
var gid *int64
|
||||
if r.group != nil {
|
||||
v := int64(r.group.(int))
|
||||
gid = &v
|
||||
}
|
||||
var ls *int64
|
||||
if r.lastSee != nil {
|
||||
v := int64(r.lastSee.(int))
|
||||
ls = &v
|
||||
}
|
||||
legacyItems = append(legacyItems, device.Device{
|
||||
ID: r.id, Ddns: r.ddns, Mac: r.mac, IP: r.ip, Description: r.desc,
|
||||
DeviceGroupID: gid, Status: device.Status(r.status), LastSeenAt: ls,
|
||||
})
|
||||
}
|
||||
|
||||
repo := NewDeviceRepo(g)
|
||||
fields := []string{"", "id", "ip", "mac", "ddns", "description", "connectedTime", "deviceGroupName"}
|
||||
orders := []string{"asc", "desc"}
|
||||
for _, f := range fields {
|
||||
for _, o := range orders {
|
||||
want := oracleOrder(legacyItems, groupName, f, o)
|
||||
got, total, err := repo.ListPaged(context.Background(), device.ListQuery{SortBy: f, Order: o})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPaged(%q,%q): %v", f, o, err)
|
||||
}
|
||||
if total != int64(len(rows)) {
|
||||
t.Errorf("sortBy=%q order=%q: total=%d want=%d", f, o, total, len(rows))
|
||||
}
|
||||
var gotDdns []string
|
||||
for _, it := range got {
|
||||
gotDdns = append(gotDdns, it.Ddns)
|
||||
}
|
||||
if fmt.Sprint(gotDdns) != fmt.Sprint(want) {
|
||||
t.Errorf("sortBy=%q order=%q ordering mismatch:\n SQL = %v\n legacy = %v", f, o, gotDdns, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// group-name join correctness
|
||||
got, _, _ := repo.ListPaged(context.Background(), device.ListQuery{SortBy: "id", Order: "asc"})
|
||||
for _, it := range got {
|
||||
var wantName string
|
||||
if it.DeviceGroupID != nil {
|
||||
wantName = groupName[*it.DeviceGroupID]
|
||||
}
|
||||
if it.GroupName != wantName {
|
||||
t.Errorf("device %s group name=%q want=%q", it.Ddns, it.GroupName, wantName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPagedSearchUnassignedPagination(t *testing.T) {
|
||||
dsn := filepath.Join(t.TempDir(), "lp2.db")
|
||||
db, _ := Open(context.Background(), Options{DSN: dsn, MaxOpenConns: 4, MaxIdleConns: 4})
|
||||
defer db.Close()
|
||||
g := db.Gorm()
|
||||
g.Exec(`CREATE TABLE device_groups (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`)
|
||||
g.Exec(`CREATE TABLE devices (id INTEGER PRIMARY KEY, ddns TEXT, mac TEXT, name TEXT DEFAULT '', description TEXT DEFAULT '',
|
||||
ip TEXT DEFAULT '', client TEXT DEFAULT '', device_group_id INTEGER, status TEXT, last_seen_at INTEGER)`)
|
||||
g.Exec(`INSERT INTO device_groups(id,name) VALUES (1,'G1')`)
|
||||
g.Exec(`INSERT INTO devices(id,ddns,mac,ip,description,device_group_id,status) VALUES
|
||||
(1,'zh71fb1','9483c4b71fb1','10.0.0.1','lab',1,'online'),
|
||||
(2,'pubacff','9483c4bbacff','10.0.0.2','',NULL,'offline'),
|
||||
(3,'aq71029','9483c4a71029','192.168.1.9','Office',NULL,'online'),
|
||||
(4,'xy00001','001122334455','10.0.0.4','SEARCHME',1,'offline')`)
|
||||
repo := NewDeviceRepo(g)
|
||||
ctx := context.Background()
|
||||
|
||||
// search by ddns fragment
|
||||
got, total, _ := repo.ListPaged(ctx, device.ListQuery{Search: "zh71"})
|
||||
if total != 1 || len(got) != 1 || got[0].Ddns != "zh71fb1" {
|
||||
t.Errorf("search zh71: got %d rows total=%d", len(got), total)
|
||||
}
|
||||
// search by MAC fragment (colon-less, as handler passes it)
|
||||
got, total, _ = repo.ListPaged(ctx, device.ListQuery{Search: "9483c4"})
|
||||
if total != 3 {
|
||||
t.Errorf("search 9483c4: total=%d want 3", total)
|
||||
}
|
||||
// case-insensitive description search
|
||||
got, total, _ = repo.ListPaged(ctx, device.ListQuery{Search: "searchme"})
|
||||
if total != 1 || got[0].Ddns != "xy00001" {
|
||||
t.Errorf("search searchme: total=%d", total)
|
||||
}
|
||||
// unassigned only
|
||||
got, total, _ = repo.ListPaged(ctx, device.ListQuery{Unassigned: true})
|
||||
if total != 2 {
|
||||
t.Errorf("unassigned: total=%d want 2", total)
|
||||
}
|
||||
for _, it := range got {
|
||||
if it.DeviceGroupID != nil {
|
||||
t.Errorf("unassigned returned grouped device %s", it.Ddns)
|
||||
}
|
||||
}
|
||||
// pagination: pageSize 2 over 4 rows, sorted by id asc (online-first)
|
||||
p1, total, _ := repo.ListPaged(ctx, device.ListQuery{SortBy: "id", Order: "asc", Page: 1, PageSize: 2})
|
||||
p2, _, _ := repo.ListPaged(ctx, device.ListQuery{SortBy: "id", Order: "asc", Page: 2, PageSize: 2})
|
||||
if total != 4 || len(p1) != 2 || len(p2) != 2 {
|
||||
t.Fatalf("pagination sizes: total=%d p1=%d p2=%d", total, len(p1), len(p2))
|
||||
}
|
||||
// online-first: page1 should be the two online devices (ids 1,3)
|
||||
if p1[0].Status != device.StatusOnline || p1[1].Status != device.StatusOnline {
|
||||
t.Errorf("online-first violated: p1=%v %v", p1[0].Status, p1[1].Status)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -86,3 +87,105 @@ func (r *DeviceRepo) ListByDeviceGroupIDs(ctx context.Context, groupIDs []int64)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// deviceListRow carries the device columns plus the joined group name.
|
||||
type deviceListRow struct {
|
||||
ID int64 `gorm:"column:id"`
|
||||
Ddns string `gorm:"column:ddns"`
|
||||
Mac string `gorm:"column:mac"`
|
||||
Name string `gorm:"column:name"`
|
||||
Description string `gorm:"column:description"`
|
||||
IP string `gorm:"column:ip"`
|
||||
Client string `gorm:"column:client"`
|
||||
DeviceGroupID *int64 `gorm:"column:device_group_id"`
|
||||
Status string `gorm:"column:status"`
|
||||
LastSeenAt *int64 `gorm:"column:last_seen_at"`
|
||||
GroupName string `gorm:"column:group_name"`
|
||||
}
|
||||
|
||||
// sortColumns whitelists the user-supplied sortBy to a safe SQL expression so
|
||||
// the value is never interpolated as raw column input.
|
||||
var sortColumns = map[string]string{
|
||||
"id": "d.id",
|
||||
"ip": "d.ip",
|
||||
"mac": "d.mac",
|
||||
"ddns": "d.ddns",
|
||||
"description": "d.description",
|
||||
"connectedTime": "COALESCE(d.last_seen_at, 0)",
|
||||
"deviceGroupName": "COALESCE(dg.name, '')",
|
||||
}
|
||||
|
||||
func (r *DeviceRepo) ListPaged(ctx context.Context, q device.ListQuery) ([]device.ListItem, int64, error) {
|
||||
base := r.db.WithContext(ctx).
|
||||
Table("devices AS d").
|
||||
Joins("LEFT JOIN device_groups AS dg ON dg.id = d.device_group_id")
|
||||
|
||||
if q.RestrictGroups != nil {
|
||||
base = base.Where("d.device_group_id IN ?", q.RestrictGroups)
|
||||
}
|
||||
if q.Unassigned {
|
||||
base = base.Where("d.device_group_id IS NULL")
|
||||
}
|
||||
if q.Status != "" {
|
||||
base = base.Where("d.status = ?", q.Status)
|
||||
}
|
||||
if q.Search != "" {
|
||||
like := "%" + q.Search + "%"
|
||||
base = base.Where(
|
||||
"lower(d.ddns) LIKE ? OR lower(d.mac) LIKE ? OR lower(d.ip) LIKE ? OR lower(d.description) LIKE ?",
|
||||
like, like, like, like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// ORDER BY: online devices first (always), then the requested field, then id
|
||||
// as a stable tie-breaker (mirrors the previous in-memory SliceStable order).
|
||||
expr, ok := sortColumns[q.SortBy]
|
||||
if !ok {
|
||||
expr = "d.ddns"
|
||||
}
|
||||
dir := "ASC"
|
||||
if strings.EqualFold(q.Order, "desc") {
|
||||
dir = "DESC"
|
||||
}
|
||||
orderBy := "(d.status = 'online') DESC, " + expr + " " + dir + ", d.id ASC"
|
||||
|
||||
tx := base.Session(&gorm.Session{}).
|
||||
Select("d.*, COALESCE(dg.name, '') AS group_name").
|
||||
Order(orderBy)
|
||||
if q.PageSize > 0 {
|
||||
page := q.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
tx = tx.Limit(q.PageSize).Offset((page - 1) * q.PageSize)
|
||||
}
|
||||
|
||||
var rows2 []deviceListRow
|
||||
if err := tx.Scan(&rows2).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
out2 := make([]device.ListItem, 0, len(rows2))
|
||||
for _, row := range rows2 {
|
||||
out2 = append(out2, device.ListItem{
|
||||
Device: device.Device{
|
||||
ID: row.ID,
|
||||
Ddns: row.Ddns,
|
||||
Mac: row.Mac,
|
||||
Name: row.Name,
|
||||
Description: row.Description,
|
||||
IP: row.IP,
|
||||
Client: row.Client,
|
||||
DeviceGroupID: row.DeviceGroupID,
|
||||
Status: device.Status(row.Status),
|
||||
LastSeenAt: row.LastSeenAt,
|
||||
},
|
||||
GroupName: row.GroupName,
|
||||
})
|
||||
}
|
||||
return out2, total, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rttys/internal/domain/devicelog"
|
||||
)
|
||||
|
||||
type DeviceLogRepo struct{ db *gorm.DB }
|
||||
|
||||
func NewDeviceLogRepo(db *gorm.DB) *DeviceLogRepo { return &DeviceLogRepo{db: db} }
|
||||
|
||||
type deviceLogRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
DeviceID string `gorm:"column:device_id"`
|
||||
DeviceMac string `gorm:"column:device_mac"`
|
||||
EventType string `gorm:"column:event_type"`
|
||||
ActorUserID int64 `gorm:"column:actor_user_id"`
|
||||
ActorName string `gorm:"column:actor_name"`
|
||||
ClientIP string `gorm:"column:client_ip"`
|
||||
Detail string `gorm:"column:detail"`
|
||||
CreatedAt int64 `gorm:"column:created_at"`
|
||||
EndedAt int64 `gorm:"column:ended_at"`
|
||||
}
|
||||
|
||||
func (deviceLogRow) TableName() string { return "device_event_logs" }
|
||||
|
||||
func (r deviceLogRow) toDomain() devicelog.Log {
|
||||
return devicelog.Log{
|
||||
ID: r.ID,
|
||||
DeviceID: r.DeviceID,
|
||||
DeviceMac: r.DeviceMac,
|
||||
EventType: devicelog.EventType(r.EventType),
|
||||
ActorUserID: r.ActorUserID,
|
||||
ActorName: r.ActorName,
|
||||
ClientIP: r.ClientIP,
|
||||
Detail: r.Detail,
|
||||
CreatedAt: r.CreatedAt,
|
||||
EndedAt: r.EndedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DeviceLogRepo) Create(ctx context.Context, l *devicelog.Log) (int64, error) {
|
||||
row := deviceLogRow{
|
||||
DeviceID: l.DeviceID,
|
||||
DeviceMac: l.DeviceMac,
|
||||
EventType: string(l.EventType),
|
||||
ActorUserID: l.ActorUserID,
|
||||
ActorName: l.ActorName,
|
||||
ClientIP: l.ClientIP,
|
||||
Detail: l.Detail,
|
||||
CreatedAt: l.CreatedAt,
|
||||
EndedAt: l.EndedAt,
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
func (r *DeviceLogRepo) UpdateEndedAt(ctx context.Context, id int64, ts int64) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Exec(`UPDATE device_event_logs SET ended_at = ? WHERE id = ?`, ts, id).Error
|
||||
}
|
||||
|
||||
func (r *DeviceLogRepo) List(ctx context.Context, q devicelog.Query) ([]devicelog.Log, int64, error) {
|
||||
tx := r.db.WithContext(ctx).Model(&deviceLogRow{})
|
||||
|
||||
if q.Mac != "" {
|
||||
tx = tx.Where("device_mac LIKE ?", "%"+q.Mac+"%")
|
||||
}
|
||||
if len(q.EventTypes) > 0 {
|
||||
types := make([]string, 0, len(q.EventTypes))
|
||||
for _, t := range q.EventTypes {
|
||||
types = append(types, string(t))
|
||||
}
|
||||
tx = tx.Where("event_type IN ?", types)
|
||||
}
|
||||
if q.From > 0 {
|
||||
tx = tx.Where("created_at >= ?", q.From)
|
||||
}
|
||||
if q.To > 0 {
|
||||
tx = tx.Where("created_at <= ?", q.To)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var rows []deviceLogRow
|
||||
if err := tx.
|
||||
Order("created_at DESC").
|
||||
Offset((q.Page - 1) * q.PageSize).
|
||||
Limit(q.PageSize).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
out := make([]devicelog.Log, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row.toDomain())
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verifies the reconnect minimal-write path: MarkOnline flips status + refreshes
|
||||
// last_seen_at without rewriting identity columns, and UpdateClient only writes
|
||||
// when the value actually changed.
|
||||
func TestMarkOnlineAndClientGuard(t *testing.T) {
|
||||
dsn := filepath.Join(t.TempDir(), "mw.db")
|
||||
db, err := Open(context.Background(), Options{DSN: dsn, MaxOpenConns: 4, MaxIdleConns: 4})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
g := db.Gorm()
|
||||
g.Exec(`CREATE TABLE devices (id INTEGER PRIMARY KEY AUTOINCREMENT, ddns TEXT, mac TEXT, name TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '', ip TEXT DEFAULT '', client TEXT DEFAULT '', device_group_id INTEGER,
|
||||
status TEXT, last_seen_at INTEGER)`)
|
||||
g.Exec(`INSERT INTO devices(ddns,mac,description,ip,client,status,last_seen_at)
|
||||
VALUES ('dev1','aabbcc','MacInfo','10.0.0.9','rtty-go','offline',100)`)
|
||||
|
||||
repo := NewDeviceMetaRepo(g)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := repo.MarkOnline(ctx, "dev1"); err != nil {
|
||||
t.Fatalf("MarkOnline: %v", err)
|
||||
}
|
||||
var status, mac, desc, ip string
|
||||
var lastSeen int64
|
||||
g.Raw(`SELECT status,mac,description,ip,last_seen_at FROM devices WHERE ddns='dev1'`).
|
||||
Row().Scan(&status, &mac, &desc, &ip, &lastSeen)
|
||||
if status != "online" {
|
||||
t.Errorf("status=%q want online", status)
|
||||
}
|
||||
if lastSeen <= 100 {
|
||||
t.Errorf("last_seen_at not refreshed: %d", lastSeen)
|
||||
}
|
||||
// identity columns untouched
|
||||
if mac != "aabbcc" || desc != "MacInfo" || ip != "10.0.0.9" {
|
||||
t.Errorf("identity columns changed: mac=%q desc=%q ip=%q", mac, desc, ip)
|
||||
}
|
||||
|
||||
// UpdateClient with the SAME value => no row written
|
||||
res := g.Exec(`UPDATE devices SET client=? WHERE ddns=? AND (client IS NULL OR client <> ?)`,
|
||||
"rtty-go", "dev1", "rtty-go")
|
||||
if res.RowsAffected != 0 {
|
||||
t.Errorf("unchanged client should not write, RowsAffected=%d", res.RowsAffected)
|
||||
}
|
||||
if err := repo.UpdateClient(ctx, "dev1", "rtty-go"); err != nil {
|
||||
t.Fatalf("UpdateClient same: %v", err)
|
||||
}
|
||||
// changed value => writes
|
||||
res = g.Exec(`UPDATE devices SET client=? WHERE ddns=? AND (client IS NULL OR client <> ?)`,
|
||||
"rtty-go-2.0", "dev1", "rtty-go-2.0")
|
||||
if res.RowsAffected != 1 {
|
||||
t.Errorf("changed client should write once, RowsAffected=%d", res.RowsAffected)
|
||||
}
|
||||
}
|
||||
@@ -38,13 +38,28 @@ func (r *DeviceMetaRepo) SaveOrUpdate(ctx context.Context, deviceID, mac, descri
|
||||
).Error
|
||||
}
|
||||
|
||||
func (r *DeviceMetaRepo) UpdateClient(ctx context.Context, deviceID, client string) error {
|
||||
// MarkOnline flips a known device back online and refreshes last_seen without
|
||||
// rewriting its (unchanged) identity columns. Used on reconnect to avoid the
|
||||
// write amplification of a full upsert during reconnect storms.
|
||||
func (r *DeviceMetaRepo) MarkOnline(ctx context.Context, deviceID string) error {
|
||||
if r.db == nil {
|
||||
return fmt.Errorf("gorm db is nil")
|
||||
}
|
||||
return r.db.WithContext(ctx).Exec(
|
||||
`UPDATE devices SET client=? WHERE ddns=?`,
|
||||
client, deviceID,
|
||||
`UPDATE devices SET status='online', last_seen_at=unixepoch() WHERE ddns=?`,
|
||||
deviceID,
|
||||
).Error
|
||||
}
|
||||
|
||||
func (r *DeviceMetaRepo) UpdateClient(ctx context.Context, deviceID, client string) error {
|
||||
if r.db == nil {
|
||||
return fmt.Errorf("gorm db is nil")
|
||||
}
|
||||
// Only write when the value actually changed, so reconnects of a device
|
||||
// whose client is unchanged don't generate a redundant write.
|
||||
return r.db.WithContext(ctx).Exec(
|
||||
`UPDATE devices SET client=? WHERE ddns=? AND (client IS NULL OR client <> ?)`,
|
||||
client, deviceID, client,
|
||||
).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rttys/internal/domain/notification"
|
||||
)
|
||||
|
||||
// ─── Repo ───────────────────────────────────────────────────────
|
||||
|
||||
type NotificationRepo struct{ db *gorm.DB }
|
||||
|
||||
func NewNotificationRepo(db *gorm.DB) *NotificationRepo {
|
||||
return &NotificationRepo{db: db}
|
||||
}
|
||||
|
||||
// ─── SMTP Config ────────────────────────────────────────────────
|
||||
|
||||
type smtpConfigRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
Host string `gorm:"column:host"`
|
||||
Port int `gorm:"column:port"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
FromEmail string `gorm:"column:from_email"`
|
||||
Encryption string `gorm:"column:encryption"`
|
||||
Enabled int `gorm:"column:enabled"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (smtpConfigRow) TableName() string { return "notification_smtp_config" }
|
||||
|
||||
func (r *NotificationRepo) GetSMTPConfig(ctx context.Context) (*notification.SMTPConfig, error) {
|
||||
var row smtpConfigRow
|
||||
err := r.db.WithContext(ctx).First(&row).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return ¬ification.SMTPConfig{Port: 587, Encryption: "starttls"}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ¬ification.SMTPConfig{
|
||||
Host: row.Host,
|
||||
Port: row.Port,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
FromEmail: row.FromEmail,
|
||||
Encryption: row.Encryption,
|
||||
Enabled: row.Enabled == 1,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *NotificationRepo) SaveSMTPConfig(ctx context.Context, cfg *notification.SMTPConfig) error {
|
||||
enabled := 0
|
||||
if cfg.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
row := smtpConfigRow{
|
||||
ID: 1,
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
FromEmail: cfg.FromEmail,
|
||||
Encryption: cfg.Encryption,
|
||||
Enabled: enabled,
|
||||
UpdatedAt: cfg.UpdatedAt,
|
||||
}
|
||||
return r.db.WithContext(ctx).Save(&row).Error
|
||||
}
|
||||
|
||||
// ─── Notify Rules ───────────────────────────────────────────────
|
||||
|
||||
type notifyRulesRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
DeviceOnline int `gorm:"column:device_online"`
|
||||
DeviceOffline int `gorm:"column:device_offline"`
|
||||
RemoteAccess int `gorm:"column:remote_access"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (notifyRulesRow) TableName() string { return "notification_rules" }
|
||||
|
||||
func (r *NotificationRepo) GetNotifyRules(ctx context.Context) (*notification.NotifyRules, error) {
|
||||
var row notifyRulesRow
|
||||
err := r.db.WithContext(ctx).First(&row).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return ¬ification.NotifyRules{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ¬ification.NotifyRules{
|
||||
DeviceOnline: row.DeviceOnline == 1,
|
||||
DeviceOffline: row.DeviceOffline == 1,
|
||||
RemoteAccess: row.RemoteAccess == 1,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *NotificationRepo) SaveNotifyRules(ctx context.Context, rules *notification.NotifyRules) error {
|
||||
boolToInt := func(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
row := notifyRulesRow{
|
||||
ID: 1,
|
||||
DeviceOnline: boolToInt(rules.DeviceOnline),
|
||||
DeviceOffline: boolToInt(rules.DeviceOffline),
|
||||
RemoteAccess: boolToInt(rules.RemoteAccess),
|
||||
UpdatedAt: rules.UpdatedAt,
|
||||
}
|
||||
return r.db.WithContext(ctx).Save(&row).Error
|
||||
}
|
||||
|
||||
// ─── Recipients ─────────────────────────────────────────────────
|
||||
|
||||
type recipientRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
Email string `gorm:"column:email"`
|
||||
CreatedAt int64 `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func (recipientRow) TableName() string { return "notification_recipients" }
|
||||
|
||||
func (r *NotificationRepo) ListRecipients(ctx context.Context) ([]notification.Recipient, error) {
|
||||
var rows []recipientRow
|
||||
if err := r.db.WithContext(ctx).Order("created_at ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]notification.Recipient, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, notification.Recipient{
|
||||
ID: row.ID,
|
||||
Email: row.Email,
|
||||
CreatedAt: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *NotificationRepo) AddRecipient(ctx context.Context, email string) (*notification.Recipient, error) {
|
||||
row := recipientRow{
|
||||
Email: email,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ¬ification.Recipient{
|
||||
ID: row.ID,
|
||||
Email: row.Email,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *NotificationRepo) RemoveRecipient(ctx context.Context, id int64) error {
|
||||
return r.db.WithContext(ctx).Delete(&recipientRow{}, id).Error
|
||||
}
|
||||
@@ -68,8 +68,10 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
FOREIGN KEY (device_group_id) REFERENCES device_groups(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_group_id ON devices(device_group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_status ON devices(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen_at);
|
||||
-- No index on status/last_seen_at: both change on every connect/disconnect (high
|
||||
-- write churn during reconnect storms) but neither is used by any read query —
|
||||
-- the device-list sort uses expressions ((status='online'), COALESCE(last_seen_at,0))
|
||||
-- that can't use a plain column index, and nothing filters by these columns.
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
|
||||
AFTER UPDATE ON users
|
||||
|
||||
@@ -34,6 +34,25 @@ type Options struct {
|
||||
LogSQL bool
|
||||
}
|
||||
|
||||
// withPragmas appends connection pragmas to the DSN so every pooled connection
|
||||
// opens in WAL mode (readers never block on the single writer), waits instead
|
||||
// of erroring on contention (busy_timeout), and uses the faster-but-safe
|
||||
// synchronous=NORMAL. Without this, a device reconnect storm serializes all DB
|
||||
// access on one connection and blocks user-facing API reads for seconds.
|
||||
func withPragmas(dsn string) string {
|
||||
const pragmas = "_pragma=busy_timeout(5000)" +
|
||||
"&_pragma=journal_mode(WAL)" +
|
||||
"&_pragma=synchronous(NORMAL)" +
|
||||
"&_pragma=foreign_keys(ON)"
|
||||
if strings.HasPrefix(dsn, "file:") {
|
||||
if strings.Contains(dsn, "?") {
|
||||
return dsn + "&" + pragmas
|
||||
}
|
||||
return dsn + "?" + pragmas
|
||||
}
|
||||
return "file:" + dsn + "?" + pragmas
|
||||
}
|
||||
|
||||
// Open opens sqlite via GORM (glebarez/sqlite) and exposes both *gorm.DB and *sql.DB.
|
||||
func Open(ctx context.Context, opt Options) (*AppDB, error) {
|
||||
if opt.DSN == "" {
|
||||
@@ -51,7 +70,7 @@ func Open(ctx context.Context, opt Options) (*AppDB, error) {
|
||||
gormCfg.Logger = logger.Default.LogMode(logger.Info)
|
||||
}
|
||||
|
||||
gdb, err := gorm.Open(gormsqlite.Open(opt.DSN), gormCfg)
|
||||
gdb, err := gorm.Open(gormsqlite.Open(withPragmas(opt.DSN)), gormCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,7 +100,70 @@ func InitSchema(ctx context.Context, db *sql.DB, schemaPath string) error {
|
||||
if err := ensureDeviceClientColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureUserIsSystemColumn(ctx, db)
|
||||
if err := ensureUserIsSystemColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureAuthProviderColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureExternalSubColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureExternalIdentityIndex(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureUserLastLoginAtColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureUserTotpSecretColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureUserTotpEnabledColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureTrustedDevicesTable(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDeviceEventLogsTable(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureNotificationTables(ctx, db)
|
||||
}
|
||||
|
||||
func ensureNotificationTables(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS notification_smtp_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
port INTEGER NOT NULL DEFAULT 587,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
from_email TEXT NOT NULL DEFAULT '',
|
||||
encryption TEXT NOT NULL DEFAULT 'starttls',
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS notification_rules (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
device_online INTEGER NOT NULL DEFAULT 0,
|
||||
device_offline INTEGER NOT NULL DEFAULT 0,
|
||||
remote_access INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS notification_recipients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureDeviceClientColumn(ctx context.Context, db *sql.DB) error {
|
||||
@@ -111,3 +193,140 @@ func ensureUserIsSystemColumn(ctx context.Context, db *sql.DB) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureAuthProviderColumn(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'`)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureExternalSubColumn(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN external_sub TEXT NOT NULL DEFAULT ''`)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureExternalIdentityIndex(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := db.ExecContext(ctx,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_identity
|
||||
ON users(auth_provider, external_sub)
|
||||
WHERE external_sub != ''`)
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureUserLastLoginAtColumn(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN last_login_at INTEGER`)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureUserTotpSecretColumn(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''`)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureUserTotpEnabledColumn(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0`)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureTrustedDevicesTable(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS user_trusted_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
last_used_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
expires_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_trusted_devices_user_id ON user_trusted_devices(user_id)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_trusted_devices_token ON user_trusted_devices(token)`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureDeviceEventLogsTable(ctx context.Context, db *sql.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS device_event_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
device_mac TEXT NOT NULL DEFAULT '',
|
||||
event_type TEXT NOT NULL,
|
||||
actor_user_id INTEGER NOT NULL DEFAULT 0,
|
||||
actor_name TEXT NOT NULL DEFAULT '',
|
||||
client_ip TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
ended_at INTEGER NOT NULL DEFAULT 0
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_device_event_logs_mac ON device_event_logs(device_mac)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_device_event_logs_type ON device_event_logs(event_type)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_device_event_logs_created_at ON device_event_logs(created_at)`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rttys/internal/domain/trusteddevice"
|
||||
)
|
||||
|
||||
type TrustedDeviceRepo struct{ db *gorm.DB }
|
||||
|
||||
func NewTrustedDeviceRepo(db *gorm.DB) *TrustedDeviceRepo { return &TrustedDeviceRepo{db: db} }
|
||||
|
||||
type trustedDeviceRow struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
UserID int64 `gorm:"column:user_id"`
|
||||
Token string `gorm:"column:token"`
|
||||
DeviceName string `gorm:"column:device_name"`
|
||||
IP string `gorm:"column:ip"`
|
||||
CreatedAt int64 `gorm:"column:created_at"`
|
||||
LastUsedAt int64 `gorm:"column:last_used_at"`
|
||||
ExpiresAt int64 `gorm:"column:expires_at"`
|
||||
}
|
||||
|
||||
func (trustedDeviceRow) TableName() string { return "user_trusted_devices" }
|
||||
|
||||
func (r trustedDeviceRow) toDomain() *trusteddevice.Device {
|
||||
return &trusteddevice.Device{
|
||||
ID: r.ID,
|
||||
UserID: r.UserID,
|
||||
Token: r.Token,
|
||||
DeviceName: r.DeviceName,
|
||||
IP: r.IP,
|
||||
CreatedAt: r.CreatedAt,
|
||||
LastUsedAt: r.LastUsedAt,
|
||||
ExpiresAt: r.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TrustedDeviceRepo) Create(ctx context.Context, d *trusteddevice.Device) (int64, error) {
|
||||
row := trustedDeviceRow{
|
||||
UserID: d.UserID,
|
||||
Token: d.Token,
|
||||
DeviceName: d.DeviceName,
|
||||
IP: d.IP,
|
||||
CreatedAt: d.CreatedAt,
|
||||
LastUsedAt: d.LastUsedAt,
|
||||
ExpiresAt: d.ExpiresAt,
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return row.ID, nil
|
||||
}
|
||||
|
||||
func (r *TrustedDeviceRepo) FindByToken(ctx context.Context, token string) (*trusteddevice.Device, error) {
|
||||
var row trustedDeviceRow
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("token = ?", token).
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row.toDomain(), nil
|
||||
}
|
||||
|
||||
func (r *TrustedDeviceRepo) ListByUserID(ctx context.Context, userID int64) ([]trusteddevice.Device, error) {
|
||||
var rows []trustedDeviceRow
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Order("last_used_at DESC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]trusteddevice.Device, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, *row.toDomain())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *TrustedDeviceRepo) Delete(ctx context.Context, id, userID int64) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Exec("DELETE FROM user_trusted_devices WHERE id = ? AND user_id = ?", id, userID).Error
|
||||
}
|
||||
|
||||
func (r *TrustedDeviceRepo) DeleteByUserID(ctx context.Context, userID int64) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Exec("DELETE FROM user_trusted_devices WHERE user_id = ?", userID).Error
|
||||
}
|
||||
|
||||
func (r *TrustedDeviceRepo) TouchLastUsed(ctx context.Context, id int64, ts int64) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Exec("UPDATE user_trusted_devices SET last_used_at = ? WHERE id = ?", ts, id).Error
|
||||
}
|
||||
|
||||
func (r *TrustedDeviceRepo) DeleteExpired(ctx context.Context, before int64) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Exec("DELETE FROM user_trusted_devices WHERE expires_at < ?", before).Error
|
||||
}
|
||||
@@ -25,6 +25,31 @@ type userRow struct {
|
||||
Role string `gorm:"column:role"`
|
||||
Status string `gorm:"column:status"`
|
||||
IsSystem bool `gorm:"column:is_system"`
|
||||
AuthProvider string `gorm:"column:auth_provider"`
|
||||
ExternalSub string `gorm:"column:external_sub"`
|
||||
LastLoginAt *int64 `gorm:"column:last_login_at"`
|
||||
TotpSecret string `gorm:"column:totp_secret"`
|
||||
TotpEnabled bool `gorm:"column:totp_enabled"`
|
||||
CreatedAt int64 `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func (r userRow) toDomain() *user.User {
|
||||
return &user.User{
|
||||
ID: r.ID,
|
||||
Username: r.Username,
|
||||
Email: r.Email,
|
||||
Description: r.Description,
|
||||
PasswordHash: r.PasswordHash,
|
||||
Role: identity.Role(r.Role),
|
||||
Status: user.Status(r.Status),
|
||||
IsSystem: r.IsSystem,
|
||||
AuthProvider: r.AuthProvider,
|
||||
ExternalSub: r.ExternalSub,
|
||||
LastLoginAt: r.LastLoginAt,
|
||||
TotpSecret: r.TotpSecret,
|
||||
TotpEnabled: r.TotpEnabled,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (userRow) TableName() string { return "users" }
|
||||
@@ -41,18 +66,7 @@ func (r *UserRepo) FindByID(ctx context.Context, id int64) (*user.User, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u := &user.User{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Email: row.Email,
|
||||
Description: row.Description,
|
||||
PasswordHash: row.PasswordHash,
|
||||
Role: identity.Role(row.Role),
|
||||
Status: user.Status(row.Status),
|
||||
IsSystem: row.IsSystem,
|
||||
}
|
||||
return u, nil
|
||||
return row.toDomain(), nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) FindByUsername(ctx context.Context, username string) (*user.User, error) {
|
||||
@@ -67,17 +81,22 @@ func (r *UserRepo) FindByUsername(ctx context.Context, username string) (*user.U
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row.toDomain(), nil
|
||||
}
|
||||
|
||||
return &user.User{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Email: row.Email,
|
||||
Description: row.Description,
|
||||
PasswordHash: row.PasswordHash,
|
||||
Role: identity.Role(row.Role),
|
||||
Status: user.Status(row.Status),
|
||||
IsSystem: row.IsSystem,
|
||||
}, nil
|
||||
func (r *UserRepo) FindByExternalID(ctx context.Context, provider, externalSub string) (*user.User, error) {
|
||||
var row userRow
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("auth_provider = ? AND external_sub = ?", provider, externalSub).
|
||||
Take(&row).Error
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row.toDomain(), nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) FindSystemAdmin(ctx context.Context) (*user.User, error) {
|
||||
@@ -92,17 +111,7 @@ func (r *UserRepo) FindSystemAdmin(ctx context.Context) (*user.User, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user.User{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Email: row.Email,
|
||||
Description: row.Description,
|
||||
PasswordHash: row.PasswordHash,
|
||||
Role: identity.Role(row.Role),
|
||||
Status: user.Status(row.Status),
|
||||
IsSystem: row.IsSystem,
|
||||
}, nil
|
||||
return row.toDomain(), nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) List(ctx context.Context) ([]user.User, error) {
|
||||
@@ -113,16 +122,7 @@ func (r *UserRepo) List(ctx context.Context) ([]user.User, error) {
|
||||
|
||||
out := make([]user.User, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, user.User{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Email: row.Email,
|
||||
Description: row.Description,
|
||||
PasswordHash: row.PasswordHash,
|
||||
Role: identity.Role(row.Role),
|
||||
Status: user.Status(row.Status),
|
||||
IsSystem: row.IsSystem,
|
||||
})
|
||||
out = append(out, *row.toDomain())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -136,6 +136,8 @@ func (r *UserRepo) Create(ctx context.Context, u *user.User) (int64, error) {
|
||||
Role: string(u.Role),
|
||||
Status: string(u.Status),
|
||||
IsSystem: u.IsSystem,
|
||||
AuthProvider: u.AuthProvider,
|
||||
ExternalSub: u.ExternalSub,
|
||||
}
|
||||
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
@@ -157,6 +159,8 @@ func (r *UserRepo) Update(ctx context.Context, u *user.User) error {
|
||||
"role": string(u.Role),
|
||||
"status": string(u.Status),
|
||||
"is_system": u.IsSystem,
|
||||
"auth_provider": u.AuthProvider,
|
||||
"external_sub": u.ExternalSub,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -165,6 +169,25 @@ func (r *UserRepo) Delete(ctx context.Context, id int64) error {
|
||||
Exec("DELETE FROM users WHERE id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *UserRepo) UpdateLastLoginAt(ctx context.Context, id int64, ts int64) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Exec("UPDATE users SET last_login_at = ? WHERE id = ?", ts, id).Error
|
||||
}
|
||||
|
||||
func (r *UserRepo) UpdateDescription(ctx context.Context, id int64, description string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Exec("UPDATE users SET description = ? WHERE id = ?", description, id).Error
|
||||
}
|
||||
|
||||
func (r *UserRepo) UpdateTotp(ctx context.Context, id int64, secret string, enabled bool) error {
|
||||
enabledInt := 0
|
||||
if enabled {
|
||||
enabledInt = 1
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Exec("UPDATE users SET totp_secret = ?, totp_enabled = ? WHERE id = ?", secret, enabledInt, id).Error
|
||||
}
|
||||
|
||||
func IsUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
+4
-1
@@ -18,6 +18,7 @@
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vue/eslint-config-typescript": "12.0.0",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/addon-web-links": "0.11.0",
|
||||
@@ -25,12 +26,14 @@
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"gl-web-main": "^1.0.0",
|
||||
"gl-web-main": "1.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsencrypt": "^3.3.2",
|
||||
"pinia": "^3.0.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.89.0",
|
||||
"simple-keyboard": "3.8.69",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vite-plugin-remove-console": "^2.2.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-clipboard3": "2.0.0",
|
||||
|
||||
@@ -23,12 +23,18 @@
|
||||
import useLanguage from './hooks/useLanguage'
|
||||
import enUS from 'ant-design-vue/es/locale/en_US'
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||
import jaJP from 'ant-design-vue/es/locale/ja_JP'
|
||||
import koKR from 'ant-design-vue/es/locale/ko_KR'
|
||||
import deDE from 'ant-design-vue/es/locale/de_DE'
|
||||
import frFR from 'ant-design-vue/es/locale/fr_FR'
|
||||
import esES from 'ant-design-vue/es/locale/es_ES'
|
||||
import { computed } from 'vue'
|
||||
import { useAppStore } from './stores/modules/app'
|
||||
import { RouterView } from 'vue-router'
|
||||
import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context'
|
||||
import { ConfigProvider as GlConfigProvider } from 'gl-web-main/components'
|
||||
import { Languages } from 'gl-web-main'
|
||||
import { AppLanguages } from './models/setting'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -39,6 +45,11 @@ const { currentLang } = useLanguage()
|
||||
const localeMap = new Map([
|
||||
[Languages.ZH, zhCN],
|
||||
[Languages.EN, enUS],
|
||||
[AppLanguages.JA, jaJP],
|
||||
[AppLanguages.KO, koKR],
|
||||
[AppLanguages.DE, deDE],
|
||||
[AppLanguages.FR, frFR],
|
||||
[AppLanguages.ES, esES],
|
||||
])
|
||||
|
||||
// ant d的语言包
|
||||
|
||||
+13
-4
@@ -2,16 +2,25 @@
|
||||
* @Author: shufei.han
|
||||
* @Date: 2025-06-11 11:48:02
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-04 09:32:31
|
||||
* @LastEditTime: 2026-03-09 12:08:45
|
||||
* @FilePath: \glkvm-cloud\ui\src\api\device.ts
|
||||
* @Description: 设备相关API
|
||||
*/
|
||||
import { ExecuteCommandParams, type DeviceInfo } from '@/models/device'
|
||||
import request, { httpService } from './request'
|
||||
|
||||
/** 获取设备列表 */
|
||||
export const getDeviceListApi = (params?: { groupId: number }) => {
|
||||
return request<{ items: DeviceInfo[]}>({
|
||||
/** 获取设备列表(服务端分页:page/pageSize;q 为搜索词;unassigned 仅未分配) */
|
||||
export const getDeviceListApi = (params?: {
|
||||
groupId?: number
|
||||
sortBy?: string
|
||||
order?: 'asc' | 'desc'
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
unassigned?: boolean
|
||||
status?: 'online' | 'offline'
|
||||
}) => {
|
||||
return request<{ items: DeviceInfo[], page: number, pageSize: number, total: number }>({
|
||||
url: '/api/devices',
|
||||
params,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Device Event Logs API
|
||||
*/
|
||||
|
||||
import request from './request'
|
||||
|
||||
export interface DeviceEventLog {
|
||||
id: number
|
||||
deviceMac: string
|
||||
eventType: string
|
||||
actorName: string
|
||||
clientIp: string
|
||||
detail: string
|
||||
createdAt: number
|
||||
endedAt: number
|
||||
}
|
||||
|
||||
export interface ListDeviceEventLogsResp {
|
||||
items: DeviceEventLog[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface DeviceEventLogQuery {
|
||||
mac?: string
|
||||
types?: string[]
|
||||
from?: number
|
||||
to?: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 设备事件日志列表查询 */
|
||||
export function reqListDeviceEventLogs (q: DeviceEventLogQuery) {
|
||||
return request<ListDeviceEventLogsResp>({
|
||||
url: '/api/device-event-logs',
|
||||
params: {
|
||||
mac: q.mac || undefined,
|
||||
types: q.types && q.types.length > 0 ? q.types.join(',') : undefined,
|
||||
from: q.from || undefined,
|
||||
to: q.to || undefined,
|
||||
page: q.page,
|
||||
pageSize: q.pageSize,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Notification Settings API
|
||||
*/
|
||||
|
||||
import request from './request'
|
||||
|
||||
// ─── SMTP Config ────────────────────────────────────────────────
|
||||
|
||||
export interface SMTPConfig {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
fromEmail: string
|
||||
encryption: string
|
||||
enabled: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export function reqGetSMTPConfig () {
|
||||
return request<SMTPConfig>({ url: '/api/notification/smtp' })
|
||||
}
|
||||
|
||||
export function reqSaveSMTPConfig (data: Omit<SMTPConfig, 'updatedAt'>) {
|
||||
return request<SMTPConfig>({ url: '/api/notification/smtp', method: 'PUT', data })
|
||||
}
|
||||
|
||||
export function reqTestSMTP (email: string) {
|
||||
return request<{ message: string }>({ url: '/api/notification/smtp/test', method: 'POST', data: { email } })
|
||||
}
|
||||
|
||||
// ─── Notify Rules ───────────────────────────────────────────────
|
||||
|
||||
export interface NotifyRules {
|
||||
deviceOnline: boolean
|
||||
deviceOffline: boolean
|
||||
remoteAccess: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export function reqGetNotifyRules () {
|
||||
return request<NotifyRules>({ url: '/api/notification/rules' })
|
||||
}
|
||||
|
||||
export function reqSaveNotifyRules (data: Omit<NotifyRules, 'updatedAt'>) {
|
||||
return request<NotifyRules>({ url: '/api/notification/rules', method: 'PUT', data })
|
||||
}
|
||||
|
||||
// ─── Recipients ─────────────────────────────────────────────────
|
||||
|
||||
export interface Recipient {
|
||||
id: number
|
||||
email: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface ListRecipientsResp {
|
||||
items: Recipient[]
|
||||
}
|
||||
|
||||
export function reqListRecipients () {
|
||||
return request<ListRecipientsResp>({ url: '/api/notification/recipients' })
|
||||
}
|
||||
|
||||
export function reqAddRecipient (email: string) {
|
||||
return request<Recipient>({ url: '/api/notification/recipients', method: 'POST', data: { email } })
|
||||
}
|
||||
|
||||
export function reqRemoveRecipient (id: number) {
|
||||
return request<{ message: string }>({ url: `/api/notification/recipients/${id}`, method: 'DELETE' })
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Personal Center 相关 API
|
||||
*/
|
||||
|
||||
import request from './request'
|
||||
import type { PersonalProfile, Setup2faResp, TrustedDeviceList } from '@/models/personal'
|
||||
|
||||
/** 获取个人信息 */
|
||||
export function reqGetProfile () {
|
||||
return request<PersonalProfile>({
|
||||
url: '/api/me/profile',
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新显示名(description) */
|
||||
export function reqUpdateProfile (data: { displayName: string }) {
|
||||
return request({
|
||||
url: '/api/me/profile',
|
||||
method: 'PUT',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 启动 2FA 注册:生成 secret 与 otpauth URL(不持久化) */
|
||||
export function reqSetup2fa () {
|
||||
return request<Setup2faResp>({
|
||||
url: '/api/me/2fa/setup',
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** 启用 2FA:提交 secret 与 6 位验证码完成绑定 */
|
||||
export function reqEnable2fa (data: { secret: string, code: string }) {
|
||||
return request({
|
||||
url: '/api/me/2fa/enable',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 关闭 2FA:需要提供当前 6 位验证码 */
|
||||
export function reqDisable2fa (data: { code: string }) {
|
||||
return request({
|
||||
url: '/api/me/2fa/disable',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
/** 信任设备列表 */
|
||||
export function reqListTrustedDevices () {
|
||||
return request<TrustedDeviceList>({
|
||||
url: '/api/me/2fa/trusted-devices',
|
||||
})
|
||||
}
|
||||
|
||||
/** 撤销单个信任设备 */
|
||||
export function reqRevokeTrustedDevice (id: number) {
|
||||
return request({
|
||||
url: `/api/me/2fa/trusted-devices/${id}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
+2
-2
@@ -8,11 +8,11 @@
|
||||
*/
|
||||
|
||||
import request from './request'
|
||||
import type { LoginParams, AuthConfig, UserInfo } from '@/models/user'
|
||||
import type { LoginParams, LoginResp, AuthConfig, UserInfo } from '@/models/user'
|
||||
|
||||
/** 登录 */
|
||||
export function reqLogin (data: LoginParams) {
|
||||
return request<{ token: string }>({
|
||||
return request<LoginResp>({
|
||||
url: '/api/login',
|
||||
method: 'POST',
|
||||
data,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
|
||||
<!--
|
||||
* @Description: Wraps gl-web-main's BaseModal with project-i18n defaults for
|
||||
* okText / cancelText. The upstream BaseModal falls back to its own internal
|
||||
* i18n table when these props are omitted, but that table only contains
|
||||
* zh/en — any other locale throws at render time and the entire footer slot
|
||||
* fails to mount, so Confirm/Cancel buttons disappear in de/es/fr/ja/ko.
|
||||
* Defaulting the props here routes labels through the project's vue-i18n,
|
||||
* which knows every supported locale.
|
||||
-->
|
||||
<template>
|
||||
<BaseModal
|
||||
:cancelText="cancelText"
|
||||
:okText="okText"
|
||||
>
|
||||
<template v-for="(_, name) in $slots" #[name]="slotData">
|
||||
<slot :name="name" v-bind="slotData || {}" />
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
okText?: string
|
||||
cancelText?: string
|
||||
}>(), {
|
||||
okText: 'common.confirm',
|
||||
cancelText: 'common.cancel',
|
||||
})
|
||||
</script>
|
||||
@@ -54,7 +54,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import useLanguage from '@/hooks/useLanguage'
|
||||
import { languageOptions, Languages } from 'gl-web-main'
|
||||
import { languageOptions } from '@/models/setting'
|
||||
import { Languages } from 'gl-web-main'
|
||||
import { BaseDropdownSelect } from 'gl-web-main/components'
|
||||
import { isForeignEnv } from '@/utils'
|
||||
import { useAppStore } from '@/stores/modules/app'
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import i18n from '@/lang'
|
||||
import { languageLabelMap } from '@/models/setting'
|
||||
import { languageLabelMap, AppLanguages } from '@/models/setting'
|
||||
import { computed } from 'vue'
|
||||
import { LocalStorageKeys, useLocalStorage } from './useLocalStorage'
|
||||
import { Languages } from 'gl-web-main'
|
||||
@@ -16,10 +16,13 @@ import { Languages } from 'gl-web-main'
|
||||
export default function useLanguage () {
|
||||
// @ts-ignore
|
||||
const browserLanguage = navigator.language || navigator.userLanguage || navigator.browserLanguage || ''
|
||||
// 判断是否为中文
|
||||
const isZhBrowser = browserLanguage.startsWith('zh') || browserLanguage.startsWith('ZH')
|
||||
const langPrefix = browserLanguage.split('-')[0].toLowerCase()
|
||||
|
||||
const {getValue, setValue} = useLocalStorage(LocalStorageKeys.STORAGE_LANGUAGE_KEY, isZhBrowser ? Languages.ZH : Languages.EN)
|
||||
// 根据浏览器语言推断默认语言
|
||||
const allLangs = Object.values(AppLanguages) as string[]
|
||||
const defaultLang = allLangs.includes(langPrefix) ? langPrefix as Languages : Languages.EN
|
||||
|
||||
const {getValue, setValue} = useLocalStorage(LocalStorageKeys.STORAGE_LANGUAGE_KEY, defaultLang)
|
||||
|
||||
const currentLang = computed(() => i18n.global.locale.value as Languages)
|
||||
|
||||
@@ -35,4 +38,5 @@ export default function useLanguage () {
|
||||
return {currentLang, setLanguage, isZh, t: i18n.global.t, currentLangLabel}
|
||||
}
|
||||
|
||||
export const t = i18n.global.t
|
||||
export const t = ((...args: any[]) =>
|
||||
(i18n.global.t as any)(...args)) as typeof i18n.global.t
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 10:18:18
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-01-05 14:22:12
|
||||
* @LastEditTime: 2026-03-25 11:06:30
|
||||
* @FilePath: \glkvm-cloud\ui\src\hooks\useLocalStorage.ts
|
||||
* @Description: 存储hook
|
||||
*/
|
||||
@@ -10,6 +10,8 @@ import { ref } from 'vue'
|
||||
|
||||
/** 整个系统 */
|
||||
export enum LocalStorageKeys {
|
||||
/** 当前系统版本 */
|
||||
APP_VERSION_KEY = 'app_version',
|
||||
/** 存储语言的key */
|
||||
STORAGE_LANGUAGE_KEY = 'language',
|
||||
/** 主题色 */
|
||||
@@ -20,6 +22,10 @@ export enum LocalStorageKeys {
|
||||
SIDEBAR_MANUAL_CONTROL_KEY = 'sidebar-manual-control',
|
||||
/** 版本号 */
|
||||
VERSION = 'version',
|
||||
/** 设备列表列表顺序 */
|
||||
DEVICE_LIST_COLUMNS_KEY = 'device-list-columns',
|
||||
/** 设备列表排序 */
|
||||
DEVICE_LIST_SORT_KEY = 'device-list-sort',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +33,7 @@ export enum LocalStorageKeys {
|
||||
* @param key 需要使用哪种数据
|
||||
* @param {T} initValue 如果没有存储,则返回的初始值
|
||||
*/
|
||||
export function useLocalStorage <T extends {toString: () => string}> (
|
||||
export function useLocalStorage <T> (
|
||||
key: LocalStorageKeys,
|
||||
initValue: T = null,
|
||||
transform: (value: string) => T = (value) => value as unknown as T,
|
||||
@@ -36,9 +42,15 @@ export function useLocalStorage <T extends {toString: () => string}> (
|
||||
const storageValue = ref<T>(initValue)
|
||||
/** 获取本地存储的值 */
|
||||
const getValue = () => {
|
||||
const storageData = localStorage.getItem(key)
|
||||
// 特殊兼容以前的版本直接存储字符串的情况
|
||||
let storageData: T = null
|
||||
try {
|
||||
storageData = JSON.parse(localStorage.getItem(key))
|
||||
} catch {
|
||||
storageData = localStorage.getItem(key) as unknown as T
|
||||
}
|
||||
if (storageData !== null) {
|
||||
return transform(storageData)
|
||||
return transform(storageData as unknown as string)
|
||||
}
|
||||
else {
|
||||
return initValue
|
||||
@@ -46,7 +58,7 @@ export function useLocalStorage <T extends {toString: () => string}> (
|
||||
}
|
||||
/** 设置本地存储的值 */
|
||||
const setValue = (value: T) => {
|
||||
localStorage.setItem(key, value?.toString())
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
/** 清除本地存储的值 */
|
||||
const removeValue = () => {
|
||||
|
||||
@@ -9,8 +9,14 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import zh from './locales/zh.json'
|
||||
import en from './locales/en.json'
|
||||
import ja from './locales/ja.json'
|
||||
import ko from './locales/ko.json'
|
||||
import de from './locales/de.json'
|
||||
import fr from './locales/fr.json'
|
||||
import es from './locales/es.json'
|
||||
import useLanguage from '@/hooks/useLanguage'
|
||||
import { Languages } from 'gl-web-main'
|
||||
import { AppLanguages } from '@/models/setting'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -26,6 +32,11 @@ const i18n = createI18n({
|
||||
const initializeAllLanguage = () => {
|
||||
i18n.global.setLocaleMessage(Languages.ZH, zh)
|
||||
i18n.global.setLocaleMessage(Languages.EN, en)
|
||||
i18n.global.setLocaleMessage(AppLanguages.JA as string, ja)
|
||||
i18n.global.setLocaleMessage(AppLanguages.KO as string, ko)
|
||||
i18n.global.setLocaleMessage(AppLanguages.DE as string, de)
|
||||
i18n.global.setLocaleMessage(AppLanguages.FR as string, fr)
|
||||
i18n.global.setLocaleMessage(AppLanguages.ES as string, es)
|
||||
useLanguage().setLanguage()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "Aktualisieren",
|
||||
"action": "Aktion",
|
||||
"d": "T",
|
||||
"h": "Std",
|
||||
"m": "Min",
|
||||
"s": "Sek",
|
||||
"copySuccess": "Erfolgreich kopiert",
|
||||
"copyFailed": "Kopieren fehlgeschlagen",
|
||||
"detail": "Details",
|
||||
"success": "Erfolgreich",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"ok": "OK",
|
||||
"close": "Schließen",
|
||||
"about": "Über",
|
||||
"maxLength": "Maximale Länge beträgt {length} Zeichen",
|
||||
"more": "Mehr",
|
||||
"pleaseSelect": "Bitte auswählen",
|
||||
"delete": "Löschen",
|
||||
"remove": "Entfernen",
|
||||
"apply": "Anwenden",
|
||||
"edit": "Bearbeiten"
|
||||
},
|
||||
"login": {
|
||||
"authorizationRequired": "Autorisierung erforderlich",
|
||||
"username": "Benutzername",
|
||||
"enterUsernameTip": "Bitte geben Sie Ihren Benutzernamen ein",
|
||||
"password": "Passwort",
|
||||
"signIn": "Anmelden",
|
||||
"enterPwdTip": "Bitte geben Sie Ihr Passwort ein",
|
||||
"incorrectPwd": "Falsches Passwort",
|
||||
"notAuthorized": "Nicht autorisiert",
|
||||
"signOut": "Abmelden",
|
||||
"authOptions": "Authentifizierungsoptionen",
|
||||
"ldapAuth": "Geben Sie Benutzername und Passwort für die LDAP-Authentifizierung ein",
|
||||
"webManagementAuth": "Lassen Sie den Benutzernamen leer und verwenden Sie das Web-Management-Passwort",
|
||||
"or": "ODER",
|
||||
"loginWithOidc": "Mit OIDC anmelden",
|
||||
"confirmPasswordValidateError": "Die eingegebenen Passwörter stimmen nicht überein.",
|
||||
"accountLogin": "Kontoanmeldung",
|
||||
"ldap": "LDAP",
|
||||
"local": "Lokal",
|
||||
"oidc": "OIDC",
|
||||
"twoFactorTitle": "Zwei-Faktor-Authentifizierung",
|
||||
"totpHelp": "Öffnen Sie Ihre Authenticator-App und geben Sie den 6-stelligen Bestätigungscode ein.",
|
||||
"enterTotpCode": "6-stelligen Code eingeben",
|
||||
"rememberThisDevice": "Diesem Gerät 30 Tage vertrauen",
|
||||
"verify": "Überprüfen",
|
||||
"back": "Zurück"
|
||||
},
|
||||
"personalCenter": {
|
||||
"title": "Persönliches Zentrum",
|
||||
"personalInformation": "Persönliche Informationen",
|
||||
"username": "Benutzername",
|
||||
"displayName": "Anzeigename",
|
||||
"authProvider": "Authentifizierungsanbieter",
|
||||
"registrationTime": "Registrierungszeitpunkt",
|
||||
"latestLoginTime": "Letzte Anmeldezeit",
|
||||
"notFilled": "Nicht festgelegt",
|
||||
"edit": "Bearbeiten",
|
||||
"securitySettings": "Sicherheitseinstellungen",
|
||||
"twoFactorAuth": "Zwei-Faktor-Authentifizierung",
|
||||
"twoFactorAuthDesc": "Bei der Anmeldung einen Bestätigungscode aus Ihrer Authenticator-App anfordern.",
|
||||
"twoFactorOnlyLocal": "Wird von Ihrem Identitätsanbieter ({provider}) verwaltet und kann hier nicht konfiguriert werden.",
|
||||
"trustedDevices": "Vertrauenswürdige Geräte",
|
||||
"trustedDevicesDesc": "Browser in dieser Liste überspringen die 2FA bei der Anmeldung für 30 Tage.",
|
||||
"deviceName": "Gerät",
|
||||
"ipAddress": "IP-Adresse",
|
||||
"lastUsed": "Zuletzt verwendet",
|
||||
"expiresAt": "Läuft ab am",
|
||||
"revoke": "Widerrufen",
|
||||
"revokeConfirm": "Möchten Sie dieses vertrauenswürdige Gerät wirklich widerrufen? Bei der nächsten Anmeldung über diesen Browser wird ein Bestätigungscode erforderlich sein.",
|
||||
"enable2fa": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"disable2fa": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"disable2faTip": "Geben Sie den aktuellen 6-stelligen Bestätigungscode zur Bestätigung ein. Alle vertrauenswürdigen Geräte werden ebenfalls entfernt.",
|
||||
"scanQrCode": "Scannen Sie den QR-Code mit Ihrer Authenticator-App",
|
||||
"secretKey": "Oder geben Sie diesen geheimen Schlüssel manuell ein",
|
||||
"verifyCode": "Bestätigungscode",
|
||||
"enterVerifyCode": "6-stelligen Code eingeben"
|
||||
},
|
||||
"device": {
|
||||
"devices": "Geräte",
|
||||
"addDevice": "Gerät hinzufügen",
|
||||
"searchTip": "Bitte geben Sie Suchbegriffe ein",
|
||||
"executeCommand": "Befehl ausführen",
|
||||
"remoteSSH": "Remote-SSH",
|
||||
"remoteControl": "Fernsteuerung",
|
||||
"deviceID": "Geräte-ID",
|
||||
"connectedTime": "Verbindungszeit",
|
||||
"uptime": "Betriebszeit",
|
||||
"IPAddress": "IP-Adresse",
|
||||
"description": "Beschreibung",
|
||||
"selectDeviceTips": "Bitte wählen Sie die Geräte aus, die Sie bedienen möchten",
|
||||
"refreshSuccess": "Erfolgreich aktualisiert",
|
||||
"noDevice": "Kein Gerät",
|
||||
"noDeviceTip": "Sie haben noch keine Geräte hinzugefügt.",
|
||||
"addDeviceTip": "Bitte führen Sie das folgende Skript in der Gerätekonsole aus, um eine Verbindung zur Cloud herzustellen",
|
||||
"copyScript": "Skript kopieren",
|
||||
"username": "Benutzername",
|
||||
"inputUsername": "Benutzername eingeben",
|
||||
"requiredUsername": "Bitte geben Sie den Benutzernamen ein",
|
||||
"command": "Befehl",
|
||||
"inputCommand": "Befehl eingeben",
|
||||
"requiredCommand": "Bitte geben Sie den Befehl ein",
|
||||
"parameter": "Parameter",
|
||||
"inputParameter": "Parameter eingeben",
|
||||
"waitTime": "Wartezeit",
|
||||
"inputWaitTime": "Wartezeit eingeben",
|
||||
"commandResponse": "Befehlsantwort",
|
||||
"commandResponseLoadingTips": "Die Konfiguration wird gesendet. Bitte warten Sie einen Moment...",
|
||||
"code": "Code",
|
||||
"errorCode": "Fehlercode",
|
||||
"errorMessage": "Fehlermeldung",
|
||||
"commandResponseDetail": "Details zur Befehlsantwort",
|
||||
"standardOutput": "Standardausgabe",
|
||||
"standardErrorOutput": "Standardfehlerausgabe",
|
||||
"status": "Status",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"editDescription": "Beschreibung bearbeiten",
|
||||
"inputDescription": "Beschreibung eingeben",
|
||||
"requiredDescription": "Bitte geben Sie eine Beschreibung ein",
|
||||
"deleteDevice": "Gerät löschen",
|
||||
"deleteDeviceConfirmTips": "Möchten Sie dieses Gerät wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"mac": "MAC-Adresse",
|
||||
"moveToGroup": "In Gruppe verschieben",
|
||||
"moveToDeviceGroup": "In Gerätegruppe verschieben",
|
||||
"moveToDeviceGroupTips": "Ein Gerät kann jeweils nur einer Gruppe zugeordnet sein. Durch das Hinzufügen zur aktuellen Gruppe wird es automatisch aus der vorherigen Gruppe entfernt.",
|
||||
"notFoundDeviceGroup": "Nicht gefunden? Jetzt erstellen",
|
||||
"requiredDeviceGroup": "Bitte wählen Sie eine Gerätegruppe aus",
|
||||
"deviceGroup": "Gerätegruppe",
|
||||
"unassigned": "Nicht zugewiesen",
|
||||
"addDeviceGroup": "Gerätegruppe hinzufügen",
|
||||
"deviceGroupName": "Gerätegruppenname",
|
||||
"requiredDeviceGroupName": "Bitte eingeben, max. 32 Zeichen",
|
||||
"requiredDeviceGroupDescription": "Bitte eingeben, max. 200 Zeichen",
|
||||
"allAssociatedDeviceGroup": "Alle zugeordneten Gerätegruppen",
|
||||
"associatedDeviceCount": "Anzahl zugeordneter Geräte",
|
||||
"associatedUserGroups": "Zugeordnete Benutzergruppen",
|
||||
"allAssociatedUserGroups": "Alle zugeordneten Benutzergruppen",
|
||||
"manageDevices": "Geräte verwalten",
|
||||
"addDeviceToGroup": "Gerät zur Gruppe hinzufügen",
|
||||
"editBasicInfo": "Basisinformationen bearbeiten",
|
||||
"basicInfo": "Basisinformationen",
|
||||
"notAdded": "Nicht hinzugefügt",
|
||||
"showOnlyUnassigned": "Nur nicht zugewiesene anzeigen",
|
||||
"deleteDeviceGroup": "Gerätegruppe löschen",
|
||||
"deleteDeviceGroupConfirmTips": "Nach dem Löschen der aktuellen Gruppe werden alle Geräte in der Gruppe automatisch in den nicht zugewiesenen Status versetzt. Möchten Sie diese Gruppe wirklich löschen?",
|
||||
"manageDevicesTips": "Durch das Entfernen eines Geräts aus der aktuellen Gruppe wird es in den nicht zugewiesenen Status versetzt.",
|
||||
"removeDevice": "Gerät entfernen",
|
||||
"currentlySelectedDevice": "Aktuell ausgewählt: {num} Geräte",
|
||||
"removeDeviceConfirmTips1": "Nach dem Entfernen des Geräts aus der aktuellen Gruppe befindet es sich im nicht zugewiesenen Status.",
|
||||
"removeDeviceConfirmTips2": "Möchten Sie es wirklich entfernen?",
|
||||
"addDeviceToGroupConfirmTips1": "Nach dem Hinzufügen des Geräts zur aktuellen Gruppe wird, sofern es bereits einer anderen Gruppe zugeordnet ist, die Zuordnung zur ursprünglichen Gruppe automatisch aufgehoben.",
|
||||
"addDeviceToGroupConfirmTips2": "Möchten Sie es wirklich hinzufügen?",
|
||||
"accessYourDevice": "Auf die Weboberfläche Ihres Geräts zugreifen",
|
||||
"protocol": "Protokoll",
|
||||
"port": "Port",
|
||||
"path": "Pfad",
|
||||
"ipNotCorrect": "IP-Adresse ist fehlerhaft",
|
||||
"portNotCorrect": "Port ist fehlerhaft",
|
||||
"remoteWeb": "Remote-Web",
|
||||
"linuxTips": "Unterstützt OpenWrt, Raspberry Pi, Ubuntu, CentOS usw.",
|
||||
"customColumns": "Benutzerdefinierte Spalten",
|
||||
"dragColumnTips": "Sie können Ihre Geräteliste anpassen. Ziehen Sie die Schaltfläche auf der rechten Seite der folgenden Elemente, um die Anzeigereihenfolge anzupassen, oder verwenden Sie die Kontrollkästchen, um bestimmte Spalten ein- oder auszublenden."
|
||||
},
|
||||
"user": {
|
||||
"user": "Benutzer",
|
||||
"userManager": "Benutzerverwaltung",
|
||||
"userGroup": "Benutzergruppe",
|
||||
"userName": "Benutzername",
|
||||
"role": "Rolle",
|
||||
"addUser": "Benutzer hinzufügen",
|
||||
"admin": "Administrator",
|
||||
"userRole": "Benutzerrolle",
|
||||
"setPassword": "Passwort festlegen",
|
||||
"enterPassword": "Bitte geben Sie ein Passwort ein",
|
||||
"reEnterPassword": "Passwort erneut eingeben",
|
||||
"reEnterPasswordPlc": "Bitte geben Sie das Passwort erneut ein",
|
||||
"addUserGroup": "Benutzergruppe hinzufügen",
|
||||
"userGroupName": "Benutzergruppenname",
|
||||
"deleteUser": "Benutzer löschen",
|
||||
"deleteUserConfirmTips": "Möchten Sie den Benutzer {name} wirklich löschen? Nach dem Löschen werden alle Berechtigungen des Benutzers sofort ungültig und die zugehörigen Benutzergruppenmitgliedschaften werden automatisch entfernt.",
|
||||
"editUser": "Benutzer bearbeiten",
|
||||
"onlyOneAdminTips": "Der aktuelle Benutzer kann nicht von Administrator zu Benutzer geändert werden. Dies ist der letzte Administrator im System; nach der Änderung wird niemand das System verwalten können.",
|
||||
"numberOfUsers": "Anzahl der Benutzer",
|
||||
"associatedDeviceGroup": "Zugeordnete Gerätegruppe",
|
||||
"associatedDeviceGroups": "Zugeordnete Gerätegruppen",
|
||||
"associatedDeviceGroupTips": "Nachdem die Benutzergruppe einer Gerätegruppe zugeordnet wurde, erhalten alle Mitglieder dieser Benutzergruppe die Berechtigung, die Geräte in der entsprechenden Gerätegruppe einzusehen.",
|
||||
"editUserGroup": "Benutzergruppe bearbeiten",
|
||||
"deleteUserGroup": "Benutzergruppe löschen",
|
||||
"deleteUserGroupConfirmTips1": "Nach dem Löschen der aktuellen Benutzergruppe werden alle zugehörigen Gerätegruppenberechtigungen automatisch entfernt.",
|
||||
"deleteUserGroupConfirmTips2": "Mitglieder dieser Benutzergruppe verlieren den Zugriff auf die Geräte in den entsprechenden Gerätegruppen.",
|
||||
"deleteUserGroupConfirmTips3": "Möchten Sie sie wirklich löschen?",
|
||||
"deleteOnlyOneAdminTips": "Löschen nicht möglich: Letzter Systemadministrator.",
|
||||
"myGroup": "Meine Gruppe",
|
||||
"userRoleDesc": "Administratoren können alle Geräte sehen, während normale Benutzer nur die Geräte in der Gerätegruppe sehen können, die der Benutzergruppe zugeordnet ist",
|
||||
"userType": "Benutzertyp"
|
||||
},
|
||||
"deviceLog": {
|
||||
"title": "Protokolle",
|
||||
"mac": "Geräte-MAC",
|
||||
"macPlaceholder": "Nach MAC suchen",
|
||||
"eventType": "Ereignistyp",
|
||||
"actor": "Akteur",
|
||||
"clientIp": "Client-IP",
|
||||
"detail": "Details",
|
||||
"createTime": "Erstellungszeit",
|
||||
"endTime": "Endzeit",
|
||||
"duration": "Dauer",
|
||||
"noData": "Keine Daten",
|
||||
"refresh": "Aktualisieren",
|
||||
"inProgress": "In Bearbeitung",
|
||||
"tabs": {
|
||||
"device": "Gerät Online/Offline",
|
||||
"access": "Fernzugriff"
|
||||
},
|
||||
"event": {
|
||||
"deviceOnline": "Gerät online",
|
||||
"deviceOffline": "Gerät offline",
|
||||
"remoteSsh": "Remote-SSH",
|
||||
"remoteWeb": "Remote-Web",
|
||||
"remoteControl": "Fernsteuerung"
|
||||
}
|
||||
},
|
||||
"rtty": {
|
||||
"requestingDeviceToCreateTerminal": "Gerät wird aufgefordert, ein Terminal zu erstellen...",
|
||||
"uploadFileToDevice": "Datei auf Gerät hochladen",
|
||||
"selectFile": "Datei auswählen",
|
||||
"copyTips": "Kopieren – Strg+Einfg",
|
||||
"pasteTips": "Einfügen – Umschalt+Einfg",
|
||||
"clearScrollBack": "Scrollverlauf löschen",
|
||||
"fontUp": "Schriftgröße vergrößern",
|
||||
"fontDown": "Schriftgröße verkleinern",
|
||||
"uploadFile": "Datei hochladen",
|
||||
"downloadFile": "Datei herunterladen",
|
||||
"splitLeft": "Teilen: Links",
|
||||
"splitRight": "Teilen: Rechts",
|
||||
"splitUp": "Teilen: Oben",
|
||||
"splitDown": "Teilen: Unten",
|
||||
"copiedToClipboard": "In die Zwischenablage kopiert",
|
||||
"executeCommandR": "Bitte führen Sie den Befehl \"rtty -R\" im aktuellen Terminal aus!",
|
||||
"executeCommandS": "Bitte führen Sie den Befehl \"rtty -S file\" im aktuellen Terminal aus!",
|
||||
"useShortcutSI": "Bitte verwenden Sie die Tastenkombination \"Umschalt+Einfg\"",
|
||||
"pastedFromClipboard": "Aus der Zwischenablage eingefügt",
|
||||
"clipboardPermissionRequired": "Zwischenablage-Berechtigung erforderlich",
|
||||
"clipboardInstructions": "Um die Zwischenablage-Berechtigung zu aktivieren, klicken Sie auf das Website-Informationssymbol links in der Adressleiste, um auf die Berechtigungseinstellungen zuzugreifen, oder konfigurieren Sie die Zwischenablage-Berechtigungen für diese Website in den Datenschutzeinstellungen des Browsers. Sie können auch die Tastenkombination Umschalt+Einfg zum Einfügen verwenden.",
|
||||
"fileTooLargeTips": "Die Datei, die Sie hochladen möchten, ist zu groß (> 4294967295 Byte)"
|
||||
},
|
||||
"errorPage": {
|
||||
"sorry": "Entschuldigung!",
|
||||
"backHome": "Zur Startseite",
|
||||
"tryRefresh": "Seite aktualisieren",
|
||||
"notLogion": "Sie haben keine Berechtigung, auf diese Seite zuzugreifen. Bitte melden Sie sich an und versuchen Sie es erneut.",
|
||||
"noPermission": "Sie sind nicht berechtigt, auf diese Seite zuzugreifen.",
|
||||
"notFound": "Diese Seite scheint nicht zu existieren.<br>Keine Sorge – unser Team arbeitet bereits daran und wird das Problem bald beheben!"
|
||||
},
|
||||
"errorCode": {
|
||||
"FAILED": "Fehlgeschlagen",
|
||||
"SEND_EMAIL_OR_PHONE_FAILED": "Bitte melden Sie sich erneut an, um die Benutzeridentität zu bestätigen.",
|
||||
"SERVER_ERROR": "Serverfehler!",
|
||||
"INVALID_TOKEN": "Ungültiges Token!",
|
||||
"PERMISSION_ERROR": "Berechtigungsfehler!",
|
||||
"MISS_PARAM": "Fehlende Parameter",
|
||||
"DUPLICATE_OPERATION": "Doppelte Operation",
|
||||
"INPUT_PARAM_ERROR": "Fehler bei Eingabeparametern",
|
||||
"MESSAGE_EXPIRED_ERROR": "Nachricht abgelaufen",
|
||||
"ACCOUNT_LOGIN_ELSEWHERE": "Konto an anderer Stelle angemeldet",
|
||||
"AUTHENTICATION_TOKEN_EXPIRED": "Authentifizierungstoken abgelaufen",
|
||||
"VALIDATE_PASSWORD_ERR": "Passwortvalidierung fehlgeschlagen",
|
||||
"PASSWORD_SPECIAL_SYMBOLS_ERR": "Fehler bei Sonderzeichen im Passwort",
|
||||
"PASSWORD_STRENGTH_ERR": "Fehler bei der Passwortstärke",
|
||||
"REQUEST_BUSY_ERROR": "Zu viele Anfragen. Bitte versuchen Sie es später erneut!",
|
||||
"DATA_USER_NOT_MATCH": "Die aktuellen Daten stimmen nicht mit dem angemeldeten Benutzer überein.",
|
||||
"DATA_XSS_CHECK_FAILED": "Die Anfrage enthält XSS-Angriffselemente. Bitte überprüfen Sie die Eingabe.",
|
||||
"AUTH_TIME_OUT": "Authentifizierungs-Zeitüberschreitung",
|
||||
"USERNAME_OR_PASSWORD_ERROR": "Ungültiger Benutzername oder ungültiges Passwort.",
|
||||
"USER_NOT_EXIST": "Benutzer existiert nicht",
|
||||
"USER_PASSWORD_NOT_MATCH": "Benutzerpasswort stimmt nicht überein",
|
||||
"USER_PASSWORD_ERROR": "Passwortfehler",
|
||||
"USER_NAME_EXISTS": "Benutzername existiert bereits",
|
||||
"USER_EMAIL_EXISTS": "Benutzer-E-Mail existiert bereits",
|
||||
"USER_EMAIL_CODE_ERROR": "Fehler beim E-Mail-Bestätigungscode",
|
||||
"USER_EMAIL_INCORRECT": "Benutzer-E-Mail fehlerhaft",
|
||||
"USER_EMAIL_EXPIRED": "E-Mail-Bestätigungscode ist abgelaufen",
|
||||
"USER_TWO_FAC_AUTH": "2FA ist aktiviert",
|
||||
"USER_2FA_ERROR": "2FA-Code-Fehler",
|
||||
"USER_EMAIL_NOT_YOURS": "Benutzer-E-Mail gehört nicht Ihnen",
|
||||
"USER_PHONE_EXPIRED": "Der mobile Bestätigungscode ist abgelaufen.",
|
||||
"USER_PHONE_CODE_ERROR": "Der mobile Bestätigungscode ist fehlerhaft.",
|
||||
"USER_PHONE_NOT_YOURS": "Benutzer-Telefonnummer gehört nicht Ihnen",
|
||||
"USER_PHONE_EXISTS": "Benutzer-Telefonnummer existiert bereits",
|
||||
"USER_PHONE_NOT_EXISTS": "Benutzer-Telefonnummer existiert nicht",
|
||||
"USERNAME_OR_PASSWORD_WITH_COUNT_ERROR": "Konto gesperrt",
|
||||
"LIMIT_CONTROL": "Limit erreicht",
|
||||
"REGION_INFO_INCORRECT": "Regionsinformationen fehlerhaft",
|
||||
"VERIFICATION_CODE_COUNT_ERROR": "Anzahl fehlerhafter Bestätigungscodes überschritten",
|
||||
"DATA_ERROR": "Datenfehler!",
|
||||
"DEVICE_OFFLINE": "Gerät offline.",
|
||||
"PARAM_ERROR": "Parameterfehler!",
|
||||
"NEED_RECAPTCHA": "Mensch-Maschine-Verifizierung erforderlich",
|
||||
"RECAPTCHA_ERROR": "Mensch-Maschine-Verifizierung fehlgeschlagen",
|
||||
"DUPLICATE_ENTRY": "Doppelter Eintrag",
|
||||
"DATA_DEPEND": "Datenabhängigkeit",
|
||||
"INFO_INCORRECT": "Information fehlerhaft",
|
||||
"UPLOAD_IMAGE_CHECK_EXCEPTION": "Fehler bei der Bildüberprüfung!",
|
||||
"UPLOAD_IMAGE_FAIL": "Bild-Upload fehlgeschlagen!",
|
||||
"REPEAT_REQUEST_VERIFICATION_CODE": "Sie haben bereits einen Bestätigungscode angefordert. Bitte senden Sie ihn nicht erneut.",
|
||||
"USER_DATA_TO_BE_MODIFIED_NOT_EXIST": "Die zu ändernden Daten existieren nicht. Bitte überprüfen Sie dies!",
|
||||
"USER_OBJECT_INFO_EMPTY": "Die Objektinformationen dürfen nicht leer sein!",
|
||||
"USER_ID_NOT_NULL": "Die Primärschlüssel-ID sollte null sein.",
|
||||
"USER_SEND_MODE_EMPTY": "Der Versandmodus (E-Mail/SMS/Systemnachrichten) darf nicht leer sein!",
|
||||
"USER_RECIPIENT_ID_EMPTY": "Die Empfänger-ID darf nicht leer sein!",
|
||||
"USER_SEND_STATUS_EMPTY": "Der Versandstatus (0-Erfolg / 1-Fehler) darf nicht leer sein!",
|
||||
"USER_ID_EMPTY": "Die Primärschlüssel-ID darf nicht leer sein!",
|
||||
"USER_USERNAME_EMPTY": "Der Benutzername darf nicht leer sein!",
|
||||
"USER_PHONE_EMPTY": "Die Telefonnummer darf nicht leer sein!",
|
||||
"USER_EMAIL_EMPTY": "Die E-Mail-Adresse darf nicht leer sein!",
|
||||
"USER_UPDATE_EMAIL_EMPTY": "Die E-Mail-Adresse darf nicht leer sein!",
|
||||
"USER_UPDATE_USER_TOKEN_EMPTY": "Das Benutzertoken darf nicht leer sein!",
|
||||
"USER_CODE_EMPTY": "Der Code darf nicht leer sein!",
|
||||
"USER_ENTER_USER_INFO": "Bitte geben Sie die Benutzerinformationen ein.",
|
||||
"USER_ACCOUNT_EMPTY": "Das Konto darf nicht leer sein!",
|
||||
"USER_PASSWORD_EMPTY": "Das Passwort darf nicht leer sein!",
|
||||
"USER_NOT_EXISTS": "Der Benutzer existiert nicht.",
|
||||
"USER_USER_ID_EMPTY": "Die Benutzer-ID darf nicht leer sein!",
|
||||
"USER_USER_ROLE_EMPTY": "Die Benutzerrolle ist leer!",
|
||||
"USER_CANNOT_DELETE_YOURSELF": "Sie können sich nicht selbst löschen!",
|
||||
"USER_CANNOT_DELETE_ADMINISTRATOR": "Der Administrator kann nicht gelöscht werden!",
|
||||
"USER_ILLEGAL_MAIL_ADDRESS": "Ungültige E-Mail-Adresse.",
|
||||
"USER_ILLEGAL_PHONE_NUMBER": "Ungültige Telefonnummer.",
|
||||
"USER_ROLES_EMPTY": "Die Rolle darf nicht leer sein!",
|
||||
"USER_RE_PASSWORD_EMPTY": "Die Passwortwiederholung darf nicht leer sein!",
|
||||
"USER_ILLEGAL_USERNAME": "Ungültiger Benutzername.",
|
||||
"USER_USERNAME_EXISTS": "Der Benutzername existiert bereits.",
|
||||
"USER_SELECT_ROLE": "Bitte wählen Sie eine Rolle aus.",
|
||||
"USER_USER_NOT_EXIST": "Der Benutzer existiert nicht.",
|
||||
"USER_PASSWORD_RESET_LINK_EXPIRED": "Der Link zum Zurücksetzen des Passworts ist abgelaufen. Bitte fordern Sie auf der Anmeldeseite einen neuen Link zum Zurücksetzen des Passworts an.",
|
||||
"USER_ILLEGAL_ROLE": "Ungültige Rolle.",
|
||||
"EMAIL_CAPTCHA_CODE_ERROR": "Fehler beim E-Mail-Captcha-Code.",
|
||||
"EMAIL_CAPTCHA_CODE_EXPIRED": "E-Mail-Captcha-Code abgelaufen.",
|
||||
"ACCOUNT_CANCELLATION_LINK_EXPIRED": "Dieser Link ist abgelaufen. Bitte fordern Sie die Kontolöschung erneut an.",
|
||||
"CLOUD_EMAIL_NOT_MATCH_USER": "Die E-Mail-Adresse stimmt nicht mit dem Benutzer überein.",
|
||||
"CLOUD_USER_NOT_EXIST": "Der Benutzer existiert nicht."
|
||||
},
|
||||
"notification": {
|
||||
"title": "Benachrichtigungen",
|
||||
"smtpConfig": "E-Mail-Server (SMTP)",
|
||||
"smtpHost": "SMTP-Host",
|
||||
"smtpPort": "Port",
|
||||
"smtpUsername": "Benutzername",
|
||||
"smtpPassword": "Passwort",
|
||||
"fromEmail": "Absender-E-Mail",
|
||||
"encryption": "Verschlüsselung",
|
||||
"enableNotification": "Aktivieren",
|
||||
"testEmail": "Test-E-Mail senden",
|
||||
"testEmailSent": "Test-E-Mail gesendet an",
|
||||
"noRecipientForTest": "Bitte fügen Sie zuerst eine Empfänger-E-Mail hinzu",
|
||||
"notifyRules": "Benachrichtigungsregeln",
|
||||
"ruleDeviceOnline": "Gerät online",
|
||||
"ruleDeviceOnlineDesc": "Benachrichtigung senden, wenn ein Gerät online geht.",
|
||||
"ruleDeviceOffline": "Gerät offline",
|
||||
"ruleDeviceOfflineDesc": "Benachrichtigung senden, wenn ein Gerät offline geht.",
|
||||
"ruleRemoteAccess": "Fernzugriff",
|
||||
"ruleRemoteAccessDesc": "Benachrichtigung bei Remote-SSH-, Web- oder Steuerungszugriff senden.",
|
||||
"recipients": "Empfänger-E-Mails",
|
||||
"recipientPlaceholder": "E-Mail-Adresse eingeben",
|
||||
"addRecipient": "Hinzufügen",
|
||||
"removeRecipientConfirm": "Diesen Empfänger entfernen?",
|
||||
"noRecipients": "Noch keine Empfänger hinzugefügt.",
|
||||
"invalidEmail": "Ungültige E-Mail-Adresse."
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"ok": "OK",
|
||||
"close": "Close",
|
||||
"about": "About",
|
||||
@@ -40,7 +41,45 @@
|
||||
"loginWithOidc": "Log in with OIDC",
|
||||
"confirmPasswordValidateError": "The passwords you typed do not match.",
|
||||
"accountLogin": "Account Login",
|
||||
"ldap": "LDAP"
|
||||
"ldap": "LDAP",
|
||||
"local": "Local",
|
||||
"oidc": "OIDC",
|
||||
"twoFactorTitle": "Two-Factor Authentication",
|
||||
"totpHelp": "Open your authenticator app and enter the 6-digit verification code.",
|
||||
"enterTotpCode": "Enter 6-digit code",
|
||||
"rememberThisDevice": "Trust this device for 30 days",
|
||||
"verify": "Verify",
|
||||
"back": "Back"
|
||||
},
|
||||
"personalCenter": {
|
||||
"title": "Personal Center",
|
||||
"personalInformation": "Personal Information",
|
||||
"username": "Username",
|
||||
"displayName": "Display Name",
|
||||
"authProvider": "Auth Provider",
|
||||
"registrationTime": "Registration Time",
|
||||
"latestLoginTime": "Last Login Time",
|
||||
"notFilled": "Not set",
|
||||
"edit": "Edit",
|
||||
"securitySettings": "Security Settings",
|
||||
"twoFactorAuth": "Two-Factor Authentication",
|
||||
"twoFactorAuthDesc": "Require a verification code from your authenticator app at sign-in.",
|
||||
"twoFactorOnlyLocal": "Managed by your identity provider ({provider}) and cannot be configured here.",
|
||||
"trustedDevices": "Trusted Devices",
|
||||
"trustedDevicesDesc": "Browsers added to this list will skip 2FA at sign-in for 30 days.",
|
||||
"deviceName": "Device",
|
||||
"ipAddress": "IP Address",
|
||||
"lastUsed": "Last Used",
|
||||
"expiresAt": "Expires At",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirm": "Are you sure you want to revoke this trusted device? The next sign-in from this browser will require a verification code.",
|
||||
"enable2fa": "Enable Two-Factor Authentication",
|
||||
"disable2fa": "Disable Two-Factor Authentication",
|
||||
"disable2faTip": "Enter the current 6-digit verification code to confirm. All trusted devices will also be cleared.",
|
||||
"scanQrCode": "Scan the QR code with your authenticator app",
|
||||
"secretKey": "Or enter this secret key manually",
|
||||
"verifyCode": "Verification Code",
|
||||
"enterVerifyCode": "Enter 6-digit code"
|
||||
},
|
||||
"device": {
|
||||
"devices": "Devices",
|
||||
@@ -124,7 +163,9 @@
|
||||
"ipNotCorrect": "IP address is incorrect",
|
||||
"portNotCorrect": "Port is incorrect",
|
||||
"remoteWeb": "Remote Web",
|
||||
"linuxTips": "Supports OpenWrt, Raspberry PI, Ubuntu, CentOS, etc."
|
||||
"linuxTips": "Supports OpenWrt, Raspberry PI, Ubuntu, CentOS, etc.",
|
||||
"customColumns": "Custom Columns",
|
||||
"dragColumnTips": "You can customize your device list. Drag the button on the right side of the following items to adjust the display order, or use the checkboxes to control the display or hide of certain columns."
|
||||
},
|
||||
"user": {
|
||||
"user": "User",
|
||||
@@ -156,7 +197,34 @@
|
||||
"deleteUserGroupConfirmTips3": "Are you sure you want to delete it?",
|
||||
"deleteOnlyOneAdminTips": "Cannot delete: Only system admin left.",
|
||||
"myGroup": "My Group",
|
||||
"userRoleDesc": "Administrators can view all devices, while ordinary users can only see the devices in the device group associated with the user group"
|
||||
"userRoleDesc": "Administrators can view all devices, while ordinary users can only see the devices in the device group associated with the user group",
|
||||
"userType": "User Type"
|
||||
},
|
||||
"deviceLog": {
|
||||
"title": "Logs",
|
||||
"mac": "Device MAC",
|
||||
"macPlaceholder": "Search by MAC",
|
||||
"eventType": "Event Type",
|
||||
"actor": "Actor",
|
||||
"clientIp": "Client IP",
|
||||
"detail": "Detail",
|
||||
"createTime": "Create Time",
|
||||
"endTime": "End Time",
|
||||
"duration": "Duration",
|
||||
"noData": "No Data",
|
||||
"refresh": "Refresh",
|
||||
"inProgress": "In progress",
|
||||
"tabs": {
|
||||
"device": "Device Up/Down",
|
||||
"access": "Remote Access"
|
||||
},
|
||||
"event": {
|
||||
"deviceOnline": "Device Online",
|
||||
"deviceOffline": "Device Offline",
|
||||
"remoteSsh": "Remote SSH",
|
||||
"remoteWeb": "Remote Web",
|
||||
"remoteControl": "Remote Control"
|
||||
}
|
||||
},
|
||||
"rtty": {
|
||||
"requestingDeviceToCreateTerminal": "Requesting device to create terminal...",
|
||||
@@ -277,5 +345,32 @@
|
||||
"ACCOUNT_CANCELLATION_LINK_EXPIRED": "This link has expired. Please request account deletion again.",
|
||||
"CLOUD_EMAIL_NOT_MATCH_USER": "The email not match the user.",
|
||||
"CLOUD_USER_NOT_EXIST": "The user does not exist."
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notifications",
|
||||
"smtpConfig": "Email Server (SMTP)",
|
||||
"smtpHost": "SMTP Host",
|
||||
"smtpPort": "Port",
|
||||
"smtpUsername": "Username",
|
||||
"smtpPassword": "Password",
|
||||
"fromEmail": "From Email",
|
||||
"encryption": "Encryption",
|
||||
"enableNotification": "Enable",
|
||||
"testEmail": "Send Test Email",
|
||||
"testEmailSent": "Test email sent to",
|
||||
"noRecipientForTest": "Please add a recipient email first",
|
||||
"notifyRules": "Notification Rules",
|
||||
"ruleDeviceOnline": "Device Online",
|
||||
"ruleDeviceOnlineDesc": "Send notification when a device comes online.",
|
||||
"ruleDeviceOffline": "Device Offline",
|
||||
"ruleDeviceOfflineDesc": "Send notification when a device goes offline.",
|
||||
"ruleRemoteAccess": "Remote Access",
|
||||
"ruleRemoteAccessDesc": "Send notification on remote SSH, Web, or Control access.",
|
||||
"recipients": "Recipient Emails",
|
||||
"recipientPlaceholder": "Enter email address",
|
||||
"addRecipient": "Add",
|
||||
"removeRecipientConfirm": "Remove this recipient?",
|
||||
"noRecipients": "No recipients added yet.",
|
||||
"invalidEmail": "Invalid email address."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "Actualizar",
|
||||
"action": "Acción",
|
||||
"d": "d",
|
||||
"h": "h",
|
||||
"m": "m",
|
||||
"s": "s",
|
||||
"copySuccess": "Copiado correctamente",
|
||||
"copyFailed": "Error al copiar",
|
||||
"detail": "Detalle",
|
||||
"success": "Éxito",
|
||||
"failed": "Error",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"ok": "Aceptar",
|
||||
"close": "Cerrar",
|
||||
"about": "Acerca de",
|
||||
"maxLength": "La longitud máxima es de {length} caracteres",
|
||||
"more": "Más",
|
||||
"pleaseSelect": "Seleccione",
|
||||
"delete": "Eliminar",
|
||||
"remove": "Quitar",
|
||||
"apply": "Aplicar",
|
||||
"edit": "Editar"
|
||||
},
|
||||
"login": {
|
||||
"authorizationRequired": "Se requiere autorización",
|
||||
"username": "Nombre de usuario",
|
||||
"enterUsernameTip": "Introduzca su nombre de usuario",
|
||||
"password": "Contraseña",
|
||||
"signIn": "Iniciar sesión",
|
||||
"enterPwdTip": "Introduzca su contraseña",
|
||||
"incorrectPwd": "Contraseña incorrecta",
|
||||
"notAuthorized": "No autorizado",
|
||||
"signOut": "Cerrar sesión",
|
||||
"authOptions": "Opciones de autenticación",
|
||||
"ldapAuth": "Introduzca nombre de usuario y contraseña para la autenticación LDAP",
|
||||
"webManagementAuth": "Deje el nombre de usuario vacío y utilice la contraseña de gestión web",
|
||||
"or": "O",
|
||||
"loginWithOidc": "Iniciar sesión con OIDC",
|
||||
"confirmPasswordValidateError": "Las contraseñas introducidas no coinciden.",
|
||||
"accountLogin": "Inicio de sesión de cuenta",
|
||||
"ldap": "LDAP",
|
||||
"local": "Local",
|
||||
"oidc": "OIDC",
|
||||
"twoFactorTitle": "Autenticación de dos factores",
|
||||
"totpHelp": "Abra su aplicación de autenticación e introduzca el código de verificación de 6 dígitos.",
|
||||
"enterTotpCode": "Introduzca el código de 6 dígitos",
|
||||
"rememberThisDevice": "Confiar en este dispositivo durante 30 días",
|
||||
"verify": "Verificar",
|
||||
"back": "Volver"
|
||||
},
|
||||
"personalCenter": {
|
||||
"title": "Centro personal",
|
||||
"personalInformation": "Información personal",
|
||||
"username": "Nombre de usuario",
|
||||
"displayName": "Nombre para mostrar",
|
||||
"authProvider": "Proveedor de autenticación",
|
||||
"registrationTime": "Fecha de registro",
|
||||
"latestLoginTime": "Último inicio de sesión",
|
||||
"notFilled": "No configurado",
|
||||
"edit": "Editar",
|
||||
"securitySettings": "Configuración de seguridad",
|
||||
"twoFactorAuth": "Autenticación de dos factores",
|
||||
"twoFactorAuthDesc": "Solicitar un código de verificación de su aplicación de autenticación al iniciar sesión.",
|
||||
"twoFactorOnlyLocal": "Gestionado por su proveedor de identidad ({provider}) y no se puede configurar aquí.",
|
||||
"trustedDevices": "Dispositivos de confianza",
|
||||
"trustedDevicesDesc": "Los navegadores añadidos a esta lista omitirán la verificación en dos pasos al iniciar sesión durante 30 días.",
|
||||
"deviceName": "Dispositivo",
|
||||
"ipAddress": "Dirección IP",
|
||||
"lastUsed": "Último uso",
|
||||
"expiresAt": "Fecha de expiración",
|
||||
"revoke": "Revocar",
|
||||
"revokeConfirm": "¿Está seguro de que desea revocar este dispositivo de confianza? El próximo inicio de sesión desde este navegador requerirá un código de verificación.",
|
||||
"enable2fa": "Activar autenticación de dos factores",
|
||||
"disable2fa": "Desactivar autenticación de dos factores",
|
||||
"disable2faTip": "Introduzca el código de verificación de 6 dígitos actual para confirmar. También se eliminarán todos los dispositivos de confianza.",
|
||||
"scanQrCode": "Escanee el código QR con su aplicación de autenticación",
|
||||
"secretKey": "O introduzca esta clave secreta manualmente",
|
||||
"verifyCode": "Código de verificación",
|
||||
"enterVerifyCode": "Introduzca el código de 6 dígitos"
|
||||
},
|
||||
"device": {
|
||||
"devices": "Dispositivos",
|
||||
"addDevice": "Añadir dispositivo",
|
||||
"searchTip": "Introduzca palabras clave para buscar",
|
||||
"executeCommand": "Ejecutar comando",
|
||||
"remoteSSH": "SSH remoto",
|
||||
"remoteControl": "Control remoto",
|
||||
"deviceID": "ID de dispositivo",
|
||||
"connectedTime": "Hora de conexión",
|
||||
"uptime": "Tiempo de actividad",
|
||||
"IPAddress": "Dirección IP",
|
||||
"description": "Descripción",
|
||||
"selectDeviceTips": "Seleccione los dispositivos que desea operar",
|
||||
"refreshSuccess": "Actualización exitosa",
|
||||
"noDevice": "Sin dispositivos",
|
||||
"noDeviceTip": "Aún no ha añadido ningún dispositivo.",
|
||||
"addDeviceTip": "Ejecute el siguiente script en la terminal del dispositivo para conectarse a la nube",
|
||||
"copyScript": "Copiar script",
|
||||
"username": "Nombre de usuario",
|
||||
"inputUsername": "Introduzca nombre de usuario",
|
||||
"requiredUsername": "Introduzca el nombre de usuario",
|
||||
"command": "Comando",
|
||||
"inputCommand": "Introduzca comando",
|
||||
"requiredCommand": "Introduzca el comando",
|
||||
"parameter": "Parámetro",
|
||||
"inputParameter": "Introduzca parámetro",
|
||||
"waitTime": "Tiempo de espera",
|
||||
"inputWaitTime": "Introduzca tiempo de espera",
|
||||
"commandResponse": "Respuesta del comando",
|
||||
"commandResponseLoadingTips": "La configuración se está enviando. Espere un momento...",
|
||||
"code": "Código",
|
||||
"errorCode": "Código de error",
|
||||
"errorMessage": "Mensaje de error",
|
||||
"commandResponseDetail": "Detalle de respuesta del comando",
|
||||
"standardOutput": "Salida estándar",
|
||||
"standardErrorOutput": "Salida de error estándar",
|
||||
"status": "Estado",
|
||||
"online": "En línea",
|
||||
"offline": "Fuera de línea",
|
||||
"editDescription": "Editar descripción",
|
||||
"inputDescription": "Introduzca descripción",
|
||||
"requiredDescription": "Introduzca la descripción",
|
||||
"deleteDevice": "Eliminar dispositivo",
|
||||
"deleteDeviceConfirmTips": "¿Está seguro de que desea eliminar este dispositivo? Esta acción no se puede deshacer.",
|
||||
"mac": "Dirección MAC",
|
||||
"moveToGroup": "Mover a grupo",
|
||||
"moveToDeviceGroup": "Mover a grupo de dispositivos",
|
||||
"moveToDeviceGroupTips": "Un dispositivo solo puede estar asociado a un grupo a la vez. Al añadirlo al grupo actual se desvinculará automáticamente de su grupo original.",
|
||||
"notFoundDeviceGroup": "¿No lo encuentra? Crear ahora",
|
||||
"requiredDeviceGroup": "Seleccione un grupo de dispositivos",
|
||||
"deviceGroup": "Grupo de dispositivos",
|
||||
"unassigned": "Sin asignar",
|
||||
"addDeviceGroup": "Añadir grupo de dispositivos",
|
||||
"deviceGroupName": "Nombre del grupo de dispositivos",
|
||||
"requiredDeviceGroupName": "Introduzca, máximo 32 caracteres",
|
||||
"requiredDeviceGroupDescription": "Introduzca, máximo 200 caracteres",
|
||||
"allAssociatedDeviceGroup": "Todos los grupos de dispositivos asociados",
|
||||
"associatedDeviceCount": "Cantidad de dispositivos asociados",
|
||||
"associatedUserGroups": "Grupos de usuarios asociados",
|
||||
"allAssociatedUserGroups": "Todos los grupos de usuarios asociados",
|
||||
"manageDevices": "Gestionar dispositivos",
|
||||
"addDeviceToGroup": "Añadir dispositivo al grupo",
|
||||
"editBasicInfo": "Editar información básica",
|
||||
"basicInfo": "Información básica",
|
||||
"notAdded": "No añadido",
|
||||
"showOnlyUnassigned": "Mostrar solo sin asignar",
|
||||
"deleteDeviceGroup": "Eliminar grupo de dispositivos",
|
||||
"deleteDeviceGroupConfirmTips": "Después de eliminar el grupo actual, todos los dispositivos del grupo quedarán automáticamente sin asignar. ¿Está seguro de que desea eliminar este grupo?",
|
||||
"manageDevicesTips": "Al quitar un dispositivo del grupo actual, quedará sin asignar.",
|
||||
"removeDevice": "Quitar dispositivo",
|
||||
"currentlySelectedDevice": "Seleccionados actualmente: {num} dispositivos",
|
||||
"removeDeviceConfirmTips1": "Después de quitar el dispositivo del grupo actual, quedará sin asignar.",
|
||||
"removeDeviceConfirmTips2": "¿Está seguro de que desea quitarlo?",
|
||||
"addDeviceToGroupConfirmTips1": "Después de añadir el dispositivo al grupo actual, si ya está asociado a otro grupo, la relación con el grupo original se terminará automáticamente.",
|
||||
"addDeviceToGroupConfirmTips2": "¿Está seguro de que desea añadirlo?",
|
||||
"accessYourDevice": "Acceder a la web de su dispositivo",
|
||||
"protocol": "Protocolo",
|
||||
"port": "Puerto",
|
||||
"path": "Ruta",
|
||||
"ipNotCorrect": "La dirección IP es incorrecta",
|
||||
"portNotCorrect": "El puerto es incorrecto",
|
||||
"remoteWeb": "Web remota",
|
||||
"linuxTips": "Compatible con OpenWrt, Raspberry PI, Ubuntu, CentOS, etc.",
|
||||
"customColumns": "Columnas personalizadas",
|
||||
"dragColumnTips": "Puede personalizar su lista de dispositivos. Arrastre el botón del lado derecho de los siguientes elementos para ajustar el orden de visualización, o use las casillas de verificación para controlar la visibilidad de ciertas columnas."
|
||||
},
|
||||
"user": {
|
||||
"user": "Usuario",
|
||||
"userManager": "Gestión de usuarios",
|
||||
"userGroup": "Grupo de usuarios",
|
||||
"userName": "Nombre de usuario",
|
||||
"role": "Rol",
|
||||
"addUser": "Añadir usuario",
|
||||
"admin": "Administrador",
|
||||
"userRole": "Rol de usuario",
|
||||
"setPassword": "Establecer contraseña",
|
||||
"enterPassword": "Introduzca la contraseña",
|
||||
"reEnterPassword": "Reintroducir contraseña",
|
||||
"reEnterPasswordPlc": "Vuelva a introducir la contraseña",
|
||||
"addUserGroup": "Añadir grupo de usuarios",
|
||||
"userGroupName": "Nombre del grupo de usuarios",
|
||||
"deleteUser": "Eliminar usuario",
|
||||
"deleteUserConfirmTips": "¿Está seguro de que desea eliminar al usuario {name}? Después de la eliminación, todos los permisos del usuario se invalidarán inmediatamente y las membresías de grupo de usuarios asociadas se eliminarán automáticamente.",
|
||||
"editUser": "Editar usuario",
|
||||
"onlyOneAdminTips": "No se puede cambiar el usuario actual de Administrador a Usuario. Este es el último administrador del sistema; nadie gestionará el sistema después del cambio.",
|
||||
"numberOfUsers": "Número de usuarios",
|
||||
"associatedDeviceGroup": "Grupo de dispositivos asociado",
|
||||
"associatedDeviceGroups": "Grupos de dispositivos asociados",
|
||||
"associatedDeviceGroupTips": "Después de asociar el grupo de usuarios con un grupo de dispositivos, todos los miembros de este grupo de usuarios obtendrán permiso para ver los dispositivos del grupo de dispositivos correspondiente.",
|
||||
"editUserGroup": "Editar grupo de usuarios",
|
||||
"deleteUserGroup": "Eliminar grupo de usuarios",
|
||||
"deleteUserGroupConfirmTips1": "Después de eliminar el grupo de usuarios actual, todos los permisos de grupos de dispositivos asociados se eliminarán automáticamente.",
|
||||
"deleteUserGroupConfirmTips2": "Los miembros de este grupo de usuarios perderán el acceso para ver los dispositivos de los grupos de dispositivos correspondientes.",
|
||||
"deleteUserGroupConfirmTips3": "¿Está seguro de que desea eliminarlo?",
|
||||
"deleteOnlyOneAdminTips": "No se puede eliminar: es el último administrador del sistema.",
|
||||
"myGroup": "Mi grupo",
|
||||
"userRoleDesc": "Los administradores pueden ver todos los dispositivos, mientras que los usuarios comunes solo pueden ver los dispositivos del grupo de dispositivos asociado al grupo de usuarios",
|
||||
"userType": "Tipo de usuario"
|
||||
},
|
||||
"deviceLog": {
|
||||
"title": "Registros",
|
||||
"mac": "MAC del dispositivo",
|
||||
"macPlaceholder": "Buscar por MAC",
|
||||
"eventType": "Tipo de evento",
|
||||
"actor": "Actor",
|
||||
"clientIp": "IP del cliente",
|
||||
"detail": "Detalle",
|
||||
"createTime": "Fecha de creación",
|
||||
"endTime": "Fecha de finalización",
|
||||
"duration": "Duración",
|
||||
"noData": "Sin datos",
|
||||
"refresh": "Actualizar",
|
||||
"inProgress": "En curso",
|
||||
"tabs": {
|
||||
"device": "Conexión/desconexión",
|
||||
"access": "Acceso remoto"
|
||||
},
|
||||
"event": {
|
||||
"deviceOnline": "Dispositivo en línea",
|
||||
"deviceOffline": "Dispositivo fuera de línea",
|
||||
"remoteSsh": "SSH remoto",
|
||||
"remoteWeb": "Web remota",
|
||||
"remoteControl": "Control remoto"
|
||||
}
|
||||
},
|
||||
"rtty": {
|
||||
"requestingDeviceToCreateTerminal": "Solicitando al dispositivo crear terminal...",
|
||||
"uploadFileToDevice": "Subir archivo al dispositivo",
|
||||
"selectFile": "Seleccionar archivo",
|
||||
"copyTips": "Copiar - Ctrl+Insert",
|
||||
"pasteTips": "Pegar - Shift+Insert",
|
||||
"clearScrollBack": "Limpiar historial",
|
||||
"fontUp": "Aumentar tamaño de fuente",
|
||||
"fontDown": "Reducir tamaño de fuente",
|
||||
"uploadFile": "Subir archivo",
|
||||
"downloadFile": "Descargar archivo",
|
||||
"splitLeft": "Dividir: izquierda",
|
||||
"splitRight": "Dividir: derecha",
|
||||
"splitUp": "Dividir: arriba",
|
||||
"splitDown": "Dividir: abajo",
|
||||
"copiedToClipboard": "Copiado al portapapeles",
|
||||
"executeCommandR": "Ejecute el comando \"rtty -R\" en la terminal actual.",
|
||||
"executeCommandS": "Ejecute el comando \"rtty -S file\" en la terminal actual.",
|
||||
"useShortcutSI": "Utilice el atajo \"Shift+Insert\"",
|
||||
"pastedFromClipboard": "Pegado desde el portapapeles",
|
||||
"clipboardPermissionRequired": "Se requiere permiso del portapapeles",
|
||||
"clipboardInstructions": "Para habilitar los permisos del portapapeles, haga clic en el icono de información del sitio a la izquierda de la barra de direcciones para acceder a la configuración de permisos, o configure los permisos del portapapeles para este sitio en la configuración de privacidad del navegador. También puede usar el atajo de teclado Shift+Insert para pegar.",
|
||||
"fileTooLargeTips": "El archivo que desea subir es demasiado grande (> 4294967295 Byte)"
|
||||
},
|
||||
"errorPage": {
|
||||
"sorry": "¡Lo sentimos!",
|
||||
"backHome": "Volver al inicio",
|
||||
"tryRefresh": "Intentar actualizar",
|
||||
"notLogion": "No tiene permiso para acceder a esta página. Inicie sesión e intente de nuevo.",
|
||||
"noPermission": "No está autorizado para acceder a esta página.",
|
||||
"notFound": "Esta página parece no existir.<br>No se preocupe. Nuestro equipo ya está trabajando en ello y lo solucionará pronto."
|
||||
},
|
||||
"errorCode": {
|
||||
"FAILED": "Error",
|
||||
"SEND_EMAIL_OR_PHONE_FAILED": "Vuelva a iniciar sesión para confirmar la identidad del usuario.",
|
||||
"SERVER_ERROR": "¡Error del servidor!",
|
||||
"INVALID_TOKEN": "¡Token no válido!",
|
||||
"PERMISSION_ERROR": "¡Error de permisos!",
|
||||
"MISS_PARAM": "Faltan parámetros",
|
||||
"DUPLICATE_OPERATION": "Operación duplicada",
|
||||
"INPUT_PARAM_ERROR": "Error en los parámetros de entrada",
|
||||
"MESSAGE_EXPIRED_ERROR": "Mensaje expirado",
|
||||
"ACCOUNT_LOGIN_ELSEWHERE": "Cuenta iniciada en otro lugar",
|
||||
"AUTHENTICATION_TOKEN_EXPIRED": "Token de autenticación expirado",
|
||||
"VALIDATE_PASSWORD_ERR": "Error en la validación de contraseña",
|
||||
"PASSWORD_SPECIAL_SYMBOLS_ERR": "Error en los símbolos especiales de la contraseña",
|
||||
"PASSWORD_STRENGTH_ERR": "Error en la fortaleza de la contraseña",
|
||||
"REQUEST_BUSY_ERROR": "Demasiadas solicitudes. ¡Intente de nuevo más tarde!",
|
||||
"DATA_USER_NOT_MATCH": "Los datos actuales no coinciden con el usuario conectado.",
|
||||
"DATA_XSS_CHECK_FAILED": "La solicitud contiene elementos de ataque XSS. Verifique",
|
||||
"AUTH_TIME_OUT": "Tiempo de autenticación agotado",
|
||||
"USERNAME_OR_PASSWORD_ERROR": "Nombre de usuario o contraseña no válidos.",
|
||||
"USER_NOT_EXIST": "El usuario no existe",
|
||||
"USER_PASSWORD_NOT_MATCH": "La contraseña del usuario no coincide",
|
||||
"USER_PASSWORD_ERROR": "Error de contraseña",
|
||||
"USER_NAME_EXISTS": "El nombre de usuario ya existe",
|
||||
"USER_EMAIL_EXISTS": "El correo electrónico del usuario ya existe",
|
||||
"USER_EMAIL_CODE_ERROR": "Error en el código de correo electrónico",
|
||||
"USER_EMAIL_INCORRECT": "Correo electrónico del usuario incorrecto",
|
||||
"USER_EMAIL_EXPIRED": "El código de verificación por correo electrónico ha expirado",
|
||||
"USER_TWO_FAC_AUTH": "La autenticación de dos factores está activada",
|
||||
"USER_2FA_ERROR": "Error en el código de autenticación de dos factores",
|
||||
"USER_EMAIL_NOT_YOURS": "El correo electrónico no le pertenece",
|
||||
"USER_PHONE_EXPIRED": "El código de verificación móvil ha expirado.",
|
||||
"USER_PHONE_CODE_ERROR": "El código de verificación móvil es incorrecto.",
|
||||
"USER_PHONE_NOT_YOURS": "El teléfono no le pertenece",
|
||||
"USER_PHONE_EXISTS": "El teléfono del usuario ya existe",
|
||||
"USER_PHONE_NOT_EXISTS": "El teléfono del usuario no existe",
|
||||
"USERNAME_OR_PASSWORD_WITH_COUNT_ERROR": "Cuenta bloqueada",
|
||||
"LIMIT_CONTROL": "Control de límite",
|
||||
"REGION_INFO_INCORRECT": "Información de región incorrecta",
|
||||
"VERIFICATION_CODE_COUNT_ERROR": "Se ha superado el límite de intentos del código de verificación",
|
||||
"DATA_ERROR": "¡Error de datos!",
|
||||
"DEVICE_OFFLINE": "Dispositivo fuera de línea.",
|
||||
"PARAM_ERROR": "¡Error de parámetro!",
|
||||
"NEED_RECAPTCHA": "Se requiere verificación humana",
|
||||
"RECAPTCHA_ERROR": "Error en la verificación humana",
|
||||
"DUPLICATE_ENTRY": "Entrada duplicada",
|
||||
"DATA_DEPEND": "Dependencia de datos",
|
||||
"INFO_INCORRECT": "Información incorrecta",
|
||||
"UPLOAD_IMAGE_CHECK_EXCEPTION": "¡Excepción en la verificación de imagen!",
|
||||
"UPLOAD_IMAGE_FAIL": "¡Error al subir la imagen!",
|
||||
"REPEAT_REQUEST_VERIFICATION_CODE": "Ya ha solicitado un código de verificación, no lo envíe de nuevo.",
|
||||
"USER_DATA_TO_BE_MODIFIED_NOT_EXIST": "Los datos a modificar no existen. ¡Verifique!",
|
||||
"USER_OBJECT_INFO_EMPTY": "¡La información del objeto no puede estar vacía!",
|
||||
"USER_ID_NOT_NULL": "El ID de clave primaria debe ser nulo.",
|
||||
"USER_SEND_MODE_EMPTY": "¡El modo de envío (correo/mensaje/notificaciones) no puede estar vacío!",
|
||||
"USER_RECIPIENT_ID_EMPTY": "¡El ID del destinatario no puede estar vacío!",
|
||||
"USER_SEND_STATUS_EMPTY": "¡El estado de envío (0-éxito / 1-error) no puede ser nulo!",
|
||||
"USER_ID_EMPTY": "¡El ID de clave primaria no puede estar vacío!",
|
||||
"USER_USERNAME_EMPTY": "¡El nombre de usuario no puede estar vacío!",
|
||||
"USER_PHONE_EMPTY": "¡El teléfono no puede estar vacío!",
|
||||
"USER_EMAIL_EMPTY": "¡El correo electrónico no puede estar vacío!",
|
||||
"USER_UPDATE_EMAIL_EMPTY": "¡La dirección de correo electrónico no puede estar vacía!",
|
||||
"USER_UPDATE_USER_TOKEN_EMPTY": "¡El token de usuario no puede estar vacío!",
|
||||
"USER_CODE_EMPTY": "¡El código no puede estar vacío!",
|
||||
"USER_ENTER_USER_INFO": "Introduzca la información del usuario.",
|
||||
"USER_ACCOUNT_EMPTY": "¡La cuenta no puede estar vacía!",
|
||||
"USER_PASSWORD_EMPTY": "¡La contraseña no puede estar vacía!",
|
||||
"USER_NOT_EXISTS": "El usuario no existe.",
|
||||
"USER_USER_ID_EMPTY": "¡El ID de usuario no puede estar vacío!",
|
||||
"USER_USER_ROLE_EMPTY": "¡El rol de usuario está vacío!",
|
||||
"USER_CANNOT_DELETE_YOURSELF": "¡No puede eliminarse a sí mismo!",
|
||||
"USER_CANNOT_DELETE_ADMINISTRATOR": "¡No se puede eliminar al administrador!",
|
||||
"USER_ILLEGAL_MAIL_ADDRESS": "Dirección de correo electrónico no válida.",
|
||||
"USER_ILLEGAL_PHONE_NUMBER": "Número de teléfono no válido.",
|
||||
"USER_ROLES_EMPTY": "¡El rol no puede estar vacío!",
|
||||
"USER_RE_PASSWORD_EMPTY": "¡La confirmación de contraseña no puede estar vacía!",
|
||||
"USER_ILLEGAL_USERNAME": "Nombre de usuario no válido.",
|
||||
"USER_USERNAME_EXISTS": "El nombre de usuario ya existe.",
|
||||
"USER_SELECT_ROLE": "Seleccione un rol.",
|
||||
"USER_USER_NOT_EXIST": "El usuario no existe.",
|
||||
"USER_PASSWORD_RESET_LINK_EXPIRED": "El enlace de restablecimiento de contraseña ha expirado. Solicite un nuevo enlace de restablecimiento de contraseña visitando la página de inicio de sesión.",
|
||||
"USER_ILLEGAL_ROLE": "Rol no válido.",
|
||||
"EMAIL_CAPTCHA_CODE_ERROR": "Error en el código captcha del correo electrónico.",
|
||||
"EMAIL_CAPTCHA_CODE_EXPIRED": "El código captcha del correo electrónico ha expirado.",
|
||||
"ACCOUNT_CANCELLATION_LINK_EXPIRED": "Este enlace ha expirado. Solicite la eliminación de la cuenta de nuevo.",
|
||||
"CLOUD_EMAIL_NOT_MATCH_USER": "El correo electrónico no coincide con el usuario.",
|
||||
"CLOUD_USER_NOT_EXIST": "El usuario no existe."
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notificaciones",
|
||||
"smtpConfig": "Servidor de correo (SMTP)",
|
||||
"smtpHost": "Host SMTP",
|
||||
"smtpPort": "Puerto",
|
||||
"smtpUsername": "Usuario",
|
||||
"smtpPassword": "Contraseña",
|
||||
"fromEmail": "Correo remitente",
|
||||
"encryption": "Cifrado",
|
||||
"enableNotification": "Activar",
|
||||
"testEmail": "Enviar correo de prueba",
|
||||
"testEmailSent": "Correo de prueba enviado a",
|
||||
"noRecipientForTest": "Agregue primero un correo destinatario",
|
||||
"notifyRules": "Reglas de notificación",
|
||||
"ruleDeviceOnline": "Dispositivo en línea",
|
||||
"ruleDeviceOnlineDesc": "Enviar notificación cuando un dispositivo se conecta.",
|
||||
"ruleDeviceOffline": "Dispositivo fuera de línea",
|
||||
"ruleDeviceOfflineDesc": "Enviar notificación cuando un dispositivo se desconecta.",
|
||||
"ruleRemoteAccess": "Acceso remoto",
|
||||
"ruleRemoteAccessDesc": "Enviar notificación en acceso remoto SSH, Web o Control.",
|
||||
"recipients": "Correos destinatarios",
|
||||
"recipientPlaceholder": "Ingrese dirección de correo",
|
||||
"addRecipient": "Agregar",
|
||||
"removeRecipientConfirm": "¿Eliminar este destinatario?",
|
||||
"noRecipients": "No se han agregado destinatarios.",
|
||||
"invalidEmail": "Dirección de correo inválida."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "Actualiser",
|
||||
"action": "Action",
|
||||
"d": "j",
|
||||
"h": "h",
|
||||
"m": "m",
|
||||
"s": "s",
|
||||
"copySuccess": "Copié avec succès",
|
||||
"copyFailed": "Échec de la copie",
|
||||
"detail": "Détail",
|
||||
"success": "Succès",
|
||||
"failed": "Échec",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer",
|
||||
"ok": "OK",
|
||||
"close": "Fermer",
|
||||
"about": "À propos",
|
||||
"maxLength": "La longueur maximale est de {length} caractères",
|
||||
"more": "Plus",
|
||||
"pleaseSelect": "Veuillez sélectionner",
|
||||
"delete": "Supprimer",
|
||||
"remove": "Retirer",
|
||||
"apply": "Appliquer",
|
||||
"edit": "Modifier"
|
||||
},
|
||||
"login": {
|
||||
"authorizationRequired": "Autorisation requise",
|
||||
"username": "Nom d'utilisateur",
|
||||
"enterUsernameTip": "Veuillez saisir votre nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"signIn": "Se connecter",
|
||||
"enterPwdTip": "Veuillez saisir votre mot de passe",
|
||||
"incorrectPwd": "Mot de passe incorrect",
|
||||
"notAuthorized": "Non autorisé",
|
||||
"signOut": "Se déconnecter",
|
||||
"authOptions": "Options d'authentification",
|
||||
"ldapAuth": "Saisissez le nom d'utilisateur et le mot de passe pour l'authentification LDAP",
|
||||
"webManagementAuth": "Laissez le nom d'utilisateur vide et utilisez le mot de passe de gestion web",
|
||||
"or": "OU",
|
||||
"loginWithOidc": "Se connecter avec OIDC",
|
||||
"confirmPasswordValidateError": "Les mots de passe saisis ne correspondent pas.",
|
||||
"accountLogin": "Connexion au compte",
|
||||
"ldap": "LDAP",
|
||||
"local": "Local",
|
||||
"oidc": "OIDC",
|
||||
"twoFactorTitle": "Authentification à deux facteurs",
|
||||
"totpHelp": "Ouvrez votre application d'authentification et saisissez le code de vérification à 6 chiffres.",
|
||||
"enterTotpCode": "Saisissez le code à 6 chiffres",
|
||||
"rememberThisDevice": "Faire confiance à cet appareil pendant 30 jours",
|
||||
"verify": "Vérifier",
|
||||
"back": "Retour"
|
||||
},
|
||||
"personalCenter": {
|
||||
"title": "Espace personnel",
|
||||
"personalInformation": "Informations personnelles",
|
||||
"username": "Nom d'utilisateur",
|
||||
"displayName": "Nom d'affichage",
|
||||
"authProvider": "Fournisseur d'authentification",
|
||||
"registrationTime": "Date d'inscription",
|
||||
"latestLoginTime": "Dernière connexion",
|
||||
"notFilled": "Non défini",
|
||||
"edit": "Modifier",
|
||||
"securitySettings": "Paramètres de sécurité",
|
||||
"twoFactorAuth": "Authentification à deux facteurs",
|
||||
"twoFactorAuthDesc": "Exiger un code de vérification de votre application d'authentification lors de la connexion.",
|
||||
"twoFactorOnlyLocal": "Géré par votre fournisseur d'identité ({provider}) et ne peut pas être configuré ici.",
|
||||
"trustedDevices": "Appareils de confiance",
|
||||
"trustedDevicesDesc": "Les navigateurs ajoutés à cette liste ne nécessiteront pas la vérification 2FA lors de la connexion pendant 30 jours.",
|
||||
"deviceName": "Appareil",
|
||||
"ipAddress": "Adresse IP",
|
||||
"lastUsed": "Dernière utilisation",
|
||||
"expiresAt": "Date d'expiration",
|
||||
"revoke": "Révoquer",
|
||||
"revokeConfirm": "Êtes-vous sûr de vouloir révoquer cet appareil de confiance ? La prochaine connexion depuis ce navigateur nécessitera un code de vérification.",
|
||||
"enable2fa": "Activer l'authentification à deux facteurs",
|
||||
"disable2fa": "Désactiver l'authentification à deux facteurs",
|
||||
"disable2faTip": "Saisissez le code de vérification à 6 chiffres actuel pour confirmer. Tous les appareils de confiance seront également supprimés.",
|
||||
"scanQrCode": "Scannez le code QR avec votre application d'authentification",
|
||||
"secretKey": "Ou saisissez cette clé secrète manuellement",
|
||||
"verifyCode": "Code de vérification",
|
||||
"enterVerifyCode": "Saisissez le code à 6 chiffres"
|
||||
},
|
||||
"device": {
|
||||
"devices": "Appareils",
|
||||
"addDevice": "Ajouter un appareil",
|
||||
"searchTip": "Veuillez saisir des mots-clés pour rechercher",
|
||||
"executeCommand": "Exécuter une commande",
|
||||
"remoteSSH": "SSH distant",
|
||||
"remoteControl": "Contrôle à distance",
|
||||
"deviceID": "ID de l'appareil",
|
||||
"connectedTime": "Heure de connexion",
|
||||
"uptime": "Durée de fonctionnement",
|
||||
"IPAddress": "Adresse IP",
|
||||
"description": "Description",
|
||||
"selectDeviceTips": "Veuillez sélectionner les appareils que vous souhaitez utiliser",
|
||||
"refreshSuccess": "Actualisation réussie",
|
||||
"noDevice": "Aucun appareil",
|
||||
"noDeviceTip": "Vous n'avez pas encore ajouté d'appareils.",
|
||||
"addDeviceTip": "Veuillez exécuter le script suivant dans le terminal de l'appareil pour le connecter au cloud",
|
||||
"copyScript": "Copier le script",
|
||||
"username": "Nom d'utilisateur",
|
||||
"inputUsername": "Saisir le nom d'utilisateur",
|
||||
"requiredUsername": "Veuillez saisir le nom d'utilisateur",
|
||||
"command": "Commande",
|
||||
"inputCommand": "Saisir la commande",
|
||||
"requiredCommand": "Veuillez saisir la commande",
|
||||
"parameter": "Paramètre",
|
||||
"inputParameter": "Saisir le paramètre",
|
||||
"waitTime": "Temps d'attente",
|
||||
"inputWaitTime": "Saisir le temps d'attente",
|
||||
"commandResponse": "Réponse de la commande",
|
||||
"commandResponseLoadingTips": "La configuration est en cours d'envoi. Veuillez patienter un instant...",
|
||||
"code": "Code",
|
||||
"errorCode": "Code d'erreur",
|
||||
"errorMessage": "Message d'erreur",
|
||||
"commandResponseDetail": "Détail de la réponse de la commande",
|
||||
"standardOutput": "Sortie standard",
|
||||
"standardErrorOutput": "Sortie d'erreur standard",
|
||||
"status": "Statut",
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
"editDescription": "Modifier la description",
|
||||
"inputDescription": "Saisir la description",
|
||||
"requiredDescription": "Veuillez saisir la description",
|
||||
"deleteDevice": "Supprimer l'appareil",
|
||||
"deleteDeviceConfirmTips": "Êtes-vous sûr de vouloir supprimer cet appareil ? Cette action est irréversible.",
|
||||
"mac": "Adresse MAC",
|
||||
"moveToGroup": "Déplacer vers le groupe",
|
||||
"moveToDeviceGroup": "Déplacer vers le groupe d'appareils",
|
||||
"moveToDeviceGroupTips": "Un appareil ne peut être associé qu'à un seul groupe à la fois. L'ajout au groupe actuel le dissociera automatiquement de son groupe d'origine.",
|
||||
"notFoundDeviceGroup": "Introuvable ? Créer maintenant",
|
||||
"requiredDeviceGroup": "Veuillez sélectionner un groupe d'appareils",
|
||||
"deviceGroup": "Groupe d'appareils",
|
||||
"unassigned": "Non attribué",
|
||||
"addDeviceGroup": "Ajouter un groupe d'appareils",
|
||||
"deviceGroupName": "Nom du groupe d'appareils",
|
||||
"requiredDeviceGroupName": "Veuillez saisir, 32 caractères max.",
|
||||
"requiredDeviceGroupDescription": "Veuillez saisir, 200 caractères max.",
|
||||
"allAssociatedDeviceGroup": "Tous les groupes d'appareils associés",
|
||||
"associatedDeviceCount": "Nombre d'appareils associés",
|
||||
"associatedUserGroups": "Groupes d'utilisateurs associés",
|
||||
"allAssociatedUserGroups": "Tous les groupes d'utilisateurs associés",
|
||||
"manageDevices": "Gérer les appareils",
|
||||
"addDeviceToGroup": "Ajouter un appareil au groupe",
|
||||
"editBasicInfo": "Modifier les informations de base",
|
||||
"basicInfo": "Informations de base",
|
||||
"notAdded": "Non ajouté",
|
||||
"showOnlyUnassigned": "Afficher uniquement les non attribués",
|
||||
"deleteDeviceGroup": "Supprimer le groupe d'appareils",
|
||||
"deleteDeviceGroupConfirmTips": "Après la suppression du groupe actuel, tous les appareils du groupe seront automatiquement placés dans un état non groupé. Êtes-vous sûr de vouloir supprimer ce groupe ?",
|
||||
"manageDevicesTips": "Le retrait d'un appareil du groupe actuel le placera dans un état non groupé.",
|
||||
"removeDevice": "Retirer l'appareil",
|
||||
"currentlySelectedDevice": "Actuellement sélectionné : {num} appareils",
|
||||
"removeDeviceConfirmTips1": "Après le retrait de l'appareil du groupe actuel, il sera dans un état non groupé.",
|
||||
"removeDeviceConfirmTips2": "Êtes-vous sûr de vouloir le retirer ?",
|
||||
"addDeviceToGroupConfirmTips1": "Après l'ajout de l'appareil au groupe actuel, s'il est déjà associé à un autre groupe, la relation avec le groupe d'origine sera automatiquement rompue.",
|
||||
"addDeviceToGroupConfirmTips2": "Êtes-vous sûr de vouloir l'ajouter ?",
|
||||
"accessYourDevice": "Accéder à l'interface web de votre appareil",
|
||||
"protocol": "Protocole",
|
||||
"port": "Port",
|
||||
"path": "Chemin",
|
||||
"ipNotCorrect": "L'adresse IP est incorrecte",
|
||||
"portNotCorrect": "Le port est incorrect",
|
||||
"remoteWeb": "Web distant",
|
||||
"linuxTips": "Compatible avec OpenWrt, Raspberry Pi, Ubuntu, CentOS, etc.",
|
||||
"customColumns": "Colonnes personnalisées",
|
||||
"dragColumnTips": "Vous pouvez personnaliser votre liste d'appareils. Faites glisser le bouton sur le côté droit des éléments suivants pour ajuster l'ordre d'affichage, ou utilisez les cases à cocher pour afficher ou masquer certaines colonnes."
|
||||
},
|
||||
"user": {
|
||||
"user": "Utilisateur",
|
||||
"userManager": "Gestion des utilisateurs",
|
||||
"userGroup": "Groupe d'utilisateurs",
|
||||
"userName": "Nom d'utilisateur",
|
||||
"role": "Rôle",
|
||||
"addUser": "Ajouter un utilisateur",
|
||||
"admin": "Administrateur",
|
||||
"userRole": "Rôle de l'utilisateur",
|
||||
"setPassword": "Définir le mot de passe",
|
||||
"enterPassword": "Veuillez saisir le mot de passe",
|
||||
"reEnterPassword": "Ressaisir le mot de passe",
|
||||
"reEnterPasswordPlc": "Veuillez ressaisir le mot de passe",
|
||||
"addUserGroup": "Ajouter un groupe d'utilisateurs",
|
||||
"userGroupName": "Nom du groupe d'utilisateurs",
|
||||
"deleteUser": "Supprimer l'utilisateur",
|
||||
"deleteUserConfirmTips": "Êtes-vous sûr de vouloir supprimer l'utilisateur {name} ? Après la suppression, toutes les permissions de l'utilisateur seront immédiatement invalidées et les appartenances aux groupes d'utilisateurs associés seront automatiquement supprimées.",
|
||||
"editUser": "Modifier l'utilisateur",
|
||||
"onlyOneAdminTips": "Impossible de changer le rôle de l'utilisateur actuel d'Administrateur à Utilisateur. Il s'agit du dernier administrateur du système ; personne ne pourra gérer le système après ce changement.",
|
||||
"numberOfUsers": "Nombre d'utilisateurs",
|
||||
"associatedDeviceGroup": "Groupe d'appareils associé",
|
||||
"associatedDeviceGroups": "Groupes d'appareils associés",
|
||||
"associatedDeviceGroupTips": "Après l'association du groupe d'utilisateurs à un groupe d'appareils, tous les membres de ce groupe d'utilisateurs auront la permission de voir les appareils du groupe d'appareils correspondant.",
|
||||
"editUserGroup": "Modifier le groupe d'utilisateurs",
|
||||
"deleteUserGroup": "Supprimer le groupe d'utilisateurs",
|
||||
"deleteUserGroupConfirmTips1": "Après la suppression du groupe d'utilisateurs actuel, toutes les permissions des groupes d'appareils associés seront automatiquement supprimées.",
|
||||
"deleteUserGroupConfirmTips2": "Les membres de ce groupe d'utilisateurs perdront l'accès aux appareils des groupes d'appareils correspondants.",
|
||||
"deleteUserGroupConfirmTips3": "Êtes-vous sûr de vouloir le supprimer ?",
|
||||
"deleteOnlyOneAdminTips": "Suppression impossible : dernier administrateur système restant.",
|
||||
"myGroup": "Mon groupe",
|
||||
"userRoleDesc": "Les administrateurs peuvent voir tous les appareils, tandis que les utilisateurs ordinaires ne peuvent voir que les appareils du groupe d'appareils associé à leur groupe d'utilisateurs",
|
||||
"userType": "Type d'utilisateur"
|
||||
},
|
||||
"deviceLog": {
|
||||
"title": "Journaux",
|
||||
"mac": "MAC de l'appareil",
|
||||
"macPlaceholder": "Rechercher par MAC",
|
||||
"eventType": "Type d'événement",
|
||||
"actor": "Acteur",
|
||||
"clientIp": "IP du client",
|
||||
"detail": "Détail",
|
||||
"createTime": "Date de création",
|
||||
"endTime": "Date de fin",
|
||||
"duration": "Durée",
|
||||
"noData": "Aucune donnée",
|
||||
"refresh": "Actualiser",
|
||||
"inProgress": "En cours",
|
||||
"tabs": {
|
||||
"device": "Connexion/Déconnexion",
|
||||
"access": "Accès distant"
|
||||
},
|
||||
"event": {
|
||||
"deviceOnline": "Appareil en ligne",
|
||||
"deviceOffline": "Appareil hors ligne",
|
||||
"remoteSsh": "SSH distant",
|
||||
"remoteWeb": "Web distant",
|
||||
"remoteControl": "Contrôle à distance"
|
||||
}
|
||||
},
|
||||
"rtty": {
|
||||
"requestingDeviceToCreateTerminal": "Demande de création du terminal à l'appareil...",
|
||||
"uploadFileToDevice": "Envoyer un fichier vers l'appareil",
|
||||
"selectFile": "Sélectionner un fichier",
|
||||
"copyTips": "Copier - Ctrl+Insert",
|
||||
"pasteTips": "Coller - Shift+Insert",
|
||||
"clearScrollBack": "Effacer l'historique",
|
||||
"fontUp": "Augmenter la taille de la police",
|
||||
"fontDown": "Diminuer la taille de la police",
|
||||
"uploadFile": "Envoyer un fichier",
|
||||
"downloadFile": "Télécharger un fichier",
|
||||
"splitLeft": "Diviser : Gauche",
|
||||
"splitRight": "Diviser : Droite",
|
||||
"splitUp": "Diviser : Haut",
|
||||
"splitDown": "Diviser : Bas",
|
||||
"copiedToClipboard": "Copié dans le presse-papiers",
|
||||
"executeCommandR": "Veuillez exécuter la commande « rtty -R » dans le terminal actuel !",
|
||||
"executeCommandS": "Veuillez exécuter la commande « rtty -S file » dans le terminal actuel !",
|
||||
"useShortcutSI": "Veuillez utiliser le raccourci « Shift+Insert »",
|
||||
"pastedFromClipboard": "Collé depuis le presse-papiers",
|
||||
"clipboardPermissionRequired": "Autorisation du presse-papiers requise",
|
||||
"clipboardInstructions": "Pour activer les autorisations du presse-papiers, cliquez sur l'icône d'informations du site à gauche de la barre d'adresse pour accéder aux paramètres d'autorisation, ou configurez les autorisations du presse-papiers pour ce site dans les paramètres de confidentialité du navigateur. Vous pouvez également utiliser le raccourci clavier Shift+Insert pour coller.",
|
||||
"fileTooLargeTips": "Le fichier que vous souhaitez envoyer est trop volumineux (> 4294967295 octets)"
|
||||
},
|
||||
"errorPage": {
|
||||
"sorry": "Désolé !",
|
||||
"backHome": "Retour à l'accueil",
|
||||
"tryRefresh": "Essayer d'actualiser",
|
||||
"notLogion": "Vous n'avez pas la permission d'accéder à cette page. Veuillez vous connecter et réessayer.",
|
||||
"noPermission": "Vous n'êtes pas autorisé à accéder à cette page.",
|
||||
"notFound": "Cette page semble introuvable.<br>Ne vous inquiétez pas. Notre équipe travaille déjà dessus et la corrigera bientôt !"
|
||||
},
|
||||
"errorCode": {
|
||||
"FAILED": "Échec",
|
||||
"SEND_EMAIL_OR_PHONE_FAILED": "Veuillez vous reconnecter pour confirmer votre identité.",
|
||||
"SERVER_ERROR": "Erreur du serveur !",
|
||||
"INVALID_TOKEN": "Jeton invalide !",
|
||||
"PERMISSION_ERROR": "Erreur de permission !",
|
||||
"MISS_PARAM": "Paramètres manquants",
|
||||
"DUPLICATE_OPERATION": "Opération en double",
|
||||
"INPUT_PARAM_ERROR": "Erreur de paramètres d'entrée",
|
||||
"MESSAGE_EXPIRED_ERROR": "Message expiré",
|
||||
"ACCOUNT_LOGIN_ELSEWHERE": "Compte connecté ailleurs",
|
||||
"AUTHENTICATION_TOKEN_EXPIRED": "Jeton d'authentification expiré",
|
||||
"VALIDATE_PASSWORD_ERR": "Échec de la validation du mot de passe",
|
||||
"PASSWORD_SPECIAL_SYMBOLS_ERR": "Erreur de caractères spéciaux dans le mot de passe",
|
||||
"PASSWORD_STRENGTH_ERR": "Erreur de robustesse du mot de passe",
|
||||
"REQUEST_BUSY_ERROR": "Trop de requêtes. Veuillez réessayer plus tard !",
|
||||
"DATA_USER_NOT_MATCH": "Les données actuelles ne correspondent pas à l'utilisateur connecté.",
|
||||
"DATA_XSS_CHECK_FAILED": "La requête contient des éléments d'attaque XSS. Veuillez vérifier.",
|
||||
"AUTH_TIME_OUT": "Délai d'authentification expiré",
|
||||
"USERNAME_OR_PASSWORD_ERROR": "Nom d'utilisateur ou mot de passe invalide.",
|
||||
"USER_NOT_EXIST": "L'utilisateur n'existe pas",
|
||||
"USER_PASSWORD_NOT_MATCH": "Le mot de passe de l'utilisateur ne correspond pas",
|
||||
"USER_PASSWORD_ERROR": "Erreurs de mot de passe",
|
||||
"USER_NAME_EXISTS": "Le nom d'utilisateur existe déjà",
|
||||
"USER_EMAIL_EXISTS": "L'adresse e-mail de l'utilisateur existe déjà",
|
||||
"USER_EMAIL_CODE_ERROR": "Erreur du code e-mail de l'utilisateur",
|
||||
"USER_EMAIL_INCORRECT": "Adresse e-mail de l'utilisateur incorrecte",
|
||||
"USER_EMAIL_EXPIRED": "Le code de vérification par e-mail a expiré",
|
||||
"USER_TWO_FAC_AUTH": "L'authentification 2FA est activée",
|
||||
"USER_2FA_ERROR": "Erreur du code 2FA",
|
||||
"USER_EMAIL_NOT_YOURS": "Cette adresse e-mail ne vous appartient pas",
|
||||
"USER_PHONE_EXPIRED": "Le code de vérification mobile a expiré.",
|
||||
"USER_PHONE_CODE_ERROR": "Le code de vérification mobile est incorrect.",
|
||||
"USER_PHONE_NOT_YOURS": "Ce numéro de téléphone ne vous appartient pas",
|
||||
"USER_PHONE_EXISTS": "Le numéro de téléphone de l'utilisateur existe déjà",
|
||||
"USER_PHONE_NOT_EXISTS": "Le numéro de téléphone de l'utilisateur n'existe pas",
|
||||
"USERNAME_OR_PASSWORD_WITH_COUNT_ERROR": "Compte verrouillé",
|
||||
"LIMIT_CONTROL": "Contrôle de limite",
|
||||
"REGION_INFO_INCORRECT": "Informations de région incorrectes",
|
||||
"VERIFICATION_CODE_COUNT_ERROR": "Le nombre d'erreurs du code de vérification dépasse la limite",
|
||||
"DATA_ERROR": "Erreur de données !",
|
||||
"DEVICE_OFFLINE": "Appareil hors ligne.",
|
||||
"PARAM_ERROR": "Erreur de paramètre !",
|
||||
"NEED_RECAPTCHA": "Vérification humaine requise",
|
||||
"RECAPTCHA_ERROR": "Échec de la vérification humaine",
|
||||
"DUPLICATE_ENTRY": "Entrée en double",
|
||||
"DATA_DEPEND": "Dépendance de données",
|
||||
"INFO_INCORRECT": "Informations incorrectes",
|
||||
"UPLOAD_IMAGE_CHECK_EXCEPTION": "Exception lors de la vérification de l'image !",
|
||||
"UPLOAD_IMAGE_FAIL": "Échec de l'envoi de l'image !",
|
||||
"REPEAT_REQUEST_VERIFICATION_CODE": "Vous avez déjà demandé un code de vérification, veuillez ne pas en envoyer un autre.",
|
||||
"USER_DATA_TO_BE_MODIFIED_NOT_EXIST": "Les données à modifier n'existent pas. Veuillez vérifier !",
|
||||
"USER_OBJECT_INFO_EMPTY": "Les informations de l'objet ne peuvent pas être vides !",
|
||||
"USER_ID_NOT_NULL": "L'identifiant de clé primaire doit être nul.",
|
||||
"USER_SEND_MODE_EMPTY": "Le mode d'envoi (e-mail/message/notifications) ne peut pas être vide !",
|
||||
"USER_RECIPIENT_ID_EMPTY": "L'identifiant du destinataire ne peut pas être vide !",
|
||||
"USER_SEND_STATUS_EMPTY": "Le statut d'envoi (0-succès / 1-échec) ne peut pas être nul !",
|
||||
"USER_ID_EMPTY": "L'identifiant de clé primaire ne peut pas être vide !",
|
||||
"USER_USERNAME_EMPTY": "Le nom d'utilisateur ne peut pas être vide !",
|
||||
"USER_PHONE_EMPTY": "Le numéro de téléphone ne peut pas être vide !",
|
||||
"USER_EMAIL_EMPTY": "L'adresse e-mail ne peut pas être vide !",
|
||||
"USER_UPDATE_EMAIL_EMPTY": "L'adresse e-mail ne peut pas être vide !",
|
||||
"USER_UPDATE_USER_TOKEN_EMPTY": "Le jeton utilisateur ne peut pas être vide !",
|
||||
"USER_CODE_EMPTY": "Le code ne peut pas être vide !",
|
||||
"USER_ENTER_USER_INFO": "Veuillez saisir les informations de l'utilisateur.",
|
||||
"USER_ACCOUNT_EMPTY": "Le compte ne peut pas être vide !",
|
||||
"USER_PASSWORD_EMPTY": "Le mot de passe ne peut pas être vide !",
|
||||
"USER_NOT_EXISTS": "L'utilisateur n'existe pas.",
|
||||
"USER_USER_ID_EMPTY": "L'identifiant de l'utilisateur ne peut pas être vide !",
|
||||
"USER_USER_ROLE_EMPTY": "Le rôle de l'utilisateur est vide !",
|
||||
"USER_CANNOT_DELETE_YOURSELF": "Vous ne pouvez pas vous supprimer vous-même !",
|
||||
"USER_CANNOT_DELETE_ADMINISTRATOR": "Impossible de supprimer l'administrateur !",
|
||||
"USER_ILLEGAL_MAIL_ADDRESS": "Adresse e-mail invalide.",
|
||||
"USER_ILLEGAL_PHONE_NUMBER": "Numéro de téléphone invalide.",
|
||||
"USER_ROLES_EMPTY": "Le rôle ne peut pas être vide !",
|
||||
"USER_RE_PASSWORD_EMPTY": "La confirmation du mot de passe ne peut pas être vide !",
|
||||
"USER_ILLEGAL_USERNAME": "Nom d'utilisateur invalide.",
|
||||
"USER_USERNAME_EXISTS": "Le nom d'utilisateur existe déjà.",
|
||||
"USER_SELECT_ROLE": "Veuillez sélectionner un rôle.",
|
||||
"USER_USER_NOT_EXIST": "L'utilisateur n'existe pas.",
|
||||
"USER_PASSWORD_RESET_LINK_EXPIRED": "Le lien de réinitialisation du mot de passe a expiré. Veuillez demander un nouveau lien de réinitialisation en vous rendant sur la page de connexion.",
|
||||
"USER_ILLEGAL_ROLE": "Rôle invalide.",
|
||||
"EMAIL_CAPTCHA_CODE_ERROR": "Erreur du code captcha par e-mail.",
|
||||
"EMAIL_CAPTCHA_CODE_EXPIRED": "Le code captcha par e-mail a expiré.",
|
||||
"ACCOUNT_CANCELLATION_LINK_EXPIRED": "Ce lien a expiré. Veuillez demander à nouveau la suppression du compte.",
|
||||
"CLOUD_EMAIL_NOT_MATCH_USER": "L'adresse e-mail ne correspond pas à l'utilisateur.",
|
||||
"CLOUD_USER_NOT_EXIST": "L'utilisateur n'existe pas."
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notifications",
|
||||
"smtpConfig": "Serveur de messagerie (SMTP)",
|
||||
"smtpHost": "Hôte SMTP",
|
||||
"smtpPort": "Port",
|
||||
"smtpUsername": "Nom d'utilisateur",
|
||||
"smtpPassword": "Mot de passe",
|
||||
"fromEmail": "E-mail expéditeur",
|
||||
"encryption": "Chiffrement",
|
||||
"enableNotification": "Activer",
|
||||
"testEmail": "Envoyer un e-mail de test",
|
||||
"testEmailSent": "E-mail de test envoyé à",
|
||||
"noRecipientForTest": "Veuillez d'abord ajouter un e-mail destinataire",
|
||||
"notifyRules": "Règles de notification",
|
||||
"ruleDeviceOnline": "Appareil en ligne",
|
||||
"ruleDeviceOnlineDesc": "Envoyer une notification lorsqu'un appareil se connecte.",
|
||||
"ruleDeviceOffline": "Appareil hors ligne",
|
||||
"ruleDeviceOfflineDesc": "Envoyer une notification lorsqu'un appareil se déconnecte.",
|
||||
"ruleRemoteAccess": "Accès distant",
|
||||
"ruleRemoteAccessDesc": "Envoyer une notification lors d'un accès SSH, Web ou contrôle distant.",
|
||||
"recipients": "E-mails destinataires",
|
||||
"recipientPlaceholder": "Entrez l'adresse e-mail",
|
||||
"addRecipient": "Ajouter",
|
||||
"removeRecipientConfirm": "Supprimer ce destinataire ?",
|
||||
"noRecipients": "Aucun destinataire ajouté.",
|
||||
"invalidEmail": "Adresse e-mail invalide."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "更新",
|
||||
"action": "操作",
|
||||
"d": "日",
|
||||
"h": "時間",
|
||||
"m": "分",
|
||||
"s": "秒",
|
||||
"copySuccess": "コピーしました",
|
||||
"copyFailed": "コピーに失敗しました",
|
||||
"detail": "詳細",
|
||||
"success": "成功",
|
||||
"failed": "失敗",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "確認",
|
||||
"ok": "OK",
|
||||
"close": "閉じる",
|
||||
"about": "このサービスについて",
|
||||
"maxLength": "最大文字数は {length} 文字です",
|
||||
"more": "もっと見る",
|
||||
"pleaseSelect": "選択してください",
|
||||
"delete": "削除",
|
||||
"remove": "除外",
|
||||
"apply": "適用",
|
||||
"edit": "編集"
|
||||
},
|
||||
"login": {
|
||||
"authorizationRequired": "認証が必要です",
|
||||
"username": "ユーザー名",
|
||||
"enterUsernameTip": "ユーザー名を入力してください",
|
||||
"password": "パスワード",
|
||||
"signIn": "サインイン",
|
||||
"enterPwdTip": "パスワードを入力してください",
|
||||
"incorrectPwd": "パスワードが正しくありません",
|
||||
"notAuthorized": "権限がありません",
|
||||
"signOut": "サインアウト",
|
||||
"authOptions": "認証オプション",
|
||||
"ldapAuth": "LDAP認証用のユーザー名とパスワードを入力してください",
|
||||
"webManagementAuth": "ユーザー名を空欄にし、Web管理パスワードを使用してください",
|
||||
"or": "または",
|
||||
"loginWithOidc": "OIDCでログイン",
|
||||
"confirmPasswordValidateError": "入力されたパスワードが一致しません。",
|
||||
"accountLogin": "アカウントログイン",
|
||||
"ldap": "LDAP",
|
||||
"local": "ローカル",
|
||||
"oidc": "OIDC",
|
||||
"twoFactorTitle": "二要素認証",
|
||||
"totpHelp": "認証アプリを開き、6桁の確認コードを入力してください。",
|
||||
"enterTotpCode": "6桁のコードを入力",
|
||||
"rememberThisDevice": "このデバイスを30日間信頼する",
|
||||
"verify": "確認",
|
||||
"back": "戻る"
|
||||
},
|
||||
"personalCenter": {
|
||||
"title": "個人センター",
|
||||
"personalInformation": "個人情報",
|
||||
"username": "ユーザー名",
|
||||
"displayName": "表示名",
|
||||
"authProvider": "認証プロバイダー",
|
||||
"registrationTime": "登録日時",
|
||||
"latestLoginTime": "最終ログイン日時",
|
||||
"notFilled": "未設定",
|
||||
"edit": "編集",
|
||||
"securitySettings": "セキュリティ設定",
|
||||
"twoFactorAuth": "二要素認証",
|
||||
"twoFactorAuthDesc": "サインイン時に認証アプリの確認コードを要求します。",
|
||||
"twoFactorOnlyLocal": "IDプロバイダー({provider})によって管理されているため、ここでは設定できません。",
|
||||
"trustedDevices": "信頼済みデバイス",
|
||||
"trustedDevicesDesc": "このリストに追加されたブラウザは、30日間サインイン時の二要素認証がスキップされます。",
|
||||
"deviceName": "デバイス",
|
||||
"ipAddress": "IPアドレス",
|
||||
"lastUsed": "最終使用日時",
|
||||
"expiresAt": "有効期限",
|
||||
"revoke": "取り消し",
|
||||
"revokeConfirm": "この信頼済みデバイスを取り消してもよろしいですか?次回このブラウザからのサインイン時に確認コードが必要になります。",
|
||||
"enable2fa": "二要素認証を有効にする",
|
||||
"disable2fa": "二要素認証を無効にする",
|
||||
"disable2faTip": "確認のため、現在の6桁の確認コードを入力してください。すべての信頼済みデバイスもクリアされます。",
|
||||
"scanQrCode": "認証アプリでQRコードをスキャンしてください",
|
||||
"secretKey": "または、このシークレットキーを手動で入力してください",
|
||||
"verifyCode": "確認コード",
|
||||
"enterVerifyCode": "6桁のコードを入力"
|
||||
},
|
||||
"device": {
|
||||
"devices": "デバイス",
|
||||
"addDevice": "デバイスを追加",
|
||||
"searchTip": "キーワードを入力して検索",
|
||||
"executeCommand": "コマンドを実行",
|
||||
"remoteSSH": "リモートSSH",
|
||||
"remoteControl": "リモートコントロール",
|
||||
"deviceID": "デバイスID",
|
||||
"connectedTime": "接続時間",
|
||||
"uptime": "稼働時間",
|
||||
"IPAddress": "IPアドレス",
|
||||
"description": "説明",
|
||||
"selectDeviceTips": "操作するデバイスを選択してください",
|
||||
"refreshSuccess": "更新しました",
|
||||
"noDevice": "デバイスがありません",
|
||||
"noDeviceTip": "まだデバイスが追加されていません。",
|
||||
"addDeviceTip": "デバイスのシェルで以下のスクリプトを実行し、クラウドに接続してください",
|
||||
"copyScript": "スクリプトをコピー",
|
||||
"username": "ユーザー名",
|
||||
"inputUsername": "ユーザー名を入力",
|
||||
"requiredUsername": "ユーザー名を入力してください",
|
||||
"command": "コマンド",
|
||||
"inputCommand": "コマンドを入力",
|
||||
"requiredCommand": "コマンドを入力してください",
|
||||
"parameter": "パラメーター",
|
||||
"inputParameter": "パラメーターを入力",
|
||||
"waitTime": "待機時間",
|
||||
"inputWaitTime": "待機時間を入力",
|
||||
"commandResponse": "コマンド応答",
|
||||
"commandResponseLoadingTips": "設定を送信中です。しばらくお待ちください...",
|
||||
"code": "コード",
|
||||
"errorCode": "エラーコード",
|
||||
"errorMessage": "エラーメッセージ",
|
||||
"commandResponseDetail": "コマンド応答の詳細",
|
||||
"standardOutput": "標準出力",
|
||||
"standardErrorOutput": "標準エラー出力",
|
||||
"status": "ステータス",
|
||||
"online": "オンライン",
|
||||
"offline": "オフライン",
|
||||
"editDescription": "説明を編集",
|
||||
"inputDescription": "説明を入力",
|
||||
"requiredDescription": "説明を入力してください",
|
||||
"deleteDevice": "デバイスを削除",
|
||||
"deleteDeviceConfirmTips": "このデバイスを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"mac": "MACアドレス",
|
||||
"moveToGroup": "グループに移動",
|
||||
"moveToDeviceGroup": "デバイスグループに移動",
|
||||
"moveToDeviceGroupTips": "デバイスは一度に1つのグループにのみ関連付けできます。現在のグループに追加すると、元のグループとの関連付けは自動的に解除されます。",
|
||||
"notFoundDeviceGroup": "見つかりませんか?今すぐ作成",
|
||||
"requiredDeviceGroup": "デバイスグループを選択してください",
|
||||
"deviceGroup": "デバイスグループ",
|
||||
"unassigned": "未割り当て",
|
||||
"addDeviceGroup": "デバイスグループを追加",
|
||||
"deviceGroupName": "デバイスグループ名",
|
||||
"requiredDeviceGroupName": "入力してください(最大32文字)",
|
||||
"requiredDeviceGroupDescription": "入力してください(最大200文字)",
|
||||
"allAssociatedDeviceGroup": "関連するすべてのデバイスグループ",
|
||||
"associatedDeviceCount": "関連デバイス数",
|
||||
"associatedUserGroups": "関連ユーザーグループ",
|
||||
"allAssociatedUserGroups": "関連するすべてのユーザーグループ",
|
||||
"manageDevices": "デバイスを管理",
|
||||
"addDeviceToGroup": "グループにデバイスを追加",
|
||||
"editBasicInfo": "基本情報を編集",
|
||||
"basicInfo": "基本情報",
|
||||
"notAdded": "未追加",
|
||||
"showOnlyUnassigned": "未割り当てのみ表示",
|
||||
"deleteDeviceGroup": "デバイスグループを削除",
|
||||
"deleteDeviceGroupConfirmTips": "現在のグループを削除すると、グループ内のすべてのデバイスは自動的に未所属の状態になります。このグループを削除してもよろしいですか?",
|
||||
"manageDevicesTips": "デバイスを現在のグループから除外すると、そのデバイスは未所属の状態になります。",
|
||||
"removeDevice": "デバイスを除外",
|
||||
"currentlySelectedDevice": "現在選択中: {num} 台のデバイス",
|
||||
"removeDeviceConfirmTips1": "デバイスを現在のグループから除外すると、未所属の状態になります。",
|
||||
"removeDeviceConfirmTips2": "除外してもよろしいですか?",
|
||||
"addDeviceToGroupConfirmTips1": "デバイスを現在のグループに追加する際、既に別のグループに関連付けられている場合、元のグループとの関連付けは自動的に解除されます。",
|
||||
"addDeviceToGroupConfirmTips2": "追加してもよろしいですか?",
|
||||
"accessYourDevice": "デバイスのWebにアクセス",
|
||||
"protocol": "プロトコル",
|
||||
"port": "ポート",
|
||||
"path": "パス",
|
||||
"ipNotCorrect": "IPアドレスが正しくありません",
|
||||
"portNotCorrect": "ポートが正しくありません",
|
||||
"remoteWeb": "リモートWeb",
|
||||
"linuxTips": "OpenWrt、Raspberry Pi、Ubuntu、CentOSなどに対応",
|
||||
"customColumns": "カラムのカスタマイズ",
|
||||
"dragColumnTips": "デバイスリストをカスタマイズできます。以下の項目の右側にあるボタンをドラッグして表示順序を調整するか、チェックボックスで列の表示・非表示を切り替えてください。"
|
||||
},
|
||||
"user": {
|
||||
"user": "ユーザー",
|
||||
"userManager": "ユーザー管理",
|
||||
"userGroup": "ユーザーグループ",
|
||||
"userName": "ユーザー名",
|
||||
"role": "ロール",
|
||||
"addUser": "ユーザーを追加",
|
||||
"admin": "管理者",
|
||||
"userRole": "ユーザーロール",
|
||||
"setPassword": "パスワードを設定",
|
||||
"enterPassword": "パスワードを入力してください",
|
||||
"reEnterPassword": "パスワードを再入力",
|
||||
"reEnterPasswordPlc": "パスワードを再入力してください",
|
||||
"addUserGroup": "ユーザーグループを追加",
|
||||
"userGroupName": "ユーザーグループ名",
|
||||
"deleteUser": "ユーザーを削除",
|
||||
"deleteUserConfirmTips": "ユーザー {name} を削除してもよろしいですか?削除後、そのユーザーのすべての権限は直ちに無効になり、関連するユーザーグループのメンバーシップも自動的に削除されます。",
|
||||
"editUser": "ユーザーを編集",
|
||||
"onlyOneAdminTips": "現在のユーザーを管理者からユーザーに変更できません。システム最後の管理者であるため、変更後にシステムを管理する人がいなくなります。",
|
||||
"numberOfUsers": "ユーザー数",
|
||||
"associatedDeviceGroup": "関連デバイスグループ",
|
||||
"associatedDeviceGroups": "関連デバイスグループ",
|
||||
"associatedDeviceGroupTips": "ユーザーグループをデバイスグループに関連付けると、このユーザーグループのすべてのメンバーが対応するデバイスグループ内のデバイスを閲覧する権限を得られます。",
|
||||
"editUserGroup": "ユーザーグループを編集",
|
||||
"deleteUserGroup": "ユーザーグループを削除",
|
||||
"deleteUserGroupConfirmTips1": "現在のユーザーグループを削除すると、関連するすべてのデバイスグループの権限が自動的に削除されます。",
|
||||
"deleteUserGroupConfirmTips2": "このユーザーグループのメンバーは、対応するデバイスグループ内のデバイスを閲覧する権限を失います。",
|
||||
"deleteUserGroupConfirmTips3": "削除してもよろしいですか?",
|
||||
"deleteOnlyOneAdminTips": "削除できません:システム最後の管理者です。",
|
||||
"myGroup": "マイグループ",
|
||||
"userRoleDesc": "管理者はすべてのデバイスを閲覧できますが、一般ユーザーはユーザーグループに関連付けられたデバイスグループ内のデバイスのみ閲覧できます",
|
||||
"userType": "ユーザータイプ"
|
||||
},
|
||||
"deviceLog": {
|
||||
"title": "ログ",
|
||||
"mac": "デバイスMAC",
|
||||
"macPlaceholder": "MACで検索",
|
||||
"eventType": "イベントタイプ",
|
||||
"actor": "実行者",
|
||||
"clientIp": "クライアントIP",
|
||||
"detail": "詳細",
|
||||
"createTime": "作成日時",
|
||||
"endTime": "終了日時",
|
||||
"duration": "所要時間",
|
||||
"noData": "データなし",
|
||||
"refresh": "更新",
|
||||
"inProgress": "進行中",
|
||||
"tabs": {
|
||||
"device": "デバイスのオン/オフ",
|
||||
"access": "リモートアクセス"
|
||||
},
|
||||
"event": {
|
||||
"deviceOnline": "デバイスオンライン",
|
||||
"deviceOffline": "デバイスオフライン",
|
||||
"remoteSsh": "リモートSSH",
|
||||
"remoteWeb": "リモートWeb",
|
||||
"remoteControl": "リモートコントロール"
|
||||
}
|
||||
},
|
||||
"rtty": {
|
||||
"requestingDeviceToCreateTerminal": "デバイスにターミナル作成をリクエスト中...",
|
||||
"uploadFileToDevice": "デバイスにファイルをアップロード",
|
||||
"selectFile": "ファイルを選択",
|
||||
"copyTips": "コピー - Ctrl+Insert",
|
||||
"pasteTips": "貼り付け - Shift+Insert",
|
||||
"clearScrollBack": "スクロールバックをクリア",
|
||||
"fontUp": "フォントサイズを拡大",
|
||||
"fontDown": "フォントサイズを縮小",
|
||||
"uploadFile": "ファイルをアップロード",
|
||||
"downloadFile": "ファイルをダウンロード",
|
||||
"splitLeft": "分割: 左",
|
||||
"splitRight": "分割: 右",
|
||||
"splitUp": "分割: 上",
|
||||
"splitDown": "分割: 下",
|
||||
"copiedToClipboard": "クリップボードにコピーしました",
|
||||
"executeCommandR": "現在のターミナルでコマンド \"rtty -R\" を実行してください!",
|
||||
"executeCommandS": "現在のターミナルでコマンド \"rtty -S file\" を実行してください!",
|
||||
"useShortcutSI": "ショートカット \"Shift+Insert\" を使用してください",
|
||||
"pastedFromClipboard": "クリップボードから貼り付けました",
|
||||
"clipboardPermissionRequired": "クリップボードの権限が必要です",
|
||||
"clipboardInstructions": "クリップボードの権限を有効にするには、アドレスバーの左側にあるサイト情報アイコンをクリックして権限設定にアクセスするか、ブラウザのプライバシー設定でこのサイトのクリップボード権限を設定してください。Shift+Insertキーボードショートカットで貼り付けることもできます。",
|
||||
"fileTooLargeTips": "アップロードしようとしているファイルが大きすぎます(> 4294967295 バイト)"
|
||||
},
|
||||
"errorPage": {
|
||||
"sorry": "申し訳ございません!",
|
||||
"backHome": "ホームに戻る",
|
||||
"tryRefresh": "再読み込みする",
|
||||
"notLogion": "このページにアクセスする権限がありません。ログインしてからもう一度お試しください。",
|
||||
"noPermission": "このページへのアクセスが許可されていません。",
|
||||
"notFound": "このページは見つかりません。<br>ご安心ください。チームが既に対応中で、まもなく修正いたします!"
|
||||
},
|
||||
"errorCode": {
|
||||
"FAILED": "失敗しました",
|
||||
"SEND_EMAIL_OR_PHONE_FAILED": "ユーザーの本人確認のため、再ログインしてください。",
|
||||
"SERVER_ERROR": "サーバーエラー!",
|
||||
"INVALID_TOKEN": "無効なトークンです!",
|
||||
"PERMISSION_ERROR": "権限エラー!",
|
||||
"MISS_PARAM": "パラメーターが不足しています",
|
||||
"DUPLICATE_OPERATION": "重複操作です",
|
||||
"INPUT_PARAM_ERROR": "入力パラメーターエラー",
|
||||
"MESSAGE_EXPIRED_ERROR": "メッセージの有効期限が切れました",
|
||||
"ACCOUNT_LOGIN_ELSEWHERE": "別の場所でログインされました",
|
||||
"AUTHENTICATION_TOKEN_EXPIRED": "認証トークンの有効期限が切れました",
|
||||
"VALIDATE_PASSWORD_ERR": "パスワードの検証に失敗しました",
|
||||
"PASSWORD_SPECIAL_SYMBOLS_ERR": "パスワードの特殊文字エラー",
|
||||
"PASSWORD_STRENGTH_ERR": "パスワードの強度エラー",
|
||||
"REQUEST_BUSY_ERROR": "リクエストが多すぎます。しばらくしてから再度お試しください。",
|
||||
"DATA_USER_NOT_MATCH": "現在のデータはログインユーザーと一致しません。",
|
||||
"DATA_XSS_CHECK_FAILED": "リクエストにXSS攻撃要素が含まれています。ご確認ください",
|
||||
"AUTH_TIME_OUT": "認証タイムアウト",
|
||||
"USERNAME_OR_PASSWORD_ERROR": "ユーザー名またはパスワードが無効です。",
|
||||
"USER_NOT_EXIST": "ユーザーが存在しません",
|
||||
"USER_PASSWORD_NOT_MATCH": "ユーザーパスワードが一致しません",
|
||||
"USER_PASSWORD_ERROR": "パスワードエラー",
|
||||
"USER_NAME_EXISTS": "ユーザー名は既に存在します",
|
||||
"USER_EMAIL_EXISTS": "メールアドレスは既に登録されています",
|
||||
"USER_EMAIL_CODE_ERROR": "メール認証コードエラー",
|
||||
"USER_EMAIL_INCORRECT": "メールアドレスが正しくありません",
|
||||
"USER_EMAIL_EXPIRED": "メール認証コードの有効期限が切れました",
|
||||
"USER_TWO_FAC_AUTH": "二要素認証が有効です",
|
||||
"USER_2FA_ERROR": "二要素認証コードエラー",
|
||||
"USER_EMAIL_NOT_YOURS": "このメールアドレスはあなたのものではありません",
|
||||
"USER_PHONE_EXPIRED": "SMS認証コードの有効期限が切れました。",
|
||||
"USER_PHONE_CODE_ERROR": "SMS認証コードが正しくありません。",
|
||||
"USER_PHONE_NOT_YOURS": "この電話番号はあなたのものではありません",
|
||||
"USER_PHONE_EXISTS": "電話番号は既に登録されています",
|
||||
"USER_PHONE_NOT_EXISTS": "電話番号が登録されていません",
|
||||
"USERNAME_OR_PASSWORD_WITH_COUNT_ERROR": "アカウントがロックされました",
|
||||
"LIMIT_CONTROL": "制限に達しました",
|
||||
"REGION_INFO_INCORRECT": "地域情報が正しくありません",
|
||||
"VERIFICATION_CODE_COUNT_ERROR": "認証コードのエラー回数が上限を超えました",
|
||||
"DATA_ERROR": "データエラー!",
|
||||
"DEVICE_OFFLINE": "デバイスはオフラインです。",
|
||||
"PARAM_ERROR": "パラメーターエラー!",
|
||||
"NEED_RECAPTCHA": "人間であることの確認が必要です",
|
||||
"RECAPTCHA_ERROR": "人間確認に失敗しました",
|
||||
"DUPLICATE_ENTRY": "重複エントリーです",
|
||||
"DATA_DEPEND": "データの依存関係があります",
|
||||
"INFO_INCORRECT": "情報が正しくありません",
|
||||
"UPLOAD_IMAGE_CHECK_EXCEPTION": "画像アップロードの検証例外!",
|
||||
"UPLOAD_IMAGE_FAIL": "画像のアップロードに失敗しました!",
|
||||
"REPEAT_REQUEST_VERIFICATION_CODE": "認証コードは既にリクエスト済みです。再送信しないでください。",
|
||||
"USER_DATA_TO_BE_MODIFIED_NOT_EXIST": "変更対象のデータが存在しません。ご確認ください!",
|
||||
"USER_OBJECT_INFO_EMPTY": "オブジェクト情報を空にすることはできません!",
|
||||
"USER_ID_NOT_NULL": "主キーIDはnullである必要があります。",
|
||||
"USER_SEND_MODE_EMPTY": "送信方法(メール/メッセージ/サイト通知)を空にすることはできません!",
|
||||
"USER_RECIPIENT_ID_EMPTY": "受信者IDを空にすることはできません!",
|
||||
"USER_SEND_STATUS_EMPTY": "送信ステータス(0-成功/1-失敗)を空にすることはできません!",
|
||||
"USER_ID_EMPTY": "主キーIDを空にすることはできません!",
|
||||
"USER_USERNAME_EMPTY": "ユーザー名を空にすることはできません!",
|
||||
"USER_PHONE_EMPTY": "電話番号を空にすることはできません!",
|
||||
"USER_EMAIL_EMPTY": "メールアドレスを空にすることはできません!",
|
||||
"USER_UPDATE_EMAIL_EMPTY": "メールアドレスを空にすることはできません!",
|
||||
"USER_UPDATE_USER_TOKEN_EMPTY": "ユーザートークンを空にすることはできません!",
|
||||
"USER_CODE_EMPTY": "コードを空にすることはできません!",
|
||||
"USER_ENTER_USER_INFO": "ユーザー情報を入力してください。",
|
||||
"USER_ACCOUNT_EMPTY": "アカウントを空にすることはできません!",
|
||||
"USER_PASSWORD_EMPTY": "パスワードを空にすることはできません!",
|
||||
"USER_NOT_EXISTS": "ユーザーが存在しません。",
|
||||
"USER_USER_ID_EMPTY": "ユーザーIDを空にすることはできません!",
|
||||
"USER_USER_ROLE_EMPTY": "ユーザーロールが設定されていません!",
|
||||
"USER_CANNOT_DELETE_YOURSELF": "自分自身は削除できません!",
|
||||
"USER_CANNOT_DELETE_ADMINISTRATOR": "管理者は削除できません!",
|
||||
"USER_ILLEGAL_MAIL_ADDRESS": "メールアドレスが不正です。",
|
||||
"USER_ILLEGAL_PHONE_NUMBER": "電話番号が不正です。",
|
||||
"USER_ROLES_EMPTY": "ロールを空にすることはできません!",
|
||||
"USER_RE_PASSWORD_EMPTY": "パスワードの再入力を空にすることはできません!",
|
||||
"USER_ILLEGAL_USERNAME": "ユーザー名が不正です。",
|
||||
"USER_USERNAME_EXISTS": "ユーザー名は既に存在します。",
|
||||
"USER_SELECT_ROLE": "ロールを選択してください。",
|
||||
"USER_USER_NOT_EXIST": "ユーザーが存在しません。",
|
||||
"USER_PASSWORD_RESET_LINK_EXPIRED": "クリックしたパスワードリセットリンクの有効期限が切れました。ログインページから新しいパスワードリセットリンクをリクエストしてください。",
|
||||
"USER_ILLEGAL_ROLE": "ロールが不正です。",
|
||||
"EMAIL_CAPTCHA_CODE_ERROR": "メールキャプチャコードエラー。",
|
||||
"EMAIL_CAPTCHA_CODE_EXPIRED": "メールキャプチャコードの有効期限が切れました。",
|
||||
"ACCOUNT_CANCELLATION_LINK_EXPIRED": "このリンクの有効期限が切れました。アカウント削除を再度リクエストしてください。",
|
||||
"CLOUD_EMAIL_NOT_MATCH_USER": "メールアドレスがユーザーと一致しません。",
|
||||
"CLOUD_USER_NOT_EXIST": "ユーザーが存在しません。"
|
||||
},
|
||||
"notification": {
|
||||
"title": "通知",
|
||||
"smtpConfig": "メールサーバー (SMTP)",
|
||||
"smtpHost": "SMTP ホスト",
|
||||
"smtpPort": "ポート",
|
||||
"smtpUsername": "ユーザー名",
|
||||
"smtpPassword": "パスワード",
|
||||
"fromEmail": "送信元メール",
|
||||
"encryption": "暗号化",
|
||||
"enableNotification": "有効化",
|
||||
"testEmail": "テストメール送信",
|
||||
"testEmailSent": "テストメール送信先",
|
||||
"noRecipientForTest": "最初に受信者のメールアドレスを追加してください",
|
||||
"notifyRules": "通知ルール",
|
||||
"ruleDeviceOnline": "デバイスオンライン",
|
||||
"ruleDeviceOnlineDesc": "デバイスがオンラインになった際に通知を送信します。",
|
||||
"ruleDeviceOffline": "デバイスオフライン",
|
||||
"ruleDeviceOfflineDesc": "デバイスがオフラインになった際に通知を送信します。",
|
||||
"ruleRemoteAccess": "リモートアクセス",
|
||||
"ruleRemoteAccessDesc": "リモートSSH、Web、またはコントロールアクセス時に通知を送信します。",
|
||||
"recipients": "受信者メール",
|
||||
"recipientPlaceholder": "メールアドレスを入力",
|
||||
"addRecipient": "追加",
|
||||
"removeRecipientConfirm": "この受信者を削除しますか?",
|
||||
"noRecipients": "受信者が追加されていません。",
|
||||
"invalidEmail": "メールアドレスが無効です。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"common": {
|
||||
"refresh": "새로고침",
|
||||
"action": "작업",
|
||||
"d": "일",
|
||||
"h": "시간",
|
||||
"m": "분",
|
||||
"s": "초",
|
||||
"copySuccess": "복사 완료",
|
||||
"copyFailed": "복사 실패",
|
||||
"detail": "상세",
|
||||
"success": "성공",
|
||||
"failed": "실패",
|
||||
"cancel": "취소",
|
||||
"confirm": "확인",
|
||||
"ok": "확인",
|
||||
"close": "닫기",
|
||||
"about": "정보",
|
||||
"maxLength": "최대 길이는 {length}자입니다",
|
||||
"more": "더 보기",
|
||||
"pleaseSelect": "선택하세요",
|
||||
"delete": "삭제",
|
||||
"remove": "제거",
|
||||
"apply": "적용",
|
||||
"edit": "편집"
|
||||
},
|
||||
"login": {
|
||||
"authorizationRequired": "인증 필요",
|
||||
"username": "사용자 이름",
|
||||
"enterUsernameTip": "사용자 이름을 입력하세요",
|
||||
"password": "비밀번호",
|
||||
"signIn": "로그인",
|
||||
"enterPwdTip": "비밀번호를 입력하세요",
|
||||
"incorrectPwd": "비밀번호가 올바르지 않습니다",
|
||||
"notAuthorized": "권한 없음",
|
||||
"signOut": "로그아웃",
|
||||
"authOptions": "인증 방식",
|
||||
"ldapAuth": "LDAP 인증을 위해 사용자 이름과 비밀번호를 입력하세요",
|
||||
"webManagementAuth": "사용자 이름을 비워두고 웹 관리 비밀번호를 사용하세요",
|
||||
"or": "또는",
|
||||
"loginWithOidc": "OIDC로 로그인",
|
||||
"confirmPasswordValidateError": "입력한 비밀번호가 일치하지 않습니다.",
|
||||
"accountLogin": "계정 로그인",
|
||||
"ldap": "LDAP",
|
||||
"local": "로컬",
|
||||
"oidc": "OIDC",
|
||||
"twoFactorTitle": "2단계 인증",
|
||||
"totpHelp": "인증 앱을 열고 6자리 인증 코드를 입력하세요.",
|
||||
"enterTotpCode": "6자리 코드 입력",
|
||||
"rememberThisDevice": "이 기기를 30일간 신뢰",
|
||||
"verify": "인증",
|
||||
"back": "뒤로"
|
||||
},
|
||||
"personalCenter": {
|
||||
"title": "개인 센터",
|
||||
"personalInformation": "개인 정보",
|
||||
"username": "사용자 이름",
|
||||
"displayName": "표시 이름",
|
||||
"authProvider": "인증 제공자",
|
||||
"registrationTime": "가입 시간",
|
||||
"latestLoginTime": "최근 로그인 시간",
|
||||
"notFilled": "미설정",
|
||||
"edit": "편집",
|
||||
"securitySettings": "보안 설정",
|
||||
"twoFactorAuth": "2단계 인증",
|
||||
"twoFactorAuthDesc": "로그인 시 인증 앱의 인증 코드를 요구합니다.",
|
||||
"twoFactorOnlyLocal": "ID 제공자({provider})에서 관리되며 여기에서 설정할 수 없습니다.",
|
||||
"trustedDevices": "신뢰할 수 있는 기기",
|
||||
"trustedDevicesDesc": "이 목록에 추가된 브라우저는 30일간 로그인 시 2단계 인증을 건너뜁니다.",
|
||||
"deviceName": "기기",
|
||||
"ipAddress": "IP 주소",
|
||||
"lastUsed": "최근 사용",
|
||||
"expiresAt": "만료 일시",
|
||||
"revoke": "해제",
|
||||
"revokeConfirm": "이 신뢰할 수 있는 기기를 해제하시겠습니까? 다음 번 이 브라우저에서 로그인 시 인증 코드가 필요합니다.",
|
||||
"enable2fa": "2단계 인증 활성화",
|
||||
"disable2fa": "2단계 인증 비활성화",
|
||||
"disable2faTip": "확인을 위해 현재 6자리 인증 코드를 입력하세요. 모든 신뢰할 수 있는 기기도 함께 초기화됩니다.",
|
||||
"scanQrCode": "인증 앱으로 QR 코드를 스캔하세요",
|
||||
"secretKey": "또는 이 비밀 키를 직접 입력하세요",
|
||||
"verifyCode": "인증 코드",
|
||||
"enterVerifyCode": "6자리 코드 입력"
|
||||
},
|
||||
"device": {
|
||||
"devices": "기기",
|
||||
"addDevice": "기기 추가",
|
||||
"searchTip": "키워드를 입력하여 검색하세요",
|
||||
"executeCommand": "명령 실행",
|
||||
"remoteSSH": "원격 SSH",
|
||||
"remoteControl": "원격 제어",
|
||||
"deviceID": "기기 ID",
|
||||
"connectedTime": "연결 시간",
|
||||
"uptime": "가동 시간",
|
||||
"IPAddress": "IP 주소",
|
||||
"description": "설명",
|
||||
"selectDeviceTips": "작업할 기기를 선택하세요",
|
||||
"refreshSuccess": "새로고침 성공",
|
||||
"noDevice": "기기 없음",
|
||||
"noDeviceTip": "아직 추가된 기기가 없습니다.",
|
||||
"addDeviceTip": "클라우드에 연결하려면 기기 셸에서 다음 스크립트를 실행하세요",
|
||||
"copyScript": "스크립트 복사",
|
||||
"username": "사용자 이름",
|
||||
"inputUsername": "사용자 이름 입력",
|
||||
"requiredUsername": "사용자 이름을 입력하세요",
|
||||
"command": "명령",
|
||||
"inputCommand": "명령 입력",
|
||||
"requiredCommand": "명령을 입력하세요",
|
||||
"parameter": "매개변수",
|
||||
"inputParameter": "매개변수 입력",
|
||||
"waitTime": "대기 시간",
|
||||
"inputWaitTime": "대기 시간 입력",
|
||||
"commandResponse": "명령 응답",
|
||||
"commandResponseLoadingTips": "설정을 전송 중입니다. 잠시 기다려 주세요...",
|
||||
"code": "코드",
|
||||
"errorCode": "오류 코드",
|
||||
"errorMessage": "오류 메시지",
|
||||
"commandResponseDetail": "명령 응답 상세",
|
||||
"standardOutput": "표준 출력",
|
||||
"standardErrorOutput": "표준 오류 출력",
|
||||
"status": "상태",
|
||||
"online": "온라인",
|
||||
"offline": "오프라인",
|
||||
"editDescription": "설명 편집",
|
||||
"inputDescription": "설명 입력",
|
||||
"requiredDescription": "설명을 입력하세요",
|
||||
"deleteDevice": "기기 삭제",
|
||||
"deleteDeviceConfirmTips": "이 기기를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"mac": "Mac 주소",
|
||||
"moveToGroup": "그룹으로 이동",
|
||||
"moveToDeviceGroup": "기기 그룹으로 이동",
|
||||
"moveToDeviceGroupTips": "기기는 한 번에 하나의 그룹에만 연결할 수 있습니다. 현재 그룹에 추가하면 기존 그룹에서 자동으로 연결이 해제됩니다.",
|
||||
"notFoundDeviceGroup": "찾을 수 없나요? 지금 생성하기",
|
||||
"requiredDeviceGroup": "기기 그룹을 선택하세요",
|
||||
"deviceGroup": "기기 그룹",
|
||||
"unassigned": "미할당",
|
||||
"addDeviceGroup": "기기 그룹 추가",
|
||||
"deviceGroupName": "기기 그룹 이름",
|
||||
"requiredDeviceGroupName": "입력하세요, 최대 32자",
|
||||
"requiredDeviceGroupDescription": "입력하세요, 최대 200자",
|
||||
"allAssociatedDeviceGroup": "모든 연결된 기기 그룹",
|
||||
"associatedDeviceCount": "연결된 기기 수",
|
||||
"associatedUserGroups": "연결된 사용자 그룹",
|
||||
"allAssociatedUserGroups": "모든 연결된 사용자 그룹",
|
||||
"manageDevices": "기기 관리",
|
||||
"addDeviceToGroup": "그룹에 기기 추가",
|
||||
"editBasicInfo": "기본 정보 편집",
|
||||
"basicInfo": "기본 정보",
|
||||
"notAdded": "추가되지 않음",
|
||||
"showOnlyUnassigned": "미할당만 표시",
|
||||
"deleteDeviceGroup": "기기 그룹 삭제",
|
||||
"deleteDeviceGroupConfirmTips": "현재 그룹을 삭제하면 그룹 내 모든 기기가 자동으로 미할당 상태가 됩니다. 이 그룹을 삭제하시겠습니까?",
|
||||
"manageDevicesTips": "현재 그룹에서 기기를 제거하면 해당 기기는 미할당 상태가 됩니다.",
|
||||
"removeDevice": "기기 제거",
|
||||
"currentlySelectedDevice": "현재 선택: {num}대",
|
||||
"removeDeviceConfirmTips1": "현재 그룹에서 기기를 제거하면 미할당 상태가 됩니다.",
|
||||
"removeDeviceConfirmTips2": "제거하시겠습니까?",
|
||||
"addDeviceToGroupConfirmTips1": "기기를 현재 그룹에 추가하면, 이미 다른 그룹에 연결되어 있는 경우 기존 그룹과의 연결이 자동으로 해제됩니다.",
|
||||
"addDeviceToGroupConfirmTips2": "추가하시겠습니까?",
|
||||
"accessYourDevice": "기기의 웹에 접속",
|
||||
"protocol": "프로토콜",
|
||||
"port": "포트",
|
||||
"path": "경로",
|
||||
"ipNotCorrect": "IP 주소가 올바르지 않습니다",
|
||||
"portNotCorrect": "포트가 올바르지 않습니다",
|
||||
"remoteWeb": "원격 웹",
|
||||
"linuxTips": "OpenWrt, Raspberry PI, Ubuntu, CentOS 등을 지원합니다.",
|
||||
"customColumns": "사용자 지정 열",
|
||||
"dragColumnTips": "기기 목록을 사용자 지정할 수 있습니다. 아래 항목의 오른쪽 버튼을 드래그하여 표시 순서를 조정하거나, 체크박스를 사용하여 특정 열의 표시 또는 숨김을 제어하세요."
|
||||
},
|
||||
"user": {
|
||||
"user": "사용자",
|
||||
"userManager": "사용자 관리",
|
||||
"userGroup": "사용자 그룹",
|
||||
"userName": "사용자 이름",
|
||||
"role": "역할",
|
||||
"addUser": "사용자 추가",
|
||||
"admin": "관리자",
|
||||
"userRole": "사용자 역할",
|
||||
"setPassword": "비밀번호 설정",
|
||||
"enterPassword": "비밀번호를 입력하세요",
|
||||
"reEnterPassword": "비밀번호 재입력",
|
||||
"reEnterPasswordPlc": "비밀번호를 다시 입력하세요",
|
||||
"addUserGroup": "사용자 그룹 추가",
|
||||
"userGroupName": "사용자 그룹 이름",
|
||||
"deleteUser": "사용자 삭제",
|
||||
"deleteUserConfirmTips": "사용자 {name}을(를) 삭제하시겠습니까? 삭제 후 해당 사용자의 모든 권한이 즉시 무효화되며, 연결된 사용자 그룹 멤버십이 자동으로 제거됩니다.",
|
||||
"editUser": "사용자 편집",
|
||||
"onlyOneAdminTips": "현재 사용자를 관리자에서 일반 사용자로 변경할 수 없습니다. 시스템의 마지막 관리자이므로 변경 후 시스템을 관리할 사람이 없습니다.",
|
||||
"numberOfUsers": "사용자 수",
|
||||
"associatedDeviceGroup": "연결된 기기 그룹",
|
||||
"associatedDeviceGroups": "연결된 기기 그룹",
|
||||
"associatedDeviceGroupTips": "사용자 그룹이 기기 그룹에 연결되면, 해당 사용자 그룹의 모든 구성원이 해당 기기 그룹의 기기를 볼 수 있는 권한을 갖게 됩니다.",
|
||||
"editUserGroup": "사용자 그룹 편집",
|
||||
"deleteUserGroup": "사용자 그룹 삭제",
|
||||
"deleteUserGroupConfirmTips1": "현재 사용자 그룹을 삭제하면 연결된 모든 기기 그룹 권한이 자동으로 제거됩니다.",
|
||||
"deleteUserGroupConfirmTips2": "이 사용자 그룹의 구성원은 해당 기기 그룹의 기기를 볼 수 있는 권한을 잃게 됩니다.",
|
||||
"deleteUserGroupConfirmTips3": "삭제하시겠습니까?",
|
||||
"deleteOnlyOneAdminTips": "삭제할 수 없습니다: 시스템에 관리자가 한 명만 남아 있습니다.",
|
||||
"myGroup": "내 그룹",
|
||||
"userRoleDesc": "관리자는 모든 기기를 볼 수 있으며, 일반 사용자는 사용자 그룹에 연결된 기기 그룹의 기기만 볼 수 있습니다",
|
||||
"userType": "사용자 유형"
|
||||
},
|
||||
"deviceLog": {
|
||||
"title": "로그",
|
||||
"mac": "기기 MAC",
|
||||
"macPlaceholder": "MAC으로 검색",
|
||||
"eventType": "이벤트 유형",
|
||||
"actor": "수행자",
|
||||
"clientIp": "클라이언트 IP",
|
||||
"detail": "상세",
|
||||
"createTime": "생성 시간",
|
||||
"endTime": "종료 시간",
|
||||
"duration": "기간",
|
||||
"noData": "데이터 없음",
|
||||
"refresh": "새로고침",
|
||||
"inProgress": "진행 중",
|
||||
"tabs": {
|
||||
"device": "기기 접속/해제",
|
||||
"access": "원격 접속"
|
||||
},
|
||||
"event": {
|
||||
"deviceOnline": "기기 온라인",
|
||||
"deviceOffline": "기기 오프라인",
|
||||
"remoteSsh": "원격 SSH",
|
||||
"remoteWeb": "원격 웹",
|
||||
"remoteControl": "원격 제어"
|
||||
}
|
||||
},
|
||||
"rtty": {
|
||||
"requestingDeviceToCreateTerminal": "기기에 터미널 생성을 요청 중...",
|
||||
"uploadFileToDevice": "기기에 파일 업로드",
|
||||
"selectFile": "파일 선택",
|
||||
"copyTips": "복사 - Ctrl+Insert",
|
||||
"pasteTips": "붙여넣기 - Shift+Insert",
|
||||
"clearScrollBack": "스크롤백 지우기",
|
||||
"fontUp": "글꼴 크기 키우기",
|
||||
"fontDown": "글꼴 크기 줄이기",
|
||||
"uploadFile": "파일 업로드",
|
||||
"downloadFile": "파일 다운로드",
|
||||
"splitLeft": "분할: 왼쪽",
|
||||
"splitRight": "분할: 오른쪽",
|
||||
"splitUp": "분할: 위",
|
||||
"splitDown": "분할: 아래",
|
||||
"copiedToClipboard": "클립보드에 복사됨",
|
||||
"executeCommandR": "현재 터미널에서 \"rtty -R\" 명령을 실행하세요!",
|
||||
"executeCommandS": "현재 터미널에서 \"rtty -S file\" 명령을 실행하세요!",
|
||||
"useShortcutSI": "\"Shift+Insert\" 단축키를 사용하세요",
|
||||
"pastedFromClipboard": "클립보드에서 붙여넣기됨",
|
||||
"clipboardPermissionRequired": "클립보드 권한 필요",
|
||||
"clipboardInstructions": "클립보드 권한을 활성화하려면 주소 표시줄 왼쪽의 사이트 정보 아이콘을 클릭하여 권한 설정에 접근하거나, 브라우저 개인정보 설정에서 이 사이트의 클립보드 권한을 구성하세요. Shift+Insert 키보드 단축키를 사용하여 붙여넣을 수도 있습니다.",
|
||||
"fileTooLargeTips": "업로드하려는 파일이 너무 큽니다(> 4294967295 Byte)"
|
||||
},
|
||||
"errorPage": {
|
||||
"sorry": "죄송합니다!",
|
||||
"backHome": "홈으로 돌아가기",
|
||||
"tryRefresh": "새로고침 시도",
|
||||
"notLogion": "이 페이지에 접근할 권한이 없습니다. 로그인 후 다시 시도하세요.",
|
||||
"noPermission": "이 페이지에 접근할 권한이 없습니다.",
|
||||
"notFound": "이 페이지를 찾을 수 없습니다.<br>걱정하지 마세요. 저희 팀이 이미 작업 중이며 곧 수정할 예정입니다!"
|
||||
},
|
||||
"errorCode": {
|
||||
"FAILED": "실패",
|
||||
"SEND_EMAIL_OR_PHONE_FAILED": "사용자 신원 확인을 위해 다시 로그인하세요.",
|
||||
"SERVER_ERROR": "서버 오류!",
|
||||
"INVALID_TOKEN": "유효하지 않은 토큰!",
|
||||
"PERMISSION_ERROR": "권한 오류!",
|
||||
"MISS_PARAM": "매개변수 누락",
|
||||
"DUPLICATE_OPERATION": "중복 작업",
|
||||
"INPUT_PARAM_ERROR": "입력 매개변수 오류",
|
||||
"MESSAGE_EXPIRED_ERROR": "메시지 만료",
|
||||
"ACCOUNT_LOGIN_ELSEWHERE": "다른 곳에서 계정 로그인됨",
|
||||
"AUTHENTICATION_TOKEN_EXPIRED": "인증 토큰 만료",
|
||||
"VALIDATE_PASSWORD_ERR": "비밀번호 검증 실패",
|
||||
"PASSWORD_SPECIAL_SYMBOLS_ERR": "비밀번호 특수문자 오류",
|
||||
"PASSWORD_STRENGTH_ERR": "비밀번호 강도 오류",
|
||||
"REQUEST_BUSY_ERROR": "요청이 너무 많습니다. 나중에 다시 시도하세요!",
|
||||
"DATA_USER_NOT_MATCH": "현재 데이터가 로그인한 사용자와 일치하지 않습니다.",
|
||||
"DATA_XSS_CHECK_FAILED": "요청에 XSS 공격 요소가 포함되어 있습니다. 확인하세요",
|
||||
"AUTH_TIME_OUT": "인증 시간 초과",
|
||||
"USERNAME_OR_PASSWORD_ERROR": "사용자 이름 또는 비밀번호가 올바르지 않습니다.",
|
||||
"USER_NOT_EXIST": "사용자가 존재하지 않습니다",
|
||||
"USER_PASSWORD_NOT_MATCH": "사용자 비밀번호가 일치하지 않습니다",
|
||||
"USER_PASSWORD_ERROR": "비밀번호 오류",
|
||||
"USER_NAME_EXISTS": "사용자 이름이 이미 존재합니다",
|
||||
"USER_EMAIL_EXISTS": "사용자 이메일이 이미 존재합니다",
|
||||
"USER_EMAIL_CODE_ERROR": "사용자 이메일 코드 오류",
|
||||
"USER_EMAIL_INCORRECT": "사용자 이메일이 올바르지 않습니다",
|
||||
"USER_EMAIL_EXPIRED": "이메일 인증 코드가 만료되었습니다",
|
||||
"USER_TWO_FAC_AUTH": "2단계 인증이 활성화되어 있습니다",
|
||||
"USER_2FA_ERROR": "2단계 인증 코드 오류",
|
||||
"USER_EMAIL_NOT_YOURS": "본인의 이메일이 아닙니다",
|
||||
"USER_PHONE_EXPIRED": "휴대폰 인증 코드가 만료되었습니다.",
|
||||
"USER_PHONE_CODE_ERROR": "휴대폰 인증 코드가 올바르지 않습니다.",
|
||||
"USER_PHONE_NOT_YOURS": "본인의 전화번호가 아닙니다",
|
||||
"USER_PHONE_EXISTS": "전화번호가 이미 존재합니다",
|
||||
"USER_PHONE_NOT_EXISTS": "전화번호가 존재하지 않습니다",
|
||||
"USERNAME_OR_PASSWORD_WITH_COUNT_ERROR": "계정이 잠겼습니다",
|
||||
"LIMIT_CONTROL": "제한 초과",
|
||||
"REGION_INFO_INCORRECT": "지역 정보가 올바르지 않습니다",
|
||||
"VERIFICATION_CODE_COUNT_ERROR": "인증 코드 오류 횟수 초과",
|
||||
"DATA_ERROR": "데이터 오류!",
|
||||
"DEVICE_OFFLINE": "기기가 오프라인입니다.",
|
||||
"PARAM_ERROR": "매개변수 오류!",
|
||||
"NEED_RECAPTCHA": "사람-기계 인증이 필요합니다",
|
||||
"RECAPTCHA_ERROR": "사람-기계 인증 실패",
|
||||
"DUPLICATE_ENTRY": "중복 항목",
|
||||
"DATA_DEPEND": "데이터 종속성",
|
||||
"INFO_INCORRECT": "정보가 올바르지 않습니다",
|
||||
"UPLOAD_IMAGE_CHECK_EXCEPTION": "이미지 업로드 검사 오류!",
|
||||
"UPLOAD_IMAGE_FAIL": "이미지 업로드 실패!",
|
||||
"REPEAT_REQUEST_VERIFICATION_CODE": "이미 인증 코드를 요청했습니다. 다시 보내지 마세요.",
|
||||
"USER_DATA_TO_BE_MODIFIED_NOT_EXIST": "수정할 데이터가 존재하지 않습니다. 확인하세요!",
|
||||
"USER_OBJECT_INFO_EMPTY": "객체 정보는 비워둘 수 없습니다!",
|
||||
"USER_ID_NOT_NULL": "기본 키 ID는 null이어야 합니다.",
|
||||
"USER_SEND_MODE_EMPTY": "발송 방식(이메일/메시지/사이트 메시지)은 비워둘 수 없습니다!",
|
||||
"USER_RECIPIENT_ID_EMPTY": "수신자 ID는 비워둘 수 없습니다!",
|
||||
"USER_SEND_STATUS_EMPTY": "발송 상태(0-성공/1-실패)는 null일 수 없습니다!",
|
||||
"USER_ID_EMPTY": "기본 키 ID는 비워둘 수 없습니다!",
|
||||
"USER_USERNAME_EMPTY": "사용자 이름은 비워둘 수 없습니다!",
|
||||
"USER_PHONE_EMPTY": "전화번호는 비워둘 수 없습니다!",
|
||||
"USER_EMAIL_EMPTY": "이메일은 비워둘 수 없습니다!",
|
||||
"USER_UPDATE_EMAIL_EMPTY": "이메일 주소는 비워둘 수 없습니다!",
|
||||
"USER_UPDATE_USER_TOKEN_EMPTY": "사용자 토큰은 비워둘 수 없습니다!",
|
||||
"USER_CODE_EMPTY": "코드는 비워둘 수 없습니다!",
|
||||
"USER_ENTER_USER_INFO": "사용자 정보를 입력하세요.",
|
||||
"USER_ACCOUNT_EMPTY": "계정은 비워둘 수 없습니다!",
|
||||
"USER_PASSWORD_EMPTY": "비밀번호는 비워둘 수 없습니다!",
|
||||
"USER_NOT_EXISTS": "사용자가 존재하지 않습니다.",
|
||||
"USER_USER_ID_EMPTY": "사용자 ID는 비워둘 수 없습니다!",
|
||||
"USER_USER_ROLE_EMPTY": "사용자 역할이 비어 있습니다!",
|
||||
"USER_CANNOT_DELETE_YOURSELF": "자기 자신은 삭제할 수 없습니다!",
|
||||
"USER_CANNOT_DELETE_ADMINISTRATOR": "관리자는 삭제할 수 없습니다!",
|
||||
"USER_ILLEGAL_MAIL_ADDRESS": "잘못된 이메일 주소입니다.",
|
||||
"USER_ILLEGAL_PHONE_NUMBER": "잘못된 전화번호입니다.",
|
||||
"USER_ROLES_EMPTY": "역할은 비워둘 수 없습니다!",
|
||||
"USER_RE_PASSWORD_EMPTY": "비밀번호 재입력은 비워둘 수 없습니다!",
|
||||
"USER_ILLEGAL_USERNAME": "잘못된 사용자 이름입니다.",
|
||||
"USER_USERNAME_EXISTS": "사용자 이름이 이미 존재합니다.",
|
||||
"USER_SELECT_ROLE": "역할을 선택하세요.",
|
||||
"USER_USER_NOT_EXIST": "사용자가 존재하지 않습니다.",
|
||||
"USER_PASSWORD_RESET_LINK_EXPIRED": "클릭하신 비밀번호 재설정 링크가 만료되었습니다. 로그인 페이지에서 새 비밀번호 재설정 링크를 요청하세요.",
|
||||
"USER_ILLEGAL_ROLE": "잘못된 역할입니다.",
|
||||
"EMAIL_CAPTCHA_CODE_ERROR": "이메일 캡차 코드 오류.",
|
||||
"EMAIL_CAPTCHA_CODE_EXPIRED": "이메일 캡차 코드 만료.",
|
||||
"ACCOUNT_CANCELLATION_LINK_EXPIRED": "이 링크가 만료되었습니다. 계정 삭제를 다시 요청하세요.",
|
||||
"CLOUD_EMAIL_NOT_MATCH_USER": "이메일이 사용자와 일치하지 않습니다.",
|
||||
"CLOUD_USER_NOT_EXIST": "사용자가 존재하지 않습니다."
|
||||
},
|
||||
"notification": {
|
||||
"title": "알림",
|
||||
"smtpConfig": "메일 서버 (SMTP)",
|
||||
"smtpHost": "SMTP 호스트",
|
||||
"smtpPort": "포트",
|
||||
"smtpUsername": "사용자 이름",
|
||||
"smtpPassword": "비밀번호",
|
||||
"fromEmail": "발신자 이메일",
|
||||
"encryption": "암호화",
|
||||
"enableNotification": "활성화",
|
||||
"testEmail": "테스트 이메일 보내기",
|
||||
"testEmailSent": "테스트 이메일 발송 완료",
|
||||
"noRecipientForTest": "먼저 수신자 이메일을 추가하세요",
|
||||
"notifyRules": "알림 규칙",
|
||||
"ruleDeviceOnline": "장치 온라인",
|
||||
"ruleDeviceOnlineDesc": "장치가 온라인 상태가 되면 알림을 보냅니다.",
|
||||
"ruleDeviceOffline": "장치 오프라인",
|
||||
"ruleDeviceOfflineDesc": "장치가 오프라인 상태가 되면 알림을 보냅니다.",
|
||||
"ruleRemoteAccess": "원격 접속",
|
||||
"ruleRemoteAccessDesc": "원격 SSH, 웹 또는 제어 접속 시 알림을 보냅니다.",
|
||||
"recipients": "수신자 이메일",
|
||||
"recipientPlaceholder": "이메일 주소 입력",
|
||||
"addRecipient": "추가",
|
||||
"removeRecipientConfirm": "이 수신자를 삭제하시겠습니까?",
|
||||
"noRecipients": "추가된 수신자가 없습니다.",
|
||||
"invalidEmail": "유효하지 않은 이메일 주소입니다."
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"ok": "确定",
|
||||
"close": "关闭",
|
||||
"about": "关于",
|
||||
@@ -40,7 +41,45 @@
|
||||
"loginWithOidc": "使用OIDC登录",
|
||||
"confirmPasswordValidateError": "密码不一致。",
|
||||
"accountLogin": "账号登录",
|
||||
"ldap": "LDAP"
|
||||
"ldap": "LDAP",
|
||||
"local": "本地",
|
||||
"oidc": "OIDC",
|
||||
"twoFactorTitle": "两步验证",
|
||||
"totpHelp": "请打开身份验证器 App,输入 6 位验证码。",
|
||||
"enterTotpCode": "请输入 6 位验证码",
|
||||
"rememberThisDevice": "信任此设备 30 天",
|
||||
"verify": "验证",
|
||||
"back": "返回"
|
||||
},
|
||||
"personalCenter": {
|
||||
"title": "个人中心",
|
||||
"personalInformation": "个人信息",
|
||||
"username": "用户名",
|
||||
"displayName": "显示名称",
|
||||
"authProvider": "认证方式",
|
||||
"registrationTime": "注册时间",
|
||||
"latestLoginTime": "最近登录时间",
|
||||
"notFilled": "未填写",
|
||||
"edit": "编辑",
|
||||
"securitySettings": "安全设置",
|
||||
"twoFactorAuth": "两步验证",
|
||||
"twoFactorAuthDesc": "登录时要求输入身份验证器 App 生成的验证码。",
|
||||
"twoFactorOnlyLocal": "由身份提供方({provider})统一管理,无法在此处配置。",
|
||||
"trustedDevices": "信任设备",
|
||||
"trustedDevicesDesc": "加入信任的浏览器,30 天内登录无需输入两步验证码。",
|
||||
"deviceName": "设备",
|
||||
"ipAddress": "IP 地址",
|
||||
"lastUsed": "最近使用",
|
||||
"expiresAt": "过期时间",
|
||||
"revoke": "撤销",
|
||||
"revokeConfirm": "确定要撤销这台信任设备吗?下次从该浏览器登录时将需要重新输入验证码。",
|
||||
"enable2fa": "启用两步验证",
|
||||
"disable2fa": "关闭两步验证",
|
||||
"disable2faTip": "输入当前的 6 位验证码以确认。所有信任设备也会被一并清除。",
|
||||
"scanQrCode": "请使用身份验证器 App 扫描下方二维码",
|
||||
"secretKey": "或手动输入以下密钥",
|
||||
"verifyCode": "验证码",
|
||||
"enterVerifyCode": "请输入 6 位验证码"
|
||||
},
|
||||
"device": {
|
||||
"devices": "设备数",
|
||||
@@ -124,7 +163,9 @@
|
||||
"ipNotCorrect": "IP 地址错误",
|
||||
"portNotCorrect": "端口号错误",
|
||||
"remoteWeb": "远程 Web",
|
||||
"linuxTips": "支持 OpenWrt、树莓派、Ubuntu、CentOS 等"
|
||||
"linuxTips": "支持 OpenWrt、树莓派、Ubuntu、CentOS 等",
|
||||
"customColumns": "自定义列",
|
||||
"dragColumnTips": "您可以自定义您的设备列表,拖动下列项右侧的按钮来调整显示顺序,也可以通过复选框来控制显示或隐藏某些列。"
|
||||
},
|
||||
"user": {
|
||||
"user": "用户",
|
||||
@@ -156,7 +197,34 @@
|
||||
"deleteUserGroupConfirmTips3": "你确定要删除它吗?",
|
||||
"deleteOnlyOneAdminTips": "无法删除:只剩一个系统管理员了。",
|
||||
"myGroup": "我的用户组",
|
||||
"userRoleDesc": "管理员可以看到所有设备,普通用户只能看到用户组关联设备组的设备"
|
||||
"userRoleDesc": "管理员可以看到所有设备,普通用户只能看到用户组关联设备组的设备",
|
||||
"userType": "用户类型"
|
||||
},
|
||||
"deviceLog": {
|
||||
"title": "日志",
|
||||
"mac": "设备 MAC",
|
||||
"macPlaceholder": "按 MAC 搜索",
|
||||
"eventType": "事件类型",
|
||||
"actor": "操作用户",
|
||||
"clientIp": "客户端 IP",
|
||||
"detail": "详情",
|
||||
"createTime": "开始时间",
|
||||
"endTime": "结束时间",
|
||||
"duration": "持续时长",
|
||||
"noData": "暂无数据",
|
||||
"refresh": "刷新",
|
||||
"inProgress": "进行中",
|
||||
"tabs": {
|
||||
"device": "设备上下线",
|
||||
"access": "远程访问"
|
||||
},
|
||||
"event": {
|
||||
"deviceOnline": "设备上线",
|
||||
"deviceOffline": "设备下线",
|
||||
"remoteSsh": "远程 SSH",
|
||||
"remoteWeb": "远程 Web",
|
||||
"remoteControl": "远程控制"
|
||||
}
|
||||
},
|
||||
"rtty": {
|
||||
"requestingDeviceToCreateTerminal": "正在请求设备创建终端...",
|
||||
@@ -277,5 +345,32 @@
|
||||
"ACCOUNT_CANCELLATION_LINK_EXPIRED": "该链接已过期,请重新申请注销。",
|
||||
"CLOUD_EMAIL_NOT_MATCH_USER": "邮箱地址跟用户不匹配。",
|
||||
"CLOUD_USER_NOT_EXIST": "该用户不存在。"
|
||||
},
|
||||
"notification": {
|
||||
"title": "通知",
|
||||
"smtpConfig": "邮件服务器 (SMTP)",
|
||||
"smtpHost": "SMTP 主机",
|
||||
"smtpPort": "端口",
|
||||
"smtpUsername": "用户名",
|
||||
"smtpPassword": "密码",
|
||||
"fromEmail": "发件人邮箱",
|
||||
"encryption": "加密方式",
|
||||
"enableNotification": "启用",
|
||||
"testEmail": "发送测试邮件",
|
||||
"testEmailSent": "测试邮件已发送至",
|
||||
"noRecipientForTest": "请先添加收件人邮箱",
|
||||
"notifyRules": "通知规则",
|
||||
"ruleDeviceOnline": "设备上线",
|
||||
"ruleDeviceOnlineDesc": "设备上线时发送通知。",
|
||||
"ruleDeviceOffline": "设备下线",
|
||||
"ruleDeviceOfflineDesc": "设备下线时发送通知。",
|
||||
"ruleRemoteAccess": "远程访问",
|
||||
"ruleRemoteAccessDesc": "远程 SSH、Web 或控制访问时发送通知。",
|
||||
"recipients": "收件人邮箱",
|
||||
"recipientPlaceholder": "输入邮箱地址",
|
||||
"addRecipient": "添加",
|
||||
"removeRecipientConfirm": "确认移除该收件人?",
|
||||
"noRecipients": "暂无收件人。",
|
||||
"invalidEmail": "邮箱地址格式不正确。"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ export interface DeviceQuery {
|
||||
searchText: string
|
||||
deviceGroupId: number
|
||||
onlyShowUnassigned: boolean
|
||||
status?: 'online' | 'offline'
|
||||
sortBy?: string
|
||||
order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
/** 执行命令参数 */
|
||||
|
||||
@@ -25,4 +25,10 @@ export enum PermissionEnum {
|
||||
USER_WRITE = 'user.write',
|
||||
/** 管理以下关系:• 用户 ↔ 用户组• 用户组 ↔ 设备组• 设备 ↔ 设备组 */
|
||||
RELATION_WRITE = 'relation.write',
|
||||
/** 查看设备事件日志 (admin only) */
|
||||
DEVICE_LOG_READ = 'device_log.read',
|
||||
/** 查看通知设置 (admin only) */
|
||||
NOTIFICATION_READ = 'notification.read',
|
||||
/** 编辑通知设置 (admin only) */
|
||||
NOTIFICATION_WRITE = 'notification.write',
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Personal Center 类型声明
|
||||
*/
|
||||
|
||||
import { AuthProviderEnum, UserRoleEnum } from './userManage'
|
||||
|
||||
export interface PersonalProfile {
|
||||
id: number
|
||||
username: string
|
||||
displayName: string
|
||||
email: string
|
||||
role: UserRoleEnum
|
||||
authProvider: AuthProviderEnum
|
||||
/** 注册时间,unix 秒,0 表示未知 */
|
||||
registrationTime: number
|
||||
/** 最近登录时间,unix 秒,null 表示从未登录 */
|
||||
lastLoginTime: number | null
|
||||
totpEnabled: boolean
|
||||
}
|
||||
|
||||
export interface Setup2faResp {
|
||||
secret: string
|
||||
otpauthUrl: string
|
||||
}
|
||||
|
||||
export interface TrustedDevice {
|
||||
id: number
|
||||
deviceName: string
|
||||
ip: string
|
||||
/** unix 秒 */
|
||||
createdAt: number
|
||||
/** unix 秒 */
|
||||
lastUsedAt: number
|
||||
/** unix 秒 */
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export interface TrustedDeviceList {
|
||||
items: TrustedDevice[]
|
||||
}
|
||||
@@ -9,14 +9,31 @@
|
||||
|
||||
import { Languages, SelectOptions } from 'gl-web-main'
|
||||
|
||||
/** 扩展语言代码(gl-web-main 的 Languages 枚举仅含 zh/en,此处补充其余语言) */
|
||||
export const AppLanguages = {
|
||||
...Languages,
|
||||
JA: 'ja' as unknown as Languages,
|
||||
KO: 'ko' as unknown as Languages,
|
||||
DE: 'de' as unknown as Languages,
|
||||
FR: 'fr' as unknown as Languages,
|
||||
ES: 'es' as unknown as Languages,
|
||||
} as const
|
||||
|
||||
export type AppLanguage = (typeof AppLanguages)[keyof typeof AppLanguages]
|
||||
|
||||
/** 语言对应的label映射 */
|
||||
export const languageLabelMap = new Map<Languages, string>([
|
||||
[Languages.ZH, '中文'],
|
||||
[Languages.EN, 'English'],
|
||||
[AppLanguages.JA, '日本語'],
|
||||
[AppLanguages.KO, '한국어'],
|
||||
[AppLanguages.DE, 'Deutsch'],
|
||||
[AppLanguages.FR, 'Français'],
|
||||
[AppLanguages.ES, 'Español'],
|
||||
])
|
||||
|
||||
/** 语言选择options */
|
||||
export const languageOptions = Object.values(Languages).map(lang => new SelectOptions(lang, languageLabelMap.get(lang)))
|
||||
export const languageOptions = Object.values(AppLanguages).map(lang => new SelectOptions(lang, languageLabelMap.get(lang as Languages)))
|
||||
|
||||
export interface Theme {
|
||||
attribute: string,
|
||||
|
||||
+12
-1
@@ -13,8 +13,19 @@ import { UserRoleEnum } from './userManage'
|
||||
/** 登录参数 (Login parameters) */
|
||||
export interface LoginParams {
|
||||
username: string;
|
||||
password: string;
|
||||
password: string;
|
||||
authMethod?: 'ldap' | 'legacy';
|
||||
/** 6 位 TOTP 验证码,2FA 启用且未绑定信任设备时需提供 */
|
||||
totpCode?: string;
|
||||
/** 是否将本浏览器加入信任设备(30 天免 2FA) */
|
||||
rememberDevice?: boolean;
|
||||
}
|
||||
|
||||
/** 登录响应 (Login response) */
|
||||
export interface LoginResp {
|
||||
token?: string;
|
||||
/** 后端要求 2FA 时为 true,前端应弹出 TOTP 输入框 */
|
||||
twoFactorRequired?: boolean;
|
||||
}
|
||||
|
||||
/** 认证配置 (Authentication configuration) */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2026-02-02 15:13:17
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-09 09:13:22
|
||||
* @LastEditTime: 2026-03-25 10:09:43
|
||||
* @FilePath: \glkvm-cloud\ui\src\models\userManage.ts
|
||||
* @Description: 用户管理相关类型声明
|
||||
*/
|
||||
@@ -22,12 +22,25 @@ export const UserRoleLabelMap = new Map([
|
||||
[UserRoleEnum.USER, 'user.user'],
|
||||
])
|
||||
|
||||
export enum AuthProviderEnum {
|
||||
LOCAL = 'local',
|
||||
LDAP = 'ldap',
|
||||
OIDC = 'oidc',
|
||||
}
|
||||
|
||||
export const AuthProviderLabelMap = new Map([
|
||||
[AuthProviderEnum.LOCAL, 'login.local'],
|
||||
[AuthProviderEnum.LDAP, 'login.ldap'],
|
||||
[AuthProviderEnum.OIDC, 'login.oidc'],
|
||||
])
|
||||
|
||||
export interface UserManage {
|
||||
id: number
|
||||
username: string
|
||||
role: UserRoleEnum
|
||||
description: string
|
||||
isSystem: boolean
|
||||
authProvider: AuthProviderEnum,
|
||||
userGroupList: {
|
||||
userGroupId: number
|
||||
userGroupName: string
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 09:44:40
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-06-19 10:12:11
|
||||
* @FilePath: /kvm-cloud-frontend/src/projectInitialize/index.ts
|
||||
* @LastEditTime: 2026-03-25 11:09:30
|
||||
* @FilePath: \glkvm-cloud\ui\src\projectInitialize\index.ts
|
||||
* @Description: 项目初始化的操作
|
||||
*/
|
||||
import type { App } from 'vue'
|
||||
@@ -12,6 +12,7 @@ import { initializeAllLanguage } from '@/lang'
|
||||
import { installComponent } from './installComponent'
|
||||
import loadAdvComponent from './loadAdvComponent'
|
||||
import { installDirective } from './installDirective'
|
||||
import { checkAndClearCache } from '@/utils/versionManager'
|
||||
|
||||
export default function (app: App ) {
|
||||
/** 加载插件 */
|
||||
@@ -28,4 +29,7 @@ export default function (app: App ) {
|
||||
|
||||
/** 初始化语言 */
|
||||
initializeAllLanguage()
|
||||
|
||||
/** 检查并清理缓存 */
|
||||
checkAndClearCache()
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 10:54:44
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-03 10:22:56
|
||||
* @LastEditTime: 2026-03-09 14:30:29
|
||||
* @FilePath: \glkvm-cloud\ui\src\projectInitialize\loadAdvComponent.ts
|
||||
* @Description: 加载Ant 组件
|
||||
*/
|
||||
import {
|
||||
import {
|
||||
ConfigProvider,
|
||||
Button,
|
||||
Pagination,
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
Select,
|
||||
Tabs,
|
||||
Radio,
|
||||
Popover,
|
||||
Popconfirm,
|
||||
DatePicker,
|
||||
} from 'ant-design-vue'
|
||||
|
||||
export default function (app: any) {
|
||||
@@ -46,4 +49,7 @@ export default function (app: any) {
|
||||
app.use(Select)
|
||||
app.use(Tabs)
|
||||
app.use(Radio)
|
||||
app.use(Popover)
|
||||
app.use(Popconfirm)
|
||||
app.use(DatePicker)
|
||||
}
|
||||
+43
-3
@@ -22,10 +22,12 @@ declare module 'vue-router' {
|
||||
menu?: boolean
|
||||
/** 联动父元素的path(例如添加设备页,若不展示在侧边栏内,但是在添加设备页,需要选中左侧的menu,则传入需要选中menu的path),仅支持设置顶级path且menu不能为true */
|
||||
linkageParentLevelPath?: string
|
||||
/** 如果需要展示到侧边栏,则title和icon必填 */
|
||||
/** 如果需要展示到侧边栏,则title和icon必填 */
|
||||
title?: string
|
||||
/** 如果需要展示到侧边栏,则title和icon必填 */
|
||||
/** 如果需要展示到侧边栏,则title和icon必填 */
|
||||
icon?: string
|
||||
/** 仅当用户具备此权限时,才在侧边栏中显示 */
|
||||
permission?: PermissionEnum
|
||||
/** 不需要在路由表的meta中添加, 仅用于侧边栏判断*/
|
||||
whetherToExpandChildElements?: boolean
|
||||
}
|
||||
@@ -88,7 +90,40 @@ const router = createRouter({
|
||||
icon: 'gl-icon-user-manage',
|
||||
},
|
||||
},
|
||||
|
||||
/** 设备事件日志 (admin only) */
|
||||
{
|
||||
path: '/log',
|
||||
component: () => import('@/views/log/logPage.vue'),
|
||||
name: 'log',
|
||||
meta: {
|
||||
menu: true,
|
||||
title: 'deviceLog.title',
|
||||
icon: 'gl-npm-list',
|
||||
permission: PermissionEnum.DEVICE_LOG_READ,
|
||||
},
|
||||
},
|
||||
/** 通知设置 (admin only) */
|
||||
{
|
||||
path: '/notification',
|
||||
component: () => import('@/views/notification/notificationSettingsPage.vue'),
|
||||
name: 'notification',
|
||||
meta: {
|
||||
menu: true,
|
||||
title: 'notification.title',
|
||||
icon: 'gl-icon-bell',
|
||||
permission: PermissionEnum.NOTIFICATION_READ,
|
||||
},
|
||||
},
|
||||
/** 个人中心 */
|
||||
{
|
||||
path: '/personal-center',
|
||||
component: () => import('@/views/personalCenter/personalCenterPage.vue'),
|
||||
name: 'personalCenter',
|
||||
meta: {
|
||||
title: 'personalCenter.title',
|
||||
},
|
||||
},
|
||||
|
||||
/** 测试页 */
|
||||
{
|
||||
path: '/test',
|
||||
@@ -183,6 +218,11 @@ router.beforeEach(async (to) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Block access to routes the user has no permission for
|
||||
if (to.meta?.permission && !hasPermission(to.meta.permission as PermissionEnum)) {
|
||||
return { path: '/' }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 09:37:06
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-01-05 14:35:02
|
||||
* @LastEditTime: 2026-03-10 11:44:32
|
||||
* @FilePath: \glkvm-cloud\ui\src\stores\modules\app.ts
|
||||
* @Description: app相关状态存储
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: shufei.han
|
||||
* @Date: 2025-06-10 16:46:00
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-01-30 17:41:49
|
||||
* @LastEditTime: 2026-03-09 12:17:03
|
||||
* @FilePath: \glkvm-cloud\ui\src\stores\modules\device.ts
|
||||
* @Description: 设备有关的状态管理
|
||||
*/
|
||||
@@ -18,7 +18,7 @@ const GET_DEVICE_POLLING_INTERVAL = 10 * 1000
|
||||
let getDeviceListTimer: number
|
||||
let pollingEnable = false
|
||||
|
||||
const DEVICE_VIEW_PAGE_SIZE = 20
|
||||
const DEVICE_VIEW_PAGE_SIZE = 50
|
||||
|
||||
export const useDeviceStore = defineStore('device', () => {
|
||||
const state = reactive({
|
||||
@@ -34,8 +34,14 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
deviceGroupId: undefined,
|
||||
/** 是否仅显示未分配项 */
|
||||
onlyShowUnassigned: false,
|
||||
/** 在线状态筛选:undefined=全部 / 'online' / 'offline' */
|
||||
status: undefined as 'online' | 'offline' | undefined,
|
||||
/** 这个字段存储是否有设备,因为UI上没有设备和没有筛选出来的设备是对应不同的展示画面的 */
|
||||
hasDevice: false,
|
||||
/** 排序字段 */
|
||||
sortBy: undefined,
|
||||
/** 排序方式 */
|
||||
order: undefined,
|
||||
})
|
||||
|
||||
const pageLink = ref(new PageLink({ size: DEVICE_VIEW_PAGE_SIZE }))
|
||||
@@ -49,39 +55,38 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
searchText: state.searchText?.replaceAll(':','').toLowerCase(),
|
||||
deviceGroupId: state.deviceGroupId,
|
||||
onlyShowUnassigned: state.onlyShowUnassigned,
|
||||
status: state.status,
|
||||
sortBy: state.sortBy,
|
||||
order: state.order,
|
||||
}
|
||||
return query
|
||||
})
|
||||
/** 设备列表的分页展示数据 */
|
||||
const deviceList= computed<DeviceInfo[]>(() => {
|
||||
/** 设备列表展示数据(服务端已分页,直接展示当前页) */
|
||||
const deviceList = computed<DeviceInfo[]>(() => state.deviceList)
|
||||
/** 获取设备列表(服务端分页 + 搜索/筛选下推后端) */
|
||||
const getDeviceList = async (isPolling = false, isGetAll = false) => {
|
||||
try {
|
||||
const { page, size } = pageLink.value
|
||||
return state.deviceList.slice((page - 1) * size, page * size)
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
})
|
||||
/** 获取设备列表 */
|
||||
const getDeviceList = async (isPolling = false, isGetAll = false) => {
|
||||
try {
|
||||
console.log('getDeviceList', computedDeviceQuery.value)
|
||||
!isPolling && (state.getDeviceLoading = true)
|
||||
const res = await getDeviceListApi()
|
||||
console.log(res)
|
||||
|
||||
const res = await getDeviceListApi({
|
||||
page: pageLink.value.page,
|
||||
pageSize: pageLink.value.size,
|
||||
q: computedDeviceQuery.value.searchText || undefined,
|
||||
groupId: computedDeviceQuery.value.deviceGroupId,
|
||||
unassigned: computedDeviceQuery.value.onlyShowUnassigned || undefined,
|
||||
status: computedDeviceQuery.value.status || undefined,
|
||||
sortBy: computedDeviceQuery.value.sortBy,
|
||||
order: computedDeviceQuery.value.order,
|
||||
})
|
||||
const total = res.data.total ?? res.data.items.length
|
||||
pageLink.value.setTotal(total)
|
||||
// hasDevice 区分“账号一台设备都没有(引导页)”和“筛选无结果(空表格)”:
|
||||
// 只有无筛选的首次加载(isGetAll)可置 false,之后只升不降。
|
||||
if (isGetAll) {
|
||||
state.hasDevice = res.data.items.length > 0
|
||||
}
|
||||
if (res.data.items.length) {
|
||||
state.hasDevice = total > 0
|
||||
} else if (total > 0) {
|
||||
state.hasDevice = true
|
||||
}
|
||||
pageLink.value.setTotal(res.data.items.length)
|
||||
state.deviceList = res.data.items.filter(d => {
|
||||
return (d?.id?.toString().toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1
|
||||
|| d?.description?.toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1) &&
|
||||
(computedDeviceQuery.value.deviceGroupId ? d.deviceGroupId === computedDeviceQuery.value.deviceGroupId : true) &&
|
||||
(!computedDeviceQuery.value.onlyShowUnassigned || (computedDeviceQuery.value.onlyShowUnassigned && !d.deviceGroupId))
|
||||
}) || []
|
||||
state.deviceList = res.data.items || []
|
||||
state.completeDeviceList = res.data.items || []
|
||||
!isPolling && (state.getDeviceLoading = false)
|
||||
} catch (error) {
|
||||
@@ -111,10 +116,19 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
pollingEnable && startPolling()
|
||||
}, GET_DEVICE_POLLING_INTERVAL)
|
||||
}
|
||||
/** 监听设备列表的查询条件变化 */
|
||||
watch(computedDeviceQuery, () => {
|
||||
/** 翻页(服务端分页):页码变化即拉取当前页 */
|
||||
watch(() => pageLink.value.page, () => {
|
||||
getDeviceList()
|
||||
})
|
||||
/** 查询条件变化(搜索/组/未分配/排序):重置到第 1 页。
|
||||
* 若已在第 1 页则直接拉取,否则改页码由上面的页码 watch 触发,避免重复请求。 */
|
||||
watch(computedDeviceQuery, () => {
|
||||
if (pageLink.value.page !== 1) {
|
||||
pageLink.value.changePage(1)
|
||||
} else {
|
||||
getDeviceList()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
state,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2026-01-30 10:19:24
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-04 10:58:29
|
||||
* @LastEditTime: 2026-02-28 09:28:39
|
||||
* @FilePath: \glkvm-cloud\ui\src\stores\modules\deviceGroup.ts
|
||||
* @Description: 设备组有关的状态管理
|
||||
*/
|
||||
@@ -63,8 +63,7 @@ export const useDeviceGroupStore = defineStore('deviceGroup', () => {
|
||||
const res = await reqDeviceGroupList()
|
||||
pageLink.value.setTotal(res.data.items.length)
|
||||
state.deviceGroupList = res.data.items.filter(d => {
|
||||
return (d?.id?.toString()?.indexOf(computedDeviceGroupQuery.value.searchText) > -1
|
||||
|| d?.description?.indexOf(computedDeviceGroupQuery.value.searchText) > -1
|
||||
return (d?.description?.indexOf(computedDeviceGroupQuery.value.searchText) > -1
|
||||
|| d?.name?.indexOf(computedDeviceGroupQuery.value.searchText) > -1)
|
||||
}) || []
|
||||
state.completeDeviceGroupList = res.data.items || []
|
||||
|
||||
@@ -56,11 +56,19 @@ export const useUserStore = defineStore('user', () => {
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
authMethod: credentials.authMethod,
|
||||
totpCode: credentials.totpCode,
|
||||
rememberDevice: credentials.rememberDevice,
|
||||
}
|
||||
|
||||
|
||||
const data = await reqLogin(params)
|
||||
// 后端在需要 2FA 时返回 twoFactorRequired=true 且不携带 token,
|
||||
// 此时不应建立会话;交由调用方弹出 TOTP 输入框后再次调用 login。
|
||||
if (data.data?.twoFactorRequired && !data.data?.token) {
|
||||
return { twoFactorRequired: true }
|
||||
}
|
||||
setToken(data.data.token)
|
||||
fetchUserInfo()
|
||||
await fetchUserInfo()
|
||||
return { twoFactorRequired: false }
|
||||
}
|
||||
|
||||
/** 合并登出方法,不可导出使用 */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2026-02-03 12:07:45
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-04 10:56:54
|
||||
* @LastEditTime: 2026-02-28 09:27:05
|
||||
* @FilePath: \glkvm-cloud\ui\src\stores\modules\userGroupManage.ts
|
||||
* @Description: 用户组管理相关状态管理
|
||||
*/
|
||||
@@ -65,7 +65,7 @@ export const useUserGroupManageStore = defineStore('userGroupManage', () => {
|
||||
const res = await reqUserGroupList()
|
||||
pageLink.value.setTotal(res.data.items.length)
|
||||
state.userGroupList = res.data.items.filter(d => {
|
||||
return (d?.id?.toString()?.indexOf(computedUserGroupManageQuery.value.searchText) > -1
|
||||
return (d?.description?.toString()?.toLowerCase()?.indexOf(computedUserGroupManageQuery.value.searchText) > -1
|
||||
|| d?.userGroup?.indexOf(computedUserGroupManageQuery.value.searchText) > -1) &&
|
||||
(computedUserGroupManageQuery.value.deviceGroupId ?
|
||||
d.deviceGroupList.some(u => u.deviceGroupId === computedUserGroupManageQuery.value.deviceGroupId) : true)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user