diff --git a/api/auth/init.go b/api/auth/init.go index fbf92c6..101d705 100644 --- a/api/auth/init.go +++ b/api/auth/init.go @@ -33,6 +33,11 @@ func init() { Router.Get("/users", "搜索用户", searchUsers) Router.Post("/users", "批量查询用户", searchUsers) + // 会话管理 + Router.Get("/sessions", "获取当前用户所有会话", listSessions) + Router.Delete("/sessions", "批量撤销其他会话", revokeOtherSessions) + Router.Delete("/sessions/{id}", "撤销指定会话", revokeSession) + // 当前用户(需要认证) meRouter := Router.SubRouter("/me") meRouter.Get("/", "获取当前用户信息", me) diff --git a/api/auth/login.go b/api/auth/login.go index 8fad3ef..37e5177 100644 --- a/api/auth/login.go +++ b/api/auth/login.go @@ -7,7 +7,9 @@ package auth import ( + "net" "net/http" + "strings" "time" "github.com/veypi/vbase/auth" @@ -117,14 +119,16 @@ func loginWithCode(x *vigo.X, req *LoginWithCodeRequest) (*AuthResponse, error) return generateAuthResponseForUser(x, &user) } -// generateAuthResponseForUser 为用户生成认证响应(登录时递增版本,踢掉旧会话) +// generateAuthResponseForUser 为用户生成认证响应(创建新 session,支持多点登录) func generateAuthResponseForUser(x *vigo.X, user *models.User) (*AuthResponse, error) { - version, err := auth.IncrTokenVersion(user.ID) + deviceInfo, ip := extractDeviceInfo(x.Request) + expiresAt := time.Now().Add(cfg.Global.JWT.RefreshExpiry) + session, err := auth.CreateSession(user.ID, deviceInfo, ip, expiresAt) if err != nil { return nil, vigo.ErrInternalServer.WithError(err) } - tokenPair, err := jwt.GenerateTokenPair(user.ID, version) + tokenPair, err := jwt.GenerateTokenPair(user.ID, session.ID, session.Version) if err != nil { return nil, vigo.ErrInternalServer.WithError(err) } @@ -210,14 +214,14 @@ func refresh(x *vigo.X, req *RefreshRequest) (*AuthResponse, error) { return nil, vigo.ErrForbidden.WithString("user is disabled") } - // 严格验证 refresh_token 版本并递增(版本不匹配=已泄露/已用) - newVersion, err := auth.ValidateRefreshAndRotate(claims.UserID, claims.Version) + // Session 级别版本旋转(版本不匹配=已泄露/已用) + newVersion, err := auth.ValidateRefreshSession(claims.UserID, claims.SessionID, claims.Version) if err != nil { return nil, vigo.ErrTokenInvalid.WithString("refresh token is stale or already used") } - // 生成新 token pair(新版本号) - tokenPair, err := jwt.GenerateTokenPair(user.ID, newVersion) + // 生成新 token pair(同一 session,新版本号) + tokenPair, err := jwt.GenerateTokenPair(user.ID, claims.SessionID, newVersion) if err != nil { return nil, vigo.ErrInternalServer.WithError(err) } @@ -236,17 +240,17 @@ func refresh(x *vigo.X, req *RefreshRequest) (*AuthResponse, error) { }, nil } -// logout 用户登出(版本递增,全部 token 失效) +// logout 用户登出(仅撤销当前会话) func logout(x *vigo.X) error { userID := cfg.Auth.UserID(x) - if userID == "" { + sessionID := auth.GetCurrentSessionID(x) + if userID == "" || sessionID == "" { + clearTokenCookies(x) return nil } - // 递增 token 版本 → 已签发的所有 token 全部失效 - if err := auth.RevokeAllTokens(userID); err != nil { + if err := auth.RevokeSession(userID, sessionID); err != nil { return vigo.ErrInternalServer.WithError(err) } - // 清除 Cookie clearTokenCookies(x) return nil } @@ -299,3 +303,102 @@ func clearTokenCookies(x *vigo.X) { HttpOnly: true, }) } + +// ========== 设备信息提取 ========== + +// extractDeviceInfo 从请求中提取设备信息和 IP +func extractDeviceInfo(r *http.Request) (deviceInfo, ip string) { + // IP + ip = r.Header.Get("X-Forwarded-For") + if ip == "" { + ip = r.Header.Get("X-Real-IP") + } + if ip == "" { + ip = r.RemoteAddr + } + // 取第一个 IP(X-Forwarded-For 可能含多个) + if idx := strings.IndexByte(ip, ','); idx != -1 { + ip = strings.TrimSpace(ip[:idx]) + } + // 去掉端口号(RemoteAddr 格式为 IP:port) + if host, _, err := net.SplitHostPort(ip); err == nil { + ip = host + } + + // User-Agent(截断到 300 字符) + ua := r.UserAgent() + if len(ua) > 300 { + ua = ua[:300] + } + deviceInfo = ua + return +} + +// ========== 会话管理 API ========== + +// SessionInfo 会话信息 +type SessionInfo struct { + ID string `json:"id"` + DeviceInfo string `json:"device_info"` + IP string `json:"ip"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + IsCurrent bool `json:"is_current"` +} + +// listSessions 获取当前用户所有活跃会话 +func listSessions(x *vigo.X) ([]SessionInfo, error) { + userID := cfg.Auth.UserID(x) + if userID == "" { + return nil, vigo.ErrUnauthorized + } + + sessions, err := auth.ListSessions(userID) + if err != nil { + return nil, vigo.ErrInternalServer.WithError(err) + } + + currentSID := auth.GetCurrentSessionID(x) + result := make([]SessionInfo, 0, len(sessions)) + for _, s := range sessions { + result = append(result, SessionInfo{ + ID: s.ID, + DeviceInfo: s.DeviceInfo, + IP: s.IP, + CreatedAt: s.CreatedAt, + ExpiresAt: s.ExpiresAt, + IsCurrent: s.ID == currentSID, + }) + } + return result, nil +} + +// RevokeSessionRequest 撤销会话请求 +type RevokeSessionRequest struct { + ID string `json:"id" src:"path" desc:"会话ID"` +} + +// revokeSession 撤销指定会话 +func revokeSession(x *vigo.X, req *RevokeSessionRequest) error { + userID := cfg.Auth.UserID(x) + if userID == "" { + return vigo.ErrUnauthorized + } + if err := auth.RevokeSession(userID, req.ID); err != nil { + return vigo.ErrInternalServer.WithError(err) + } + return nil +} + +// revokeOtherSessions 批量撤销除当前外的所有会话 +func revokeOtherSessions(x *vigo.X) error { + userID := cfg.Auth.UserID(x) + sessionID := auth.GetCurrentSessionID(x) + if userID == "" || sessionID == "" { + return vigo.ErrUnauthorized + } + if err := auth.RevokeOtherSessions(userID, sessionID); err != nil { + return vigo.ErrInternalServer.WithError(err) + } + return nil +} diff --git a/auth/auth.go b/auth/auth.go index 48bc3e1..e076f88 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -12,20 +12,23 @@ import ( "fmt" "strconv" "strings" + "time" - "github.com/redis/go-redis/v9" "github.com/veypi/vbase/cfg" "github.com/veypi/vbase/libs/jwt" "github.com/veypi/vbase/models" "github.com/veypi/vigo" pub "github.com/veypi/vigo/contrib/auth" "github.com/veypi/vigo/contrib/event" + "github.com/veypi/vigo/logv" "gorm.io/gorm" ) const ( // CtxKeyUserID 用户ID上下文键 CtxKeyUserID = "auth:user_id" + // CtxKeySessionID 会话ID上下文键 + CtxKeySessionID = "auth:session_id" // 权限等级 LevelNone = 0 @@ -129,14 +132,15 @@ func (a *vbaseProvider) UserID(x *vigo.X) string { return "" } - // 验证 token 版本(允许 ±1 防止多 tab 并发刷新互相踢) - if !validateTokenVersion(claims.UserID, claims.Version) { + // 验证 access token 对应的 session(允许当前版本或上一版本,防止多 tab 并发刷新互踢) + if !ValidateAccessSession(claims.SessionID, claims.UserID, claims.Version) { x.Set(ctxKeyTokenParsed, true) return "" } // 5. 设置到上下文中,供后续调用使用 x.Set(CtxKeyUserID, claims.UserID) + x.Set(CtxKeySessionID, claims.SessionID) x.Set(ctxKeyTokenParsed, true) return claims.UserID @@ -525,77 +529,231 @@ func extractToken(x *vigo.X) string { return x.Request.URL.Query().Get("access_token") } -// ========== Token 版本管理 ========== +// ========== Session 管理 ========== -func userTokenVersionKey(userID string) string { - return fmt.Sprintf("vb:user:%s:token_ver", userID) +func sessionKey(sid string) string { + return fmt.Sprintf("vb:session:%s", sid) } -// getTokenVersion 获取用户当前 token 版本号 -func getTokenVersion(userID string) (int64, error) { - rds := cfg.Redis() - if rds == nil { - return 0, fmt.Errorf("redis not available") +func userSessionsKey(uid string) string { + return fmt.Sprintf("vb:user_sessions:%s", uid) +} + +// CreateSession 创建登录会话(DB + Redis) +func CreateSession(userID, deviceInfo, ip string, expiresAt time.Time) (*models.Session, error) { + session := &models.Session{ + UserID: userID, + Version: 1, + DeviceInfo: deviceInfo, + IP: ip, + ExpiresAt: expiresAt, } - val, err := rds.Get(context.Background(), userTokenVersionKey(userID)).Result() - if errors.Is(err, redis.Nil) { - return 0, nil // 首次,版本为 0 + if err := cfg.DB().Create(session).Error; err != nil { + return nil, err } - if err != nil { - return 0, err + + // 写 Redis 缓存 + fillSessionCache(session) + return session, nil +} + +// GetCurrentSessionID 从请求上下文获取当前 session ID +func GetCurrentSessionID(x *vigo.X) string { + if sid, ok := x.Get(CtxKeySessionID).(string); ok { + return sid } - return strconv.ParseInt(val, 10, 64) + return "" } -// incrTokenVersion 递增用户 token 版本号,返回新版本 -func incrTokenVersion(userID string) (int64, error) { +// ValidateAccessSession 验证 access token 对应的 session(允许当前版本或上一版本) +func ValidateAccessSession(sessionID, userID string, tokenVer int64) bool { + // 先查 Redis rds := cfg.Redis() - if rds == nil { - return 0, fmt.Errorf("redis not available") + if rds != nil { + revoked, err := rds.HGet(context.Background(), sessionKey(sessionID), "revoked").Result() + if err == nil { + verStr, _ := rds.HGet(context.Background(), sessionKey(sessionID), "version").Result() + suid, _ := rds.HGet(context.Background(), sessionKey(sessionID), "user_id").Result() + if revoked == "true" || suid != userID { + return false + } + ver, err := strconv.ParseInt(verStr, 10, 64) + if err != nil { + return false + } + return tokenVer == ver || tokenVer == ver-1 + } } - newVer, err := rds.Incr(context.Background(), userTokenVersionKey(userID)).Result() - if err != nil { + + // Redis 未命中,查 DB + var session models.Session + if err := cfg.DB().Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil { + return false + } + if session.Revoked { + return false + } + // 回填 Redis + fillSessionCache(&session) + return tokenVer == session.Version || tokenVer == session.Version-1 +} + +// ValidateRefreshSession 验证 refresh token 并旋转版本号,返回新版本 +func ValidateRefreshSession(userID, sessionID string, tokenVer int64) (int64, error) { + var session models.Session + if err := cfg.DB().Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil { + return 0, fmt.Errorf("session not found") + } + if session.Revoked { + return 0, fmt.Errorf("session revoked") + } + if tokenVer != session.Version { + return 0, fmt.Errorf("refresh token version mismatch: expected %d, got %d", session.Version, tokenVer) + } + + // 版本 +1,同时延长 session 过期时间 + newVer := session.Version + 1 + newExpiresAt := time.Now().Add(cfg.Global.JWT.RefreshExpiry) + if err := cfg.DB().Model(&session).Updates(map[string]interface{}{ + "version": newVer, + "expires_at": newExpiresAt, + }).Error; err != nil { return 0, err } + + // 更新 Redis + rds := cfg.Redis() + if rds != nil { + ttl := time.Until(newExpiresAt) + if ttl > 0 { + pipe := rds.Pipeline() + pipe.HSet(context.Background(), sessionKey(sessionID), "version", newVer) + pipe.Expire(context.Background(), sessionKey(sessionID), ttl) + pipe.Expire(context.Background(), userSessionsKey(userID), ttl) + pipe.Exec(context.Background()) + } + } + return newVer, nil } -// validateTokenVersion 验证 token 版本,access_token 允许 ±1(多 tab 容差) -func validateTokenVersion(userID string, tokenVer int64) bool { - currentVer, err := getTokenVersion(userID) - if err != nil { - return false +// RevokeSession 撤销指定会话 +func RevokeSession(userID, sessionID string) error { + now := time.Now() + res := cfg.DB().Model(&models.Session{}).Where("id = ? AND user_id = ?", sessionID, userID).Updates(map[string]interface{}{ + "revoked": true, + "revoked_at": now, + }) + if res.Error != nil { + return res.Error } - // 允许当前版本或上一版本(防止多 tab 并发刷新导致误踢) - return tokenVer == currentVer || tokenVer == currentVer-1 -} -// GetTokenVersion 获取用户当前 token 版本号(导出给 api/auth 使用) -func GetTokenVersion(userID string) (int64, error) { - return getTokenVersion(userID) + // 更新 Redis + rds := cfg.Redis() + if rds != nil { + if err := rds.HSet(context.Background(), sessionKey(sessionID), "revoked", "true").Err(); err != nil { + logv.Warn().Msgf("RevokeSession: redis HSet failed: %v", err) + } + if err := rds.SRem(context.Background(), userSessionsKey(userID), sessionID).Err(); err != nil { + logv.Warn().Msgf("RevokeSession: redis SRem failed: %v", err) + } + } + + return nil } -// IncrTokenVersion 递增用户 token 版本号,返回新版本(登录时调用,实现踢旧会话) -func IncrTokenVersion(userID string) (int64, error) { - return incrTokenVersion(userID) +// RevokeOtherSessions 撤销用户除当前外的其他所有会话 +func RevokeOtherSessions(userID, currentSessionID string) error { + now := time.Now() + if err := cfg.DB().Model(&models.Session{}).Where("user_id = ? AND id != ? AND revoked = ?", userID, currentSessionID, false).Updates(map[string]interface{}{ + "revoked": true, + "revoked_at": now, + }).Error; err != nil { + return err + } + + rds := cfg.Redis() + if rds != nil { + members, err := rds.SMembers(context.Background(), userSessionsKey(userID)).Result() + if err == nil { + for _, sid := range members { + if sid != currentSessionID { + rds.HSet(context.Background(), sessionKey(sid), "revoked", "true") + rds.SRem(context.Background(), userSessionsKey(userID), sid) + } + } + } else { + logv.Warn().Msgf("RevokeOtherSessions: redis SMembers failed: %v", err) + } + } + + return nil } -// RevokeAllTokens 撤销用户所有 token(递增版本号) -func RevokeAllTokens(userID string) error { - _, err := incrTokenVersion(userID) - return err +// RevokeAllSessions 撤销用户所有会话 +func RevokeAllSessions(userID string) error { + now := time.Now() + if err := cfg.DB().Model(&models.Session{}).Where("user_id = ? AND revoked = ?", userID, false).Updates(map[string]interface{}{ + "revoked": true, + "revoked_at": now, + }).Error; err != nil { + return err + } + + // 清理 Redis + rds := cfg.Redis() + if rds != nil { + members, err := rds.SMembers(context.Background(), userSessionsKey(userID)).Result() + if err == nil { + for _, sid := range members { + rds.Del(context.Background(), sessionKey(sid)) + } + } else { + logv.Warn().Msgf("RevokeAllSessions: redis SMembers failed: %v", err) + } + if err := rds.Del(context.Background(), userSessionsKey(userID)).Err(); err != nil { + logv.Warn().Msgf("RevokeAllSessions: redis Del failed: %v", err) + } + } + + return nil } -// ValidateRefreshAndRotate 严格验证 refresh_token 版本并轮换,返回新版本号 -func ValidateRefreshAndRotate(userID string, tokenVer int64) (int64, error) { - currentVer, err := getTokenVersion(userID) - if err != nil { - return 0, err +// ListSessions 列出用户所有活跃会话 +func ListSessions(userID string) ([]models.Session, error) { + var sessions []models.Session + if err := cfg.DB().Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).Order("created_at DESC").Find(&sessions).Error; err != nil { + return nil, err } - if tokenVer != currentVer { - return 0, fmt.Errorf("refresh token version mismatch: expected %d, got %d", currentVer, tokenVer) + return sessions, nil +} + +// fillSessionCache 回填 Redis 缓存 +func fillSessionCache(session *models.Session) { + rds := cfg.Redis() + if rds == nil { + return + } + ttl := time.Until(session.ExpiresAt) + if ttl <= 0 { + return + } + revoked := "false" + if session.Revoked { + revoked = "true" + } + pipe := rds.Pipeline() + pipe.HSet(context.Background(), sessionKey(session.ID), + "version", session.Version, + "revoked", revoked, + "user_id", session.UserID, + ) + pipe.Expire(context.Background(), sessionKey(session.ID), ttl) + pipe.SAdd(context.Background(), userSessionsKey(session.UserID), session.ID) + pipe.Expire(context.Background(), userSessionsKey(session.UserID), ttl) + if _, err := pipe.Exec(context.Background()); err != nil { + logv.Warn().Msgf("fillSessionCache: redis pipeline failed: %v", err) } - return incrTokenVersion(userID) } func validatePermission(code string, level int) error { diff --git a/libs/jwt/jwt.go b/libs/jwt/jwt.go index f7dcdbb..6fa8a3a 100644 --- a/libs/jwt/jwt.go +++ b/libs/jwt/jwt.go @@ -25,9 +25,10 @@ var ( // Claims JWT声明(精简版,仅存鉴权必需字段) type Claims struct { jwt.RegisteredClaims - UserID string `json:"uid"` - Type string `json:"type"` // "access" / "refresh" - Version int64 `json:"ver"` // token 版本号,用于撤销 + UserID string `json:"uid"` + Type string `json:"type"` // "access" / "refresh" + SessionID string `json:"sid"` // 归属的会话 ID + Version int64 `json:"ver"` // 会话内 token 版本号,用于撤销 } // TokenPair Token对 @@ -39,13 +40,13 @@ type TokenPair struct { } // GenerateTokenPair 生成token对 -func GenerateTokenPair(userID string, version int64) (*TokenPair, error) { - accessToken, err := GenerateAccessToken(userID, version) +func GenerateTokenPair(userID, sessionID string, version int64) (*TokenPair, error) { + accessToken, err := GenerateAccessToken(userID, sessionID, version) if err != nil { return nil, err } - refreshToken, err := GenerateRefreshToken(userID, version) + refreshToken, err := GenerateRefreshToken(userID, sessionID, version) if err != nil { return nil, err } @@ -58,8 +59,8 @@ func GenerateTokenPair(userID string, version int64) (*TokenPair, error) { }, nil } -// GenerateAccessToken 生成访问令牌(轻量,仅存 UserID + Version) -func GenerateAccessToken(userID string, version int64) (string, error) { +// GenerateAccessToken 生成访问令牌 +func GenerateAccessToken(userID, sessionID string, version int64) (string, error) { now := time.Now() claims := Claims{ RegisteredClaims: jwt.RegisteredClaims{ @@ -71,9 +72,10 @@ func GenerateAccessToken(userID string, version int64) (string, error) { NotBefore: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(cfg.Global.JWT.AccessExpiry)), }, - UserID: userID, - Type: "access", - Version: version, + UserID: userID, + Type: "access", + SessionID: sessionID, + Version: version, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) @@ -81,7 +83,7 @@ func GenerateAccessToken(userID string, version int64) (string, error) { } // GenerateRefreshToken 生成刷新令牌 -func GenerateRefreshToken(userID string, version int64) (string, error) { +func GenerateRefreshToken(userID, sessionID string, version int64) (string, error) { now := time.Now() claims := Claims{ RegisteredClaims: jwt.RegisteredClaims{ @@ -91,9 +93,10 @@ func GenerateRefreshToken(userID string, version int64) (string, error) { IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(cfg.Global.JWT.RefreshExpiry)), }, - UserID: userID, - Type: "refresh", - Version: version, + UserID: userID, + Type: "refresh", + SessionID: sessionID, + Version: version, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) diff --git a/models/user.go b/models/user.go index 9fb4319..273d3e2 100644 --- a/models/user.go +++ b/models/user.go @@ -44,16 +44,15 @@ type Identity struct { User User `json:"user,omitempty" gorm:"foreignKey:UserID;references:ID"` } -// Session 登录会话 +// Session 登录会话(每次登录创建一个) type Session struct { vigo.Model - UserID string `json:"user_id" gorm:"index;not null"` - TokenID string `json:"token_id" gorm:"uniqueIndex;size:36"` // JWT jti - Type string `json:"type" gorm:"size:20;not null"` // access/refresh - DeviceInfo string `json:"device_info" gorm:"size:200"` - IP string `json:"ip" gorm:"size:50"` - ExpiresAt time.Time `json:"expires_at"` - Revoked bool `json:"revoked" gorm:"default:false"` + UserID string `json:"user_id" gorm:"index;not null"` + Version int64 `json:"version" gorm:"default:1"` // 会话内 token 版本号,用于刷新轮换 + DeviceInfo string `json:"device_info" gorm:"size:300"` + IP string `json:"ip" gorm:"size:50"` + ExpiresAt time.Time `json:"expires_at"` // 会话过期时间(= refresh token 过期时间) + Revoked bool `json:"revoked" gorm:"default:false"` RevokedAt *time.Time `json:"revoked_at"` // 外键关联 diff --git a/ui/langs.json b/ui/langs.json index f0e66b4..84e4474 100644 --- a/ui/langs.json +++ b/ui/langs.json @@ -84,6 +84,20 @@ "nav.oauth": "OAuth应用", "nav.oauth_providers": "身份源管理", "nav.profile": "个人中心", + "nav.sessions": "凭证管理", + "session.current": "当前", + "session.days_ago": "天前", + "session.empty": "暂无活跃会话", + "session.expires": "过期", + "session.hours_ago": "小时前", + "session.just_now": "刚刚", + "session.minutes_ago": "分钟前", + "session.revoke": "撤销", + "session.revoke_confirm": "确定撤销该会话吗?", + "session.revoke_others": "撤销其他会话", + "session.revoke_others_confirm": "确定撤销所有其他会话吗?", + "session.revoke_success": "会话已撤销", + "session.title": "登录凭证管理", "nav.roles": "角色管理", "nav.settings": "系统设置", "nav.users": "用户管理", @@ -300,6 +314,20 @@ "nav.oauth": "OAuth Apps", "nav.oauth_providers": "Identity Providers", "nav.profile": "Profile", + "nav.sessions": "Sessions", + "session.current": "Current", + "session.days_ago": "days ago", + "session.empty": "No active sessions", + "session.expires": "Expires", + "session.hours_ago": "hours ago", + "session.just_now": "Just now", + "session.minutes_ago": "minutes ago", + "session.revoke": "Revoke", + "session.revoke_confirm": "Are you sure you want to revoke this session?", + "session.revoke_others": "Revoke Others", + "session.revoke_others_confirm": "Revoke all other sessions?", + "session.revoke_success": "Session revoked", + "session.title": "Session Management", "nav.roles": "Roles", "nav.settings": "Settings", "nav.users": "Users", diff --git a/ui/layout/default.html b/ui/layout/default.html index 8f43b42..caa1b4d 100644 --- a/ui/layout/default.html +++ b/ui/layout/default.html @@ -134,6 +134,7 @@ menuItems = [ {label: () => $t('nav.dashboard'), icon: "", path: "/"}, {label: () => $t('nav.profile'), icon: "", path: "/profile"}, + {label: () => $t('nav.sessions'), icon: "", path: "/sessions"}, // Admin only items would be filtered here ideally {label: () => $t('nav.users'), icon: "", path: "/users"}, {label: () => $t('nav.roles'), icon: "", path: "/roles"}, diff --git a/ui/layout/ico.html b/ui/layout/ico.html index cdcc935..02de424 100644 --- a/ui/layout/ico.html +++ b/ui/layout/ico.html @@ -150,6 +150,7 @@ menus = [ {label: () => $t('nav.dashboard'), icon: "", path: "/"}, {label: () => $t('nav.profile'), icon: "", path: "/profile"}, + {label: () => $t('nav.sessions'), icon: "", path: "/sessions"}, // Admin only items would be filtered here ideally {label: () => $t('nav.users'), icon: "", path: "/users"}, {label: () => $t('nav.roles'), icon: "", path: "/roles"}, diff --git a/ui/page/user/sessions.html b/ui/page/user/sessions.html new file mode 100644 index 0000000..c63d604 --- /dev/null +++ b/ui/page/user/sessions.html @@ -0,0 +1,326 @@ + + + +
+ +{{ $t('session.empty') }}
+