feat: improve login UX and add admin login lock reset
- Translate all login error messages to Chinese (network errors, HTTP status codes, URL validation errors) - Add password visibility toggle (eye icon) to login form - Add live countdown timer when login is locked, disable login button during cooldown, auto-clear error when countdown ends - Add X button red hover feedback in server settings dialog - Add LoginGuard.Unlock() method to clear Redis lock/failure keys - Expose POST /admin-users/:id/reset-login-lock endpoint in ops API - Add "重置登录保护" button in ops-web user management table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -391,10 +391,23 @@ function formatLoginError(err: unknown): string {
|
||||
login_in_progress: '登录请求正在处理中',
|
||||
login_guard_unavailable: '登录保护暂时不可用',
|
||||
}
|
||||
const message = messages[err.message] ?? err.message.replaceAll('_', ' ')
|
||||
return err.detail ? `${message},${err.detail}` : message
|
||||
if (err.message in messages) {
|
||||
const message = messages[err.message]!
|
||||
return err.message === 'login_locked' && err.detail ? `${message},${err.detail}` : message
|
||||
}
|
||||
if (err.status === 404) return '登录接口不存在,请确认服务器地址'
|
||||
if (err.status && err.status >= 500) return '服务器内部错误,请稍后重试'
|
||||
if (err.status && err.status >= 400) return '请求被服务器拒绝,请检查账号信息'
|
||||
if (!err.status) return '网络连接失败,请检查服务器地址或网络'
|
||||
return err.message.replaceAll('_', ' ')
|
||||
}
|
||||
return err instanceof Error ? err.message : '登录失败,请稍后重试'
|
||||
if (err instanceof Error) {
|
||||
const msg = err.message
|
||||
if (msg.includes('Invalid URL') || msg.includes('Failed to construct')) {
|
||||
return '服务器地址无效,请检查网关 API 地址设置'
|
||||
}
|
||||
}
|
||||
return '登录失败,请稍后重试'
|
||||
}
|
||||
|
||||
async function performLogin(payload: LoginRequest): Promise<void> {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { DEFAULT_API_BASE_URL, useDesktopSession } from '../composables/useDesktopSession'
|
||||
|
||||
const { apiBaseURL, error, pending, setApiBaseURL, login } = useDesktopSession()
|
||||
|
||||
const menuOpen = ref(false)
|
||||
const showServerDialog = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const rememberPassword = ref(!!localStorage.getItem('geo_rankly_saved_password'))
|
||||
const savedIdentifier =
|
||||
localStorage.getItem('geo_rankly_saved_identifier') ??
|
||||
@@ -69,12 +70,45 @@ async function submitLogin() {
|
||||
await window.desktopBridge?.app?.setWindowMode?.('main', { source: 'login' })
|
||||
}
|
||||
|
||||
const lockCountdown = ref(0)
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function clearCountdown() {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(error, (msg) => {
|
||||
clearCountdown()
|
||||
lockCountdown.value = 0
|
||||
if (!msg) return
|
||||
const match = msg.match(/请\s*(\d+)\s*秒后重试/)
|
||||
if (!match) return
|
||||
lockCountdown.value = parseInt(match[1], 10)
|
||||
countdownTimer = setInterval(() => {
|
||||
lockCountdown.value--
|
||||
if (lockCountdown.value <= 0) clearCountdown()
|
||||
}, 1000)
|
||||
}, { flush: 'sync' })
|
||||
|
||||
const displayError = computed(() => {
|
||||
if (!error.value) return null
|
||||
if (error.value.startsWith('登录已被临时保护')) {
|
||||
if (lockCountdown.value <= 0) return null
|
||||
return error.value.replace(/\d+(?=\s*秒后重试)/, String(lockCountdown.value))
|
||||
}
|
||||
return error.value
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
clearCountdown()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -122,14 +156,25 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field field--password">
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
placeholder="密码"
|
||||
required
|
||||
/>
|
||||
<button type="button" class="eye-toggle" tabindex="-1" @click="showPassword = !showPassword">
|
||||
<svg v-if="showPassword" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="options-row">
|
||||
@@ -139,11 +184,11 @@ onBeforeUnmount(() => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-button" :disabled="pending">
|
||||
<button type="submit" class="submit-button" :disabled="pending || lockCountdown > 0">
|
||||
{{ pending ? '登录中...' : '登 录' }}
|
||||
</button>
|
||||
|
||||
<p v-if="error" class="error-text">{{ error }}</p>
|
||||
<p v-if="displayError" class="error-text">{{ displayError }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -171,7 +216,6 @@ onBeforeUnmount(() => {
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<p class="dialog-hint">修改后对本机下次登录生效。</p>
|
||||
</div>
|
||||
<footer class="dialog-footer">
|
||||
<button type="button" class="btn-ghost" @click="closeServerSettings">取消</button>
|
||||
@@ -424,6 +468,40 @@ onBeforeUnmount(() => {
|
||||
0 0 0 3px rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
|
||||
.field--password {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.field--password input {
|
||||
padding-right: 42px;
|
||||
}
|
||||
|
||||
.eye-toggle {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9aa4b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eye-toggle:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.eye-toggle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.options-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -538,8 +616,13 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.dialog-close:hover {
|
||||
background: #f2f6ff;
|
||||
color: #1677ff;
|
||||
background: #fff0f0;
|
||||
color: #e53935;
|
||||
}
|
||||
|
||||
.dialog-close:active {
|
||||
background: #ffe0e0;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
|
||||
@@ -330,7 +330,6 @@ watch(apiBaseURL, (next) => {
|
||||
overflow: hidden;
|
||||
background: #f4f4f5;
|
||||
color: #0a0a0a;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
@@ -418,8 +417,8 @@ watch(apiBaseURL, (next) => {
|
||||
.settings-heading h1 {
|
||||
margin: 0;
|
||||
color: #000000;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
font-size:22px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,18 @@
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="重置登录保护">
|
||||
<a-popconfirm
|
||||
title="确认清除该用户的登录锁定状态?"
|
||||
:ok-button-props="{ loading: resetLoginLockLoadingId === record.id }"
|
||||
@confirm="resetLoginLock(record)"
|
||||
>
|
||||
<a-button type="text" shape="circle" size="small" class="action-btn action-unlock">
|
||||
<UnlockOutlined />
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip :title="record.status === 'active' ? '停用用户' : '启用用户'">
|
||||
<a-popconfirm
|
||||
:title="record.status === 'active' ? '确认停用该用户?' : '确认启用该用户?'"
|
||||
@@ -283,6 +295,7 @@ import {
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
StopOutlined,
|
||||
UnlockOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
@@ -608,6 +621,20 @@ async function resetPlanUsage(row: AdminUserRow) {
|
||||
}
|
||||
}
|
||||
|
||||
const resetLoginLockLoadingId = ref<number | null>(null)
|
||||
|
||||
async function resetLoginLock(row: AdminUserRow) {
|
||||
resetLoginLockLoadingId.value = row.id
|
||||
try {
|
||||
await http.post(`/admin-users/${row.id}/reset-login-lock`)
|
||||
message.success('登录保护已重置,用户可立即重新登录')
|
||||
} catch (error) {
|
||||
if (error instanceof OpsApiError) message.error(error.detail || error.message)
|
||||
} finally {
|
||||
resetLoginLockLoadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const resetOpen = ref(false)
|
||||
const resetLoading = ref(false)
|
||||
const resetTarget = ref<AdminUserRow | null>(null)
|
||||
@@ -806,4 +833,8 @@ onMounted(() => {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-unlock :deep(.anticon) {
|
||||
color: #16a34a;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -81,7 +81,8 @@ func main() {
|
||||
loginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("ops"))
|
||||
authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer, loginGuard)
|
||||
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
|
||||
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode)
|
||||
tenantLoginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("tenant"))
|
||||
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode).WithLoginGuard(tenantLoginGuard)
|
||||
kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache)
|
||||
siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc)
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ const (
|
||||
ActionAdminUserSetKOL = "admin_user.set_kol"
|
||||
ActionAdminUserDisable = "admin_user.disable"
|
||||
ActionAdminUserEnable = "admin_user.enable"
|
||||
ActionAdminUserResetPassword = "admin_user.reset_password"
|
||||
ActionAdminUserResetPassword = "admin_user.reset_password"
|
||||
ActionAdminUserResetLoginLock = "admin_user.reset_login_lock"
|
||||
)
|
||||
|
||||
var phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
||||
@@ -36,6 +37,7 @@ var phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
||||
type AdminUserService struct {
|
||||
users *repository.AdminUserRepository
|
||||
audits *AuditService
|
||||
loginGuard *sharedauth.LoginGuard
|
||||
configMu sync.RWMutex
|
||||
defaultPlanCode string
|
||||
}
|
||||
@@ -52,6 +54,11 @@ func NewAdminUserService(users *repository.AdminUserRepository, audits *AuditSer
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminUserService) WithLoginGuard(g *sharedauth.LoginGuard) *AdminUserService {
|
||||
s.loginGuard = g
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *AdminUserService) UpdateDefaultPlanCode(defaultPlanCode string) {
|
||||
if s == nil {
|
||||
return
|
||||
@@ -596,6 +603,26 @@ func (s *AdminUserService) ResetPassword(ctx context.Context, actor *Actor, id i
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AdminUserService) ResetLoginLock(ctx context.Context, actor *Actor, id int64) error {
|
||||
user, err := s.users.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||
return response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||
}
|
||||
return response.ErrInternal(50034, "query_failed", "failed to get user")
|
||||
}
|
||||
if s.loginGuard != nil {
|
||||
_ = s.loginGuard.Unlock(ctx, user.Phone)
|
||||
if user.Email != nil && *user.Email != "" {
|
||||
_ = s.loginGuard.Unlock(ctx, *user.Email)
|
||||
}
|
||||
}
|
||||
if actor != nil {
|
||||
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetLoginLock, "admin_user", id, nil))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AdminUserService) mapWriteError(err error) error {
|
||||
if errors.Is(err, repository.ErrDefaultPlanNotFound) {
|
||||
return response.ErrInternal(50034, "default_plan_missing", "默认套餐未配置")
|
||||
|
||||
@@ -284,3 +284,18 @@ func resetAdminUserPasswordHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||
response.Success(c, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
|
||||
func resetAdminUserLoginLockHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.ResetLoginLock(c.Request.Context(), actorFromGin(c), id); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ func RegisterRoutes(d Deps) {
|
||||
authed.POST("/admin-users/:id/kol", setAdminUserKOLHandler(d.AdminUsers))
|
||||
authed.POST("/admin-users/:id/status", setAdminUserStatusHandler(d.AdminUsers))
|
||||
authed.POST("/admin-users/:id/password", resetAdminUserPasswordHandler(d.AdminUsers))
|
||||
authed.POST("/admin-users/:id/reset-login-lock", resetAdminUserLoginLockHandler(d.AdminUsers))
|
||||
|
||||
authed.GET("/kol/packages", listKolSubscriptionPackagesHandler(d.KolSubs))
|
||||
authed.GET("/kol/subscriptions", listKolSubscriptionsHandler(d.KolSubs))
|
||||
|
||||
@@ -74,8 +74,8 @@ func DefaultLoginGuardConfig(scope string) LoginGuardConfig {
|
||||
RequestPairWindow: time.Minute,
|
||||
FailureIdentifierLimit: 8,
|
||||
FailurePairLimit: 5,
|
||||
FailureWindow: 15 * time.Minute,
|
||||
FailureLockBaseTTL: 5 * time.Minute,
|
||||
FailureWindow: 5 * time.Minute,
|
||||
FailureLockBaseTTL: 2 * time.Minute,
|
||||
FailureLockMaxTTL: time.Hour,
|
||||
ConcurrentAttemptLockTTL: 15 * time.Second,
|
||||
}
|
||||
@@ -83,11 +83,11 @@ func DefaultLoginGuardConfig(scope string) LoginGuardConfig {
|
||||
if strings.EqualFold(strings.TrimSpace(scope), "ops") {
|
||||
cfg.RequestIPLimit = 30
|
||||
cfg.RequestIdentifierLimit = 8
|
||||
cfg.RequestPairLimit = 5
|
||||
cfg.FailureIdentifierLimit = 5
|
||||
cfg.FailurePairLimit = 3
|
||||
cfg.FailureLockBaseTTL = 10 * time.Minute
|
||||
cfg.FailureLockMaxTTL = 2 * time.Hour
|
||||
cfg.RequestPairLimit = 6
|
||||
cfg.FailureIdentifierLimit = 10
|
||||
cfg.FailurePairLimit = 5
|
||||
cfg.FailureLockBaseTTL = 2 * time.Minute
|
||||
cfg.FailureLockMaxTTL = 30 * time.Minute
|
||||
}
|
||||
|
||||
return cfg
|
||||
@@ -256,6 +256,18 @@ func (p *LoginPermit) Release(ctx context.Context) {
|
||||
_, _ = loginGuardReleaseScript.Run(ctx, p.guard.rdb, []string{p.keys.inflightIdentifier}, p.token).Result()
|
||||
}
|
||||
|
||||
func (g *LoginGuard) Unlock(ctx context.Context, identifier string) error {
|
||||
if g == nil || !g.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
keys := g.keysFor(LoginGuardSubject{Identifier: identifier})
|
||||
if g.rdb == nil {
|
||||
g.fallback.unlock(keys)
|
||||
return nil
|
||||
}
|
||||
return g.rdb.Del(ctx, keys.lockIdentifier, keys.failureIdentifier).Err()
|
||||
}
|
||||
|
||||
type loginGuardKeys struct {
|
||||
requestIP string
|
||||
requestIdentifier string
|
||||
@@ -582,6 +594,13 @@ func (g *memoryLoginGuard) bumpFailureLocked(failureKey, lockKey string, thresho
|
||||
g.locks[lockKey] = now.Add(lockTTL)
|
||||
}
|
||||
|
||||
func (g *memoryLoginGuard) unlock(keys loginGuardKeys) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.locks, keys.lockIdentifier)
|
||||
delete(g.failures, keys.failureIdentifier)
|
||||
}
|
||||
|
||||
func (g *memoryLoginGuard) releaseLocked(key, token string) {
|
||||
current, ok := g.inflight[key]
|
||||
if !ok || current.token != token {
|
||||
|
||||
Reference in New Issue
Block a user