修复用户安全信息弹窗与会话校验
This commit is contained in:
parent
31ee54942a
commit
306af4b7b0
@ -728,6 +728,77 @@ func BindExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Re
|
||||
response.NewSuccessResponse(map[string]any{"id": item.ID, "challenge_id": challenge.ID, "status": item.Status, "email": maskEmail(input.Email), "expires_at": challenge.ExpiresAt.UTC()}).FPrint(w)
|
||||
}
|
||||
|
||||
// ChangeOwnExternalEmail 发送当前用户外部邮箱变更验证码,旧邮箱会保留到验证成功。
|
||||
func ChangeOwnExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
runtime := currentSecurityRuntime()
|
||||
if ctx == nil || ctx.UserID <= 0 || runtime.external == nil || runtime.verification == nil {
|
||||
writeSecurityError(w, response.NeedLogin, "请先登录")
|
||||
return
|
||||
}
|
||||
if !ctx.RecentAuth {
|
||||
writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "此操作需要最近十分钟内完成登录,请确认当前密码", string(access.CodeRecentAuthRequired))
|
||||
return
|
||||
}
|
||||
var input externalEmailRequest
|
||||
if err := decodeJSONRequest(w, req, &input); err != nil {
|
||||
writeSecurityError(w, response.ParamsError, "请求无效")
|
||||
return
|
||||
}
|
||||
normalizedEmail, err := externalemail.NormalizeEmail(input.Email)
|
||||
if err != nil {
|
||||
writeSecurityError(w, response.ParamsError, "请输入有效的外部邮箱地址")
|
||||
return
|
||||
}
|
||||
owned, err := currentUserPrimaryEmail(ctx.UserID, normalizedEmail)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).WithError(err).Error("check current user primary email failed")
|
||||
writeSecurityError(w, response.ServerError, "外部邮箱变更失败")
|
||||
return
|
||||
}
|
||||
if owned {
|
||||
writeSecurityError(w, response.ParamsError, "不能将当前邮箱绑定为外部安全邮箱")
|
||||
return
|
||||
}
|
||||
emailType := strings.TrimSpace(input.Type)
|
||||
if emailType == "" {
|
||||
emailType = models.ExternalEmailTypePrimary
|
||||
}
|
||||
item, err := runtime.external.BeginReplacement(externalemail.BindRequest{
|
||||
UserID: ctx.UserID,
|
||||
Email: normalizedEmail,
|
||||
Type: emailType,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, externalemail.ErrBindingExists) {
|
||||
writeSecurityError(w, response.ParamsError, "该外部邮箱已经绑定或正在被其他账号使用")
|
||||
return
|
||||
}
|
||||
log.WithContext(ctx).WithError(err).Error("begin own external email replacement failed")
|
||||
writeSecurityError(w, response.ParamsError, "外部邮箱变更失败")
|
||||
return
|
||||
}
|
||||
if runtime.verification.Store != nil {
|
||||
runtime.verification.Store.InvalidateResource(item.ID, verification.PurposeExternalEmailChange)
|
||||
runtime.verification.Store.InvalidateResource(item.ID, verification.PurposeExternalEmailBind)
|
||||
}
|
||||
challenge, err := runtime.verification.Issue(verification.IssueRequest{
|
||||
Purpose: verification.PurposeExternalEmailChange,
|
||||
UserID: ctx.UserID,
|
||||
Account: ctx.UserAccount,
|
||||
Email: normalizedEmail,
|
||||
ResourceID: item.ID,
|
||||
}, remoteIP(req))
|
||||
if err != nil {
|
||||
log.WithContext(ctx).WithError(err).Warn("issue own external email verification failed")
|
||||
writeSecurityError(w, response.ParamsError, externalEmailVerificationIssueMessage(err))
|
||||
return
|
||||
}
|
||||
response.NewSuccessResponse(map[string]any{
|
||||
"binding_id": item.ID, "challenge_id": challenge.ID,
|
||||
"email": maskEmail(normalizedEmail), "expires_at": challenge.ExpiresAt.UTC(),
|
||||
}).FPrint(w)
|
||||
}
|
||||
|
||||
func currentUserPrimaryEmail(userID int, email string) (bool, error) {
|
||||
if db.Instance == nil || userID <= 0 {
|
||||
return false, errors.New("database is unavailable")
|
||||
@ -758,6 +829,44 @@ func VerifyExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.
|
||||
response.NewSuccessResponse(map[string]any{"verified": true}).FPrint(w)
|
||||
}
|
||||
|
||||
// VerifyOwnExternalEmail 验证成功后原子替换当前用户同用途的旧邮箱。
|
||||
func VerifyOwnExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
runtime := currentSecurityRuntime()
|
||||
if ctx == nil || ctx.UserID <= 0 || runtime.external == nil || runtime.verification == nil {
|
||||
writeSecurityError(w, response.NeedLogin, "请先登录")
|
||||
return
|
||||
}
|
||||
var input verificationCheckRequest
|
||||
if err := decodeJSONRequest(w, req, &input); err != nil || input.BindingID <= 0 {
|
||||
writeSecurityError(w, response.ParamsError, "验证请求无效")
|
||||
return
|
||||
}
|
||||
normalizedEmail, err := externalemail.NormalizeEmail(input.Email)
|
||||
if err != nil {
|
||||
writeSecurityError(w, response.ParamsError, "验证失败")
|
||||
return
|
||||
}
|
||||
if err := runtime.verification.VerifyBound(
|
||||
input.ID,
|
||||
ctx.UserAccount,
|
||||
normalizedEmail,
|
||||
input.Code,
|
||||
verification.PurposeExternalEmailChange,
|
||||
input.BindingID,
|
||||
remoteIP(req),
|
||||
); err != nil {
|
||||
writeSecurityError(w, response.ParamsError, externalEmailVerificationCheckMessage(err))
|
||||
return
|
||||
}
|
||||
if err := runtime.external.VerifyReplacement(ctx.UserID, input.BindingID); err != nil {
|
||||
log.WithContext(ctx).WithError(err).Error("verify own external email replacement failed")
|
||||
writeSecurityError(w, response.ParamsError, "外部邮箱替换失败")
|
||||
return
|
||||
}
|
||||
InvalidateSecurityRuntimeUser(ctx.UserID, ctx.UserAccount)
|
||||
response.NewSuccessResponse(map[string]any{"verified": true}).FPrint(w)
|
||||
}
|
||||
|
||||
func DisableExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
runtime := currentSecurityRuntime()
|
||||
if ctx == nil || ctx.UserID <= 0 || runtime.external == nil {
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"git.pcux.cn/xuans/Smail/services/auth"
|
||||
"git.pcux.cn/xuans/Smail/utils/array"
|
||||
"git.pcux.cn/xuans/Smail/utils/context"
|
||||
"git.pcux.cn/xuans/Smail/utils/csrf"
|
||||
"git.pcux.cn/xuans/Smail/utils/password"
|
||||
"git.pcux.cn/xuans/Smail/utils/validation"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@ -204,6 +205,11 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
if _, err := csrf.Ensure(w, req, !config.DevelopmentMode); err != nil {
|
||||
log.WithContext(ctx).WithError(err).Error("restore csrf token failed")
|
||||
response.NewErrorResponse(response.ServerError, "登录状态恢复失败", "").FPrint(w)
|
||||
return
|
||||
}
|
||||
mailDomains := config.CurrentMailDomains()
|
||||
if mailDomains.Primary == "" {
|
||||
response.NewErrorResponse(response.ServerError, "服务配置不可用", "").FPrint(w)
|
||||
@ -212,6 +218,7 @@ func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
domains := array.Difference(mailDomains.Accepted, []string{mailDomains.Primary})
|
||||
domains = append([]string{mailDomains.Primary}, domains...)
|
||||
result := map[string]any{
|
||||
"id": ctx.UserID,
|
||||
"account": ctx.UserAccount,
|
||||
"name": ctx.UserName,
|
||||
"role": ctx.EffectiveRole(),
|
||||
|
||||
@ -41,6 +41,8 @@ func registerV1Routes(mux *http.ServeMux) {
|
||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/list", "/api/security/external-email/list", http.MethodGet, 0, controllers.ListExternalEmails)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/bind", "/api/security/external-email/bind", http.MethodPost, 16<<10, controllers.BindExternalEmail)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/verify", "/api/security/external-email/verify", http.MethodPost, 16<<10, controllers.VerifyExternalEmail)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/change", "/api/security/external-email/change", http.MethodPost, 16<<10, controllers.ChangeOwnExternalEmail)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/change/verify", "/api/security/external-email/change/verify", http.MethodPost, 16<<10, controllers.VerifyOwnExternalEmail)
|
||||
registerV1MethodRoute(mux, "/api/v1/security/external-email/disable", "/api/security/external-email/disable", http.MethodPost, 16<<10, controllers.DisableExternalEmail)
|
||||
registerV1MethodRoute(mux, "/api/v1/logout", "/api/logout", http.MethodPost, 4<<10, controllers.Logout)
|
||||
registerV1MethodRoute(mux, "/api/v1/system/restart", "/api/v1/system/restart", http.MethodPost, 4<<10, controllers.RestartSmail)
|
||||
|
||||
@ -192,6 +192,8 @@ func TestFrontendV1RoutesAreRegistered(t *testing.T) {
|
||||
"/api/v1/security/credentials/protocols/generate",
|
||||
"/api/v1/security/credentials/protocols/revoke",
|
||||
"/api/v1/security/external-email/bind",
|
||||
"/api/v1/security/external-email/change",
|
||||
"/api/v1/security/external-email/change/verify",
|
||||
"/api/v1/security/external-email/disable",
|
||||
"/api/v1/security/external-email/list",
|
||||
"/api/v1/security/external-email/verify",
|
||||
|
||||
@ -74,6 +74,8 @@ func router(mux *http.ServeMux) error {
|
||||
mux.HandleFunc("/api/security/external-email/list", onlyMethod(http.MethodGet, contextIterceptor(controllers.ListExternalEmails)))
|
||||
mux.HandleFunc("/api/security/external-email/bind", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.BindExternalEmail))))
|
||||
mux.HandleFunc("/api/security/external-email/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyExternalEmail))))
|
||||
mux.HandleFunc("/api/security/external-email/change", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.ChangeOwnExternalEmail))))
|
||||
mux.HandleFunc("/api/security/external-email/change/verify", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.VerifyOwnExternalEmail))))
|
||||
mux.HandleFunc("/api/security/external-email/disable", onlyMethod(http.MethodPost, limitBody(16<<10, contextIterceptor(controllers.DisableExternalEmail))))
|
||||
mux.HandleFunc("/api/logout", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.Logout))))
|
||||
mux.HandleFunc("/api/group", onlyMethod(http.MethodGet, contextIterceptor(controllers.GetUserGroup)))
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/session"
|
||||
@ -29,6 +30,20 @@ func Issue(w http.ResponseWriter, r *http.Request, secure bool) (string, error)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Ensure 为已认证会话恢复双提交 CSRF Cookie。
|
||||
// Cookie 缺失或与服务端会话不一致时轮换令牌,避免继续信任过期的浏览器状态。
|
||||
func Ensure(w http.ResponseWriter, r *http.Request, secure bool) (string, error) {
|
||||
if session.Instance == nil {
|
||||
return "", errors.New("session manager is unavailable")
|
||||
}
|
||||
stored := session.Instance.GetString(r.Context(), SessionKey)
|
||||
cookie, err := r.Cookie(CookieName)
|
||||
if err == nil && stored != "" && constantTimeEqual(stored, cookie.Value) {
|
||||
return stored, nil
|
||||
}
|
||||
return Issue(w, r, secure)
|
||||
}
|
||||
|
||||
func Clear(w http.ResponseWriter, r *http.Request, secure bool) {
|
||||
if session.Instance != nil {
|
||||
session.Instance.Remove(r.Context(), SessionKey)
|
||||
@ -49,9 +64,12 @@ func Valid(r *http.Request) bool {
|
||||
return false
|
||||
}
|
||||
stored := session.Instance.GetString(r.Context(), SessionKey)
|
||||
if stored == "" || len(stored) != len(header) || len(cookie.Value) != len(header) {
|
||||
if stored == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(stored), []byte(header)) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(header)) == 1
|
||||
return constantTimeEqual(stored, header) && constantTimeEqual(cookie.Value, header)
|
||||
}
|
||||
|
||||
func constantTimeEqual(left, right string) bool {
|
||||
return len(left) == len(right) && subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
|
||||
}
|
||||
|
||||
@ -76,3 +76,73 @@ func TestClearInvalidatesExistingToken(t *testing.T) {
|
||||
t.Fatalf("unexpected CSRF clear cookie: %+v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRestoresMissingCookieWithRotatedToken(t *testing.T) {
|
||||
old := session.Instance
|
||||
manager := scs.New()
|
||||
session.Instance = manager
|
||||
t.Cleanup(func() { session.Instance = old })
|
||||
|
||||
request := testRequestWithToken(t, manager, "old-token", "", "")
|
||||
recorder := httptest.NewRecorder()
|
||||
token, err := Ensure(recorder, request, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if token == "" || token == "old-token" {
|
||||
t.Fatal("missing cookie did not rotate the CSRF token")
|
||||
}
|
||||
if stored := manager.GetString(request.Context(), SessionKey); stored != token {
|
||||
t.Fatalf("rotated token was not stored in session: %q", stored)
|
||||
}
|
||||
cookies := recorder.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("unexpected CSRF cookies: %+v", cookies)
|
||||
}
|
||||
cookie := cookies[0]
|
||||
if cookie.Name != CookieName || cookie.Value != token || cookie.Path != "/" ||
|
||||
cookie.MaxAge != 86400 || !cookie.Secure || cookie.HttpOnly ||
|
||||
cookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("unexpected restored CSRF cookie: %+v", cookie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureKeepsMatchingTokenWithoutReissuingCookie(t *testing.T) {
|
||||
old := session.Instance
|
||||
manager := scs.New()
|
||||
session.Instance = manager
|
||||
t.Cleanup(func() { session.Instance = old })
|
||||
|
||||
request := testRequestWithToken(t, manager, "current-token", "", "current-token")
|
||||
recorder := httptest.NewRecorder()
|
||||
token, err := Ensure(recorder, request, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if token != "current-token" {
|
||||
t.Fatalf("matching token was unexpectedly rotated: %q", token)
|
||||
}
|
||||
if cookies := recorder.Result().Cookies(); len(cookies) != 0 {
|
||||
t.Fatalf("matching token unexpectedly reissued cookies: %+v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRotatesMismatchedCookie(t *testing.T) {
|
||||
old := session.Instance
|
||||
manager := scs.New()
|
||||
session.Instance = manager
|
||||
t.Cleanup(func() { session.Instance = old })
|
||||
|
||||
request := testRequestWithToken(t, manager, "stored-token", "", "stale-token")
|
||||
recorder := httptest.NewRecorder()
|
||||
token, err := Ensure(recorder, request, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if token == "" || token == "stored-token" || token == "stale-token" {
|
||||
t.Fatalf("mismatched cookie did not rotate token: %q", token)
|
||||
}
|
||||
if cookies := recorder.Result().Cookies(); len(cookies) != 1 || cookies[0].Secure {
|
||||
t.Fatalf("unexpected development CSRF cookie: %+v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!busy"
|
||||
:show-close="!busy"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
class="recovery-code-dialog"
|
||||
@update:model-value="value => { if (!value) close() }"
|
||||
>
|
||||
|
||||
@ -15,6 +15,10 @@ export const userExternalEmailDialogProps = {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
selfManaged: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
}
|
||||
|
||||
const verificationCodeLength = 8
|
||||
@ -30,6 +34,21 @@ function assertSuccess(response, fallback) {
|
||||
return response
|
||||
}
|
||||
|
||||
export function externalEmailEndpoints(selfManaged) {
|
||||
if (selfManaged) {
|
||||
return {
|
||||
list: '/api/v1/security/external-email/list',
|
||||
change: '/api/v1/security/external-email/change',
|
||||
verify: '/api/v1/security/external-email/change/verify',
|
||||
}
|
||||
}
|
||||
return {
|
||||
list: '/api/v1/user/security/external-email/list',
|
||||
change: '/api/v1/user/security/external-email/change',
|
||||
verify: '/api/v1/user/security/external-email/verify',
|
||||
}
|
||||
}
|
||||
|
||||
export function useUserExternalEmailDialog(props, emit) {
|
||||
const form = reactive({ email: '', type: 'primary', code: '' })
|
||||
const challenge = reactive({ id: '', bindingId: 0, maskedEmail: '', expiresAt: '' })
|
||||
@ -47,6 +66,7 @@ export function useUserExternalEmailDialog(props, emit) {
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
const busy = computed(() => sending.value || verifying.value)
|
||||
const endpoints = computed(() => externalEmailEndpoints(props.selfManaged))
|
||||
const hasChallenge = computed(() => Boolean(challenge.id && challenge.bindingId))
|
||||
const remainingSeconds = computed(() => {
|
||||
if (!challenge.expiresAt) return 0
|
||||
@ -93,12 +113,13 @@ export function useUserExternalEmailDialog(props, emit) {
|
||||
if (!props.user?.ID) return false
|
||||
try {
|
||||
const response = await requestWithRecentAuthentication(async () => {
|
||||
const result = await http.get('/api/v1/user/security/external-email/list', {
|
||||
params: { id: props.user.ID },
|
||||
})
|
||||
const config = props.selfManaged ? undefined : { params: { id: props.user.ID } }
|
||||
const result = await http.get(endpoints.value.list, config)
|
||||
return assertSuccess(result, '读取外部邮箱失败')
|
||||
})
|
||||
const items = Array.isArray(response.data?.items) ? response.data.items : []
|
||||
const items = props.selfManaged
|
||||
? (Array.isArray(response.data) ? response.data : [])
|
||||
: (Array.isArray(response.data?.items) ? response.data.items : [])
|
||||
const primary = items.find(item => item.type === 'primary') || items[0]
|
||||
currentEmail.value = primary?.email || ''
|
||||
if (primary?.type) form.type = primary.type
|
||||
@ -122,11 +143,12 @@ export function useUserExternalEmailDialog(props, emit) {
|
||||
clearMessages()
|
||||
try {
|
||||
const response = await requestWithRecentAuthentication(async () => {
|
||||
const result = await http.post('/api/v1/user/security/external-email/change', {
|
||||
user_id: props.user.ID,
|
||||
const payload = {
|
||||
email,
|
||||
type: form.type,
|
||||
})
|
||||
}
|
||||
if (!props.selfManaged) payload.user_id = props.user.ID
|
||||
const result = await http.post(endpoints.value.change, payload)
|
||||
return assertSuccess(result, '验证码发送失败')
|
||||
})
|
||||
if (!response.data?.challenge_id || !response.data?.binding_id) {
|
||||
@ -169,13 +191,18 @@ export function useUserExternalEmailDialog(props, emit) {
|
||||
clearMessages()
|
||||
try {
|
||||
const response = await requestWithRecentAuthentication(async () => {
|
||||
const result = await http.post('/api/v1/user/security/external-email/verify', {
|
||||
user_id: props.user.ID,
|
||||
challenge_id: challenge.id,
|
||||
const payload = {
|
||||
binding_id: challenge.bindingId,
|
||||
email: form.email,
|
||||
code,
|
||||
})
|
||||
}
|
||||
if (props.selfManaged) {
|
||||
payload.id = challenge.id
|
||||
} else {
|
||||
payload.user_id = props.user.ID
|
||||
payload.challenge_id = challenge.id
|
||||
}
|
||||
const result = await http.post(endpoints.value.verify, payload)
|
||||
return assertSuccess(result, '外部邮箱验证失败')
|
||||
})
|
||||
emit('changed')
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="编辑外部邮箱"
|
||||
:title="selfManaged ? '编辑我的外部邮箱' : '编辑外部邮箱'"
|
||||
width="min(520px, calc(100vw - 32px))"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!busy"
|
||||
:show-close="!busy"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
class="user-external-email-dialog"
|
||||
@update:model-value="value => { if (!value) close() }"
|
||||
>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {reactive, ref} from 'vue'
|
||||
import {computed, reactive, ref} from 'vue'
|
||||
|
||||
import lang from '../../../i18n/i18n';
|
||||
|
||||
@ -8,6 +8,8 @@ import {ElNotification} from "element-plus";
|
||||
|
||||
import {Plus} from "@element-plus/icons-vue";
|
||||
|
||||
import {useGlobalStatusStore} from "@/stores/useGlobalStatusStore";
|
||||
|
||||
const roleOptions = {
|
||||
super_admin: { label: '超级管理员', type: 'danger' },
|
||||
admin: { label: '管理员', type: 'primary' },
|
||||
@ -39,7 +41,23 @@ export function accountStatusMeta(disabled) {
|
||||
: { label: '正常', type: 'success' }
|
||||
}
|
||||
|
||||
export function isSameUser(selectedUser, currentUser) {
|
||||
const selectedID = Number(selectedUser?.ID || 0)
|
||||
const currentID = Number(currentUser?.id || 0)
|
||||
if (selectedID > 0 && currentID > 0) {
|
||||
return selectedID === currentID
|
||||
}
|
||||
return Boolean(selectedUser?.Account && currentUser?.account)
|
||||
&& selectedUser.Account === currentUser.account
|
||||
}
|
||||
|
||||
export function canManageRecoveryCode(user) {
|
||||
return Boolean(user?.ID) && user.Role !== 'super_admin'
|
||||
}
|
||||
|
||||
export function usePage() {
|
||||
const globalStatus = useGlobalStatusStore()
|
||||
|
||||
const userList = reactive([])
|
||||
|
||||
const currentPage = ref(1)
|
||||
@ -55,6 +73,10 @@ export function usePage() {
|
||||
const recoveryCodeDialogVisible = ref(false)
|
||||
|
||||
const externalEmailDialogVisible = ref(false)
|
||||
|
||||
const editingCurrentUser = computed(() => isSameUser(selectedUser.value, globalStatus.userInfos))
|
||||
|
||||
const recoveryCodeWebManaged = computed(() => canManageRecoveryCode(selectedUser.value))
|
||||
|
||||
const editUserInfo = reactive({
|
||||
"account": "",
|
||||
@ -101,7 +123,7 @@ export function usePage() {
|
||||
}
|
||||
|
||||
const openRecoveryCodeDialog = function () {
|
||||
if (!selectedUser.value?.ID) return
|
||||
if (!recoveryCodeWebManaged.value) return
|
||||
recoveryCodeDialogVisible.value = true
|
||||
}
|
||||
|
||||
@ -174,6 +196,8 @@ export function usePage() {
|
||||
editModel,
|
||||
editUserInfo,
|
||||
selectedUser,
|
||||
editingCurrentUser,
|
||||
recoveryCodeWebManaged,
|
||||
recoveryCodeDialogVisible,
|
||||
externalEmailDialogVisible,
|
||||
openRecoveryCodeDialog,
|
||||
|
||||
@ -115,9 +115,17 @@
|
||||
<div class="security-edit-item">
|
||||
<div>
|
||||
<strong>安全码</strong>
|
||||
<span>{{ recoveryCodeMeta(selectedUser?.RecoveryCodeConfigured).label }}</span>
|
||||
<span v-if="recoveryCodeWebManaged">
|
||||
{{ recoveryCodeMeta(selectedUser?.RecoveryCodeConfigured).label }}
|
||||
</span>
|
||||
<span v-else>部署端管理</span>
|
||||
</div>
|
||||
<el-button @click="openRecoveryCodeDialog">管理安全码</el-button>
|
||||
<el-button
|
||||
:disabled="!recoveryCodeWebManaged"
|
||||
@click="openRecoveryCodeDialog"
|
||||
>
|
||||
{{ recoveryCodeWebManaged ? '管理安全码' : '仅支持本机管理' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="security-edit-item">
|
||||
<div>
|
||||
@ -164,6 +172,7 @@
|
||||
<UserExternalEmailDialog
|
||||
v-model="externalEmailDialogVisible"
|
||||
:user="selectedUser"
|
||||
:self-managed="editingCurrentUser"
|
||||
@changed="handleSecurityChanged"
|
||||
/>
|
||||
</div>
|
||||
@ -188,6 +197,8 @@ const {
|
||||
editModel,
|
||||
editUserInfo,
|
||||
selectedUser,
|
||||
editingCurrentUser,
|
||||
recoveryCodeWebManaged,
|
||||
recoveryCodeDialogVisible,
|
||||
externalEmailDialogVisible,
|
||||
openRecoveryCodeDialog,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user