fix(*): 邮箱与系统公告 - 合并 PR #417 并修复迁移冲突和提醒关闭逻辑
保留历史数据库迁移谱系与公告按需加载,复用邮件配置快照校验注册域名,补充白名单及公告回归测试。
This commit is contained in:
@@ -158,6 +158,55 @@ func TestMigrateSchemaV4AddsResourceUploadKeyToExistingSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRepairsLegacyAssetFoldersMigrationOrder(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-legacy-v6-order?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range schemaMigrations[:5] {
|
||||
if err := db.Create(&schemaMigration{Version: item.version, Name: item.name, Checksum: item.checksum, AppliedAt: time.Now().UTC()}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Create(&schemaMigration{Version: 6, Name: "asset_library_folders", Checksum: assetLibraryFoldersChecksum, AppliedAt: time.Now().UTC()}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := MigrateSchema(db); err != nil {
|
||||
t.Fatalf("repair legacy migration order: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasColumn(&model.Resource{}, "playback_status") || !db.Migrator().HasColumn(&model.Resource{}, "playback_object_key") || !db.Migrator().HasColumn(&model.Resource{}, "playback_error") {
|
||||
t.Fatal("legacy database did not receive resource playback columns")
|
||||
}
|
||||
status, err := ReadSchemaStatus(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !status.Ready || status.Current != CurrentSchemaVersion {
|
||||
t.Fatalf("unexpected repaired schema status: %#v", status)
|
||||
}
|
||||
var applied schemaMigration
|
||||
if err := db.First(&applied, "version = ?", 6).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied.Name != "asset_library_folders" || applied.Checksum != assetLibraryFoldersChecksum {
|
||||
t.Fatalf("historical migration 6 must be preserved: %#v", applied)
|
||||
}
|
||||
var playback schemaMigration
|
||||
if err := db.First(&playback, "version = ?", 7).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if playback.Name != "resource_playback_variant" || playback.Checksum != resourcePlaybackChecksum {
|
||||
t.Fatalf("migration 7 must supply playback schema: %#v", playback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRollsBackFailedMigration(t *testing.T) {
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-rollback?mode=memory&cache=shared"})
|
||||
if err != nil {
|
||||
|
||||
@@ -459,8 +459,8 @@ func validAnnouncementLevel(level model.AnnouncementLevel) bool {
|
||||
func normalizeAnnouncementInput(req CreateAnnouncementRequest) (string, string, model.AnnouncementLevel, error) {
|
||||
title := strings.TrimSpace(req.Title)
|
||||
content := strings.TrimSpace(req.Content)
|
||||
if title == "" || content == "" {
|
||||
return "", "", "", BadAuthRequest("请填写公告标题和正文")
|
||||
if title == "" {
|
||||
return "", "", "", BadAuthRequest("请填写公告标题")
|
||||
}
|
||||
if utf8.RuneCountInString(title) > 120 {
|
||||
return "", "", "", BadAuthRequest("公告标题不能超过 120 个字符")
|
||||
|
||||
@@ -262,6 +262,19 @@ func TestAnnouncementPublishRejectsInvalidInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncementPublishAllowsEmptyContent(t *testing.T) {
|
||||
svc, _ := newAnnouncementImageTestService(t)
|
||||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
|
||||
announcement, err := svc.CreateAnnouncement(admin, CreateAnnouncementRequest{Title: "仅标题公告", Content: " ", Level: model.AnnouncementLevelInfo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if announcement.Title != "仅标题公告" || announcement.Content != "" {
|
||||
t.Fatalf("announcement = %+v", announcement)
|
||||
}
|
||||
}
|
||||
|
||||
func newAnnouncementImageTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
@@ -129,6 +129,9 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
|
||||
if email == "" {
|
||||
return nil, BadAuthRequest("请输入邮箱")
|
||||
}
|
||||
if err := s.validateRegistrationEmailDomain(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verifiedCode, err = s.VerifyRegistrationEmailCode(email, req.EmailCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -28,41 +28,56 @@ const emailSettingKey = "email"
|
||||
const registrationEmailPurpose = "registration"
|
||||
const registrationCodeTTL = 10 * time.Minute
|
||||
|
||||
var defaultRegistrationEmailDomains = []string{
|
||||
"gmail.com",
|
||||
"163.com",
|
||||
"126.com",
|
||||
"qq.com",
|
||||
"outlook.com",
|
||||
"hotmail.com",
|
||||
"icloud.com",
|
||||
"yahoo.com",
|
||||
"foxmail.com",
|
||||
}
|
||||
|
||||
type EmailSettingRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Encryption string `json:"encryption"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
FromName string `json:"fromName"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Encryption string `json:"encryption"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
FromName string `json:"fromName"`
|
||||
RegistrationAllowedDomains []string `json:"registrationAllowedDomains"`
|
||||
}
|
||||
|
||||
type PublicEmailSetting struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Encryption string `json:"encryption"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
FromName string `json:"fromName"`
|
||||
FromNameInherited bool `json:"fromNameInherited"`
|
||||
HasPassword bool `json:"hasPassword"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Encryption string `json:"encryption"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
FromName string `json:"fromName"`
|
||||
FromNameInherited bool `json:"fromNameInherited"`
|
||||
HasPassword bool `json:"hasPassword"`
|
||||
RegistrationAllowedDomains []string `json:"registrationAllowedDomains"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type emailSettingValue struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Encryption string `json:"encryption"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
FromName string `json:"fromName"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Encryption string `json:"encryption"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
FromName string `json:"fromName"`
|
||||
RegistrationAllowedDomains []string `json:"registrationAllowedDomains"`
|
||||
}
|
||||
|
||||
func (s *Service) AdminEmailSetting(actor *model.User) (*PublicEmailSetting, error) {
|
||||
@@ -84,7 +99,15 @@ func (s *Service) UpdateEmailSetting(actor *model.User, req EmailSettingRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := normalizeEmailSetting(emailSettingValue{Enabled: req.Enabled, Host: req.Host, Port: req.Port, Username: req.Username, Password: req.Password, Encryption: req.Encryption, FromEmail: req.FromEmail, FromName: req.FromName})
|
||||
allowedDomains := req.RegistrationAllowedDomains
|
||||
if allowedDomains == nil {
|
||||
allowedDomains = current.RegistrationAllowedDomains
|
||||
}
|
||||
next := normalizeEmailSetting(emailSettingValue{
|
||||
Enabled: req.Enabled, Host: req.Host, Port: req.Port, Username: req.Username, Password: req.Password,
|
||||
Encryption: req.Encryption, FromEmail: req.FromEmail, FromName: req.FromName,
|
||||
RegistrationAllowedDomains: allowedDomains,
|
||||
})
|
||||
if next.Password == "" {
|
||||
next.Password = current.Password
|
||||
}
|
||||
@@ -146,6 +169,9 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRegistrationEmailDomain(email, setting.RegistrationAllowedDomains); err != nil {
|
||||
return err
|
||||
}
|
||||
if !setting.Enabled {
|
||||
return Forbidden("平台尚未启用注册邮件,请联系管理员")
|
||||
}
|
||||
@@ -249,6 +275,9 @@ func (s *Service) readEmailSetting() (*model.SystemSetting, emailSettingValue, e
|
||||
}
|
||||
|
||||
func validateEmailSetting(value emailSettingValue) error {
|
||||
if err := validateRegistrationEmailDomains(value.RegistrationAllowedDomains); err != nil {
|
||||
return err
|
||||
}
|
||||
if !value.Enabled {
|
||||
return nil
|
||||
}
|
||||
@@ -273,6 +302,10 @@ func normalizeEmailSetting(value emailSettingValue) emailSettingValue {
|
||||
value.Password = strings.TrimSpace(value.Password)
|
||||
value.FromEmail = normalizeEmail(value.FromEmail)
|
||||
value.FromName = strings.TrimSpace(value.FromName)
|
||||
if value.RegistrationAllowedDomains == nil {
|
||||
value.RegistrationAllowedDomains = append([]string(nil), defaultRegistrationEmailDomains...)
|
||||
}
|
||||
value.RegistrationAllowedDomains = normalizeEmailDomains(value.RegistrationAllowedDomains)
|
||||
if value.Port == 0 {
|
||||
value.Port = 587
|
||||
}
|
||||
@@ -287,7 +320,11 @@ func normalizeEmailSetting(value emailSettingValue) emailSettingValue {
|
||||
func (s *Service) publicEmailSetting(setting *model.SystemSetting, value emailSettingValue) *PublicEmailSetting {
|
||||
inherited := value.FromName == "" || value.FromName == defaultAppearanceBrandName
|
||||
value = resolveEmailSender(value, s.appearanceBrandName())
|
||||
result := &PublicEmailSetting{Enabled: value.Enabled, Host: value.Host, Port: value.Port, Username: value.Username, Encryption: value.Encryption, FromEmail: value.FromEmail, FromName: value.FromName, FromNameInherited: inherited, HasPassword: value.Password != ""}
|
||||
result := &PublicEmailSetting{
|
||||
Enabled: value.Enabled, Host: value.Host, Port: value.Port, Username: value.Username, Encryption: value.Encryption,
|
||||
FromEmail: value.FromEmail, FromName: value.FromName, FromNameInherited: inherited, HasPassword: value.Password != "",
|
||||
RegistrationAllowedDomains: value.RegistrationAllowedDomains,
|
||||
}
|
||||
if setting != nil {
|
||||
result.UpdatedBy = setting.UpdatedBy
|
||||
result.CreatedAt = setting.CreatedAt
|
||||
@@ -296,6 +333,75 @@ func (s *Service) publicEmailSetting(setting *model.SystemSetting, value emailSe
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeEmailDomains(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, raw := range values {
|
||||
value := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "@")))
|
||||
value = strings.TrimSuffix(value, ".")
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validateRegistrationEmailDomains(allowed []string) error {
|
||||
if len(allowed) > 100 {
|
||||
return BadAuthRequest("电子邮件域名白名单最多填写 100 项")
|
||||
}
|
||||
for _, domain := range allowed {
|
||||
if !validEmailDomain(domain) {
|
||||
return BadAuthRequest("电子邮件域名格式不正确:" + domain)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validEmailDomain(value string) bool {
|
||||
if len(value) > 253 || !strings.Contains(value, ".") || strings.HasPrefix(value, ".") || strings.HasSuffix(value, ".") {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(value, ".") {
|
||||
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return false
|
||||
}
|
||||
for _, char := range label {
|
||||
if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) validateRegistrationEmailDomain(email string) error {
|
||||
_, setting, err := s.readEmailSetting()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRegistrationEmailDomain(email, setting.RegistrationAllowedDomains)
|
||||
}
|
||||
|
||||
func validateRegistrationEmailDomain(email string, allowedDomains []string) error {
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) != 2 {
|
||||
return BadAuthRequest("邮箱格式不正确")
|
||||
}
|
||||
domain := strings.ToLower(parts[1])
|
||||
if len(allowedDomains) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, allowed := range allowedDomains {
|
||||
if domain == allowed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return BadAuthRequest("该邮箱域名不在管理员设置的白名单内")
|
||||
}
|
||||
|
||||
func resolveEmailSender(value emailSettingValue, brandName string) emailSettingValue {
|
||||
if value.FromName == "" || value.FromName == defaultAppearanceBrandName {
|
||||
value.FromName = brandName
|
||||
|
||||
134
backend/internal/service/email_domain_test.go
Normal file
134
backend/internal/service/email_domain_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestRegistrationDomainPolicyChecksSendingAndRegistration(t *testing.T) {
|
||||
svc, db := newPasswordResetTestService(t)
|
||||
admin := &model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
if err := db.Create(admin).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.SystemSetting{Key: registrationSettingKey, ValueJSON: `{"enabled":true}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var deliveredCode string
|
||||
sends := 0
|
||||
svc.mailSender = func(_ emailSettingValue, _, _ string, body string) error {
|
||||
sends++
|
||||
deliveredCode = codeFromEmailBody(body)
|
||||
return nil
|
||||
}
|
||||
if err := svc.SendRegistrationEmailCode("member@blocked.example"); err == nil || !strings.Contains(err.Error(), "白名单") {
|
||||
t.Fatalf("blocked domain must not receive registration code: %v", err)
|
||||
}
|
||||
var codes int64
|
||||
if err := db.Model(&model.EmailVerificationCode{}).Count(&codes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sends != 0 || codes != 0 {
|
||||
t.Fatalf("blocked send created side effects: sends=%d codes=%d", sends, codes)
|
||||
}
|
||||
if err := svc.SendRegistrationEmailCode("member@example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deliveredCode == "" || sends != 1 {
|
||||
t.Fatal("allowed domain did not receive a code")
|
||||
}
|
||||
// A code issued before an administrator tightens the policy must not bypass it.
|
||||
if _, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{"gmail.com"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Register(RegisterRequest{Username: "member", Email: "member@example.com", Password: "strong-password", EmailCode: deliveredCode}); err == nil || !strings.Contains(err.Error(), "白名单") {
|
||||
t.Fatalf("registration must recheck current domain policy: %v", err)
|
||||
}
|
||||
count, err := svc.repo.UserCount()
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("blocked registration created a user: count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailDomainSettingsRoundTripAndValidation(t *testing.T) {
|
||||
svc, _ := newPasswordResetTestService(t)
|
||||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
setting, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{" @GMAIL.COM. ", "gmail.com"}})
|
||||
if err != nil || setting == nil || len(setting.RegistrationAllowedDomains) != 1 || setting.RegistrationAllowedDomains[0] != "gmail.com" {
|
||||
t.Fatalf("normalization failed: setting=%+v err=%v", setting, err)
|
||||
}
|
||||
if _, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{"bad_domain"}}); err == nil {
|
||||
t.Fatal("invalid whitelist accepted while SMTP is disabled")
|
||||
}
|
||||
setting, err = svc.AdminEmailSetting(admin)
|
||||
if err != nil || setting == nil || len(setting.RegistrationAllowedDomains) != 1 || setting.RegistrationAllowedDomains[0] != "gmail.com" {
|
||||
t.Fatalf("failed update changed the saved whitelist: setting=%+v err=%v", setting, err)
|
||||
}
|
||||
if _, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err = svc.AdminEmailSetting(admin)
|
||||
if err != nil || setting == nil || setting.RegistrationAllowedDomains == nil || len(setting.RegistrationAllowedDomains) != 0 {
|
||||
t.Fatalf("explicit empty whitelist must survive persistence: setting=%+v err=%v", setting, err)
|
||||
}
|
||||
if err := svc.validateRegistrationEmailDomain("member@custom.example"); err != nil {
|
||||
t.Fatalf("empty saved whitelist should allow custom domains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEmailSettingDefaultsToMainstreamRegistrationDomains(t *testing.T) {
|
||||
setting := normalizeEmailSetting(emailSettingValue{})
|
||||
for _, domain := range []string{"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com", "icloud.com", "yahoo.com", "foxmail.com"} {
|
||||
if !containsEmailDomain(setting.RegistrationAllowedDomains, domain) {
|
||||
t.Fatalf("default allowed domains = %#v, missing %q", setting.RegistrationAllowedDomains, domain)
|
||||
}
|
||||
}
|
||||
|
||||
setting = normalizeEmailSetting(emailSettingValue{RegistrationAllowedDomains: []string{}})
|
||||
if len(setting.RegistrationAllowedDomains) != 0 {
|
||||
t.Fatalf("explicit empty allowed domains = %#v, want unrestricted", setting.RegistrationAllowedDomains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistrationEmailDomainUsesExactWhitelistMatch(t *testing.T) {
|
||||
allowed := []string{"gmail.com", "example.org"}
|
||||
for _, email := range []string{"member@gmail.com", "member@example.org"} {
|
||||
if err := validateRegistrationEmailDomain(email, allowed); err != nil {
|
||||
t.Fatalf("%s should be allowed: %v", email, err)
|
||||
}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
email string
|
||||
want string
|
||||
}{
|
||||
{email: "member@fakegmail.com", want: "不在管理员设置的白名单"},
|
||||
{email: "member@blocked.example", want: "不在管理员设置的白名单"},
|
||||
} {
|
||||
if err := validateRegistrationEmailDomain(test.email, allowed); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("validateRegistrationEmailDomain(%q) error = %v", test.email, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistrationEmailDomainsRejectsInvalidDomain(t *testing.T) {
|
||||
if err := validateRegistrationEmailDomains([]string{"invalid_domain"}); err == nil {
|
||||
t.Fatal("invalid domain should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistrationEmailDomainAllowsAnyDomainWhenWhitelistIsEmpty(t *testing.T) {
|
||||
if err := validateRegistrationEmailDomain("member@example.org", nil); err != nil {
|
||||
t.Fatalf("empty whitelist should not restrict registration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func containsEmailDomain(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -21,7 +21,8 @@ func TestPasswordResetChangesPasswordConsumesCodeAndRevokesSessions(t *testing.T
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := model.User{ID: "user-1", Username: "creator", Email: "creator@example.com", DisplayName: "Creator", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: oldHash}
|
||||
// Existing users can reset passwords even outside the registration whitelist.
|
||||
user := model.User{ID: "user-1", Username: "creator", Email: "creator@external.example", DisplayName: "Creator", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: oldHash}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -255,7 +256,7 @@ func newPasswordResetTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.EmailVerificationCode{}, &model.SystemSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settingJSON, err := json.Marshal(emailSettingValue{Enabled: true, Host: "smtp.example.com", Port: 587, Encryption: "starttls", FromEmail: "noreply@example.com", FromName: "影策"})
|
||||
settingJSON, err := json.Marshal(emailSettingValue{Enabled: true, Host: "smtp.example.com", Port: 587, Encryption: "starttls", FromEmail: "noreply@example.com", FromName: "影策", RegistrationAllowedDomains: []string{"example.com"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ description: 影策后端核心持久化表与状态边界
|
||||
|
||||
密码重置在单个数据库事务中条件消费 `password_reset` 验证码、更新 `users.password_hash` 并删除 `auth_sessions`。任一步失败都会回滚,不能出现密码已修改但旧会话仍有效,或验证码已消耗但密码未更新的部分状态。
|
||||
|
||||
邮件系统设置还保存电子邮件域名白名单。新配置默认填入 Gmail、163、126、QQ、Outlook、Hotmail、iCloud、Yahoo 和 Foxmail;管理员可按行编辑完整域名。白名单为空时不限制邮箱域名,非空时只有精确匹配的域名可注册。该规则只作用于新用户注册和注册验证码,不限制 SMTP 发件邮箱,也不影响已有账号找回密码。
|
||||
|
||||
## 技能包与版本
|
||||
|
||||
| 表 | 用途 | 关键边界 |
|
||||
@@ -144,7 +146,7 @@ description: 影策后端核心持久化表与状态边界
|
||||
| `announcement_image_drafts` | 标记尚未发布的公告配图资源 | 发布/更新事务消费草稿;取消编辑显式回收,超过 24 小时的异常遗留草稿由后台清理 |
|
||||
| `user_announcement_reads` | 保存用户已读状态 | 同一用户和公告唯一;公告重新发布时清除旧已读记录 |
|
||||
|
||||
活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。替换或移除配图时,公告更新、旧资源记录删除和物理删除任务入队在同一数据库事务完成。
|
||||
活动公告按置顶状态优先、发布时间倒序返回;置顶不会绕过关闭状态或用户登录校验。公告只要求标题,正文和配图均可为空。用户首次进入工作区时会自动看到未读公告,可以只关闭本次或选择今日不再提醒;主动打开公告中心后才会写入服务端已读状态。替换或移除配图时,公告更新、旧资源记录删除和物理删除任务入队在同一数据库事务完成。
|
||||
|
||||
## 资源存储
|
||||
|
||||
|
||||
@@ -5,6 +5,15 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 邮箱域名白名单与公告自动提醒(PR #417)
|
||||
|
||||
- 新增注册邮箱域名白名单:默认常见服务商、空列表不限制、精确域名匹配;发送注册验证码及提交注册都会校验,已有账号找回密码不受限制。保存失败不覆盖既有配置。
|
||||
- 公告允许仅标题发布;工作区自动提示未读公告,支持关闭本次及今日不再提醒。保留弹窗按需加载;主动打开后即使已读同步失败,关闭也不会立刻重新弹出。
|
||||
- 合并沿用主分支历史迁移方案,不重复执行迁移或重写版本 6 的历史记录;未修改用户数据库,历史升级仍需备份及 PostgreSQL 实际环境验收。
|
||||
- 已通过前端构建及本次相关后端数据库、handler、service 专项测试;公告提醒前端回归使用静态契约检查,不等同于浏览器交互验收。
|
||||
- 基线复测确认既有失败:后端渠道私网策略 2 项、视频恢复策略 2 项,以及前端工作区加载骨架断言 1 项;本次不修改这些无关逻辑。
|
||||
- 待人工验证:邮箱设置保存/清空/刷新及错误展示、仅标题公告发布与重新发布、明暗主题、窄屏弹窗、跨日提醒、不同账号的今日免打扰隔离,以及网络失败后的手动关闭与重试。
|
||||
|
||||
## v1.2.6.rc1 候选版本发布
|
||||
|
||||
- 发布版本与镜像标签分别为 `v1.2.6.rc1` 和 `1.2.6.rc1`;GitHub Release 应标记为预发布,不能被当作正式版本推荐。
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"build:bridge": "bun ../canvas-agent/scripts/build-comfy-bridge.ts",
|
||||
"dev": "vite --host 0.0.0.0 --port 3000",
|
||||
"build": "bun run build:bridge && tsc --noEmit && vite build",
|
||||
"pretest": "bun test test/admin-ui-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts",
|
||||
"pretest": "bun test test/admin-ui-regressions.test.ts test/announcement-prompt-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:canvas": "bun test test/canvas-media-performance.test.ts test/canvas-node-title-interaction.test.ts test/canvas-spatial-index.test.ts",
|
||||
"test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-pricing.test.ts test/model-protocols.test.ts test/model-capabilities.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { Tag } from "antd";
|
||||
import { Bell } from "lucide-react";
|
||||
import { lazy, Suspense, useState, type CSSProperties } from "react";
|
||||
import { lazy, Suspense, useEffect, useState, type CSSProperties } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
@@ -11,6 +11,7 @@ const AnnouncementTimelineModal = lazy(() => import("@/components/ui/aceternity/
|
||||
|
||||
const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000;
|
||||
const ANNOUNCEMENT_CACHE_TTL_MS = 60_000;
|
||||
const ANNOUNCEMENT_DISMISS_TODAY_PREFIX = "yingce.announcements.dismiss-today";
|
||||
|
||||
type AnnouncementFeed = Awaited<ReturnType<typeof getAnnouncementFeed>>;
|
||||
|
||||
@@ -21,12 +22,15 @@ type SystemAnnouncementCenterProps = {
|
||||
showLabel?: boolean;
|
||||
labelClassName?: string;
|
||||
staticMotion?: boolean;
|
||||
autoOpen?: boolean;
|
||||
};
|
||||
|
||||
export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false }: SystemAnnouncementCenterProps) {
|
||||
export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false, autoOpen = false }: SystemAnnouncementCenterProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [automaticPrompt, setAutomaticPrompt] = useState(false);
|
||||
const [dismissedFingerprint, setDismissedFingerprint] = useState("");
|
||||
const queryKey = ["system-announcements", userId] as const;
|
||||
const feedQuery = useQuery({
|
||||
queryKey,
|
||||
@@ -41,7 +45,16 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
|
||||
const unreadCount = Math.max(0, feedQuery.data?.unreadCount || 0);
|
||||
const error = feedQuery.error instanceof Error ? feedQuery.error.message : feedQuery.error ? "读取公告失败" : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoOpen || open || !feedQuery.isSuccess || unreadCount <= 0 || announcements.length === 0) return;
|
||||
const fingerprint = announcementFeedFingerprint(announcements);
|
||||
if (!fingerprint || fingerprint === dismissedFingerprint || announcementAutoPromptSuppressed(userId)) return;
|
||||
setAutomaticPrompt(true);
|
||||
setOpen(true);
|
||||
}, [announcements, autoOpen, dismissedFingerprint, feedQuery.isSuccess, open, unreadCount, userId]);
|
||||
|
||||
const openAnnouncements = async () => {
|
||||
setAutomaticPrompt(false);
|
||||
setOpen(true);
|
||||
const feed = (await feedQuery.refetch()).data;
|
||||
if (!feed?.unreadCount) return;
|
||||
@@ -55,6 +68,14 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
|
||||
}
|
||||
};
|
||||
|
||||
const dismissAutomaticPrompt = (duration: "once" | "today") => {
|
||||
const fingerprint = announcementFeedFingerprint(announcements);
|
||||
if (fingerprint) setDismissedFingerprint(fingerprint);
|
||||
if (duration === "today") rememberAnnouncementDismissalToday(userId);
|
||||
setAutomaticPrompt(false);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.button
|
||||
@@ -93,9 +114,47 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
|
||||
</motion.button>
|
||||
{open ? (
|
||||
<Suspense fallback={null}>
|
||||
<AnnouncementTimelineModal open announcements={announcements} loading={feedQuery.isFetching} error={announcements.length ? "" : error} onClose={() => setOpen(false)} onRetry={() => void feedQuery.refetch()} />
|
||||
<AnnouncementTimelineModal
|
||||
open
|
||||
announcements={announcements}
|
||||
loading={feedQuery.isFetching}
|
||||
error={announcements.length ? "" : error}
|
||||
automaticPrompt={automaticPrompt}
|
||||
onClose={() => dismissAutomaticPrompt("once")}
|
||||
onDismissOnce={() => dismissAutomaticPrompt("once")}
|
||||
onDismissToday={() => dismissAutomaticPrompt("today")}
|
||||
onRetry={() => void feedQuery.refetch()}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function announcementFeedFingerprint(announcements: AnnouncementFeed["announcements"]) {
|
||||
return announcements
|
||||
.map((announcement) => `${announcement.id}:${announcement.publishedAt}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function announcementAutoPromptSuppressed(userId: string) {
|
||||
try {
|
||||
return localStorage.getItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`) === localDateKey();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function rememberAnnouncementDismissalToday(userId: string) {
|
||||
try {
|
||||
localStorage.setItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`, localDateKey());
|
||||
} catch {
|
||||
// 浏览器禁用存储时仍允许关闭弹窗,本次组件生命周期内不会重复打开。
|
||||
}
|
||||
}
|
||||
|
||||
function localDateKey() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen:
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{user ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-topbar-icon-button" /> : null}
|
||||
{user ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-topbar-icon-button" autoOpen /> : null}
|
||||
<AnimatedThemeToggler className="app-workspace-topbar-icon-button" theme={theme} onThemeChange={setTheme} aria-label="切换主题" />
|
||||
<WorkspaceAccountMenu />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { Image as AntImage, Modal } from "antd";
|
||||
import { Button, Image as AntImage, Modal } from "antd";
|
||||
import { Bell, BellOff, CircleAlert, Info, Pin, ShieldAlert, Wrench } from "lucide-react";
|
||||
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
@@ -11,7 +11,10 @@ type AnnouncementTimelineModalProps = {
|
||||
announcements: SystemAnnouncement[];
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
automaticPrompt?: boolean;
|
||||
onClose: () => void;
|
||||
onDismissOnce?: () => void;
|
||||
onDismissToday?: () => void;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
|
||||
@@ -22,7 +25,7 @@ const levelMeta: Record<AnnouncementLevel, { label: string; dot: string; icon: t
|
||||
critical: { label: "重要通知", dot: "bg-red-500", icon: ShieldAlert },
|
||||
};
|
||||
|
||||
export function AnnouncementTimelineModal({ open, announcements, loading = false, error = "", onClose, onRetry }: AnnouncementTimelineModalProps) {
|
||||
export function AnnouncementTimelineModal({ open, announcements, loading = false, error = "", automaticPrompt = false, onClose, onDismissOnce, onDismissToday, onRetry }: AnnouncementTimelineModalProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
@@ -30,7 +33,12 @@ export function AnnouncementTimelineModal({ open, announcements, loading = false
|
||||
open={open}
|
||||
width={960}
|
||||
centered
|
||||
footer={null}
|
||||
footer={automaticPrompt ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button onClick={onDismissToday}>今日不再提醒</Button>
|
||||
<Button type="primary" onClick={onDismissOnce}>关闭本次</Button>
|
||||
</div>
|
||||
) : null}
|
||||
onCancel={onClose}
|
||||
title={
|
||||
<div className="flex min-w-0 items-center gap-3 pr-8">
|
||||
@@ -111,7 +119,7 @@ function AnnouncementTimelineItem({ announcement, last, reducedMotion }: { annou
|
||||
{announcement.pinned ? <span className="inline-flex items-center gap-1 text-[var(--fs-label)] font-medium text-amber-500"><Pin className="size-3" />置顶</span> : null}
|
||||
</div>
|
||||
{announcement.imageUrl ? <AntImage src={announcementImageUrl(announcement)} alt={`${announcement.title} 配图`} preview className="mt-3 max-h-36 w-56 max-w-full rounded-lg border border-border/70 bg-muted/20 object-contain p-1" /> : null}
|
||||
<AnnouncementContent content={announcement.content} className="mt-1 text-sm leading-6 text-foreground/75 sm:text-[var(--fs-body-lg)]" />
|
||||
{announcement.content ? <AnnouncementContent content={announcement.content} className="mt-1 text-sm leading-6 text-foreground/75 sm:text-[var(--fs-body-lg)]" /> : null}
|
||||
<time dateTime={announcement.publishedAt} className="mt-2 block text-xs tabular-nums text-foreground/40">{relativeTime(announcement.publishedAt)} · {formatDateTime(announcement.publishedAt)}</time>
|
||||
</div>
|
||||
</motion.article>
|
||||
|
||||
@@ -810,13 +810,10 @@ function AnnouncementEditor({
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="公告正文"
|
||||
rules={[
|
||||
{ required: true, whitespace: true, message: "请填写公告正文" },
|
||||
{ max: 4000, message: "正文不能超过 4000 个字符" },
|
||||
]}
|
||||
label="公告正文(可选)"
|
||||
rules={[{ max: 4000, message: "正文不能超过 4000 个字符" }]}
|
||||
>
|
||||
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 14, maxRows: 24 }} placeholder="填写服务状态、影响范围和用户需要采取的操作" />
|
||||
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 14, maxRows: 24 }} placeholder="可选填写服务状态、影响范围和用户需要采取的操作" />
|
||||
</Form.Item>
|
||||
</section>
|
||||
|
||||
@@ -835,7 +832,7 @@ function AnnouncementEditor({
|
||||
</div>
|
||||
<h3>{watchedTitle?.trim() || "公告标题将在这里显示"}</h3>
|
||||
{imagePreviewUrl ? <img src={imagePreviewUrl} alt="公告配图预览" className="mt-4 max-h-56 w-full rounded-lg border border-border/70 bg-muted/20 object-contain p-1" /> : null}
|
||||
{watchedContent?.trim() ? <AnnouncementContent content={watchedContent.trim()} className="admin-announcement-preview-content" /> : <p className="admin-announcement-preview-placeholder">公告正文将在这里显示。</p>}
|
||||
{watchedContent?.trim() ? <AnnouncementContent content={watchedContent.trim()} className="admin-announcement-preview-content" /> : <p className="admin-announcement-preview-placeholder">此公告仅展示标题。</p>}
|
||||
</section>
|
||||
</div>
|
||||
</Form>
|
||||
@@ -889,7 +886,7 @@ function AnnouncementEditor({
|
||||
<div className="is-wide">
|
||||
<dt>公告正文</dt>
|
||||
<dd>
|
||||
<AnnouncementContent content={pending.content} className="admin-announcement-confirm-content" />
|
||||
{pending.content ? <AnnouncementContent content={pending.content} className="admin-announcement-confirm-content" /> : "无正文"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -8,7 +8,13 @@ import { getAdminEmailSetting, updateAdminEmailSetting, type EmailSetting } from
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
|
||||
|
||||
type EmailFormValues = Pick<EmailSetting, "enabled" | "host" | "port" | "username" | "password" | "encryption" | "fromEmail" | "fromName">;
|
||||
type EmailFormValues = Pick<EmailSetting, "enabled" | "host" | "port" | "username" | "password" | "encryption" | "fromEmail" | "fromName"> & {
|
||||
registrationAllowedDomains: string;
|
||||
};
|
||||
|
||||
type NormalizedEmailFormValues = Omit<EmailFormValues, "registrationAllowedDomains"> & {
|
||||
registrationAllowedDomains: string[];
|
||||
};
|
||||
|
||||
export default function EmailSettingsPanel() {
|
||||
const { message, modal } = App.useApp();
|
||||
@@ -289,8 +295,25 @@ export default function EmailSettingsPanel() {
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
|
||||
{draftEnabled ? (
|
||||
<div id="admin-email-smtp" className="admin-settings-anchor">
|
||||
<Form
|
||||
form={form}
|
||||
className="admin-email-form-stack"
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
disabled={loading || refreshing || saving}
|
||||
onValuesChange={() => {
|
||||
const values = form.getFieldsValue(true);
|
||||
setDraftEnabled(Boolean(values.enabled));
|
||||
setDirty(hasEmailChanges(values, setting));
|
||||
setSaveError("");
|
||||
}}
|
||||
>
|
||||
<Form.Item name="enabled" valuePropName="checked" hidden>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
{draftEnabled ? (
|
||||
<div id="admin-email-smtp" className="admin-settings-anchor">
|
||||
<SettingsSectionCard
|
||||
className="admin-email-section admin-email-configuration-section"
|
||||
icon={<Server className="size-4" aria-hidden="true" />}
|
||||
@@ -310,28 +333,12 @@ export default function EmailSettingsPanel() {
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
|
||||
保存并启用
|
||||
{draftEnabled ? "保存并启用" : "保存设置"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
disabled={loading || refreshing || saving}
|
||||
onValuesChange={() => {
|
||||
const values = form.getFieldsValue(true);
|
||||
setDraftEnabled(Boolean(values.enabled));
|
||||
setDirty(hasEmailChanges(values, setting));
|
||||
setSaveError("");
|
||||
}}
|
||||
>
|
||||
<Form.Item name="enabled" valuePropName="checked" hidden>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<div className="admin-email-form-section">
|
||||
<FormSectionTitle icon={<Server className="size-4" />} title="服务器连接" description="填写 SMTP 主机、端口和传输加密;STARTTLS 通常使用 587,直接 TLS 通常使用 465。" />
|
||||
<div className="admin-email-form-grid is-connection">
|
||||
@@ -402,10 +409,53 @@ export default function EmailSettingsPanel() {
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div id="admin-email-registration-domains" className="admin-settings-anchor">
|
||||
<SettingsSectionCard
|
||||
className="admin-email-section"
|
||||
icon={<MailCheck className="size-4" aria-hidden="true" />}
|
||||
title="3. 电子邮件域名白名单"
|
||||
description="每行填写一个允许注册的邮箱域名;不在白名单内的邮箱无法注册。"
|
||||
status={<AdminStatusBadge label={currentValues.registrationAllowedDomains.length > 0 ? "已限制范围" : "不限制服务商"} tone={currentValues.registrationAllowedDomains.length > 0 ? "info" : "neutral"} />}
|
||||
footer={
|
||||
<>
|
||||
<div className="admin-email-footer-note">
|
||||
<BadgeCheck className="size-4" aria-hidden="true" />
|
||||
<span>规则只影响新用户注册,不限制发件邮箱和已有账号找回密码</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{dirty ? (
|
||||
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
|
||||
撤销
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="admin-email-form-section">
|
||||
<Form.Item
|
||||
name="registrationAllowedDomains"
|
||||
label="电子邮件域名白名单"
|
||||
extra="每行一个域名。留空保存表示不限制邮箱域名。"
|
||||
rules={[{ validator: (_, value: string | undefined) => validateDomainList(value) }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 9, maxRows: 16 }}
|
||||
placeholder="gmail.com 163.com 126.com qq.com"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
) : null}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -432,10 +482,11 @@ function toEmailFormValues(setting: EmailSetting): EmailFormValues {
|
||||
encryption: setting.encryption,
|
||||
fromEmail: setting.fromEmail,
|
||||
fromName: setting.fromNameInherited ? "" : setting.fromName,
|
||||
registrationAllowedDomains: setting.registrationAllowedDomains.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEmailFormValues(values: Partial<EmailFormValues>): EmailFormValues {
|
||||
function normalizeEmailFormValues(values: Partial<EmailFormValues>): NormalizedEmailFormValues {
|
||||
return {
|
||||
enabled: Boolean(values.enabled),
|
||||
host: values.host?.trim() || "",
|
||||
@@ -445,6 +496,7 @@ function normalizeEmailFormValues(values: Partial<EmailFormValues>): EmailFormVa
|
||||
encryption: values.encryption === "tls" || values.encryption === "none" ? values.encryption : "starttls",
|
||||
fromEmail: values.fromEmail?.trim().toLowerCase() || "",
|
||||
fromName: values.fromName?.trim() || "",
|
||||
registrationAllowedDomains: normalizeDomains(values.registrationAllowedDomains),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -453,7 +505,8 @@ function hasEmailChanges(values: Partial<EmailFormValues>, setting: EmailSetting
|
||||
const draft = normalizeEmailFormValues(values);
|
||||
const saved = normalizeEmailFormValues(toEmailFormValues(setting));
|
||||
if (draft.password) return true;
|
||||
return (Object.keys(saved) as Array<keyof EmailFormValues>).some((key) => key !== "password" && draft[key] !== saved[key]);
|
||||
const scalarFields: Array<keyof EmailFormValues> = ["enabled", "host", "port", "username", "encryption", "fromEmail", "fromName"];
|
||||
return scalarFields.some((key) => draft[key] !== saved[key]) || !arraysEqual(draft.registrationAllowedDomains, saved.registrationAllowedDomains);
|
||||
}
|
||||
|
||||
function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | null) {
|
||||
@@ -466,11 +519,11 @@ function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | nul
|
||||
return "";
|
||||
}
|
||||
|
||||
function emailResponseMatches(setting: EmailSetting, expected: EmailFormValues) {
|
||||
function emailResponseMatches(setting: EmailSetting, expected: NormalizedEmailFormValues) {
|
||||
const actual = normalizeEmailFormValues(toEmailFormValues(setting));
|
||||
const fields: Array<keyof EmailFormValues> = ["enabled", "host", "port", "username", "encryption", "fromEmail", "fromName"];
|
||||
if (expected.password && !setting.hasPassword) return false;
|
||||
return fields.every((key) => actual[key] === expected[key]);
|
||||
return fields.every((key) => actual[key] === expected[key]) && arraysEqual(actual.registrationAllowedDomains, expected.registrationAllowedDomains);
|
||||
}
|
||||
|
||||
function isEmailSetting(value: unknown): value is EmailSetting {
|
||||
@@ -485,10 +538,36 @@ function isEmailSetting(value: unknown): value is EmailSetting {
|
||||
typeof setting.fromEmail === "string" &&
|
||||
typeof setting.fromName === "string" &&
|
||||
typeof setting.fromNameInherited === "boolean" &&
|
||||
typeof setting.hasPassword === "boolean"
|
||||
typeof setting.hasPassword === "boolean" &&
|
||||
Array.isArray(setting.registrationAllowedDomains) &&
|
||||
setting.registrationAllowedDomains.every((value) => typeof value === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDomains(value?: string) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(value || "")
|
||||
.split(/[\s,,;;]+/)
|
||||
.map((domain) => domain.trim().toLowerCase().replace(/^@/, "").replace(/\.$/, ""))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function arraysEqual(left: string[], right: string[]) {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function validateDomainList(value?: string) {
|
||||
const invalid = invalidDomain(value);
|
||||
return invalid ? Promise.reject(new Error(`邮箱域名格式不正确:“${invalid}”`)) : Promise.resolve();
|
||||
}
|
||||
|
||||
function invalidDomain(value?: string) {
|
||||
return normalizeDomains(value).find((domain) => !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(domain));
|
||||
}
|
||||
|
||||
function isValidEmail(value: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ export type EmailSetting = {
|
||||
fromName: string;
|
||||
fromNameInherited: boolean;
|
||||
hasPassword: boolean;
|
||||
registrationAllowedDomains: string[];
|
||||
updatedBy?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
|
||||
@@ -3337,6 +3337,12 @@
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-email-form-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-email-settings .admin-settings-section {
|
||||
border: 1px solid var(--admin-card-border);
|
||||
}
|
||||
|
||||
14
web/test/announcement-prompt-regressions.test.ts
Normal file
14
web/test/announcement-prompt-regressions.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("announcement prompts preserve lazy loading and dismiss even when read synchronization fails", async () => {
|
||||
const source = await Bun.file(new URL("../src/components/layout/system-announcement-center.tsx", import.meta.url)).text();
|
||||
expect(source).toContain('lazy(() => import("@/components/ui/aceternity/announcement-timeline-modal")');
|
||||
expect(source).toContain("{open ? (");
|
||||
expect(source).toContain("<Suspense fallback={null}>");
|
||||
expect(source).toContain("automaticPrompt={automaticPrompt}");
|
||||
expect(source).toContain('onClose={() => dismissAutomaticPrompt("once")}');
|
||||
expect(source).toContain('onDismissToday={() => dismissAutomaticPrompt("today")}');
|
||||
expect(source).toContain("setDismissedFingerprint(fingerprint)");
|
||||
expect(source).toContain("fingerprint === dismissedFingerprint");
|
||||
expect(source).toContain("`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`");
|
||||
});
|
||||
Reference in New Issue
Block a user