diff --git a/api/admin/admin.go b/api/admin/admin.go index 299aa4c..9cdcdef 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -189,6 +189,7 @@ type IAdminV1 interface { Admin2FAVerify(ctx context.Context, req *v1.Admin2FAVerifyReq) (res *v1.Admin2FAVerifyRes, err error) Admin2FAConfirm(ctx context.Context, req *v1.Admin2FAConfirmReq) (res *v1.Admin2FAConfirmRes, err error) AdminLoginHistory(ctx context.Context, req *v1.AdminLoginHistoryReq) (res *v1.AdminLoginHistoryRes, err error) + AdminTenantLoginHistory(ctx context.Context, req *v1.AdminTenantLoginHistoryReq) (res *v1.AdminTenantLoginHistoryRes, err error) AdminSettingsCategories(ctx context.Context, req *v1.AdminSettingsCategoriesReq) (res *v1.AdminSettingsCategoriesRes, err error) AdminSettingsGet(ctx context.Context, req *v1.AdminSettingsGetReq) (res *v1.AdminSettingsGetRes, err error) AdminSettingsUpdate(ctx context.Context, req *v1.AdminSettingsUpdateReq) (res *v1.AdminSettingsUpdateRes, err error) diff --git a/api/admin/v1/security.go b/api/admin/v1/security.go index 916c8d7..f21f538 100644 --- a/api/admin/v1/security.go +++ b/api/admin/v1/security.go @@ -120,3 +120,44 @@ type LoginHistoryItem struct { FailReason string `json:"fail_reason"` CreatedAt string `json:"created_at"` } + +// ============================================================ +// 租户用户登录历史(管理后台查看) +// ============================================================ + +// AdminTenantLoginHistoryReq 租户用户登录历史(管理后台查看) +type AdminTenantLoginHistoryReq struct { + g.Meta `path:"/security/tenant-login-history" method:"get" tags:"管理后台-安全" summary:"租户用户登录历史"` + Page int `json:"page" in:"query" d:"1" dc:"页码"` + PageSize int `json:"page_size" in:"query" d:"20" dc:"每页数量"` + TenantID int64 `json:"tenant_id" in:"query" dc:"租户ID筛选"` + Username string `json:"username" in:"query" dc:"用户名(模糊搜索)"` + IpAddress string `json:"ip_address" in:"query" dc:"IP地址(模糊搜索)"` + Success *bool `json:"success" in:"query" dc:"登录状态:true成功/false失败"` + LoginMethod string `json:"login_method" in:"query" dc:"登录方式:password/totp/sso/backup_code"` + StartTime string `json:"start_time" in:"query" dc:"开始时间(格式:2006-01-02)"` + EndTime string `json:"end_time" in:"query" dc:"结束时间(格式:2006-01-02)"` +} + +type AdminTenantLoginHistoryRes struct { + List []AdminTenantLoginHistoryItem `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type AdminTenantLoginHistoryItem struct { + ID int64 `json:"id"` + UserId int64 `json:"user_id"` + TenantId int64 `json:"tenant_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + LoginMethod string `json:"login_method"` + IpAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + Location string `json:"location"` + IsNewDevice bool `json:"is_new_device"` + Success bool `json:"success"` + FailReason string `json:"fail_reason"` + CreatedAt string `json:"created_at"` +} diff --git a/api/tenant/v1/security.go b/api/tenant/v1/security.go index af808de..8127f70 100644 --- a/api/tenant/v1/security.go +++ b/api/tenant/v1/security.go @@ -90,11 +90,17 @@ type Tenant2FAConfirmRes struct { // 登录历史 // ============================================================ -// TenantLoginHistoryReq 登录历史列表 +// TenantLoginHistoryReq 登录历史列表(owner/admin 查看所有成员,member 仅查看自己) type TenantLoginHistoryReq struct { - g.Meta `path:"/security/login-history" method:"get" tags:"租户控制台-安全" summary:"登录历史"` - Page int `json:"page" in:"query" d:"1" dc:"页码"` - PageSize int `json:"page_size" in:"query" d:"20" dc:"每页数量"` + g.Meta `path:"/security/login-history" method:"get" tags:"租户控制台-安全" summary:"登录历史"` + Page int `json:"page" in:"query" d:"1" dc:"页码"` + PageSize int `json:"page_size" in:"query" d:"20" dc:"每页数量"` + Username string `json:"username" in:"query" dc:"用户名(模糊搜索,仅owner/admin有效)"` + IpAddress string `json:"ip_address" in:"query" dc:"IP地址(模糊搜索)"` + Success *bool `json:"success" in:"query" dc:"登录状态:true成功/false失败"` + LoginMethod string `json:"login_method" in:"query" dc:"登录方式:password/totp/sso"` + StartTime string `json:"start_time" in:"query" dc:"开始时间(格式:2006-01-02)"` + EndTime string `json:"end_time" in:"query" dc:"结束时间(格式:2006-01-02)"` } type TenantLoginHistoryRes struct { @@ -106,6 +112,9 @@ type TenantLoginHistoryRes struct { type TenantLoginHistoryItem struct { ID int64 `json:"id"` + UserId int64 `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` LoginMethod string `json:"login_method"` IpAddress string `json:"ip_address"` UserAgent string `json:"user_agent"` diff --git a/internal/controller/admin/admin_v1_admin_tenant_login_history.go b/internal/controller/admin/admin_v1_admin_tenant_login_history.go new file mode 100644 index 0000000..cf23f30 --- /dev/null +++ b/internal/controller/admin/admin_v1_admin_tenant_login_history.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) AdminTenantLoginHistory(ctx context.Context, req *v1.AdminTenantLoginHistoryReq) (res *v1.AdminTenantLoginHistoryRes, err error) { + return service.Admin().TenantLoginHistory(ctx, req) +} diff --git a/internal/logic/admin/security.go b/internal/logic/admin/security.go index 8f196a4..92cb4d7 100644 --- a/internal/logic/admin/security.go +++ b/internal/logic/admin/security.go @@ -314,6 +314,130 @@ func buildAdminUserMap(ctx context.Context, records []entity.AudLoginHistory) ma return m } +// TenantLoginHistory returns login history for tenant users (admin view). +func (s *sAdmin) TenantLoginHistory(ctx context.Context, req *v1.AdminTenantLoginHistoryReq) (*v1.AdminTenantLoginHistoryRes, error) { + page, pageSize := common.NormalizePagination(req.Page, req.PageSize) + + q := dao.AudLoginHistory.Ctx(ctx).Where("user_type", "tenant") + + if req.TenantID > 0 { + q = q.Where("tenant_id", req.TenantID) + } + if req.Username != "" { + userIds, err := tenantUserIdsByKeyword(ctx, req.TenantID, req.Username) + if err != nil { + return nil, err + } + if len(userIds) == 0 { + return &v1.AdminTenantLoginHistoryRes{ + List: []v1.AdminTenantLoginHistoryItem{}, Total: 0, Page: page, PageSize: pageSize, + }, nil + } + q = q.WhereIn("user_id", userIds) + } + if req.IpAddress != "" { + q = q.WhereLike("ip_address", "%"+req.IpAddress+"%") + } + if req.Success != nil { + q = q.Where("success", *req.Success) + } + if req.LoginMethod != "" { + q = q.Where("login_method", req.LoginMethod) + } + if req.StartTime != "" { + q = q.WhereGTE("created_at", req.StartTime+" 00:00:00") + } + if req.EndTime != "" { + q = q.WhereLTE("created_at", req.EndTime+" 23:59:59") + } + + total, err := q.Count() + if err != nil { + return nil, err + } + + var records []entity.AudLoginHistory + err = q.OrderDesc("created_at").Page(page, pageSize).Scan(&records) + if err != nil { + return nil, err + } + + userMap := buildTenantUserMap(ctx, records) + items := make([]v1.AdminTenantLoginHistoryItem, len(records)) + for i, r := range records { + items[i] = v1.AdminTenantLoginHistoryItem{ + ID: r.Id, + UserId: r.UserId, + TenantId: r.TenantId, + Username: userMap[r.UserId].Username, + DisplayName: userMap[r.UserId].DisplayName, + LoginMethod: r.LoginMethod, + IpAddress: r.IpAddress, + UserAgent: r.UserAgent, + Location: r.Location, + IsNewDevice: r.IsNewDevice, + Success: r.Success, + FailReason: r.FailReason, + } + if r.CreatedAt != nil { + items[i].CreatedAt = r.CreatedAt.Format("Y-m-d H:i:s") + } + } + + return &v1.AdminTenantLoginHistoryRes{ + List: items, Total: total, Page: page, PageSize: pageSize, + }, nil +} + +type tenantUserBrief struct { + Username string + DisplayName string +} + +func tenantUserIdsByKeyword(ctx context.Context, tenantID int64, keyword string) ([]int64, error) { + q := dao.TntUsers.Ctx(ctx).Fields("id") + q = q.Where("username LIKE ? OR display_name LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + if tenantID > 0 { + q = q.Where("tenant_id", tenantID) + } + var users []entity.TntUsers + err := q.Scan(&users) + if err != nil { + return nil, err + } + ids := make([]int64, len(users)) + for i, u := range users { + ids[i] = u.Id + } + return ids, nil +} + +func buildTenantUserMap(ctx context.Context, records []entity.AudLoginHistory) map[int64]tenantUserBrief { + m := make(map[int64]tenantUserBrief) + idSet := make(map[int64]struct{}) + for _, r := range records { + if r.UserId > 0 { + idSet[r.UserId] = struct{}{} + } + } + if len(idSet) == 0 { + return m + } + ids := make([]int64, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + var users []entity.TntUsers + _ = dao.TntUsers.Ctx(ctx). + Fields("id, username, display_name"). + WhereIn("id", ids). + Scan(&users) + for _, u := range users { + m[u.Id] = tenantUserBrief{Username: u.Username, DisplayName: u.DisplayName} + } + return m +} + // Pending TOTP secret is stored in Redis temporarily during setup flow. func getPendingTOTPSecret(ctx context.Context, userID int64) (string, error) { key := fmt.Sprintf("2fa:pending:admin:%d", userID) diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index d9bd141..a225016 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -183,6 +183,11 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant return nil, err } + // Get client info early for login history recording + ipAddress := g.RequestFromCtx(ctx).GetClientIp() + ua := g.RequestFromCtx(ctx).Header.Get("User-Agent") + deviceFP := common.DeviceFingerprint(ua, ipAddress) + var tenant entity.TntTenants var user entity.TntUsers @@ -197,6 +202,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant return nil, err } if user.Id == 0 { + _ = common.RecordLoginHistory(ctx, "tenant", 0, 0, "password", ipAddress, ua, deviceFP, false, "用户不存在") return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials) } @@ -207,12 +213,14 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant return nil, err } if tenant.Id == 0 { + _ = common.RecordLoginHistory(ctx, "tenant", user.Id, user.TenantId, "password", ipAddress, ua, deviceFP, false, "租户不存在") return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials) } } else { // RAM login: account is username@tenant_code parts := strings.SplitN(account, "@", 2) if len(parts) != 2 { + _ = common.RecordLoginHistory(ctx, "tenant", 0, 0, "password", ipAddress, ua, deviceFP, false, "账号格式错误") return nil, common.NewBadRequestError("账号格式错误,应为 用户名@组织代码") } username := parts[0] @@ -225,6 +233,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant return nil, err } if tenant.Id == 0 { + _ = common.RecordLoginHistory(ctx, "tenant", 0, 0, "password", ipAddress, ua, deviceFP, false, "租户不存在") return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials) } @@ -237,34 +246,40 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant return nil, err } if user.Id == 0 { + _ = common.RecordLoginHistory(ctx, "tenant", 0, tenant.Id, "password", ipAddress, ua, deviceFP, false, "用户不存在") return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials) } } if tenant.Status != "active" { + _ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "租户已停用") return nil, common.NewBusinessError(consts.CodeTenantSuspended, consts.MsgTenantSuspended) } // Check account lockout if user.LockedUntil != nil && time.Now().Before(user.LockedUntil.Time) { remaining := time.Until(user.LockedUntil.Time).Minutes() + _ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "账号已锁定") return nil, common.NewBusinessError(consts.CodeAccountLocked, fmt.Sprintf("账号已被锁定,%d 分钟后重试", int(remaining))) } // Check status if user.Status != "active" { + _ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "账号已禁用") return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials) } // Check IP whitelist - ipAddr := g.RequestFromCtx(ctx).GetClientIp() - if !CheckIPWhitelist(ctx, tenant.Id, ipAddr) { + if !CheckIPWhitelist(ctx, tenant.Id, ipAddress) { + _ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "IP不在白名单") return nil, common.NewBusinessError(consts.CodeIpRestricted, consts.MsgIpRestricted) } // Verify password if !crypto.VerifyPassword(req.Password, user.PasswordHash) { + _ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "密码错误") + // Increment failed attempts failedAttempts := user.FailedAttempts + 1 updateData := do.TntUsers{FailedAttempts: failedAttempts} @@ -295,10 +310,6 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant LockedUntil: nil, }).Update() } - // Get client info - ipAddress := g.RequestFromCtx(ctx).GetClientIp() - ua := g.RequestFromCtx(ctx).Header.Get("User-Agent") - deviceFP := common.DeviceFingerprint(ua, ipAddress) // Check if 2FA is enabled → return provisional token if user.TotpEnabled { @@ -349,6 +360,8 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant LastLoginIp: ipAddress, }).Update() + _ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, true, "") + res := &v1.TenantLoginRes{ AccessToken: tokenPair.AccessToken, RefreshToken: refreshToken, diff --git a/internal/logic/tenant/security.go b/internal/logic/tenant/security.go index 8debd03..e474cd8 100644 --- a/internal/logic/tenant/security.go +++ b/internal/logic/tenant/security.go @@ -190,41 +190,71 @@ func (s *sTenant) ConfirmHighRisk(ctx context.Context, req *v1.Tenant2FAConfirmR return &v1.Tenant2FAConfirmRes{ConfirmToken: token}, nil } -// LoginHistory returns the login history for the current tenant user. +// LoginHistory returns the login history for tenant users. +// owner/admin see all members in the tenant; member sees only own records. func (s *sTenant) LoginHistory(ctx context.Context, req *v1.TenantLoginHistoryReq) (*v1.TenantLoginHistoryRes, error) { userID := ctxUserID(ctx) - page := req.Page - pageSize := req.PageSize - if page < 1 { - page = 1 - } - if pageSize < 1 || pageSize > 100 { - pageSize = 20 + tenantID := ctxTenantID(ctx) + role := ctxUserRole(ctx) + page, pageSize := common.NormalizePagination(req.Page, req.PageSize) + + q := dao.AudLoginHistory.Ctx(ctx). + Where("user_type", "tenant"). + Where("tenant_id", tenantID) + + // Role-based access: member only sees own records + if role == "member" { + q = q.Where("user_id", userID) + } else if req.Username != "" { + // owner/admin can filter by username + userIds, err := tenantUserIdsByUsernameLocal(ctx, tenantID, req.Username) + if err != nil { + return nil, err + } + if len(userIds) == 0 { + return &v1.TenantLoginHistoryRes{ + List: []v1.TenantLoginHistoryItem{}, Total: 0, Page: page, PageSize: pageSize, + }, nil + } + q = q.WhereIn("user_id", userIds) } - total, err := dao.AudLoginHistory.Ctx(ctx). - Where("user_type", "tenant"). - Where("user_id", userID). - Count() + if req.IpAddress != "" { + q = q.WhereLike("ip_address", "%"+req.IpAddress+"%") + } + if req.Success != nil { + q = q.Where("success", *req.Success) + } + if req.LoginMethod != "" { + q = q.Where("login_method", req.LoginMethod) + } + if req.StartTime != "" { + q = q.WhereGTE("created_at", req.StartTime+" 00:00:00") + } + if req.EndTime != "" { + q = q.WhereLTE("created_at", req.EndTime+" 23:59:59") + } + + total, err := q.Count() if err != nil { return nil, err } var records []entity.AudLoginHistory - err = dao.AudLoginHistory.Ctx(ctx). - Where("user_type", "tenant"). - Where("user_id", userID). - OrderDesc("created_at"). - Page(page, pageSize). - Scan(&records) + err = q.OrderDesc("created_at").Page(page, pageSize).Scan(&records) if err != nil { return nil, err } + userMap := buildTenantUserMapLocal(ctx, records) + items := make([]v1.TenantLoginHistoryItem, len(records)) for i, r := range records { items[i] = v1.TenantLoginHistoryItem{ ID: r.Id, + UserId: r.UserId, + Username: userMap[r.UserId].Username, + DisplayName: userMap[r.UserId].DisplayName, LoginMethod: r.LoginMethod, IpAddress: r.IpAddress, UserAgent: r.UserAgent, @@ -246,6 +276,54 @@ func (s *sTenant) LoginHistory(ctx context.Context, req *v1.TenantLoginHistoryRe }, nil } +type tenantUserBriefLocal struct { + Username string + DisplayName string +} + +func tenantUserIdsByUsernameLocal(ctx context.Context, tenantID int64, keyword string) ([]int64, error) { + var users []entity.TntUsers + err := dao.TntUsers.Ctx(ctx). + Fields("id"). + Where("tenant_id", tenantID). + Where("username LIKE ? OR display_name LIKE ?", "%"+keyword+"%", "%"+keyword+"%"). + Scan(&users) + if err != nil { + return nil, err + } + ids := make([]int64, len(users)) + for i, u := range users { + ids[i] = u.Id + } + return ids, nil +} + +func buildTenantUserMapLocal(ctx context.Context, records []entity.AudLoginHistory) map[int64]tenantUserBriefLocal { + m := make(map[int64]tenantUserBriefLocal) + idSet := make(map[int64]struct{}) + for _, r := range records { + if r.UserId > 0 { + idSet[r.UserId] = struct{}{} + } + } + if len(idSet) == 0 { + return m + } + ids := make([]int64, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + var users []entity.TntUsers + _ = dao.TntUsers.Ctx(ctx). + Fields("id, username, display_name"). + WhereIn("id", ids). + Scan(&users) + for _, u := range users { + m[u.Id] = tenantUserBriefLocal{Username: u.Username, DisplayName: u.DisplayName} + } + return m +} + func getPendingTOTPSecret(ctx context.Context, userID int64) (string, error) { key := fmt.Sprintf("2fa:pending:tenant:%d", userID) val, err := g.Redis().Do(ctx, "GET", key) diff --git a/internal/service/admin.go b/internal/service/admin.go index 125f7af..2a8457e 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -329,6 +329,8 @@ type ( ConfirmHighRisk(ctx context.Context, req *v1.Admin2FAConfirmReq) (*v1.Admin2FAConfirmRes, error) // LoginHistory returns the login history for all admin users with search filters. LoginHistory(ctx context.Context, req *v1.AdminLoginHistoryReq) (*v1.AdminLoginHistoryRes, error) + // TenantLoginHistory returns login history for tenant users (admin view). + TenantLoginHistory(ctx context.Context, req *v1.AdminTenantLoginHistoryReq) (*v1.AdminTenantLoginHistoryRes, error) // GetSettingsCategories returns all available setting categories. GetSettingsCategories(ctx context.Context, _ *v1.AdminSettingsCategoriesReq) (*v1.AdminSettingsCategoriesRes, error) // GetSettings retrieves settings with schema for a given category. diff --git a/internal/service/tenant.go b/internal/service/tenant.go index f46360c..149980c 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -277,7 +277,8 @@ type ( RegenerateBackupCodes(ctx context.Context, req *v1.Tenant2FARegenerateBackupCodesReq) (*v1.Tenant2FARegenerateBackupCodesRes, error) // ConfirmHighRisk generates a confirm token for high-risk operations. ConfirmHighRisk(ctx context.Context, req *v1.Tenant2FAConfirmReq) (*v1.Tenant2FAConfirmRes, error) - // LoginHistory returns the login history for the current tenant user. + // LoginHistory returns the login history for tenant users. + // owner/admin see all members in the tenant; member sees only own records. LoginHistory(ctx context.Context, req *v1.TenantLoginHistoryReq) (*v1.TenantLoginHistoryRes, error) // GetIPWhitelist returns the tenant's IP whitelist configuration. GetIPWhitelist(ctx context.Context, _ *v1.TenantIPWhitelistGetReq) (*v1.TenantIPWhitelistGetRes, error) diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts index 5a581e2..aee1047 100644 --- a/web/admin/src/router/admin.ts +++ b/web/admin/src/router/admin.ts @@ -120,6 +120,12 @@ const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/views/security/LoginHistoryPage.vue'), meta: { title: '登录历史' }, }, + { + path: 'tenant-login-history', + name: 'AdminTenantLoginHistory', + component: () => import('@/views/security/TenantLoginHistoryPage.vue'), + meta: { title: '租户登录历史' }, + }, { path: 'task-logs', name: 'AdminTaskLogs', diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 8fccdbb..13af984 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -186,6 +186,7 @@ const menuGroups = [ icon: IconSafe, items: [ { name: 'AdminLoginHistory', label: '登录历史', icon: IconClockCircle }, + { name: 'AdminTenantLoginHistory', label: '租户登录历史', icon: IconClockCircle }, { name: 'AdminSessions', label: '会话管理', icon: IconClockCircle }, { name: 'AdminPermissions', label: '权限管理', icon: IconSafe }, { name: 'AdminAudit', label: '操作日志', icon: IconFile }, diff --git a/web/admin/src/views/security/TenantLoginHistoryPage.vue b/web/admin/src/views/security/TenantLoginHistoryPage.vue new file mode 100644 index 0000000..6bcef66 --- /dev/null +++ b/web/admin/src/views/security/TenantLoginHistoryPage.vue @@ -0,0 +1,207 @@ + + + diff --git a/web/tenant/src/router/tenant.ts b/web/tenant/src/router/tenant.ts index f0dc82b..64d3f30 100644 --- a/web/tenant/src/router/tenant.ts +++ b/web/tenant/src/router/tenant.ts @@ -195,6 +195,12 @@ const tenantRoutes: RouteRecordRaw[] = [ component: () => import('@/views/settings/AuditConfigPage.vue'), meta: { title: '审计设置', roles: ['owner', 'admin'] }, }, + { + path: 'login-history', + name: 'TenantLoginHistory', + component: () => import('@/views/settings/LoginHistoryPage.vue'), + meta: { title: '登录历史', roles: ['owner', 'admin', 'member'] }, + }, { path: 'profile', name: 'TenantProfile', diff --git a/web/tenant/src/views/settings/LoginHistoryPage.vue b/web/tenant/src/views/settings/LoginHistoryPage.vue new file mode 100644 index 0000000..125f92c --- /dev/null +++ b/web/tenant/src/views/settings/LoginHistoryPage.vue @@ -0,0 +1,223 @@ + + + diff --git a/web/tenant/src/views/settings/ProfilePage.vue b/web/tenant/src/views/settings/ProfilePage.vue index 00e1ff9..a37fa42 100644 --- a/web/tenant/src/views/settings/ProfilePage.vue +++ b/web/tenant/src/views/settings/ProfilePage.vue @@ -334,6 +334,22 @@ onMounted(() => { 暂无会话 + + + +
+
+
+ +
+
+

登录历史

+

查看登录记录,包括成功和失败的登录尝试

+
+
+ +
+