20 Commits

Author SHA1 Message Date
GL.iNet-Yongping.Xie 80112c7978 fix: resolve incorrect conflicts when merging dev v2.4.0
Fix incorrect merge conflicts in the dev v2.4.0 branch.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-25 01:06:33 -07:00
GL.iNet-Yongping.Xie b943a18959 fix: test device group sorting and LDAP/OIDC user creation
Verify device group sorting.
Test automatic user creation for LDAP and OIDC login.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-25 01:03:56 -07:00
pengyu.lu ea37a171e9 Merge branch 'main' into dev-ui-0129 2026-03-25 11:45:00 +08:00
pengyu.lu 689793d47f feat: Some UI experiences have been optimized 2026-03-25 11:44:22 +08:00
GL.iNet-Yongping.Xie 55854afba8 feat: add device group sorting and auto-create LDAP/OIDC users
- support device group sorting
- auto-create local users for LDAP and OIDC login

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-24 02:00:09 -07:00
iclannad c8c67d5f0e Merge pull request #34 from PiexlPuck/Docker-compose-file-fixes
chore: remove unused service and update env template
2026-03-18 19:30:49 +08:00
Brayden 66566e74a8 chore: remove unused service and update env template
- Commented out the deprecated configuration line in `docker-compose/.env.example`
- Removed the associated service from Docker Compose files as it is no longer required
2026-03-18 21:28:00 +11:00
pengyu.lu 30a132cb4e Merge branch 'dev-ui-0129' 2026-03-10 11:56:20 +08:00
pengyu.lu 52117f044b feat: Optimize the logic for storing data on the front end 2026-03-10 11:56:03 +08:00
pengyu.lu 884f537ae8 Merge branch 'dev-ui-0129' 2026-03-10 10:46:19 +08:00
pengyu.lu 807bab3e2a fix: Fix the issue where the version number is displayed with double quotes 2026-03-10 10:45:56 +08:00
GL.iNet-Yongping.Xie a1823096cc Merge branch 'feature/devicetable-order' 2026-03-09 19:22:38 -07:00
GL.iNet-Yongping.Xie b1aa8e3cae feat: bump version to v2.3.0
- Update project version to v2.3.0.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-09 19:20:30 -07:00
pengyu.lu 3d75a87140 Merge branch 'dev-ui-0129' 2026-03-10 10:17:54 +08:00
pengyu.lu d237847f0d feat: Some UI issues have been optimized to enhance user experience 2026-03-10 10:17:26 +08:00
GL.iNet-Yongping.Xie 59021535b7 feat: add sorting support for the device list
Implemented multi-column sorting (ascending and descending) for the device list, supporting ID, IP, MAC, connectedTime, description, and DDNS.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-08 19:37:01 -07:00
GL.iNet-Yongping.Xie 133f344713 Merge branch 'bugfix/v2.1.0' 2026-02-27 18:28:24 -08:00
GL.iNet-Yongping.Xie 7fd9c39ffb fix: resolve issues in v2.1.0
Fix reported bugs and stability issues in v2.1.0.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-27 18:27:43 -08:00
pengyu.lu 96936ecdd8 Merge branch 'dev-ui-0129' 2026-02-28 10:10:40 +08:00
pengyu.lu a44c9a4833 fix: Fix some UI issues 2026-02-28 10:10:11 +08:00
49 changed files with 1596 additions and 743 deletions
+1
View File
@@ -3,6 +3,7 @@
"ddns",
"glkvm",
"repassword",
"sortablejs",
"webrtc"
]
}
+14
View File
@@ -102,6 +102,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 +133,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=
+15 -1
View File
@@ -63,7 +63,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 +101,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 +132,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=
+4 -2
View File
@@ -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,8 @@ 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:-}
# ---- Reverse Proxy ----
REVERSE_PROXY_ENABLED: ${REVERSE_PROXY_ENABLED:-false}
+3 -1
View File
@@ -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}}
+2
View File
@@ -20,4 +20,6 @@ type User struct {
Role identity.Role
Status Status
IsSystem bool
AuthProvider string // "local", "oidc", "ldap"
ExternalSub string // OIDC sub claim / LDAP user DN
}
+1
View File
@@ -5,6 +5,7 @@ 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)
+89 -1
View File
@@ -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,90 @@ 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)
}
// 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
}
}
}
+5 -4
View File
@@ -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 {
+1
View File
@@ -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"`
}
+16 -4
View File
@@ -1,6 +1,7 @@
package handler
import (
"rttys/internal/domain/identity"
"rttys/internal/pkg/ldap"
"rttys/xconfig"
"strings"
@@ -12,6 +13,7 @@ import (
"rttys/internal/store/memory"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
type AuthHandler struct {
@@ -40,7 +42,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,12 +51,22 @@ 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 {
+64 -1
View File
@@ -118,13 +118,76 @@ func (h *DeviceHandler) ListDevices(c *gin.Context) {
}
}
// Parse sort parameters: sortBy and order
sortBy := strings.TrimSpace(c.Query("sortBy")) // id, ip, mac, connectedTime, description, ddns
sortOrder := strings.TrimSpace(c.Query("order")) // asc, desc (default: asc)
ascending := true
if strings.EqualFold(sortOrder, "desc") {
ascending = false
}
sort.SliceStable(items, func(i, j int) bool {
// Online devices always come first regardless of sort field/order
oi := items[i].Status == device.StatusOnline
oj := items[j].Status == device.StatusOnline
if oi != oj {
return oi
}
return items[i].Ddns < items[j].Ddns
// Secondary sort by the requested field
// cmp: -1 means i<j, 0 means equal, 1 means i>j
var cmp int
switch sortBy {
case "id":
switch {
case items[i].ID < items[j].ID:
cmp = -1
case items[i].ID > items[j].ID:
cmp = 1
}
case "ip":
cmp = strings.Compare(items[i].IP, items[j].IP)
case "mac":
cmp = strings.Compare(items[i].Mac, items[j].Mac)
case "connectedTime":
var ti, tj int64
if items[i].LastSeenAt != nil {
ti = *items[i].LastSeenAt
}
if items[j].LastSeenAt != nil {
tj = *items[j].LastSeenAt
}
switch {
case ti < tj:
cmp = -1
case ti > tj:
cmp = 1
}
case "description":
cmp = strings.Compare(items[i].Description, items[j].Description)
case "ddns":
cmp = strings.Compare(items[i].Ddns, items[j].Ddns)
case "deviceGroupName":
var gi, gj string
if items[i].DeviceGroupID != nil {
gi = groupNameByID[*items[i].DeviceGroupID]
}
if items[j].DeviceGroupID != nil {
gj = groupNameByID[*items[j].DeviceGroupID]
}
cmp = strings.Compare(gi, gj)
default:
cmp = strings.Compare(items[i].Ddns, items[j].Ddns)
}
if cmp == 0 {
return false // equal, preserve original order
}
if ascending {
return cmp < 0
}
return cmp > 0
})
out := make([]dto.Device, 0, len(items))
+5 -4
View File
@@ -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,
}))
+25
View File
@@ -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))
+7
View File
@@ -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,
})
+72 -22
View File
@@ -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
}
+4 -2
View File
@@ -138,14 +138,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
}
+44 -5
View File
@@ -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)
+3 -3
View File
@@ -1,9 +1,9 @@
package server
const RttysVersion = "5.2.0"
const KVMCloudVersion = "v2.0.0"
const KVMCloudVersion = "v2.4.0"
var (
GitCommit = ""
BuildTime = ""
GitCommit = ""
BuildTime = ""
)
+49 -1
View File
@@ -81,7 +81,16 @@ 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
}
return ensureExternalIdentityIndex(ctx, db)
}
func ensureDeviceClientColumn(ctx context.Context, db *sql.DB) error {
@@ -111,3 +120,42 @@ 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
}
+41
View File
@@ -25,6 +25,8 @@ 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"`
}
func (userRow) TableName() string { return "users" }
@@ -51,6 +53,8 @@ func (r *UserRepo) FindByID(ctx context.Context, id int64) (*user.User, error) {
Role: identity.Role(row.Role),
Status: user.Status(row.Status),
IsSystem: row.IsSystem,
AuthProvider: row.AuthProvider,
ExternalSub: row.ExternalSub,
}
return u, nil
}
@@ -77,6 +81,35 @@ func (r *UserRepo) FindByUsername(ctx context.Context, username string) (*user.U
Role: identity.Role(row.Role),
Status: user.Status(row.Status),
IsSystem: row.IsSystem,
AuthProvider: row.AuthProvider,
ExternalSub: row.ExternalSub,
}, 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 &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,
AuthProvider: row.AuthProvider,
ExternalSub: row.ExternalSub,
}, nil
}
@@ -102,6 +135,8 @@ func (r *UserRepo) FindSystemAdmin(ctx context.Context) (*user.User, error) {
Role: identity.Role(row.Role),
Status: user.Status(row.Status),
IsSystem: row.IsSystem,
AuthProvider: row.AuthProvider,
ExternalSub: row.ExternalSub,
}, nil
}
@@ -122,6 +157,8 @@ func (r *UserRepo) List(ctx context.Context) ([]user.User, error) {
Role: identity.Role(row.Role),
Status: user.Status(row.Status),
IsSystem: row.IsSystem,
AuthProvider: row.AuthProvider,
ExternalSub: row.ExternalSub,
})
}
return out, nil
@@ -136,6 +173,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 +196,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
}
+2 -1
View File
@@ -25,12 +25,13 @@
"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",
"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",
+2 -2
View File
@@ -2,7 +2,7 @@
* @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
*/
@@ -10,7 +10,7 @@ import { ExecuteCommandParams, type DeviceInfo } from '@/models/device'
import request, { httpService } from './request'
/** 获取设备列表 */
export const getDeviceListApi = (params?: { groupId: number }) => {
export const getDeviceListApi = (params?: { groupId: number, sortBy?: string, order?: 'asc' | 'desc' }) => {
return request<{ items: DeviceInfo[]}>({
url: '/api/devices',
params,
File diff suppressed because one or more lines are too long
+17 -5
View File
@@ -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 = () => {
+8 -3
View File
@@ -40,7 +40,9 @@
"loginWithOidc": "Log in with OIDC",
"confirmPasswordValidateError": "The passwords you typed do not match.",
"accountLogin": "Account Login",
"ldap": "LDAP"
"ldap": "LDAP",
"local": "Local",
"oidc": "OIDC"
},
"device": {
"devices": "Devices",
@@ -124,7 +126,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 +160,8 @@
"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"
},
"rtty": {
"requestingDeviceToCreateTerminal": "Requesting device to create terminal...",
+8 -3
View File
@@ -40,7 +40,9 @@
"loginWithOidc": "使用OIDC登录",
"confirmPasswordValidateError": "密码不一致。",
"accountLogin": "账号登录",
"ldap": "LDAP"
"ldap": "LDAP",
"local": "本地",
"oidc": "OIDC"
},
"device": {
"devices": "设备数",
@@ -124,7 +126,9 @@
"ipNotCorrect": "IP 地址错误",
"portNotCorrect": "端口号错误",
"remoteWeb": "远程 Web",
"linuxTips": "支持 OpenWrt、树莓派、Ubuntu、CentOS 等"
"linuxTips": "支持 OpenWrt、树莓派、Ubuntu、CentOS 等",
"customColumns": "自定义列",
"dragColumnTips": "您可以自定义您的设备列表,拖动下列项右侧的按钮来调整显示顺序,也可以通过复选框来控制显示或隐藏某些列。"
},
"user": {
"user": "用户",
@@ -156,7 +160,8 @@
"deleteUserGroupConfirmTips3": "你确定要删除它吗?",
"deleteOnlyOneAdminTips": "无法删除:只剩一个系统管理员了。",
"myGroup": "我的用户组",
"userRoleDesc": "管理员可以看到所有设备,普通用户只能看到用户组关联设备组的设备"
"userRoleDesc": "管理员可以看到所有设备,普通用户只能看到用户组关联设备组的设备",
"userType": "用户类型"
},
"rtty": {
"requestingDeviceToCreateTerminal": "正在请求设备创建终端...",
+2
View File
@@ -26,6 +26,8 @@ export interface DeviceQuery {
searchText: string
deviceGroupId: number
onlyShowUnassigned: boolean
sortBy?: string
order?: 'asc' | 'desc'
}
/** 执行命令参数 */
+14 -1
View File
@@ -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
+6 -2
View File
@@ -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()
}
+3 -1
View File
@@ -2,7 +2,7 @@
* @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 组件
*/
@@ -25,6 +25,7 @@ import {
Select,
Tabs,
Radio,
Popover,
} from 'ant-design-vue'
export default function (app: any) {
@@ -46,4 +47,5 @@ export default function (app: any) {
app.use(Select)
app.use(Tabs)
app.use(Radio)
app.use(Popover)
}
+1 -1
View File
@@ -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相关状态存储
*/
+15 -3
View File
@@ -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: 设备有关的状态管理
*/
@@ -36,6 +36,10 @@ export const useDeviceStore = defineStore('device', () => {
onlyShowUnassigned: false,
/** 这个字段存储是否有设备,因为UI上没有设备和没有筛选出来的设备是对应不同的展示画面的 */
hasDevice: false,
/** 排序字段 */
sortBy: undefined,
/** 排序方式 */
order: undefined,
})
const pageLink = ref(new PageLink({ size: DEVICE_VIEW_PAGE_SIZE }))
@@ -49,6 +53,8 @@ export const useDeviceStore = defineStore('device', () => {
searchText: state.searchText?.replaceAll(':','').toLowerCase(),
deviceGroupId: state.deviceGroupId,
onlyShowUnassigned: state.onlyShowUnassigned,
sortBy: state.sortBy,
order: state.order,
}
return query
})
@@ -66,7 +72,11 @@ export const useDeviceStore = defineStore('device', () => {
try {
console.log('getDeviceList', computedDeviceQuery.value)
!isPolling && (state.getDeviceLoading = true)
const res = await getDeviceListApi()
const res = await getDeviceListApi({
groupId: computedDeviceQuery.value.deviceGroupId,
sortBy: computedDeviceQuery.value.sortBy,
order: computedDeviceQuery.value.order,
})
console.log(res)
if (isGetAll) {
@@ -77,7 +87,9 @@ export const useDeviceStore = defineStore('device', () => {
}
pageLink.value.setTotal(res.data.items.length)
state.deviceList = res.data.items.filter(d => {
return (d?.id?.toString().toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1
return (d?.ddns?.toString().toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1
|| d?.mac?.toString().toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1
|| d?.ip?.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))
+2 -3
View File
@@ -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 || []
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -2,7 +2,7 @@
* @Author: LPY
* @Date: 2026-02-02 15:00:13
* @LastEditors: LPY
* @LastEditTime: 2026-02-03 12:08:51
* @LastEditTime: 2026-02-28 09:26:50
* @FilePath: \glkvm-cloud\ui\src\stores\modules\userManage.ts
* @Description: 用户管理相关状态管理
*/
@@ -69,7 +69,7 @@ export const useUserManageStore = defineStore('userManage', () => {
const res = await reqUserList()
pageLink.value.setTotal(res.data.items.length)
state.userList = res.data.items.filter(d => {
return (d?.id?.toString().toLowerCase()?.indexOf(computedUserManageQuery.value.searchText) > -1
return (d?.description?.toString().toLowerCase()?.indexOf(computedUserManageQuery.value.searchText) > -1
|| d?.username?.toLowerCase()?.indexOf(computedUserManageQuery.value.searchText) > -1) &&
(computedUserManageQuery.value.userGroupId ? d.userGroupList.some(u => u.userGroupId === computedUserManageQuery.value.userGroupId) : true)
}) || []
+10
View File
@@ -60,6 +60,16 @@ a {
padding: 10px 12px !important;
}
}
// 增加类名,可以让列的表头左对齐(例如筛选按钮紧挨着列标题)
.custom-table-header-cell-to-left {
.ant-table-column-sorters {
justify-content: flex-start !important;
}
.ant-table-column-title {
flex: unset !important;
}
}
}
.text-nowrap {
+32
View File
@@ -0,0 +1,32 @@
/*
* @Author: LPY
* @Date: 2026-03-25 11:01:34
* @LastEditors: LPY
* @LastEditTime: 2026-03-25 11:15:12
* @FilePath: \glkvm-cloud\ui\src\utils\versionManager.ts
* @Description: 版本管理工具,主要用于清理缓存
*/
import { LocalStorageKeys, useLocalStorage } from '@/hooks/useLocalStorage'
const APP_VERSION = '2.4.0' // 当前应用版本
const CACHE_KEYS_TO_CLEAR = [LocalStorageKeys.DEVICE_LIST_COLUMNS_KEY] // 需要清理的缓存key
export function checkAndClearCache () {
const cachedVersion = useLocalStorage(LocalStorageKeys.APP_VERSION_KEY).getValue()
if (cachedVersion !== APP_VERSION) {
// 版本不一致,清理指定缓存
CACHE_KEYS_TO_CLEAR.forEach(key => {
useLocalStorage(key).removeValue()
})
// 更新版本号
useLocalStorage(LocalStorageKeys.APP_VERSION_KEY).setValue(APP_VERSION)
console.log(`缓存已清理,版本从 ${cachedVersion} 升级到 ${APP_VERSION}`)
return true
}
return false
}
@@ -2,7 +2,7 @@
* @Author: LPY
* @Date: 2025-08-25 09:32:42
* @LastEditors: LPY
* @LastEditTime: 2026-02-11 09:31:33
* @LastEditTime: 2026-03-25 10:11:49
* @FilePath: \glkvm-cloud\ui\src\views\device\components\addDeviceDialog.vue
* @Description: 添加设备弹窗
-->
@@ -65,8 +65,8 @@ const OperatingSystemTranslated = computed(() => {
return useTranslatedOptions([
{ label: operatingSystemLabelMap.get(OperatingSystemEnum.GL_KVM), value: OperatingSystemEnum.GL_KVM },
{ label: operatingSystemLabelMap.get(OperatingSystemEnum.LINUX), value: OperatingSystemEnum.LINUX },
{ label: operatingSystemLabelMap.get(OperatingSystemEnum.WINDOWS), value: OperatingSystemEnum.WINDOWS },
{ label: operatingSystemLabelMap.get(OperatingSystemEnum.MAC_OS), value: OperatingSystemEnum.MAC_OS },
// { label: operatingSystemLabelMap.get(OperatingSystemEnum.WINDOWS), value: OperatingSystemEnum.WINDOWS },
// { label: operatingSystemLabelMap.get(OperatingSystemEnum.MAC_OS), value: OperatingSystemEnum.MAC_OS },
])
})
@@ -0,0 +1,188 @@
<!--
* @Author: LPY
* @Date: 2026-03-09 14:27:32
* @LastEditors: LPY
* @LastEditTime: 2026-03-10 11:48:15
* @FilePath: \glkvm-cloud\ui\src\views\device\components\components\customColumns.vue
* @Description: 自定义table列组件
-->
<template>
<APopover trigger="click" placement="bottom" :arrow="false" overlayClassName="custom-columns-popper" @openChange="handleOpenChange">
<template #content>
<div class="top-tips">
<BaseText type="head-m">{{ $t('device.customColumns') }}</BaseText>
<GlSvg name="gl-icon-help" tooltip :size="20">{{ $t('device.dragColumnTips') }}</GlSvg>
</div>
<div class="dividing-line"></div>
<div ref="customColumnsBoxRef" class="custom-columns-box">
<div v-for="item in clonedColumns" :key="item.key" class="custom-columns-item" :style="{'cursor': dragging ? 'grabbing' : undefined}">
<div class="custom-columns-item-left">
<ACheckbox :checked="item.show" @change="handleColumnVisibilityChange(item)"></ACheckbox>
<BaseText v-ellipsis class="title">{{ item.title }}</BaseText>
</div>
<div class="custom-columns-item-right">
<GlSvg name="gl-icon-grip-dots-vertical-regular"></GlSvg>
</div>
</div>
</div>
</template>
<GlSvg name="gl-icon-gear-regular" :size="20" class="custom-columns-icon"></GlSvg>
</APopover>
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { GlSvg } from 'gl-web-main/components'
import Sortable from 'sortablejs'
import { LocalStorageKeys, useLocalStorage } from '@/hooks/useLocalStorage'
import { TableColumnType } from 'ant-design-vue'
// Avoid deep type instantiation by defining only the properties you use
interface CustomTableColumnType extends TableColumnType {
show?: boolean
title?: string
}
const props = withDefaults(defineProps<{
storageName: LocalStorageKeys
columns: CustomTableColumnType[]
completeColumns: CustomTableColumnType[]
}>(), {
})
const emits = defineEmits<{
(e: 'change', value: TableColumnType[]): void
}>()
const customColumnsBoxRef = ref<HTMLDivElement>()
const dragging = ref(false)
const clonedColumns = ref<CustomTableColumnType[]>([])
const initDragFn = () => {
// 注册拖拽元素
Sortable.create(customColumnsBoxRef.value, {
group: 'columns',
animation: 150,
draggable: '.custom-columns-item',
dragClass: 'custom-columns-item-dragging',
forceFallback: true,
handle: '.custom-columns-item-right',
onStart () {
dragging.value = true
},
onEnd: (evt) => handleSortEnd(evt),
})
}
const handleSortEnd = ({
oldIndex,
newIndex,
from,
to,
}) => {
dragging.value = false
console.log(oldIndex, newIndex, from, to)
const movedColumn = clonedColumns.value[oldIndex]
clonedColumns.value.splice(oldIndex, 1)
clonedColumns.value.splice(newIndex, 0, movedColumn)
emits('change', clonedColumns.value)
useLocalStorage(props.storageName).setValue(clonedColumns.value)
}
const handleColumnVisibilityChange = (column: CustomTableColumnType) => {
column.show = !column.show
// 找到对应的列并更新show属性
emits('change', clonedColumns.value)
useLocalStorage(props.storageName).setValue(clonedColumns.value)
}
const handleOpenChange = (open: boolean) => {
if (open) {
// 使用 nextTick 确保 DOM 已渲染
nextTick(() => {
if (customColumnsBoxRef.value) {
initDragFn()
// 初始化数据,若有存储,则以存储为准,否则使用 completeColumns 的默认值
const storedColumns = useLocalStorage(props.storageName).getValue() as CustomTableColumnType[] | null
if (storedColumns) {
clonedColumns.value = storedColumns
} else {
clonedColumns.value = props.completeColumns.map(col => ({
...col,
show: props.columns.find(c => c.key === col.key)?.show ?? true,
}))
}
}
})
}
}
</script>
<style scoped lang="scss">
.top-tips {
width: 200px;
padding: 0 8px;
display: flex;
justify-content: space-between;
}
.dividing-line {
height: 1px;
background-color: var(--gl-color-line-divider1);
margin: 12px 0;
}
.custom-columns-box {
width: 200px;
.custom-columns-item {
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 4px;
height: 36px;
padding: 0 8px;
cursor: pointer;
&:hover {
background-color: var(--gl-color-bg-item-hover);
}
.custom-columns-item-left {
display: flex;
align-items: center;
.title {
max-width: 134px;
padding-left: 10px;
}
}
.custom-columns-item-right {
display: flex;
justify-content: center;
align-items: center;
width: 32px;
height: 32px;
cursor: grabbing;
}
}
}
.custom-columns-icon {
display: inline-block;
height: 20px;
line-height: 20px;
margin-right: 12px;
cursor: pointer;
}
.custom-columns-item-dragging {
box-shadow: 0px 3px 14px 0px rgba(0,0,0,0.25);
opacity: 1 !important;
background-color: var(--gl-color-bg-surface1);
}
</style>
@@ -2,7 +2,7 @@
* @Author: shufei.han
* @Date: 2025-06-11 12:04:48
* @LastEditors: LPY
* @LastEditTime: 2026-02-06 10:42:16
* @LastEditTime: 2026-03-25 10:54:42
* @FilePath: \glkvm-cloud\ui\src\views\device\components\deviceListView.vue
* @Description:
-->
@@ -26,7 +26,13 @@
</ASelect>
</div>
<div>
<div class="flex">
<CustomColumns
:columns="deviceColumns"
:completeColumns="deviceCompleteColumns"
:storageName="LocalStorageKeys.DEVICE_LIST_COLUMNS_KEY"
@change="handleDeviceColumnsChange"
/>
<BaseButton size="middle" style="margin-right: 12px;" @click="refresh">{{ $t('common.refresh') }}</BaseButton>
<BaseButton size="middle" style="margin-right: 12px;" @click="executeCommand">{{ $t('device.executeCommand') }}</BaseButton>
<BaseButton
@@ -46,6 +52,7 @@
:columns="deviceColumns"
rowKey="id"
:rowSelection="{ selectedRowKeys: state.selectedRowKeys, onChange: onSelectChange }"
@change="tableChange"
>
<template #mac="{ record }">
{{ macAddressFormatter(record.mac) }}
@@ -165,14 +172,14 @@ import BasePagination from '@/components/base/basePagination.vue'
import BaseTable from '@/components/base/baseTable.vue'
import { t } from '@/hooks/useLanguage'
import { useDeviceStore } from '@/stores/modules/device'
import { message, type TableColumnType } from 'ant-design-vue'
import { message, TableProps, type TableColumnType } from 'ant-design-vue'
import { computed, reactive, ref } from 'vue'
import ExecuteCommandDialog from './executeCommandDialog.vue'
import { DeviceInfo, DeviceStatusEnum, ExecuteCommandFormData } from '@/models/device'
import CommandResponseDialog from './commandResponseDialog.vue'
import { BaseDropdownSelect, BaseTag, GlSvg } from 'gl-web-main/components'
import EditDescriptionDialog from './editDescriptionDialog.vue'
import { baseCustomModal, macAddressFormatter, SelectOptions } from 'gl-web-main'
import { baseCustomModal, deepClone, macAddressFormatter, SelectOptions } from 'gl-web-main'
import { reqDeleteDevice, reqDeviceGroupListOptions } from '@/api/device'
import AddDeviceDialog from './addDeviceDialog.vue'
import MoveToDeviceGroupDialog from './moveToDeviceGroupDialog.vue'
@@ -180,21 +187,35 @@ import { PermissionEnum } from '@/models/permission'
import { hasPermission } from '@/utils/permission'
import AccessDeviceWebDialog from './accessDeviceWebDialog.vue'
import dayjs from 'dayjs'
import CustomColumns from './components/customColumns.vue'
import { LocalStorageKeys, useLocalStorage } from '@/hooks/useLocalStorage'
const deviceStore = useDeviceStore()
const deviceColumns = computed<TableColumnType[]>(() => {
return [
{title: t('device.deviceID'), dataIndex: 'ddns', ellipsis: true},
{title: t('device.IPAddress'), dataIndex: 'ip', ellipsis: true},
{title: t('device.mac'), dataIndex: 'mac', ellipsis: true},
{title: t('device.status'), dataIndex: 'status', ellipsis: true},
{title: t('device.connectedTime'), dataIndex: 'connectedTime', ellipsis: true},
{title: t('user.associatedDeviceGroup'), dataIndex: 'deviceGroupName', ellipsis: true, width: 190},
{title: t('device.description'), dataIndex: 'description'},
{title: t('common.action'), dataIndex: 'action', width: 270},
]
})
const deviceColumns = ref<TableColumnType[]>([
{title: t('device.deviceID'), dataIndex: 'ddns', key: 'ddns', ellipsis: true,
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
},
{title: t('device.IPAddress'), dataIndex: 'ip', key: 'ip', ellipsis: true,
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
},
{title: t('device.mac'), dataIndex: 'mac', key: 'mac', ellipsis: true,
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
},
{title: t('device.status'), dataIndex: 'status', key: 'status', ellipsis: true},
{title: t('device.connectedTime'), dataIndex: 'connectedTime', key: 'connectedTime', ellipsis: true,
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
},
{title: t('user.associatedDeviceGroup'), dataIndex: 'deviceGroupName', key: 'deviceGroupName', ellipsis: true, width: 190,
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
},
{title: t('device.description'), dataIndex: 'description', key: 'description',
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
},
{title: t('common.action'), dataIndex: 'action', key: 'action', width: 270},
])
const deviceCompleteColumns = deepClone(deviceColumns.value)
const page = computed({
get: () => deviceStore.pageLink.page,
@@ -228,6 +249,22 @@ const onSelectChange = (selectedRowKeys: Key[], selectedRows: DeviceInfo[]) => {
state.selectedRows = selectedRows
}
const tableChange: TableProps['onChange'] = (pagination, filters, sorter: any) => {
console.log('params', pagination, filters, sorter)
if (sorter?.order) {
deviceStore.state.sortBy = sorter.field as string
deviceStore.state.order = sorter.order === 'ascend' ? 'asc' : 'desc'
useLocalStorage(LocalStorageKeys.DEVICE_LIST_SORT_KEY).setValue({
sortBy: deviceStore.state.sortBy,
order: deviceStore.state.order,
})
} else {
deviceStore.state.sortBy = undefined
deviceStore.state.order = undefined
useLocalStorage(LocalStorageKeys.DEVICE_LIST_SORT_KEY).removeValue()
}
}
/** 计算时间 */
// const calculateWithDuration = (connected: number, isMilliseconds: boolean = false) => {
// const time = isMilliseconds ? connected / 1000 : connected
@@ -389,7 +426,42 @@ const getDeviceGroupListOptions = async () => {
state.groupList = res.data.items
}
getDeviceGroupListOptions()
const handleDeviceColumnsChange = (columns: TableColumnType[]) => {
deviceColumns.value = columns.filter(col => (col as any).show)
// 兼容JSON.parse后丢失的函数等属性,重新赋值customHeaderCell属性
deviceColumns.value.forEach((col: any) => {
const completeCol = deviceCompleteColumns.find(c => c.key === col.key)
if (completeCol) {
col.customHeaderCell = completeCol.customHeaderCell
}
})
}
const init = () => {
getDeviceGroupListOptions()
const storedColumns = useLocalStorage(LocalStorageKeys.DEVICE_LIST_COLUMNS_KEY).getValue()
if (storedColumns) {
const parsedColumns = storedColumns as Array<TableColumnType & { show: boolean }>
// 兼容JSON.parse后丢失的函数等属性,重新赋值customHeaderCell属性
parsedColumns.forEach((col: any) => {
const completeCol = deviceCompleteColumns.find(c => c.key === col.key)
if (completeCol) {
col.customHeaderCell = completeCol.customHeaderCell
}
})
deviceColumns.value = parsedColumns.filter(col => (col as any).show)
}
const sortKey = useLocalStorage(LocalStorageKeys.DEVICE_LIST_SORT_KEY).getValue() as { sortBy: string, order: string }
if (sortKey) {
deviceStore.state.sortBy = sortKey.sortBy
deviceStore.state.order = sortKey.order
deviceColumns.value.find(col => col.key === sortKey.sortBy).defaultSortOrder = sortKey.order === 'asc' ? 'ascend' : 'descend'
}
}
init()
</script>
<style lang="scss" scoped>
+6 -1
View File
@@ -2,7 +2,7 @@
* @Author: LPY
* @Date: 2025-05-30 10:48:43
* @LastEditors: LPY
* @LastEditTime: 2026-02-05 17:34:48
* @LastEditTime: 2026-02-28 09:32:36
* @FilePath: \glkvm-cloud\ui\src\views\login\loginPage.vue
* @Description: 登录页面
-->
@@ -137,6 +137,11 @@ onMounted(async () => {
// 提取配置数据 (Extract config data)
authConfig.value = response.data
useAppStore().setVersion(authConfig.value.kvmCloudVersion)
// 若支持LDAP且当前登录方式为legacy,则切换到ldap (If LDAP is supported and current auth method is legacy, switch to ldap)
if (authConfig.value.ldapEnabled && state.formModel.authMethod === 'legacy') {
state.formModel.authMethod = 'ldap'
}
} catch (error) {
console.error('Failed to load auth config:', error)
// 回退 - 无LDAP可用 (Fallback - no LDAP available)
@@ -2,7 +2,7 @@
* @Author: LPY
* @Date: 2026-02-03 11:24:20
* @LastEditors: LPY
* @LastEditTime: 2026-02-09 09:14:59
* @LastEditTime: 2026-03-25 10:19:00
* @FilePath: \glkvm-cloud\ui\src\views\userManage\components\editUserDialog.vue
* @Description: 编辑用户弹窗
-->
@@ -44,7 +44,13 @@
</ARadioGroup>
</AFormItem>
<AFormItem name="username" :label="$t('user.userName')" labelAlign="left">
<AInput v-model:value="state.formData.username" :maxlength="32" :placeholder="$t('device.requiredDeviceGroupName')" style="width: 100%;" />
<AInput
v-model:value="state.formData.username"
:maxlength="32"
:placeholder="$t('device.requiredDeviceGroupName')"
:disabled="props.currentUser?.isSystem ||
props.currentUser?.authProvider == AuthProviderEnum.LDAP || props.currentUser?.authProvider == AuthProviderEnum.OIDC"
style="width: 100%;" />
</AFormItem>
<AFormItem name="description" :label="$t('device.description')" labelAlign="left">
<ATextarea
@@ -58,6 +64,7 @@
v-model:value="state.formData.password"
:placeholder="$t('user.enterPassword')"
autocomplete="off"
:disabled="props.currentUser?.isSystem"
style="width: 100%;" />
</AFormItem>
<AFormItem name="repassword" :label="$t('user.reEnterPassword')" labelAlign="left">
@@ -65,6 +72,7 @@
v-model:value="state.formData.repassword"
:placeholder="$t('user.reEnterPasswordPlc')"
autocomplete="off"
:disabled="props.currentUser?.isSystem"
style="width: 100%;" />
</AFormItem>
<div class="flex-end">
@@ -93,7 +101,7 @@ import { FormRules, OnBeforeOk } from 'gl-web-main'
import { t } from '@/hooks/useLanguage'
import { FormInstance, Tooltip } from 'ant-design-vue'
import { reqUserGroupListOptions } from '@/api/deviceGroup'
import { UserManage, UserRoleEnum, UserRoleLabelMap } from '@/models/userManage'
import { AuthProviderEnum, UserManage, UserRoleEnum, UserRoleLabelMap } from '@/models/userManage'
import { reqEditUser } from '@/api/userManage'
import AddUserGroupDialog from './addUserGroupDialog.vue'
import { useUserManageStore } from '@/stores/modules/userManage'
+6 -2
View File
@@ -2,7 +2,7 @@
* @Author: LPY
* @Date: 2026-02-02 14:32:56
* @LastEditors: LPY
* @LastEditTime: 2026-02-06 18:00:36
* @LastEditTime: 2026-03-25 10:10:53
* @FilePath: \glkvm-cloud\ui\src\views\userManage\userManagePage.vue
* @Description: 用户管理页
-->
@@ -50,6 +50,9 @@
style="background-color: var(--gl-color-warning-primary);color: var(--gl-color-warning-background);"
>{{ $t(UserRoleLabelMap.get(record.role)) }}</BaseTag>
</template>
<template #authProvider="{ record }">
{{ $t(AuthProviderLabelMap.get(record.authProvider || AuthProviderEnum.LOCAL)) }}
</template>
<template #userGroupList="{ record }">
<div class="groups-a">
<a
@@ -131,7 +134,7 @@ import BaseLoadingContainer from '@/components/base/baseLoadingContainer.vue'
import BasePagination from '@/components/base/basePagination.vue'
import BaseTable from '@/components/base/baseTable.vue'
import { t } from '@/hooks/useLanguage'
import { UserManage, UserRoleEnum, UserRoleLabelMap } from '@/models/userManage'
import { AuthProviderEnum, AuthProviderLabelMap, UserManage, UserRoleEnum, UserRoleLabelMap } from '@/models/userManage'
import { useUserManageStore } from '@/stores/modules/userManage'
import { message, TableColumnType, Tooltip } from 'ant-design-vue'
import { baseCustomModal, SelectOptions } from 'gl-web-main'
@@ -156,6 +159,7 @@ const userColumns = computed<TableColumnType[]>(() => {
return [
{title: t('user.userName'), dataIndex: 'username', ellipsis: true},
{title: t('user.role'), dataIndex: 'role', ellipsis: true},
{title: t('user.userType'), dataIndex: 'authProvider'},
{title: t('device.description'), dataIndex: 'description'},
{title: t('device.associatedUserGroups'), dataIndex: 'userGroupList', ellipsis: true},
{title: t('common.action'), dataIndex: 'action', width: 270},
+1
View File
@@ -14,6 +14,7 @@
"strictNullChecks": false,
"composite": true,
"noEmit": true,
"moduleResolution": "bundler",
"lib": ["ES2015", "ES2017", "ES2018", "DOM", "ES2021"],
"verbatimModuleSyntax": false,
"baseUrl": ".",
+9 -9
View File
@@ -23,39 +23,39 @@ export default defineConfig(({ mode }) => {
port: 3011,
proxy: {
'/devs': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
},
'/api': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
changeOrigin: true,
},
'/signout': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
},
'/alive': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
},
'/get': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
},
'^/cmd/.*': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
},
'^/connect/.*': {
ws: true,
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
},
'^/web/*': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
},
'/auth-config': {
target: 'https://107.173.152.173',
target: 'https://106.55.158.199/',
secure: false,
changeOrigin: true,
},
+655 -627
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -70,6 +70,8 @@ type Config struct {
LdapUserFilter string
LdapAllowedGroups string
LdapAllowedUsers string
LdapAdminGroup string
LdapAdminUsers string
// Generic OIDC Provider (supports any standard OIDC provider)
OIDCEnabled bool
@@ -84,6 +86,8 @@ type Config struct {
OIDCGenericAllowedSubs []string
OIDCGenericAllowedUsernames []string
OIDCGenericAllowedGroups []string
OIDCAdminGroup []string
OIDCAdminUsers []string
// =====================================================
// Reverse Proxy / Proxy Mode
@@ -195,6 +199,8 @@ func parseYamlCfg(cfg *Config, conf string) error {
getConfigOpt(yamlCfg, "ldap-user-filter", &cfg.LdapUserFilter)
getConfigOpt(yamlCfg, "ldap-allowed-groups", &cfg.LdapAllowedGroups)
getConfigOpt(yamlCfg, "ldap-allowed-users", &cfg.LdapAllowedUsers)
getConfigOpt(yamlCfg, "ldap-admin-group", &cfg.LdapAdminGroup)
getConfigOpt(yamlCfg, "ldap-admin-users", &cfg.LdapAdminUsers)
// ===== OIDC configuration (generic OIDC provider) =====
// Switch and basic endpoints
@@ -236,6 +242,16 @@ func parseYamlCfg(cfg *Config, conf string) error {
cfg.OIDCGenericAllowedGroups = splitScopes(s)
}
// OIDC admin group
if s, err := yamlCfg.Get("oidc-admin-group"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCAdminGroup = splitScopes(s)
}
// OIDC admin users (preferred_username / email whitelist for admin role)
if s, err := yamlCfg.Get("oidc-admin-users"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCAdminUsers = splitScopes(s)
}
return nil
}
@@ -274,6 +290,22 @@ func applyEnvCfg(cfg *Config) error {
cfg.LdapBindPassword = envPassword
}
// LDAP admin group / admin users
if v := strings.TrimSpace(os.Getenv("LDAP_ADMIN_GROUP")); v != "" {
cfg.LdapAdminGroup = v
}
if v := strings.TrimSpace(os.Getenv("LDAP_ADMIN_USERS")); v != "" {
cfg.LdapAdminUsers = v
}
// OIDC admin group / admin users
if v := strings.TrimSpace(os.Getenv("OIDC_ADMIN_GROUP")); v != "" {
cfg.OIDCAdminGroup = splitScopes(v)
}
if v := strings.TrimSpace(os.Getenv("OIDC_ADMIN_USERS")); v != "" {
cfg.OIDCAdminUsers = splitScopes(v)
}
// Note: oidc-generic-client-secret is intentionally not read from YAML
// to avoid checking secrets into config files and leaking in logs.
// It is always read directly from the OIDC_CLIENT_SECRET environment variable below.