This commit is contained in:
parent
05b01bbc83
commit
46572da0f5
@ -103,7 +103,7 @@ func GetNotificationStatus(ctx *context.Context, w http.ResponseWriter, req *htt
|
|||||||
"user_id": user.ID, "account": user.Account, "enabled": item.Enabled == 1,
|
"user_id": user.ID, "account": user.Account, "enabled": item.Enabled == 1,
|
||||||
"configured": notification.Configured(item, *user), "version": item.CodeVersion,
|
"configured": notification.Configured(item, *user), "version": item.CodeVersion,
|
||||||
"can_reveal": notification.Configured(item, *user) && item.CodeCiphertext != "" &&
|
"can_reveal": notification.Configured(item, *user) && item.CodeCiphertext != "" &&
|
||||||
access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), ctx.UserID == user.ID),
|
(ctx.UserID == user.ID || access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), false)),
|
||||||
"account_available": user.Disabled == 0 && user.MustChangePassword == 0,
|
"account_available": user.Disabled == 0 && user.MustChangePassword == 0,
|
||||||
"can_manage": access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), ctx.UserID == user.ID),
|
"can_manage": access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), ctx.UserID == user.ID),
|
||||||
"minute_limit": item.MinuteLimit, "day_limit": item.DayLimit,
|
"minute_limit": item.MinuteLimit, "day_limit": item.DayLimit,
|
||||||
@ -216,20 +216,48 @@ func RevealNotificationCredential(ctx *context.Context, w http.ResponseWriter, r
|
|||||||
writeSecurityError(w, response.ParamsError, "请填写当前登录密码并选择有效账号")
|
writeSecurityError(w, response.ParamsError, "请填写当前登录密码并选择有效账号")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, ok := loadNotificationUser(ctx, w, input.UserID, true)
|
user, ok := loadNotificationUser(ctx, w, input.UserID, false)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err := auth.AuthenticateUserByID(ctx.UserID, input.Password)
|
if err := authorizeCapability(ctx, access.CapabilityNotificationReveal, user.EffectiveRole(), ctx.UserID == user.ID); err != nil {
|
||||||
|
writeSecurityError(w, response.NoAccessPrivileges, "无权查看该账号的通知发信凭据")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userKey := reauthUserAttempts.accountKey(strconv.Itoa(ctx.UserID))
|
||||||
|
ipKey := reauthIPAttempts.ipKey(req)
|
||||||
|
if reauthUserAttempts.blocked(userKey) || reauthIPAttempts.blocked(ipKey) {
|
||||||
|
writeReauthError(w, http.StatusTooManyRequests, "REAUTH_RATE_LIMITED", "尝试次数过多,请稍后再试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor, err := auth.AuthenticateUserByID(ctx.UserID, input.Password)
|
||||||
input.Password = ""
|
input.Password = ""
|
||||||
if err != nil {
|
if err != nil {
|
||||||
recordNotificationManagement(ctx, user.ID, "查看系统通知凭据", err, nil)
|
recordNotificationManagement(ctx, user.ID, "查看系统通知凭据", err, nil)
|
||||||
if !writePasswordBusy(w, err) {
|
if !writePasswordBusy(w, err) {
|
||||||
|
reauthUserAttempts.recordFailure(userKey)
|
||||||
|
reauthIPAttempts.recordFailure(ipKey)
|
||||||
writeSecurityError(w, response.ParamsError, "密码验证失败")
|
writeSecurityError(w, response.ParamsError, "密码验证失败")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
code, err := notification.Reveal(db.Instance, *user, input.Version, config.ROOT_PATH, accountWriteGuard(ctx, *user))
|
if actor.SessionVersion != ctx.SessionVersion || actor.SecurityVersion != ctx.SecurityVersion || actor.EffectiveRole() != ctx.EffectiveRole() {
|
||||||
|
writeSecurityError(w, response.NeedLogin, "登录状态已变化,请重新登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard := accountgroup.OwnGuard(ctx.UserID, ctx.EffectiveRole(), ctx.SessionVersion, ctx.SecurityVersion)
|
||||||
|
if ctx.UserID != user.ID {
|
||||||
|
verified := *ctx
|
||||||
|
verified.RecentAuth = true
|
||||||
|
if _, ok := loadNotificationUser(&verified, w, user.ID, true); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard = accountWriteGuard(ctx, *user)
|
||||||
|
}
|
||||||
|
code, err := notification.Reveal(db.Instance, *user, input.Version, config.ROOT_PATH, guard)
|
||||||
|
if err == nil {
|
||||||
|
reauthUserAttempts.clear(userKey)
|
||||||
|
}
|
||||||
recordNotificationManagement(ctx, user.ID, "查看系统通知凭据", err, nil)
|
recordNotificationManagement(ctx, user.ID, "查看系统通知凭据", err, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeNotificationError(w, err)
|
writeNotificationError(w, err)
|
||||||
|
|||||||
@ -63,6 +63,10 @@ func Authorize(request Request) error {
|
|||||||
|
|
||||||
func authorizeResource(request Request) error {
|
func authorizeResource(request Request) error {
|
||||||
switch request.Capability {
|
switch request.Capability {
|
||||||
|
case CapabilityNotificationReveal:
|
||||||
|
if !request.OwnResource && !CanManageNotification(request.ActorRole, request.TargetRole, false) {
|
||||||
|
return deny(CodeAccessDenied)
|
||||||
|
}
|
||||||
case CapabilityNotificationManage:
|
case CapabilityNotificationManage:
|
||||||
if !CanManageNotification(request.ActorRole, request.TargetRole, request.OwnResource) {
|
if !CanManageNotification(request.ActorRole, request.TargetRole, request.OwnResource) {
|
||||||
return deny(CodeTargetRoleDenied)
|
return deny(CodeTargetRoleDenied)
|
||||||
|
|||||||
@ -26,6 +26,7 @@ const (
|
|||||||
CapabilityAuditRead Capability = "audit.read"
|
CapabilityAuditRead Capability = "audit.read"
|
||||||
CapabilityAuditExport Capability = "audit.export"
|
CapabilityAuditExport Capability = "audit.export"
|
||||||
CapabilityNotificationManage Capability = "notification.manage"
|
CapabilityNotificationManage Capability = "notification.manage"
|
||||||
|
CapabilityNotificationReveal Capability = "notification.reveal"
|
||||||
CapabilityAccountGroupManage Capability = "account_group.manage"
|
CapabilityAccountGroupManage Capability = "account_group.manage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -38,6 +39,7 @@ type Policy struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var policies = map[Capability]Policy{
|
var policies = map[Capability]Policy{
|
||||||
|
CapabilityNotificationReveal: {Capability: CapabilityNotificationReveal, HighRisk: true, RequiresTarget: true},
|
||||||
CapabilityAccountGroupManage: {Capability: CapabilityAccountGroupManage, HighRisk: true, RequiresRecentAuth: true},
|
CapabilityAccountGroupManage: {Capability: CapabilityAccountGroupManage, HighRisk: true, RequiresRecentAuth: true},
|
||||||
CapabilityNotificationManage: {Capability: CapabilityNotificationManage, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true},
|
CapabilityNotificationManage: {Capability: CapabilityNotificationManage, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true},
|
||||||
CapabilityConfigRead: {Capability: CapabilityConfigRead},
|
CapabilityConfigRead: {Capability: CapabilityConfigRead},
|
||||||
@ -66,6 +68,7 @@ var policies = map[Capability]Policy{
|
|||||||
|
|
||||||
var grants = map[Role]map[Capability]struct{}{
|
var grants = map[Role]map[Capability]struct{}{
|
||||||
RoleSuperAdmin: grant(
|
RoleSuperAdmin: grant(
|
||||||
|
CapabilityNotificationReveal,
|
||||||
CapabilityAccountGroupManage,
|
CapabilityAccountGroupManage,
|
||||||
CapabilityNotificationManage,
|
CapabilityNotificationManage,
|
||||||
CapabilityPluginReadGlobal, CapabilityUserCreate, CapabilityProfileWriteOwn,
|
CapabilityPluginReadGlobal, CapabilityUserCreate, CapabilityProfileWriteOwn,
|
||||||
@ -77,6 +80,7 @@ var grants = map[Role]map[Capability]struct{}{
|
|||||||
CapabilityAuditRead, CapabilityAuditExport,
|
CapabilityAuditRead, CapabilityAuditExport,
|
||||||
),
|
),
|
||||||
RoleAdmin: grant(
|
RoleAdmin: grant(
|
||||||
|
CapabilityNotificationReveal,
|
||||||
CapabilityNotificationManage,
|
CapabilityNotificationManage,
|
||||||
CapabilityPluginReadGlobal, CapabilityPluginRead, CapabilityPluginWriteGlobal, CapabilityProfileWriteOwn,
|
CapabilityPluginReadGlobal, CapabilityPluginRead, CapabilityPluginWriteGlobal, CapabilityProfileWriteOwn,
|
||||||
CapabilityPluginWriteAnyUser, CapabilityPluginWriteOwn, CapabilityUserRead,
|
CapabilityPluginWriteAnyUser, CapabilityPluginWriteOwn, CapabilityUserRead,
|
||||||
@ -84,6 +88,7 @@ var grants = map[Role]map[Capability]struct{}{
|
|||||||
CapabilityAuditRead,
|
CapabilityAuditRead,
|
||||||
),
|
),
|
||||||
RoleUser: grant(
|
RoleUser: grant(
|
||||||
|
CapabilityNotificationReveal,
|
||||||
CapabilityProfileWriteOwn,
|
CapabilityProfileWriteOwn,
|
||||||
CapabilityPluginRead, CapabilityPluginWriteOwn, CapabilityUserRead,
|
CapabilityPluginRead, CapabilityPluginWriteOwn, CapabilityUserRead,
|
||||||
CapabilityPasswordChangeOwn, CapabilityAuditRead,
|
CapabilityPasswordChangeOwn, CapabilityAuditRead,
|
||||||
|
|||||||
@ -9,8 +9,8 @@ type Endpoint struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var managementEndpoints = []Endpoint{
|
var managementEndpoints = []Endpoint{
|
||||||
{Method: "POST", Path: "/api/system/notification/credential/reveal", Capability: CapabilityNotificationManage},
|
{Method: "POST", Path: "/api/system/notification/credential/reveal", Capability: CapabilityNotificationReveal},
|
||||||
{Method: "POST", Path: "/api/v1/system/notification/credential/reveal", Capability: CapabilityNotificationManage},
|
{Method: "POST", Path: "/api/v1/system/notification/credential/reveal", Capability: CapabilityNotificationReveal},
|
||||||
{Method: "POST", Path: "/api/system/notification/authorization", Capability: CapabilityNotificationManage},
|
{Method: "POST", Path: "/api/system/notification/authorization", Capability: CapabilityNotificationManage},
|
||||||
{Method: "POST", Path: "/api/system/notification/credential", Capability: CapabilityNotificationManage},
|
{Method: "POST", Path: "/api/system/notification/credential", Capability: CapabilityNotificationManage},
|
||||||
{Method: "POST", Path: "/api/v1/user/groups/save", Capability: CapabilityAccountGroupManage},
|
{Method: "POST", Path: "/api/v1/user/groups/save", Capability: CapabilityAccountGroupManage},
|
||||||
|
|||||||
@ -186,6 +186,28 @@ func ReplaceMembership(tx *xorm.Session, userID int, ids []int) error {
|
|||||||
|
|
||||||
type WriteGuard func(*xorm.Session) error
|
type WriteGuard func(*xorm.Session) error
|
||||||
|
|
||||||
|
// OwnGuard 只锁定并复核当前账号,不授予管理本人或其他账号的能力。
|
||||||
|
func OwnGuard(actorID int, role access.Role, sessionVersion, securityVersion int) WriteGuard {
|
||||||
|
return func(tx *xorm.Session) error {
|
||||||
|
if tx == nil || actorID <= 0 || !role.Valid() {
|
||||||
|
return ErrDenied
|
||||||
|
}
|
||||||
|
if _, err := tx.ID(actorID).SetExpr("session_version", "session_version").Update(&models.User{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var current models.User
|
||||||
|
found, err := tx.ID(actorID).Get(¤t)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !found || current.Disabled != 0 || current.MustChangePassword != 0 || current.EffectiveRole() != role ||
|
||||||
|
current.SessionVersion != sessionVersion || current.SecurityVersion != securityVersion {
|
||||||
|
return ErrDenied
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func CheckWrite(tx *xorm.Session, guards ...WriteGuard) error {
|
func CheckWrite(tx *xorm.Session, guards ...WriteGuard) error {
|
||||||
for _, guard := range guards {
|
for _, guard := range guards {
|
||||||
if guard == nil {
|
if guard == nil {
|
||||||
|
|||||||
@ -26,11 +26,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrDenied = errors.New("notification authorization denied")
|
ErrDenied = errors.New("notification authorization denied")
|
||||||
ErrConflict = errors.New("notification authorization changed")
|
ErrConflict = errors.New("notification authorization changed")
|
||||||
ErrQuota = errors.New("notification quota exceeded")
|
ErrQuota = errors.New("notification quota exceeded")
|
||||||
ErrUnavailable = errors.New("notification authorization unavailable")
|
ErrUnavailable = errors.New("notification authorization unavailable")
|
||||||
ErrLimits = errors.New("invalid notification limits")
|
ErrLimits = errors.New("invalid notification limits")
|
||||||
ErrNoCiphertext = errors.New("notification credential must be regenerated before reveal")
|
ErrNoCiphertext = errors.New("notification credential must be regenerated before reveal")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -60,6 +60,51 @@
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-credential {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 12px 0;
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-credential-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-credential-code {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-credential-mask {
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-credential-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-credential-actions :deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-countdown {
|
||||||
|
color: var(--el-color-warning-dark-2);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.notification-compact {
|
.notification-compact {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@ -20,12 +20,17 @@ export function usePage(props = {}) {
|
|||||||
const secretAccount = ref('')
|
const secretAccount = ref('')
|
||||||
const secretVisible = ref(false)
|
const secretVisible = ref(false)
|
||||||
const seconds = ref(0)
|
const seconds = ref(0)
|
||||||
|
const verifyVisible = ref(false)
|
||||||
|
const verifyPassword = ref('')
|
||||||
|
let verifyTarget = null
|
||||||
|
let secretSequence = 0
|
||||||
let secretTimer = null
|
let secretTimer = null
|
||||||
let requestSequence = 0
|
let requestSequence = 0
|
||||||
let accountsSequence = 0
|
let accountsSequence = 0
|
||||||
let disposed = false
|
let disposed = false
|
||||||
|
|
||||||
const canManage = computed(() => manager.value && Boolean(status.value?.can_manage))
|
const canManage = computed(() => manager.value && Boolean(status.value?.can_manage))
|
||||||
|
const canReveal = computed(() => Boolean(status.value?.can_reveal))
|
||||||
const stateText = computed(() => {
|
const stateText = computed(() => {
|
||||||
if (!status.value?.enabled) return '未开启'
|
if (!status.value?.enabled) return '未开启'
|
||||||
if (!status.value.account_available) return '账号不可用'
|
if (!status.value.account_available) return '账号不可用'
|
||||||
@ -44,6 +49,7 @@ export function usePage(props = {}) {
|
|||||||
ElNotification({ title: '操作失败', message: cause?.message || '请稍后重试', type: 'error' })
|
ElNotification({ title: '操作失败', message: cause?.message || '请稍后重试', type: 'error' })
|
||||||
}
|
}
|
||||||
const clearSecret = () => {
|
const clearSecret = () => {
|
||||||
|
secretSequence += 1
|
||||||
if (secretTimer) clearInterval(secretTimer)
|
if (secretTimer) clearInterval(secretTimer)
|
||||||
secretTimer = null
|
secretTimer = null
|
||||||
secret.value = ''
|
secret.value = ''
|
||||||
@ -52,6 +58,55 @@ export function usePage(props = {}) {
|
|||||||
secretVisible.value = false
|
secretVisible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearVerification = () => {
|
||||||
|
verifyPassword.value = ''
|
||||||
|
verifyTarget = null
|
||||||
|
verifyVisible.value = false
|
||||||
|
}
|
||||||
|
const showSecret = (code, account) => {
|
||||||
|
clearSecret()
|
||||||
|
if (!code || document.hidden) return
|
||||||
|
secret.value = code
|
||||||
|
secretAccount.value = account
|
||||||
|
secretVisible.value = true
|
||||||
|
const expiresAt = Date.now() + 60000
|
||||||
|
seconds.value = 60
|
||||||
|
secretTimer = setInterval(() => {
|
||||||
|
seconds.value = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
|
||||||
|
if (seconds.value === 0) clearSecret()
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
const openVerify = () => {
|
||||||
|
if (!canReveal.value || loading.value || submitting.value || dirty.value) return
|
||||||
|
clearSecret()
|
||||||
|
verifyPassword.value = ''
|
||||||
|
verifyTarget = status.value
|
||||||
|
verifyVisible.value = true
|
||||||
|
}
|
||||||
|
const verifyAndReveal = async () => {
|
||||||
|
if (!verifyTarget || !verifyPassword.value || submitting.value || !canReveal.value) return
|
||||||
|
const target = verifyTarget
|
||||||
|
const sequence = ++secretSequence
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const data = dataOf(await http.post('/api/v1/system/notification/credential/reveal', {
|
||||||
|
user_id: target.user_id, version: target.version, password: verifyPassword.value
|
||||||
|
}))
|
||||||
|
if (disposed || sequence !== secretSequence || target.user_id !== status.value?.user_id) return
|
||||||
|
clearVerification()
|
||||||
|
showSecret(data.code, target.account)
|
||||||
|
} catch (cause) { showError(cause) }
|
||||||
|
finally {
|
||||||
|
verifyPassword.value = ''
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hideOnBackground = () => {
|
||||||
|
if (!document.hidden) return
|
||||||
|
clearSecret()
|
||||||
|
clearVerification()
|
||||||
|
}
|
||||||
|
|
||||||
const loadStatus = async () => {
|
const loadStatus = async () => {
|
||||||
const sequence = ++requestSequence
|
const sequence = ++requestSequence
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@ -103,6 +158,7 @@ export function usePage(props = {}) {
|
|||||||
}
|
}
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
if (!(await beforeLeave())) return
|
if (!(await beforeLeave())) return
|
||||||
|
clearSecret()
|
||||||
await Promise.all([loadStatus(), loadAccounts()])
|
await Promise.all([loadStatus(), loadAccounts()])
|
||||||
}
|
}
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
@ -135,19 +191,12 @@ export function usePage(props = {}) {
|
|||||||
confirmButtonText: '生成新凭据', cancelButtonText: '取消', type: 'warning'
|
confirmButtonText: '生成新凭据', cancelButtonText: '取消', type: 'warning'
|
||||||
})
|
})
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
|
const sequence = ++secretSequence
|
||||||
const data = dataOf(await requestWithRecentAuthentication(() => http.post('/api/v1/system/notification/credential', {
|
const data = dataOf(await requestWithRecentAuthentication(() => http.post('/api/v1/system/notification/credential', {
|
||||||
user_id: target.user_id, version: target.version
|
user_id: target.user_id, version: target.version
|
||||||
})))
|
})))
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
clearSecret()
|
if (sequence === secretSequence) showSecret(data.code, target.account)
|
||||||
secret.value = data.code
|
|
||||||
secretAccount.value = target.account
|
|
||||||
seconds.value = 60
|
|
||||||
secretVisible.value = true
|
|
||||||
secretTimer = setInterval(() => {
|
|
||||||
seconds.value -= 1
|
|
||||||
if (seconds.value <= 0) clearSecret()
|
|
||||||
}, 1000)
|
|
||||||
await Promise.all([loadStatus(), loadAccounts()])
|
await Promise.all([loadStatus(), loadAccounts()])
|
||||||
} catch (cause) { showError(cause) }
|
} catch (cause) { showError(cause) }
|
||||||
finally { submitting.value = false }
|
finally { submitting.value = false }
|
||||||
@ -160,12 +209,24 @@ export function usePage(props = {}) {
|
|||||||
} catch { showError(new Error('复制失败,请手动复制授权码')) }
|
} catch { showError(new Error('复制失败,请手动复制授权码')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { loadStatus(); loadAccounts() })
|
onMounted(() => {
|
||||||
onUnmounted(() => { disposed = true; requestSequence += 1; accountsSequence += 1; clearSecret() })
|
loadStatus()
|
||||||
|
loadAccounts()
|
||||||
|
document.addEventListener('visibilitychange', hideOnBackground)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
disposed = true
|
||||||
|
requestSequence += 1
|
||||||
|
accountsSequence += 1
|
||||||
|
clearSecret()
|
||||||
|
clearVerification()
|
||||||
|
document.removeEventListener('visibilitychange', hideOnBackground)
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
manager, loading, submitting, error, status, accounts, page, total, form,
|
manager, loading, submitting, error, status, accounts, page, total, form,
|
||||||
secret, secretAccount, secretVisible, seconds, canManage, stateText, dirty,
|
secret, secretAccount, secretVisible, seconds, canManage, stateText, dirty,
|
||||||
|
canReveal, verifyVisible, verifyPassword, openVerify, verifyAndReveal, clearVerification,
|
||||||
loadAccounts, selectAccount, refresh, save, rotate, clearSecret, copySecret, beforeLeave
|
loadAccounts, selectAccount, refresh, save, rotate, clearSecret, copySecret, beforeLeave
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
<el-alert
|
<el-alert
|
||||||
v-if="!compact"
|
v-if="!compact"
|
||||||
title="默认关闭,逐账号授权"
|
title="默认关闭,逐账号授权"
|
||||||
:description="manager ? '只有管理范围内的账号可以授权。管理员修改授权前需要身份确认;关闭时永久撤销旧凭据。' : '普通用户只能查看自己的状态和连接参数。需要开启或重置凭据时,请联系管理员。'"
|
:description="manager ? '只有管理范围内的账号可以授权。管理员修改授权前需要身份确认;关闭时永久撤销旧凭据。' : '可查看自己的状态,并验证本人密码查看通知凭据。开启或重置凭据请联系管理员。'"
|
||||||
type="info"
|
type="info"
|
||||||
show-icon
|
show-icon
|
||||||
:closable="false"
|
:closable="false"
|
||||||
@ -54,6 +54,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="!status.account_available" class="notification-warning">账号已禁用或尚未修改临时密码,不能生成或使用通知凭据。</p>
|
<p v-if="!status.account_available" class="notification-warning">账号已禁用或尚未修改临时密码,不能生成或使用通知凭据。</p>
|
||||||
|
|
||||||
|
<div v-if="status.configured" class="notification-credential">
|
||||||
|
<span class="notification-credential-label">通知发信凭据</span>
|
||||||
|
<template v-if="secretVisible && secret">
|
||||||
|
<code class="notification-credential-code">{{ secret }}</code>
|
||||||
|
<div class="notification-credential-actions">
|
||||||
|
<el-button size="small" type="primary" @click="copySecret">复制凭据</el-button>
|
||||||
|
<el-button size="small" @click="clearSecret">隐藏</el-button>
|
||||||
|
<span class="notification-countdown" role="status">{{ seconds }} 秒后自动隐藏</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="notification-credential-mask" aria-label="凭据已隐藏">••••••••••••••••</span>
|
||||||
|
<el-button
|
||||||
|
v-if="canReveal"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:disabled="submitting || loading || dirty"
|
||||||
|
@click="openVerify"
|
||||||
|
>安全验证查看明文</el-button>
|
||||||
|
<span v-else class="notification-hint">旧凭据未保留密文,请联系管理员重新生成。</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="notification-columns">
|
<div class="notification-columns">
|
||||||
<el-form v-if="canManage" label-position="top" :disabled="submitting || loading" @submit.prevent="save">
|
<el-form v-if="canManage" label-position="top" :disabled="submitting || loading" @submit.prevent="save">
|
||||||
<el-form-item label="允许该账号通过 SMTP 发送系统通知">
|
<el-form-item label="允许该账号通过 SMTP 发送系统通知">
|
||||||
@ -99,13 +123,34 @@
|
|||||||
<el-empty v-else-if="!loading && !error" description="尚未读取账号状态" />
|
<el-empty v-else-if="!loading && !error" description="尚未读取账号状态" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog v-model="secretVisible" title="通知发信凭据(仅显示一次)" width="min(560px, 92vw)" destroy-on-close @closed="clearSecret">
|
<el-dialog
|
||||||
<p>账号:{{ secretAccount }}。请安全保存并交付给该账号的使用者。</p>
|
v-model="verifyVisible"
|
||||||
<el-alert :title="`${seconds} 秒后自动隐藏;关闭后无法再次查看,只能重新生成。`" type="warning" :closable="false" show-icon />
|
title="安全验证"
|
||||||
<el-input :model-value="secret" readonly aria-label="新通知发信凭据" class="notification-secret" />
|
width="min(420px, 92vw)"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="!submitting"
|
||||||
|
:show-close="!submitting"
|
||||||
|
@closed="clearVerification"
|
||||||
|
>
|
||||||
|
<el-form label-position="top" @submit.prevent="verifyAndReveal">
|
||||||
|
<el-form-item label="当前登录密码">
|
||||||
|
<el-input
|
||||||
|
v-model="verifyPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="请输入本人登录密码"
|
||||||
|
:disabled="submitting"
|
||||||
|
aria-label="当前登录密码"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<p class="notification-hint">验证后显示 60 秒,隐藏后可重新验证查看。</p>
|
||||||
|
<button type="submit" hidden></button>
|
||||||
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="copySecret">复制凭据</el-button>
|
<el-button :disabled="submitting" @click="clearVerification">取消</el-button>
|
||||||
<el-button type="primary" @click="clearSecret">已保存,关闭</el-button>
|
<el-button type="primary" :loading="submitting" :disabled="!verifyPassword" @click="verifyAndReveal">验证并查看</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
@ -122,6 +167,7 @@ const props = defineProps({
|
|||||||
const {
|
const {
|
||||||
manager, loading, submitting, error, status, accounts, page, total, form,
|
manager, loading, submitting, error, status, accounts, page, total, form,
|
||||||
secret, secretAccount, secretVisible, seconds, canManage, stateText, dirty,
|
secret, secretAccount, secretVisible, seconds, canManage, stateText, dirty,
|
||||||
|
canReveal, verifyVisible, verifyPassword, openVerify, verifyAndReveal, clearVerification,
|
||||||
loadAccounts, selectAccount, refresh, save, rotate, clearSecret, copySecret, beforeLeave
|
loadAccounts, selectAccount, refresh, save, rotate, clearSecret, copySecret, beforeLeave
|
||||||
} = usePage(props)
|
} = usePage(props)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user