feat(权限组): 增加账号配额与邮件发送限制
- 为权限组与用户新增配额字段,支持附件大小、SMTP/IMAP/POP3 频率限制。 - 后端补充权限组配额校验、迁移与 SMTP 发送限流,邮件发送/定时发送时统一生效。 - 前端在管理页、邮件撰写页和个人资料页展示并编辑配额信息。
This commit is contained in:
@@ -120,6 +120,7 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
permissions_json TEXT NOT NULL DEFAULT '[]',
|
||||
limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}',
|
||||
system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@@ -254,6 +255,13 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
sent_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_scheduled_sends_due ON scheduled_sends(status, send_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS smtp_send_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_smtp_send_events_user_created ON smtp_send_events(user_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -343,12 +351,45 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migratePermissionGroupLimits(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(permission_groups)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasLimits := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull int
|
||||
var dflt any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if name == "limits_json" {
|
||||
hasLimits = true
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasLimits {
|
||||
return nil
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `ALTER TABLE permission_groups ADD COLUMN limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}'`)
|
||||
return err
|
||||
}
|
||||
|
||||
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed()
|
||||
// that implicitly created an admin mailbox with display_name "LanQin Admin".
|
||||
// Current seed() creates mailboxes with display_name = admin email, so this migration
|
||||
|
||||
@@ -183,6 +183,20 @@ func updateRegularPermissionGroup(t *testing.T, admin *testClient, permissions [
|
||||
return group
|
||||
}
|
||||
|
||||
func updateRegularPermissionGroupWithLimits(t *testing.T, admin *testClient, permissions []string, limits PermissionLimits) PermissionGroup {
|
||||
t.Helper()
|
||||
var group PermissionGroup
|
||||
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
|
||||
"name": "Regular Users",
|
||||
"description": "Default permissions for regular users",
|
||||
"permissions": permissions,
|
||||
"limits": limits,
|
||||
}, &group); code != http.StatusOK {
|
||||
t.Fatalf("update regular permission group limits code=%d group=%+v", code, group)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
func systemSettingsPayload(settings SystemSettings) map[string]any {
|
||||
return map[string]any{
|
||||
"publicHostname": settings.PublicHostname,
|
||||
@@ -380,6 +394,70 @@ func TestScheduleSendQueuesFutureMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionGroupMailLimits(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1})
|
||||
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
sender := createTestMailbox(t, admin, domainID, "limited-sender", "Limited Sender", "Password123!", nil)
|
||||
recipient := createTestMailbox(t, admin, domainID, "limited-recipient", "Limited Recipient", "Password123!", nil)
|
||||
|
||||
user := &testClient{t: t, server: ts}
|
||||
if code := user.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("user login code=%d", code)
|
||||
}
|
||||
var me struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := user.do("GET", "/api/me", nil, &me); code != http.StatusOK {
|
||||
t.Fatalf("me code=%d user=%+v", code, me.User)
|
||||
}
|
||||
if me.User.Limits.MaxAttachmentMB != 1 || me.User.Limits.SMTPMinuteLimit != 1 || me.User.Limits.IMAPMinuteLimit != 1 || me.User.Limits.POP3MinuteLimit != 1 {
|
||||
t.Fatalf("user limits not attached: %+v", me.User.Limits)
|
||||
}
|
||||
|
||||
tooLargeAttachment := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("x"), 1024*1024+1))
|
||||
var errBody map[string]any
|
||||
if code := user.do("POST", "/api/mail/send", map[string]any{
|
||||
"mailboxId": sender.ID,
|
||||
"to": []string{recipient.Address},
|
||||
"subject": "too large",
|
||||
"text": "body",
|
||||
"html": "<p>body</p>",
|
||||
"attachments": []map[string]string{{
|
||||
"filename": "large.bin",
|
||||
"contentType": "application/octet-stream",
|
||||
"contentBase64": tooLargeAttachment,
|
||||
}},
|
||||
}, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("oversized attachment should be rejected code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
var sent MailMessage
|
||||
payload := map[string]any{
|
||||
"mailboxId": sender.ID,
|
||||
"to": []string{recipient.Address},
|
||||
"subject": "first limited send",
|
||||
"text": "body",
|
||||
"html": "<p>body</p>",
|
||||
}
|
||||
if code := user.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated {
|
||||
t.Fatalf("first send code=%d msg=%+v", code, sent)
|
||||
}
|
||||
payload["subject"] = "second limited send"
|
||||
if code := user.do("POST", "/api/mail/send", payload, &errBody); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("smtp minute limit should reject second send code=%d body=%v", code, errBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
@@ -925,6 +1003,9 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
||||
t.Fatalf("missing fixed permission group %s in %+v", group.ID, groups.Items)
|
||||
}
|
||||
}
|
||||
if groupByID[PermissionGroupRegular].Limits != defaultPermissionLimits() {
|
||||
t.Fatalf("regular group limits=%+v want %+v", groupByID[PermissionGroupRegular].Limits, defaultPermissionLimits())
|
||||
}
|
||||
if groups.Items[0].ID != PermissionGroupSuperAdmin || groups.Items[1].ID != PermissionGroupRegular {
|
||||
t.Fatalf("unexpected fixed permission groups: %+v", groups.Items)
|
||||
}
|
||||
@@ -938,9 +1019,13 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
||||
"name": "Mailbox Viewers",
|
||||
"description": "Can view mailboxes only",
|
||||
"permissions": []string{PermissionAdminOverview, PermissionMailboxesView},
|
||||
"limits": PermissionLimits{MaxAttachmentMB: 5, SMTPDailyLimit: 8, SMTPMinuteLimit: 2, IMAPMinuteLimit: 5, POP3MinuteLimit: 3},
|
||||
}, &customGroup); code != http.StatusCreated {
|
||||
t.Fatalf("custom permission group creation code=%d group=%+v", code, customGroup)
|
||||
}
|
||||
if customGroup.Limits.MaxAttachmentMB != 5 || customGroup.Limits.SMTPDailyLimit != 8 || customGroup.Limits.SMTPMinuteLimit != 2 || customGroup.Limits.IMAPMinuteLimit != 5 || customGroup.Limits.POP3MinuteLimit != 3 {
|
||||
t.Fatalf("custom permission group limits=%+v", customGroup.Limits)
|
||||
}
|
||||
if customGroup.System || customGroup.ID == "" || !userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesView) || userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesCreate) {
|
||||
t.Fatalf("custom permission group permissions=%+v", customGroup)
|
||||
}
|
||||
|
||||
@@ -391,7 +391,7 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
msg, err := a.sendMailNow(r.Context(), mb, req)
|
||||
msg, err := a.sendMailNow(r.Context(), currentUser(r), mb, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNoRecipients) {
|
||||
badRequest(w, err)
|
||||
@@ -401,6 +401,14 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errAttachmentTooLarge) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errSMTPRateLimited) {
|
||||
respondError(w, http.StatusTooManyRequests, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(err.Error(), "smtp delivery failed:") {
|
||||
respondError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
@@ -413,8 +421,13 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var errNoRecipients = errors.New("at least one recipient is required")
|
||||
var errInvalidMIME = errors.New("invalid mime message")
|
||||
var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit")
|
||||
var errSMTPRateLimited = errors.New("smtp send rate limit exceeded")
|
||||
|
||||
func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
|
||||
func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
|
||||
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.To, req.CC, req.BCC = dedupeEmails(req.To), dedupeEmails(req.CC), dedupeEmails(req.BCC)
|
||||
allRecipients := append(append([]string{}, req.To...), append(req.CC, req.BCC...)...)
|
||||
if len(allRecipients) == 0 {
|
||||
@@ -439,6 +452,9 @@ func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", errInvalidMIME, err)
|
||||
}
|
||||
if err := a.recordSMTPRate(ctx, user, mb); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.cfg.SMTPHost != "" {
|
||||
if err := a.sendSMTP(mb.Address, allRecipients, mimeBytes); err != nil {
|
||||
return nil, fmt.Errorf("smtp delivery failed: %w", err)
|
||||
@@ -505,6 +521,83 @@ func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func userLimits(user *User) PermissionLimits {
|
||||
if user == nil {
|
||||
return defaultPermissionLimits()
|
||||
}
|
||||
return user.Limits
|
||||
}
|
||||
|
||||
func validateAttachmentLimit(attachments []AttachmentInput, limits PermissionLimits) error {
|
||||
limitBytes := attachmentLimitBytes(limits)
|
||||
if limitBytes == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, att := range attachments {
|
||||
decodedLen, err := decodedBase64Len(att.ContentBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errInvalidMIME, err)
|
||||
}
|
||||
if decodedLen > limitBytes {
|
||||
return fmt.Errorf("%w: max %d MB", errAttachmentTooLarge, limits.MaxAttachmentMB)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodedBase64Len(value string) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(len(data)), nil
|
||||
}
|
||||
|
||||
func (a *App) recordSMTPRate(ctx context.Context, user *User, mb *Mailbox) error {
|
||||
if user == nil || mb == nil || user.Role == "admin" {
|
||||
return nil
|
||||
}
|
||||
limits := user.Limits
|
||||
if limits.SMTPDailyLimit == 0 && limits.SMTPMinuteLimit == 0 {
|
||||
return nil
|
||||
}
|
||||
now := a.now().UTC()
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM smtp_send_events WHERE created_at<?`, now.Add(-24*time.Hour).Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
if limits.SMTPDailyLimit > 0 {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?`, user.ID, now.Add(-24*time.Hour).Format(time.RFC3339Nano)).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= limits.SMTPDailyLimit {
|
||||
return fmt.Errorf("%w: daily limit %d", errSMTPRateLimited, limits.SMTPDailyLimit)
|
||||
}
|
||||
}
|
||||
if limits.SMTPMinuteLimit > 0 {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?`, user.ID, now.Add(-time.Minute).Format(time.RFC3339Nano)).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= limits.SMTPMinuteLimit {
|
||||
return fmt.Errorf("%w: per-minute limit %d", errSMTPRateLimited, limits.SMTPMinuteLimit)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO smtp_send_events(id,user_id,mailbox_id,created_at) VALUES(?,?,?,?)`, newID("smtp"), user.ID, mb.ID, now.Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
|
||||
var req mailDraftInput
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
@@ -516,6 +609,16 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
if req.Attachments != nil {
|
||||
if err := validateAttachmentLimit(*req.Attachments, userLimits(currentUser(r))); err != nil {
|
||||
if errors.Is(err, errAttachmentTooLarge) || errors.Is(err, errInvalidMIME) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
compose := mailComposeInput{MailboxID: req.MailboxID, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML}
|
||||
compose.To, compose.CC, compose.BCC = dedupeEmails(compose.To), dedupeEmails(compose.CC), dedupeEmails(compose.BCC)
|
||||
subject := strings.TrimSpace(compose.Subject)
|
||||
@@ -687,6 +790,14 @@ func (a *App) handleScheduleSend(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
compose := mailComposeInput{MailboxID: req.MailboxID, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML, Attachments: req.Attachments}
|
||||
if err := validateAttachmentLimit(compose.Attachments, userLimits(currentUser(r))); err != nil {
|
||||
if errors.Is(err, errAttachmentTooLarge) || errors.Is(err, errInvalidMIME) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
compose.To, compose.CC, compose.BCC = dedupeEmails(compose.To), dedupeEmails(compose.CC), dedupeEmails(compose.BCC)
|
||||
if len(append(append([]string{}, compose.To...), append(compose.CC, compose.BCC...)...)) == 0 {
|
||||
badRequest(w, errNoRecipients)
|
||||
@@ -823,7 +934,7 @@ func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID,
|
||||
return
|
||||
}
|
||||
compose := mailComposeInput{MailboxID: payload.MailboxID, To: payload.To, CC: payload.CC, BCC: payload.BCC, Subject: payload.Subject, Text: payload.Text, HTML: payload.HTML, Attachments: payload.Attachments}
|
||||
if _, err := a.sendMailNow(ctx, mb, compose); err != nil {
|
||||
if _, err := a.sendMailNow(ctx, user, mb, compose); err != nil {
|
||||
a.markScheduledSendFailed(ctx, id, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func (a *App) handlePermissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,system,created_at,updated_at
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,limits_json,system,created_at,updated_at
|
||||
FROM permission_groups
|
||||
ORDER BY created_at ASC,name ASC`)
|
||||
if err != nil {
|
||||
@@ -25,14 +25,15 @@ func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request)
|
||||
items := []PermissionGroup{}
|
||||
for rows.Next() {
|
||||
var item PermissionGroup
|
||||
var raw, created, updated string
|
||||
var rawPermissions, rawLimits, created, updated string
|
||||
var system int
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &raw, &system, &created, &updated); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &rawPermissions, &rawLimits, &system, &created, &updated); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan permission groups")
|
||||
return
|
||||
}
|
||||
item.Permissions = decodeStoredPermissions(raw)
|
||||
item.Permissions = decodeStoredPermissions(rawPermissions)
|
||||
item.Limits = decodeStoredLimits(rawLimits)
|
||||
item.System = intBool(system)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.UpdatedAt = parseTime(updated)
|
||||
@@ -83,9 +84,10 @@ func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits *PermissionLimits `json:"limits"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -105,10 +107,23 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request
|
||||
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
|
||||
return
|
||||
}
|
||||
limits := defaultPermissionLimits()
|
||||
if req.Limits != nil {
|
||||
var err error
|
||||
limits, err = normalizePermissionLimits(*req.Limits)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !actorCanGrantLimits(currentUser(r), limits) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
|
||||
return
|
||||
}
|
||||
id := newID("pg")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), now, now); err != nil {
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), now, now); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
@@ -132,9 +147,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits *PermissionLimits `json:"limits"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -154,8 +170,28 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
|
||||
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,updated_at=? WHERE id=?`,
|
||||
name, strings.TrimSpace(req.Description), encodePermissions(permissions), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
limits := defaultPermissionLimits()
|
||||
if req.Limits != nil {
|
||||
var err error
|
||||
limits, err = normalizePermissionLimits(*req.Limits)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var rawLimits string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT limits_json FROM permission_groups WHERE id=?`, id).Scan(&rawLimits); err != nil {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
limits = decodeStoredLimits(rawLimits)
|
||||
}
|
||||
if !actorCanGrantLimits(currentUser(r), limits) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,limits_json=?,updated_at=? WHERE id=?`,
|
||||
name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -125,14 +125,125 @@ type PermissionGroupSummary struct {
|
||||
}
|
||||
|
||||
type PermissionGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
System bool `json:"system"`
|
||||
UserCount int `json:"userCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits PermissionLimits `json:"limits"`
|
||||
System bool `json:"system"`
|
||||
UserCount int `json:"userCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PermissionLimits struct {
|
||||
MaxAttachmentMB int `json:"maxAttachmentMb"`
|
||||
SMTPDailyLimit int `json:"smtpDailyLimit"`
|
||||
SMTPMinuteLimit int `json:"smtpMinuteLimit"`
|
||||
IMAPMinuteLimit int `json:"imapMinuteLimit"`
|
||||
POP3MinuteLimit int `json:"pop3MinuteLimit"`
|
||||
}
|
||||
|
||||
func defaultPermissionLimits() PermissionLimits {
|
||||
return PermissionLimits{
|
||||
MaxAttachmentMB: 25,
|
||||
SMTPDailyLimit: 200,
|
||||
SMTPMinuteLimit: 20,
|
||||
IMAPMinuteLimit: 200,
|
||||
POP3MinuteLimit: 150,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error) {
|
||||
if limits.MaxAttachmentMB < 0 {
|
||||
return PermissionLimits{}, errors.New("maxAttachmentMb cannot be negative")
|
||||
}
|
||||
if limits.SMTPDailyLimit < 0 {
|
||||
return PermissionLimits{}, errors.New("smtpDailyLimit cannot be negative")
|
||||
}
|
||||
if limits.SMTPMinuteLimit < 0 {
|
||||
return PermissionLimits{}, errors.New("smtpMinuteLimit cannot be negative")
|
||||
}
|
||||
if limits.IMAPMinuteLimit < 0 {
|
||||
return PermissionLimits{}, errors.New("imapMinuteLimit cannot be negative")
|
||||
}
|
||||
if limits.POP3MinuteLimit < 0 {
|
||||
return PermissionLimits{}, errors.New("pop3MinuteLimit cannot be negative")
|
||||
}
|
||||
return limits, nil
|
||||
}
|
||||
|
||||
func decodeStoredLimits(value string) PermissionLimits {
|
||||
limits := defaultPermissionLimits()
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return limits
|
||||
}
|
||||
_ = json.Unmarshal([]byte(value), &limits)
|
||||
normalized, err := normalizePermissionLimits(limits)
|
||||
if err != nil {
|
||||
return defaultPermissionLimits()
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func encodePermissionLimits(limits PermissionLimits) string {
|
||||
normalized, err := normalizePermissionLimits(limits)
|
||||
if err != nil {
|
||||
normalized = defaultPermissionLimits()
|
||||
}
|
||||
data, _ := json.Marshal(normalized)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func mergePermissionLimits(left, right PermissionLimits) PermissionLimits {
|
||||
return PermissionLimits{
|
||||
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
|
||||
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
|
||||
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
|
||||
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
|
||||
POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit),
|
||||
}
|
||||
}
|
||||
|
||||
func mergeLimitValue(left, right int) int {
|
||||
if left == 0 || right == 0 {
|
||||
return 0
|
||||
}
|
||||
if right > left {
|
||||
return right
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func actorCanGrantLimits(actor *User, limits PermissionLimits) bool {
|
||||
if actor == nil {
|
||||
return false
|
||||
}
|
||||
if actor.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
return canGrantLimitValue(actor.Limits.MaxAttachmentMB, limits.MaxAttachmentMB) &&
|
||||
canGrantLimitValue(actor.Limits.SMTPDailyLimit, limits.SMTPDailyLimit) &&
|
||||
canGrantLimitValue(actor.Limits.SMTPMinuteLimit, limits.SMTPMinuteLimit) &&
|
||||
canGrantLimitValue(actor.Limits.IMAPMinuteLimit, limits.IMAPMinuteLimit) &&
|
||||
canGrantLimitValue(actor.Limits.POP3MinuteLimit, limits.POP3MinuteLimit)
|
||||
}
|
||||
|
||||
func canGrantLimitValue(actorLimit, requestedLimit int) bool {
|
||||
if actorLimit == 0 {
|
||||
return true
|
||||
}
|
||||
if requestedLimit == 0 {
|
||||
return false
|
||||
}
|
||||
return requestedLimit <= actorLimit
|
||||
}
|
||||
|
||||
func attachmentLimitBytes(limits PermissionLimits) int64 {
|
||||
if limits.MaxAttachmentMB <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(limits.MaxAttachmentMB) * 1024 * 1024
|
||||
}
|
||||
|
||||
var legacyPermissionExpansions = map[string][]string{
|
||||
@@ -314,6 +425,7 @@ func defaultPermissionGroups() []PermissionGroup {
|
||||
Name: "超级管理员",
|
||||
Description: "拥有全部后台权限,由用户身份决定,不通过权限组分配。",
|
||||
Permissions: allPermissionKeys(),
|
||||
Limits: PermissionLimits{},
|
||||
System: true,
|
||||
},
|
||||
{
|
||||
@@ -321,6 +433,7 @@ func defaultPermissionGroups() []PermissionGroup {
|
||||
Name: "普通用户",
|
||||
Description: "仅可使用自己的邮箱功能,不包含后台权限。",
|
||||
Permissions: regularUserDefaultPermissions(),
|
||||
Limits: defaultPermissionLimits(),
|
||||
System: true,
|
||||
},
|
||||
}
|
||||
@@ -386,15 +499,15 @@ func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE permission_groups SET name=name || ' (' || id || ')' WHERE name=? AND id<>?`, item.Name, item.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
query := `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, permissions_json=excluded.permissions_json, system=excluded.system, updated_at=excluded.updated_at`
|
||||
query := `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, permissions_json=excluded.permissions_json, limits_json=excluded.limits_json, system=excluded.system, updated_at=excluded.updated_at`
|
||||
if item.ID == PermissionGroupRegular {
|
||||
query = `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?)
|
||||
query = `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, system=excluded.system, updated_at=excluded.updated_at`
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, query, item.ID, item.Name, item.Description, encodePermissions(item.Permissions), boolInt(item.System), now, now); err != nil {
|
||||
if _, err := a.db.ExecContext(ctx, query, item.ID, item.Name, item.Description, encodePermissions(item.Permissions), encodePermissionLimits(item.Limits), boolInt(item.System), now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -475,11 +588,16 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, err := a.limitsForUser(ctx, u.ID, u.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupIDs, groups, err := a.permissionGroupsForUser(ctx, u.ID, u.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Permissions = permissions
|
||||
u.Limits = limits
|
||||
u.PermissionGroupIDs = groupIDs
|
||||
u.PermissionGroups = groups
|
||||
u.Protected = a.isDefaultAdminUser(u)
|
||||
@@ -526,6 +644,59 @@ func (a *App) permissionsForUser(ctx context.Context, userID, role string) ([]st
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) limitsForUser(ctx context.Context, userID, role string) (PermissionLimits, error) {
|
||||
if role == "admin" {
|
||||
return PermissionLimits{}, nil
|
||||
}
|
||||
limits, ok, err := a.regularGroupLimits(ctx, nil)
|
||||
if err != nil {
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
if !ok {
|
||||
limits = defaultPermissionLimits()
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT pg.id,pg.limits_json
|
||||
FROM permission_groups pg
|
||||
JOIN user_permission_groups upg ON upg.group_id=pg.id
|
||||
WHERE upg.user_id=?`, userID)
|
||||
if err != nil {
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var groupID, raw string
|
||||
if err := rows.Scan(&groupID, &raw); err != nil {
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
if !isAssignablePermissionGroupID(groupID) {
|
||||
continue
|
||||
}
|
||||
limits = mergePermissionLimits(limits, decodeStoredLimits(raw))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
return limits, nil
|
||||
}
|
||||
|
||||
func (a *App) regularGroupLimits(ctx context.Context, tx *sql.Tx) (PermissionLimits, bool, error) {
|
||||
var raw string
|
||||
query := `SELECT limits_json FROM permission_groups WHERE id=?`
|
||||
var err error
|
||||
if tx != nil {
|
||||
err = tx.QueryRowContext(ctx, query, PermissionGroupRegular).Scan(&raw)
|
||||
} else {
|
||||
err = a.db.QueryRowContext(ctx, query, PermissionGroupRegular).Scan(&raw)
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return PermissionLimits{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return PermissionLimits{}, false, err
|
||||
}
|
||||
return decodeStoredLimits(raw), true, nil
|
||||
}
|
||||
|
||||
func (a *App) addRegularGroupPermissions(ctx context.Context, tx *sql.Tx, seen map[string]bool) error {
|
||||
var raw string
|
||||
query := `SELECT permissions_json FROM permission_groups WHERE id=?`
|
||||
@@ -568,6 +739,21 @@ func (a *App) effectivePermissionsForUserGroups(ctx context.Context, tx *sql.Tx,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) effectiveLimitsForUserGroups(ctx context.Context, tx *sql.Tx, groupIDs []string) (PermissionLimits, error) {
|
||||
limits, ok, err := a.regularGroupLimits(ctx, tx)
|
||||
if err != nil {
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
if !ok {
|
||||
limits = defaultPermissionLimits()
|
||||
}
|
||||
groupLimits, err := a.limitsForGroupIDs(ctx, tx, groupIDs)
|
||||
if err != nil {
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
return mergePermissionLimits(limits, groupLimits), nil
|
||||
}
|
||||
|
||||
func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) {
|
||||
if role == "admin" {
|
||||
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"}
|
||||
@@ -742,6 +928,31 @@ func (a *App) permissionsForGroupIDs(ctx context.Context, tx *sql.Tx, groupIDs [
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) limitsForGroupIDs(ctx context.Context, tx *sql.Tx, groupIDs []string) (PermissionLimits, error) {
|
||||
limits := PermissionLimits{MaxAttachmentMB: 1, SMTPDailyLimit: 1, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1}
|
||||
for _, groupID := range cleanIDList(groupIDs) {
|
||||
if !isAssignablePermissionGroupID(groupID) {
|
||||
return PermissionLimits{}, fmt.Errorf("permission group not assignable: %s", groupID)
|
||||
}
|
||||
var raw string
|
||||
query := `SELECT limits_json FROM permission_groups WHERE id=?`
|
||||
var err error
|
||||
if tx != nil {
|
||||
err = tx.QueryRowContext(ctx, query, groupID).Scan(&raw)
|
||||
} else {
|
||||
err = a.db.QueryRowContext(ctx, query, groupID).Scan(&raw)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return PermissionLimits{}, fmt.Errorf("permission group not found: %s", groupID)
|
||||
}
|
||||
return PermissionLimits{}, err
|
||||
}
|
||||
limits = mergePermissionLimits(limits, decodeStoredLimits(raw))
|
||||
}
|
||||
return limits, nil
|
||||
}
|
||||
|
||||
func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID string, groupIDs []string, actor *User) error {
|
||||
groupIDs = cleanIDList(groupIDs)
|
||||
for _, groupID := range groupIDs {
|
||||
@@ -756,6 +967,13 @@ func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID st
|
||||
if !actorCanGrantPermissions(actor, groupPermissions) {
|
||||
return errors.New("cannot assign permissions you do not hold")
|
||||
}
|
||||
groupLimits, err := a.effectiveLimitsForUserGroups(ctx, tx, groupIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !actorCanGrantLimits(actor, groupLimits) {
|
||||
return errors.New("cannot assign limits above your own")
|
||||
}
|
||||
exec := func(query string, args ...any) error {
|
||||
var err error
|
||||
if tx != nil {
|
||||
@@ -778,18 +996,19 @@ func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID st
|
||||
}
|
||||
|
||||
func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGroup, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT pg.id,pg.name,pg.description,pg.permissions_json,pg.system,pg.created_at,pg.updated_at,COUNT(upg.user_id)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT pg.id,pg.name,pg.description,pg.permissions_json,pg.limits_json,pg.system,pg.created_at,pg.updated_at,COUNT(upg.user_id)
|
||||
FROM permission_groups pg
|
||||
LEFT JOIN user_permission_groups upg ON upg.group_id=pg.id
|
||||
WHERE pg.id=?
|
||||
GROUP BY pg.id,pg.name,pg.description,pg.permissions_json,pg.system,pg.created_at,pg.updated_at`, id)
|
||||
GROUP BY pg.id,pg.name,pg.description,pg.permissions_json,pg.limits_json,pg.system,pg.created_at,pg.updated_at`, id)
|
||||
var group PermissionGroup
|
||||
var raw, created, updated string
|
||||
var rawPermissions, rawLimits, created, updated string
|
||||
var system int
|
||||
if err := row.Scan(&group.ID, &group.Name, &group.Description, &raw, &system, &created, &updated, &group.UserCount); err != nil {
|
||||
if err := row.Scan(&group.ID, &group.Name, &group.Description, &rawPermissions, &rawLimits, &system, &created, &updated, &group.UserCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group.Permissions = decodeStoredPermissions(raw)
|
||||
group.Permissions = decodeStoredPermissions(rawPermissions)
|
||||
group.Limits = decodeStoredLimits(rawLimits)
|
||||
group.System = intBool(system)
|
||||
group.CreatedAt = parseTime(created)
|
||||
group.UpdatedAt = parseTime(updated)
|
||||
|
||||
@@ -11,6 +11,7 @@ type User struct {
|
||||
Protected bool `json:"protected"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits PermissionLimits `json:"limits"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
@@ -47,9 +47,10 @@ export type PermissionKey =
|
||||
| "admin.templates.update"
|
||||
| "admin.templates.reset"
|
||||
export type PermissionInfo = { key: PermissionKey; label: string; description: string; category: string }
|
||||
export type PermissionLimits = { maxAttachmentMb: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
|
||||
export type PermissionGroupSummary = { id: string; name: string }
|
||||
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; system: boolean; userCount: number; createdAt: string; updatedAt: string }
|
||||
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
|
||||
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
|
||||
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
|
||||
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
|
||||
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
|
||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey } from "./api-types"
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
@@ -64,8 +64,8 @@ export const api = {
|
||||
adminOverview: () => request<AdminOverview>("/api/admin/overview"),
|
||||
users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
|
||||
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"),
|
||||
createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[] }) => request<PermissionGroup>("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[] }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
|
||||
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -3,7 +3,7 @@ import DOMPurify from "dompurify"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, Circle, Copy, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, PermissionGroup, PermissionInfo, SystemSettings } from "@/lib/api"
|
||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -52,6 +52,7 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
|
||||
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
|
||||
const projectTag = import.meta.env.VITE_APP_VERSION || ""
|
||||
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
|
||||
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
||||
|
||||
export function AdminPage() {
|
||||
const me = useMe()
|
||||
@@ -349,6 +350,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
||||
</DropdownMenu>}
|
||||
</div>
|
||||
<PermissionBadges permissions={group.permissions} catalog={catalog} />
|
||||
<PermissionLimitBadges limits={group.limits} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -367,8 +369,12 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
|
||||
const dialogOpen = open ?? internalOpen
|
||||
const setDialogOpen = onOpenChange ?? setInternalOpen
|
||||
const [permissions, setPermissions] = React.useState<PermissionKey[]>(group?.permissions || [])
|
||||
const [limits, setLimits] = React.useState<PermissionLimits>(group?.limits || defaultPermissionLimits)
|
||||
React.useEffect(() => {
|
||||
if (dialogOpen) setPermissions(group?.permissions || [])
|
||||
if (dialogOpen) {
|
||||
setPermissions(group?.permissions || [])
|
||||
setLimits(group?.limits || defaultPermissionLimits)
|
||||
}
|
||||
}, [dialogOpen, group])
|
||||
const mutation = useMutation({
|
||||
mutationFn: (form: FormData) => {
|
||||
@@ -376,6 +382,7 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
|
||||
name: String(form.get("name") || ""),
|
||||
description: String(form.get("description") || ""),
|
||||
permissions,
|
||||
limits,
|
||||
}
|
||||
return group ? api.updatePermissionGroup(group.id, payload) : api.createPermissionGroup(payload)
|
||||
},
|
||||
@@ -401,6 +408,7 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
|
||||
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" />
|
||||
<Field name="description" label="说明" defaultValue={group?.description || ""} required={false} />
|
||||
</div>
|
||||
<PermissionLimitEditor value={limits} onChange={setLimits} />
|
||||
<PermissionPicker catalog={catalog} value={permissions} onChange={setPermissions} />
|
||||
<DialogFooter><Button disabled={mutation.isPending}>{mutation.isPending ? "保存中..." : "保存"}</Button></DialogFooter>
|
||||
</form>
|
||||
@@ -455,6 +463,43 @@ function PermissionPicker({ catalog, value, onChange }: { catalog: PermissionInf
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; onChange: (value: PermissionLimits) => void }) {
|
||||
function update(key: keyof PermissionLimits, raw: string) {
|
||||
const next = Number(raw)
|
||||
onChange({ ...value, [key]: Number.isFinite(next) && next > 0 ? Math.floor(next) : 0 })
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Label>账号配额</Label>
|
||||
<span className="text-xs text-muted-foreground">填 0 表示不限制</span>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>附件上限 MB</Label>
|
||||
<Input type="number" min={0} value={value.maxAttachmentMb} onChange={(event) => update("maxAttachmentMb", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>SMTP 每日封数</Label>
|
||||
<Input type="number" min={0} value={value.smtpDailyLimit} onChange={(event) => update("smtpDailyLimit", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>SMTP 每分钟封数</Label>
|
||||
<Input type="number" min={0} value={value.smtpMinuteLimit} onChange={(event) => update("smtpMinuteLimit", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>IMAP 每分钟命令数</Label>
|
||||
<Input type="number" min={0} value={value.imapMinuteLimit} onChange={(event) => update("imapMinuteLimit", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>POP3 每分钟命令数</Label>
|
||||
<Input type="number" min={0} value={value.pop3MinuteLimit} onChange={(event) => update("pop3MinuteLimit", event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey[]; catalog: PermissionInfo[] }) {
|
||||
const labelByKey = new Map(catalog.map((item) => [item.key, item.label]))
|
||||
if (permissions.length === 0) return <div className="mt-3 text-sm text-muted-foreground">无后台权限</div>
|
||||
@@ -468,6 +513,23 @@ function PermissionBadges({ permissions, catalog }: { permissions: PermissionKey
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) {
|
||||
const value = limits || defaultPermissionLimits
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary" className="font-normal">附件 {limitText(value.maxAttachmentMb, "MB")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">SMTP 每日 {limitText(value.smtpDailyLimit, "封")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">SMTP 每分钟 {limitText(value.smtpMinuteLimit, "封")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">IMAP 每分钟 {limitText(value.imapMinuteLimit, "次")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">POP3 每分钟 {limitText(value.pop3MinuteLimit, "次")}</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function limitText(value: number, unit: string) {
|
||||
return value > 0 ? `${value} ${unit}` : "不限"
|
||||
}
|
||||
|
||||
function groupPermissionCatalog(catalog: PermissionInfo[]) {
|
||||
const order: string[] = []
|
||||
const grouped = new Map<string, PermissionInfo[]>()
|
||||
|
||||
@@ -13,7 +13,7 @@ import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, Pencil, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, ShieldCheck, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
|
||||
import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend } from "@/lib/api"
|
||||
import { api, ListResponse, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend, PermissionLimits } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { useDisplayMode } from "@/lib/display-mode"
|
||||
@@ -954,7 +954,7 @@ export function MailPage() {
|
||||
)}
|
||||
</SidebarProvider>
|
||||
|
||||
<ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) }} />
|
||||
<ComposeDialog mailbox={selectedMailbox} open={composeOpen} draft={composeDraft} limits={user?.limits} canSend={canSendMail} canManageDrafts={canManageDrafts} canSchedule={canScheduleMail} canManageSignatures={canManageSignatures} onOpenChange={(open) => { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) }} />
|
||||
<ConfirmDialog
|
||||
open={!!pendingConfirm}
|
||||
title={pendingConfirm?.title || ""}
|
||||
@@ -1816,7 +1816,7 @@ function MailLabelBadge({ label }: { label: MailLabel }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; canSend: boolean; canManageDrafts: boolean; canSchedule: boolean; canManageSignatures: boolean; onOpenChange: (v: boolean) => void; onSent: () => void }) {
|
||||
function ComposeDialog({ mailbox, open, draft, limits, canSend, canManageDrafts, canSchedule, canManageSignatures, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; limits?: PermissionLimits; canSend: boolean; canManageDrafts: boolean; canSchedule: boolean; canManageSignatures: boolean; onOpenChange: (v: boolean) => void; onSent: () => void }) {
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const [files, setFiles] = React.useState<File[]>([])
|
||||
@@ -1841,6 +1841,8 @@ function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSche
|
||||
const composerText = draft?.html || (draft?.text !== undefined ? draft.text : signatureText ? `\n\n-- \n${signatureText}` : "")
|
||||
const [body, setBody] = React.useState<ComposerValue>(() => draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText))
|
||||
const activeMailboxId = draft?.mailboxId || mailbox?.id || ""
|
||||
const maxAttachmentBytes = attachmentLimitBytes(limits)
|
||||
const maxAttachmentText = maxAttachmentBytes > 0 ? formatBytes(maxAttachmentBytes) : "不限"
|
||||
const composePayload = React.useMemo<DraftPayload>(() => ({
|
||||
mailboxId: activeMailboxId,
|
||||
to: splitEmails(toValue),
|
||||
@@ -1975,9 +1977,30 @@ function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSche
|
||||
})
|
||||
}
|
||||
|
||||
function addFiles(nextFiles: File[]) {
|
||||
if (nextFiles.length === 0) return
|
||||
const allowed = maxAttachmentBytes > 0 ? nextFiles.filter((file) => file.size <= maxAttachmentBytes) : nextFiles
|
||||
const blockedCount = nextFiles.length - allowed.length
|
||||
if (blockedCount > 0) {
|
||||
toast({ title: "附件超过权限组上限", description: `当前单个附件上限 ${maxAttachmentText}` })
|
||||
}
|
||||
if (allowed.length > 0) {
|
||||
setAttachmentsTouched(true)
|
||||
setFiles((current) => [...current, ...allowed])
|
||||
}
|
||||
}
|
||||
|
||||
function attachmentsWithinLimit() {
|
||||
if (maxAttachmentBytes <= 0) return true
|
||||
if (files.every((file) => file.size <= maxAttachmentBytes)) return true
|
||||
toast({ title: "附件超过权限组上限", description: `当前单个附件上限 ${maxAttachmentText}` })
|
||||
return false
|
||||
}
|
||||
|
||||
async function prepareSend() {
|
||||
if (!canSend) return
|
||||
if (!mailbox) return
|
||||
if (!attachmentsWithinLimit()) return
|
||||
const attachments = await Promise.all(files.map(fileToAttachment))
|
||||
const to = splitEmails(toValue)
|
||||
const cc = showCc ? splitEmails(ccValue) : []
|
||||
@@ -2015,6 +2038,7 @@ function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSche
|
||||
toast({ title: "请选择发件邮箱" })
|
||||
return
|
||||
}
|
||||
if (!attachmentsWithinLimit()) return
|
||||
const attachments = await Promise.all(files.map(fileToAttachment))
|
||||
const payload: SendPayload & { draftId?: string; sendAt: string } = {
|
||||
mailboxId: mailbox.id,
|
||||
@@ -2093,8 +2117,9 @@ function ComposeDialog({ mailbox, open, draft, canSend, canManageDrafts, canSche
|
||||
defaultHtml={draft?.html}
|
||||
files={files}
|
||||
signatureText={signatureText}
|
||||
maxAttachmentText={maxAttachmentText}
|
||||
onChange={setBody}
|
||||
onPickFiles={(nextFiles) => { setAttachmentsTouched(true); setFiles((current) => [...current, ...nextFiles]) }}
|
||||
onPickFiles={addFiles}
|
||||
onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }}
|
||||
/>
|
||||
</div>
|
||||
@@ -2328,7 +2353,7 @@ function scheduleToNodeAttributes(schedule: ScheduleDraft) {
|
||||
}
|
||||
}
|
||||
|
||||
function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onChange, onPickFiles, onRemoveFile }: { defaultValue: string; defaultHtml?: string; files: File[]; signatureText: string; onChange: (value: ComposerValue) => void; onPickFiles: (files: File[]) => void; onRemoveFile: (index: number) => void }) {
|
||||
function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, maxAttachmentText, onChange, onPickFiles, onRemoveFile }: { defaultValue: string; defaultHtml?: string; files: File[]; signatureText: string; maxAttachmentText: string; onChange: (value: ComposerValue) => void; onPickFiles: (files: File[]) => void; onRemoveFile: (index: number) => void }) {
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const dirtyRef = React.useRef(false)
|
||||
const lastDefaultRef = React.useRef(`${defaultValue}\n${defaultHtml || ""}`)
|
||||
@@ -2507,6 +2532,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
|
||||
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setHorizontalRule().run()}><span className="h-4 w-4 border-t border-current" aria-hidden />分隔线</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">附件 {maxAttachmentText}</span>
|
||||
<ToolbarTextButton label="日程" icon={<Calendar className="h-4 w-4" />} onClick={() => setScheduleOpen(true)} />
|
||||
<DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -3065,6 +3091,10 @@ function quoteMessage(message: MailMessage) {
|
||||
return `\n\n----- 原始邮件 -----\nFrom: ${senderTitle(message)}\nTo: ${message.to.join(", ")}\nDate: ${formatDateTime(message.receivedAt)}\nSubject: ${message.subject}\n\n${quote}`
|
||||
}
|
||||
function stripHtml(html: string) { const div = document.createElement("div"); div.innerHTML = DOMPurify.sanitize(html); return div.textContent || div.innerText || "" }
|
||||
function attachmentLimitBytes(limits?: PermissionLimits) {
|
||||
const mb = limits?.maxAttachmentMb || 0
|
||||
return mb > 0 ? mb * 1024 * 1024 : 0
|
||||
}
|
||||
async function fileToAttachment(file: File) {
|
||||
const buffer = await file.arrayBuffer()
|
||||
let binary = ""
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats } from "@/lib/api"
|
||||
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
||||
@@ -303,9 +303,27 @@ export function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
|
||||
function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>账号配额</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<LimitBadge label="附件上限" value={user.limits?.maxAttachmentMb} unit="MB" />
|
||||
<LimitBadge label="SMTP 每日" value={user.limits?.smtpDailyLimit} unit="封" />
|
||||
<LimitBadge label="SMTP 每分钟" value={user.limits?.smtpMinuteLimit} unit="封" />
|
||||
<LimitBadge label="IMAP 每分钟" value={user.limits?.imapMinuteLimit} unit="次" />
|
||||
<LimitBadge label="POP3 每分钟" value={user.limits?.pop3MinuteLimit} unit="次" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showStats && <StatsSummary stats={stats} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>账户信息</CardTitle>
|
||||
@@ -451,7 +469,18 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, show
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showStats && <StatsSummary stats={stats} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LimitBadge({ label, value, unit }: { label: string; value?: number; unit: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold tabular-nums tracking-tight">
|
||||
{value !== undefined && value > 0 ? value : "不限"}
|
||||
</div>
|
||||
{value !== undefined && value > 0 && <div className="text-xs text-muted-foreground">{unit}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user