This commit is contained in:
parent
08ed8c3636
commit
05b01bbc83
@ -13,6 +13,7 @@ import (
|
|||||||
"git.pcux.cn/xuans/Smail/models"
|
"git.pcux.cn/xuans/Smail/models"
|
||||||
"git.pcux.cn/xuans/Smail/services/access"
|
"git.pcux.cn/xuans/Smail/services/access"
|
||||||
"git.pcux.cn/xuans/Smail/services/accountgroup"
|
"git.pcux.cn/xuans/Smail/services/accountgroup"
|
||||||
|
"git.pcux.cn/xuans/Smail/services/auth"
|
||||||
"git.pcux.cn/xuans/Smail/services/logstore"
|
"git.pcux.cn/xuans/Smail/services/logstore"
|
||||||
"git.pcux.cn/xuans/Smail/services/notification"
|
"git.pcux.cn/xuans/Smail/services/notification"
|
||||||
"git.pcux.cn/xuans/Smail/utils/context"
|
"git.pcux.cn/xuans/Smail/utils/context"
|
||||||
@ -101,6 +102,8 @@ func GetNotificationStatus(ctx *context.Context, w http.ResponseWriter, req *htt
|
|||||||
response.NewSuccessResponse(map[string]any{
|
response.NewSuccessResponse(map[string]any{
|
||||||
"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 != "" &&
|
||||||
|
access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), ctx.UserID == user.ID),
|
||||||
"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,
|
||||||
@ -191,7 +194,7 @@ func RotateNotificationCredential(ctx *context.Context, w http.ResponseWriter, r
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
code, err := notification.Rotate(db.Instance, *user, input.Version, accountWriteGuard(ctx, *user))
|
code, err := notification.Rotate(db.Instance, *user, input.Version, config.ROOT_PATH, accountWriteGuard(ctx, *user))
|
||||||
recordNotificationManagement(ctx, user.ID, "重置系统通知凭据", err, nil)
|
recordNotificationManagement(ctx, user.ID, "重置系统通知凭据", err, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeNotificationError(w, err)
|
writeNotificationError(w, err)
|
||||||
@ -201,6 +204,40 @@ func RotateNotificationCredential(ctx *context.Context, w http.ResponseWriter, r
|
|||||||
response.NewSuccessResponse(map[string]any{"code": code, "user_id": user.ID}).FPrint(w)
|
response.NewSuccessResponse(map[string]any{"code": code, "user_id": user.ID}).FPrint(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RevealNotificationCredential 每次查看都验证操作人密码,日志不得包含密码或凭据。
|
||||||
|
func RevealNotificationCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
var input struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if decodeJSONRequest(w, req, &input) != nil || input.Version < 1 || input.Password == "" {
|
||||||
|
writeSecurityError(w, response.ParamsError, "请填写当前登录密码并选择有效账号")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, ok := loadNotificationUser(ctx, w, input.UserID, true)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := auth.AuthenticateUserByID(ctx.UserID, input.Password)
|
||||||
|
input.Password = ""
|
||||||
|
if err != nil {
|
||||||
|
recordNotificationManagement(ctx, user.ID, "查看系统通知凭据", err, nil)
|
||||||
|
if !writePasswordBusy(w, err) {
|
||||||
|
writeSecurityError(w, response.ParamsError, "密码验证失败")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code, err := notification.Reveal(db.Instance, *user, input.Version, config.ROOT_PATH, accountWriteGuard(ctx, *user))
|
||||||
|
recordNotificationManagement(ctx, user.ID, "查看系统通知凭据", err, nil)
|
||||||
|
if err != nil {
|
||||||
|
writeNotificationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NewSuccessResponse(map[string]any{"code": code, "user_id": user.ID}).FPrint(w)
|
||||||
|
}
|
||||||
|
|
||||||
func recordNotificationManagement(ctx *context.Context, userID int, action string, err error, changes map[string]any) {
|
func recordNotificationManagement(ctx *context.Context, userID int, action string, err error, changes map[string]any) {
|
||||||
result := "success"
|
result := "success"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -217,6 +254,8 @@ func writeNotificationError(w http.ResponseWriter, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
|
case errors.Is(err, notification.ErrNoCiphertext):
|
||||||
|
writeSecurityError(w, response.ParamsError, "旧凭据未保留加密密文,请重新生成后再查看")
|
||||||
case errors.Is(err, notification.ErrConflict):
|
case errors.Is(err, notification.ErrConflict):
|
||||||
writeSecurityError(w, response.ParamsError, "授权状态已变化,请刷新后重试")
|
writeSecurityError(w, response.ParamsError, "授权状态已变化,请刷新后重试")
|
||||||
case errors.Is(err, notification.ErrDenied):
|
case errors.Is(err, notification.ErrDenied):
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
package migration
|
package migration
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func applyNotificationCiphertextSchema(ctx context.Context, exec executor, dialect Dialect) error {
|
||||||
|
return ensureColumn(ctx, exec, dialect, "notification_grant", "code_ciphertext", "VARCHAR(512) NULL")
|
||||||
|
}
|
||||||
|
|
||||||
func notificationGrantStatements() map[Dialect][]string {
|
func notificationGrantStatements() map[Dialect][]string {
|
||||||
// 不依赖自增、布尔或日期方言;计数窗口统一使用 UTC 字符串。
|
// 不依赖自增、布尔或日期方言;计数窗口统一使用 UTC 字符串。
|
||||||
statement := `CREATE TABLE IF NOT EXISTS notification_grant (
|
statement := `CREATE TABLE IF NOT EXISTS notification_grant (
|
||||||
|
|||||||
@ -41,6 +41,7 @@ func DefaultMigrations() []Migration {
|
|||||||
}),
|
}),
|
||||||
DefineForDialects(25, "create_notification_grants", notificationGrantStatements()),
|
DefineForDialects(25, "create_notification_grants", notificationGrantStatements()),
|
||||||
DefineForDialects(26, "create_account_groups", accountGroupStatements()),
|
DefineForDialects(26, "create_account_groups", accountGroupStatements()),
|
||||||
|
DefineCustom(27, "expand_notification_ciphertext", "expand_notification_ciphertext_v1", applyNotificationCiphertextSchema),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -110,6 +110,7 @@ func registerNotificationRoutes(mux *http.ServeMux) {
|
|||||||
registerV1MethodRoute(mux, "/api/v1/system/notification/accounts", "/api/system/notification/accounts", http.MethodGet, 0, controllers.ListNotificationAccounts)
|
registerV1MethodRoute(mux, "/api/v1/system/notification/accounts", "/api/system/notification/accounts", http.MethodGet, 0, controllers.ListNotificationAccounts)
|
||||||
registerV1MethodRoute(mux, "/api/v1/system/notification/authorization", "/api/system/notification/authorization", http.MethodPost, 4<<10, controllers.SetNotificationAuthorization)
|
registerV1MethodRoute(mux, "/api/v1/system/notification/authorization", "/api/system/notification/authorization", http.MethodPost, 4<<10, controllers.SetNotificationAuthorization)
|
||||||
registerV1MethodRoute(mux, "/api/v1/system/notification/credential", "/api/system/notification/credential", http.MethodPost, 4<<10, controllers.RotateNotificationCredential)
|
registerV1MethodRoute(mux, "/api/v1/system/notification/credential", "/api/system/notification/credential", http.MethodPost, 4<<10, controllers.RotateNotificationCredential)
|
||||||
|
registerV1MethodRoute(mux, "/api/v1/system/notification/credential/reveal", "/api/system/notification/credential/reveal", http.MethodPost, 4<<10, controllers.RevealNotificationCredential)
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerV1SenderFiltersRoute(mux *http.ServeMux) {
|
func registerV1SenderFiltersRoute(mux *http.ServeMux) {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
// loggedOperations 注册显式的静态状态变更路由,仅记录状态变更操作,不将列表查询误认为操作,也不复制请求或响应载荷。
|
// loggedOperations 注册显式的静态状态变更路由,仅记录状态变更操作,不将列表查询误认为操作,也不复制请求或响应载荷。
|
||||||
var loggedOperations = map[string]string{
|
var loggedOperations = map[string]string{
|
||||||
|
"/api/system/notification/credential/reveal": "查看系统通知发信凭据",
|
||||||
"/api/system/notification/authorization": "修改系统通知发信权限",
|
"/api/system/notification/authorization": "修改系统通知发信权限",
|
||||||
"/api/system/notification/credential": "生成系统通知发信凭据",
|
"/api/system/notification/credential": "生成系统通知发信凭据",
|
||||||
"/api/email/draft/save": "保存邮件草稿",
|
"/api/email/draft/save": "保存邮件草稿",
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
// NotificationGrant 与业务数据共用数据库,只保存通知发信授权及不可逆凭据摘要。
|
// NotificationGrant 与业务数据共用数据库;摘要用于认证,密文仅供身份验证后查看。
|
||||||
type NotificationGrant struct {
|
type NotificationGrant struct {
|
||||||
UserID int `xorm:"user_id pk" json:"user_id"`
|
UserID int `xorm:"user_id pk" json:"user_id"`
|
||||||
Enabled int `xorm:"enabled" json:"-"`
|
Enabled int `xorm:"enabled" json:"-"`
|
||||||
CodeVersion int `xorm:"code_version" json:"version"`
|
CodeVersion int `xorm:"code_version" json:"version"`
|
||||||
CodeHash string `xorm:"code_hash" json:"-"`
|
CodeHash string `xorm:"code_hash" json:"-"`
|
||||||
|
CodeCiphertext string `xorm:"code_ciphertext varchar(512) null" json:"-"`
|
||||||
SessionVersion int `xorm:"session_version" json:"-"`
|
SessionVersion int `xorm:"session_version" json:"-"`
|
||||||
SecurityVersion int `xorm:"security_version" json:"-"`
|
SecurityVersion int `xorm:"security_version" json:"-"`
|
||||||
MinuteLimit int `xorm:"minute_limit" json:"minute_limit"`
|
MinuteLimit int `xorm:"minute_limit" json:"minute_limit"`
|
||||||
|
|||||||
@ -9,6 +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/v1/system/notification/credential/reveal", Capability: CapabilityNotificationManage},
|
||||||
{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},
|
||||||
|
|||||||
@ -7,11 +7,13 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.pcux.cn/xuans/Smail/models"
|
"git.pcux.cn/xuans/Smail/models"
|
||||||
"git.pcux.cn/xuans/Smail/services/accountgroup"
|
"git.pcux.cn/xuans/Smail/services/accountgroup"
|
||||||
|
"git.pcux.cn/xuans/Smail/utils/credential"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -29,6 +31,7 @@ var (
|
|||||||
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")
|
||||||
)
|
)
|
||||||
|
|
||||||
func IsUsername(username string) bool {
|
func IsUsername(username string) bool {
|
||||||
@ -88,6 +91,7 @@ func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabl
|
|||||||
item.Enabled = 1
|
item.Enabled = 1
|
||||||
} else {
|
} else {
|
||||||
item.CodeHash = ""
|
item.CodeHash = ""
|
||||||
|
item.CodeCiphertext = ""
|
||||||
}
|
}
|
||||||
item.MinuteLimit, item.DayLimit = minuteLimit, dayLimit
|
item.MinuteLimit, item.DayLimit = minuteLimit, dayLimit
|
||||||
item.CodeVersion++
|
item.CodeVersion++
|
||||||
@ -98,7 +102,7 @@ func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabl
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
affected, err := tx.ID(user.ID).Where("code_version = ?", expected).
|
affected, err := tx.ID(user.ID).Where("code_version = ?", expected).
|
||||||
Cols("enabled", "code_hash", "code_version", "minute_limit", "day_limit").Update(&item)
|
Cols("enabled", "code_hash", "code_ciphertext", "code_version", "minute_limit", "day_limit").Update(&item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -108,9 +112,9 @@ func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabl
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rotate 仅返回一次明文。随机凭据具有 256 位熵,数据库只保存 SHA-256 摘要。
|
// Rotate 生成独立随机凭据;加密绑定账号,关闭授权时同时清除摘要与密文。
|
||||||
func Rotate(engine *xorm.Engine, user models.User, expected int, guards ...accountgroup.WriteGuard) (string, error) {
|
func Rotate(engine *xorm.Engine, user models.User, expected int, root string, guards ...accountgroup.WriteGuard) (string, error) {
|
||||||
if engine == nil || user.ID <= 0 {
|
if engine == nil || user.ID <= 0 || strings.TrimSpace(root) == "" {
|
||||||
return "", ErrUnavailable
|
return "", ErrUnavailable
|
||||||
}
|
}
|
||||||
tx := engine.NewSession()
|
tx := engine.NewSession()
|
||||||
@ -138,11 +142,16 @@ func Rotate(engine *xorm.Engine, user models.User, expected int, guards ...accou
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
code := "sn_" + base64.RawURLEncoding.EncodeToString(entropy[:])
|
code := "sn_" + base64.RawURLEncoding.EncodeToString(entropy[:])
|
||||||
|
ciphertext, err := credential.Encrypt(root, strconv.Itoa(user.ID)+":"+code)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrUnavailable
|
||||||
|
}
|
||||||
item.CodeHash = digest(code)
|
item.CodeHash = digest(code)
|
||||||
|
item.CodeCiphertext = ciphertext
|
||||||
item.CodeVersion++
|
item.CodeVersion++
|
||||||
item.SessionVersion, item.SecurityVersion = user.SessionVersion, user.SecurityVersion
|
item.SessionVersion, item.SecurityVersion = user.SessionVersion, user.SecurityVersion
|
||||||
affected, err := tx.ID(user.ID).Where("code_version = ? AND enabled = 1", expected).
|
affected, err := tx.ID(user.ID).Where("code_version = ? AND enabled = 1", expected).
|
||||||
Cols("code_hash", "code_version", "session_version", "security_version").Update(&item)
|
Cols("code_hash", "code_ciphertext", "code_version", "session_version", "security_version").Update(&item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -155,6 +164,49 @@ func Rotate(engine *xorm.Engine, user models.User, expected int, guards ...accou
|
|||||||
return code, nil
|
return code, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reveal 在事务内复核管理范围、账号安全版本和凭据版本,绝不重置用量或凭据。
|
||||||
|
func Reveal(engine *xorm.Engine, user models.User, expected int, root string, guards ...accountgroup.WriteGuard) (string, error) {
|
||||||
|
if engine == nil || user.ID <= 0 || strings.TrimSpace(root) == "" {
|
||||||
|
return "", ErrUnavailable
|
||||||
|
}
|
||||||
|
tx := engine.NewSession()
|
||||||
|
defer tx.Close()
|
||||||
|
if err := tx.Begin(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if err := accountgroup.CheckWrite(tx, guards...); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var item models.NotificationGrant
|
||||||
|
found, err := tx.ID(user.ID).Get(&item)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !found || !Configured(item, user) {
|
||||||
|
return "", ErrDenied
|
||||||
|
}
|
||||||
|
if item.CodeVersion != expected {
|
||||||
|
return "", ErrConflict
|
||||||
|
}
|
||||||
|
if item.CodeCiphertext == "" {
|
||||||
|
return "", ErrNoCiphertext
|
||||||
|
}
|
||||||
|
plain, err := credential.Decrypt(root, item.CodeCiphertext)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrUnavailable
|
||||||
|
}
|
||||||
|
account, code, ok := strings.Cut(plain, ":")
|
||||||
|
if !ok || account != strconv.Itoa(user.ID) || len(code) != 46 || !strings.HasPrefix(code, "sn_") ||
|
||||||
|
subtle.ConstantTimeCompare([]byte(item.CodeHash), []byte(digest(code))) != 1 {
|
||||||
|
return "", ErrUnavailable
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
|
||||||
type Grant struct {
|
type Grant struct {
|
||||||
engine *xorm.Engine
|
engine *xorm.Engine
|
||||||
userID int
|
userID int
|
||||||
|
|||||||
@ -59,3 +59,118 @@
|
|||||||
.notification-secret {
|
.notification-secret {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-compact {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-header {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-panel {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-heading {
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact h3 {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||||
|
gap: 12px 22px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-columns > :deep(.el-form) {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-columns > :deep(.el-descriptions) {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-columns > :deep(.el-descriptions ~ .el-descriptions) {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1 / span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-hint {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 3;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-columns > :deep(.el-descriptions ~ .el-descriptions + .notification-hint) {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-columns > :deep(.el-alert) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-limits {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact :deep(.el-form-item) {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact :deep(.el-input-number) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-actions {
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-actions :deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact :deep(.el-descriptions__header) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact :deep(.el-descriptions__table) {
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact :deep(.el-descriptions__label) {
|
||||||
|
width: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact :deep(.el-descriptions__cell) {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.notification-compact .notification-columns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-compact .notification-columns > * {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -331,6 +331,26 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.notification-dialog) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 90vh;
|
||||||
|
max-height: 90dvh;
|
||||||
|
margin-bottom: 5vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.notification-dialog > .el-dialog__header) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.notification-dialog > .el-dialog__body) {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
.account-form > :deep(.el-form-item) {
|
.account-form > :deep(.el-form-item) {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user