mirror of
https://github.com/gl-inet/glkvm-cloud.git
synced 2026-10-04 12:41:42 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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"
|
||||
]
|
||||
}
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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 = () => {
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "正在请求设备创建终端...",
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface DeviceQuery {
|
||||
searchText: string
|
||||
deviceGroupId: number
|
||||
onlyShowUnassigned: boolean
|
||||
sortBy?: string
|
||||
order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
/** 执行命令参数 */
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -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: 设备有关的状态管理
|
||||
*/
|
||||
@@ -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,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,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,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)
|
||||
}) || []
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"strictNullChecks": false,
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2015", "ES2017", "ES2018", "DOM", "ES2021"],
|
||||
"verbatimModuleSyntax": false,
|
||||
"baseUrl": ".",
|
||||
|
||||
+9
-9
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user