Ver código fonte

feat: add optional registration approval

AI-Co-Authored-By: Codex
chendeben 1 mês atrás
pai
commit
b61a33a24d
60 arquivos alterados com 310 adições e 46 exclusões
  1. 2 0
      .env.example
  2. 0 0
      public/assets/Admin-DUVnon0q.js
  3. 0 0
      public/assets/ApiTokens-CsrrFg6_.js
  4. 1 1
      public/assets/CodeBlock-B8oVHT9c.js
  5. 1 1
      public/assets/CopyOutlined-DIsSRLKB.js
  6. 0 1
      public/assets/Dashboard-Byy8J9_C.js
  7. 2 2
      public/assets/DashboardCharts-Bx_Z_RN8.js
  8. 0 0
      public/assets/DnsApi-BQ3vGiSA.js
  9. 0 0
      public/assets/DomainDetail-XYsUb3nP.js
  10. 0 0
      public/assets/Domains-CRnd-K9E.js
  11. 1 1
      public/assets/EditOutlined-D5870S0F.js
  12. 1 1
      public/assets/EllipsisOutlined-B_1Q6yFN.js
  13. 0 0
      public/assets/Inbox-CTE3UnSi.js
  14. 1 1
      public/assets/PlusOutlined-BsubesQg.js
  15. 1 1
      public/assets/ReloadOutlined-CymPmaTf.js
  16. 1 1
      public/assets/SearchOutlined-D3DEMDXm.js
  17. 0 0
      public/assets/SendingLogs-CLQnGbZh.js
  18. 0 0
      public/assets/Settings-CEV9En54.js
  19. 0 0
      public/assets/Settings-Dw_F8H8a.js
  20. 0 0
      public/assets/SmtpCredentials-COhLie7_.js
  21. 0 0
      public/assets/StatusPill-DYejnVUU.js
  22. 1 1
      public/assets/StatusTag-5kTuQ3q1.js
  23. 1 1
      public/assets/ThunderboltOutlined-Bnk5EjOs.js
  24. 0 0
      public/assets/Webhooks-BfQuuVxm.js
  25. 1 1
      public/assets/form-DcO_GhUr.js
  26. 1 1
      public/assets/grid-CbrYQCAq.js
  27. 0 1
      public/assets/index-8y_yP12y.js
  28. 0 0
      public/assets/list-BBhECcFB.js
  29. 0 0
      public/assets/login-BiJl_nHK.js
  30. 0 0
      public/assets/login-Cu9iKgzv.js
  31. 1 1
      public/assets/modal-tQWt4gNJ.js
  32. 0 0
      public/assets/popconfirm-C40LmrYU.js
  33. 1 0
      public/assets/row-CYL70Wz3.js
  34. 0 1
      public/assets/row-IaWgwDT6.js
  35. 0 0
      public/assets/table-Cy9MyhM5.js
  36. 0 0
      public/assets/theme-DI8XQWlV.js
  37. 2 2
      public/index.html
  38. 4 4
      public/login.html
  39. 17 4
      src/db.js
  40. 1 1
      src/frontend/auth/auth-model.js
  41. 10 0
      src/frontend/i18n/index.js
  42. 1 0
      src/frontend/types.ts
  43. 16 0
      src/pages/Settings.tsx
  44. 17 4
      src/server.js
  45. 1 1
      src/system-mail.js
  46. 23 3
      test/db.test.js
  47. 9 2
      test/frontend-auth-model.test.js
  48. 5 0
      test/frontend-i18n.test.js
  49. 154 8
      test/server-admin-api.test.js
  50. 2 0
      test/system-mail.test.js
  51. 1 0
      test/ui/add-domain-drawer.test.tsx
  52. 1 0
      test/ui/admin-layout.test.tsx
  53. 1 0
      test/ui/api-token-secret.test.tsx
  54. 1 0
      test/ui/dashboard.test.tsx
  55. 1 0
      test/ui/domains-delete.test.tsx
  56. 1 0
      test/ui/domains-workflow.test.tsx
  57. 1 0
      test/ui/inbox-navigation.test.tsx
  58. 1 0
      test/ui/integration-resource-resilience.test.tsx
  59. 1 0
      test/ui/operations-navigation.test.tsx
  60. 22 0
      test/ui/settings-navigation-guard.test.tsx

+ 2 - 0
.env.example

@@ -4,6 +4,8 @@ ADMIN_USER=admin
 ADMIN_EMAIL=admin@example.com
 ADMIN_PASSWORD=change-this-admin-password
 SESSION_SECRET=change-this-long-random-session-secret
+# Require administrator approval after a newly registered user verifies their email.
+REGISTRATION_REQUIRES_APPROVAL=false
 
 # Legacy admin send token. New users should generate their own token in the web UI.
 API_TOKEN=change-this-legacy-api-token

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/Admin-DUVnon0q.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/ApiTokens-CsrrFg6_.js


+ 1 - 1
public/assets/CodeBlock-CkHhWvU6.js → public/assets/CodeBlock-B8oVHT9c.js

@@ -1 +1 @@
-import{kt as e,n as t,t as n}from"./jsx-runtime-dm82oUI8.js";import{t as r}from"./CopyOutlined-CHs5xB1H.js";var i=n();function a({value:n,onCopy:a}){return(0,i.jsxs)(`div`,{className:`code-block`,children:[(0,i.jsx)(t.Paragraph,{code:!0,className:`code-block__value`,children:n}),a?(0,i.jsx)(e,{type:`text`,size:`small`,icon:(0,i.jsx)(r,{}),className:`code-block__copy`,onClick:()=>a(n),"aria-label":`Copy`}):null]})}export{a as t};
+import{kt as e,n as t,t as n}from"./jsx-runtime-dm82oUI8.js";import{t as r}from"./CopyOutlined-DIsSRLKB.js";var i=n();function a({value:n,onCopy:a}){return(0,i.jsxs)(`div`,{className:`code-block`,children:[(0,i.jsx)(t.Paragraph,{code:!0,className:`code-block__value`,children:n}),a?(0,i.jsx)(e,{type:`text`,size:`small`,icon:(0,i.jsx)(r,{}),className:`code-block__copy`,onClick:()=>a(n),"aria-label":`Copy`}):null]})}export{a as t};

+ 1 - 1
public/assets/CopyOutlined-CHs5xB1H.js → public/assets/CopyOutlined-DIsSRLKB.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-Bw5pObCZ.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v688c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V96c0-17.7-14.3-32-32-32zM704 192H192c-17.7 0-32 14.3-32 32v530.7c0 8.5 3.4 16.6 9.4 22.6l173.3 173.3c2.2 2.2 4.7 4 7.4 5.5v1.9h4.2c3.5 1.3 7.2 2 11 2H704c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32zM350 856.2L263.9 770H350v86.2zM664 888H414V746c0-22.1-17.9-40-40-40H232V264h432v624z`}}]},name:`copy`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v688c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V96c0-17.7-14.3-32-32-32zM704 192H192c-17.7 0-32 14.3-32 32v530.7c0 8.5 3.4 16.6 9.4 22.6l173.3 173.3c2.2 2.2 4.7 4 7.4 5.5v1.9h4.2c3.5 1.3 7.2 2 11 2H704c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32zM350 856.2L263.9 770H350v86.2zM664 888H414V746c0-22.1-17.9-40-40-40H232V264h432v624z`}}]},name:`copy`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 1
public/assets/Dashboard-Byy8J9_C.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 2 - 2
public/assets/DashboardCharts-Bx_Z_RN8.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/DnsApi-BQ3vGiSA.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/DomainDetail-XYsUb3nP.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/Domains-CRnd-K9E.js


+ 1 - 1
public/assets/EditOutlined-DcT9QuKO.js → public/assets/EditOutlined-D5870S0F.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-Bw5pObCZ.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default}))),l=e(n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`}}))());function u(){return u=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},u.apply(this,arguments)}var d=a.forwardRef((e,t)=>a.createElement(r,u({},e,{ref:t,icon:l.default})));export{c as n,d as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default}))),l=e(n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`}}))());function u(){return u=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},u.apply(this,arguments)}var d=a.forwardRef((e,t)=>a.createElement(r,u({},e,{ref:t,icon:l.default})));export{c as n,d as t};

+ 1 - 1
public/assets/EllipsisOutlined-1lLPAYiv.js → public/assets/EllipsisOutlined-B_1Q6yFN.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-Bw5pObCZ.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M176 511a56 56 0 10112 0 56 56 0 10-112 0zm280 0a56 56 0 10112 0 56 56 0 10-112 0zm280 0a56 56 0 10112 0 56 56 0 10-112 0z`}}]},name:`ellipsis`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M176 511a56 56 0 10112 0 56 56 0 10-112 0zm280 0a56 56 0 10112 0 56 56 0 10-112 0zm280 0a56 56 0 10112 0 56 56 0 10-112 0z`}}]},name:`ellipsis`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/Inbox-CTE3UnSi.js


+ 1 - 1
public/assets/PlusOutlined-C4jbNaBZ.js → public/assets/PlusOutlined-BsubesQg.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-Bw5pObCZ.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};

+ 1 - 1
public/assets/ReloadOutlined-5WE6_PnE.js → public/assets/ReloadOutlined-CymPmaTf.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-Bw5pObCZ.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.1 209.3l-56.4 44.1C775.8 155.1 656.2 92 521.9 92 290 92 102.3 279.5 102 511.5 101.7 743.7 289.8 932 521.9 932c181.3 0 335.8-115 394.6-276.1 1.5-4.2-.7-8.9-4.9-10.3l-56.7-19.5a8 8 0 00-10.1 4.8c-1.8 5-3.8 10-5.9 14.9-17.3 41-42.1 77.8-73.7 109.4A344.77 344.77 0 01655.9 829c-42.3 17.9-87.4 27-133.8 27-46.5 0-91.5-9.1-133.8-27A341.5 341.5 0 01279 755.2a342.16 342.16 0 01-73.7-109.4c-17.9-42.4-27-87.4-27-133.9s9.1-91.5 27-133.9c17.3-41 42.1-77.8 73.7-109.4 31.6-31.6 68.4-56.4 109.3-73.8 42.3-17.9 87.4-27 133.8-27 46.5 0 91.5 9.1 133.8 27a341.5 341.5 0 01109.3 73.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.6 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c-.1-6.6-7.8-10.3-13-6.2z`}}]},name:`reload`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.1 209.3l-56.4 44.1C775.8 155.1 656.2 92 521.9 92 290 92 102.3 279.5 102 511.5 101.7 743.7 289.8 932 521.9 932c181.3 0 335.8-115 394.6-276.1 1.5-4.2-.7-8.9-4.9-10.3l-56.7-19.5a8 8 0 00-10.1 4.8c-1.8 5-3.8 10-5.9 14.9-17.3 41-42.1 77.8-73.7 109.4A344.77 344.77 0 01655.9 829c-42.3 17.9-87.4 27-133.8 27-46.5 0-91.5-9.1-133.8-27A341.5 341.5 0 01279 755.2a342.16 342.16 0 01-73.7-109.4c-17.9-42.4-27-87.4-27-133.9s9.1-91.5 27-133.9c17.3-41 42.1-77.8 73.7-109.4 31.6-31.6 68.4-56.4 109.3-73.8 42.3-17.9 87.4-27 133.8-27 46.5 0 91.5 9.1 133.8 27a341.5 341.5 0 01109.3 73.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.6 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c-.1-6.6-7.8-10.3-13-6.2z`}}]},name:`reload`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};

+ 1 - 1
public/assets/SearchOutlined-Bq_eGz3G.js → public/assets/SearchOutlined-D3DEMDXm.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-Bw5pObCZ.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z`}}]},name:`search`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z`}}]},name:`search`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/SendingLogs-CLQnGbZh.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/Settings-CEV9En54.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/Settings-Dw_F8H8a.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/SmtpCredentials-COhLie7_.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/StatusPill-DYejnVUU.js


+ 1 - 1
public/assets/StatusTag-vIbkqN7p.js → public/assets/StatusTag-5kTuQ3q1.js

@@ -1 +1 @@
-import{a as e}from"./theme-Bw5pObCZ.js";import{t}from"./jsx-runtime-dm82oUI8.js";import{t as n}from"./badge-CT2dDAGA.js";import{t as r}from"./StatusPill-DNZtlEeS.js";import{r as i}from"./domain-model-BLOrIAIx.js";var a=t();function o(e){switch(e){case`success`:return`success`;case`warning`:case`processing`:return`warning`;case`error`:return`error`;default:return`neutral`}}function s({status:t,record:s,label:c,mode:l=`tag`}){let{t:u}=e(),d=i(s||{status:t}),f=c||u(`status.${d.key}`);return l===`badge`?(0,a.jsx)(n,{status:d.color==="default"?`default`:d.color,text:f}):(0,a.jsx)(r,{tone:o(d.color),children:f})}export{s as t};
+import{a as e}from"./theme-DI8XQWlV.js";import{t}from"./jsx-runtime-dm82oUI8.js";import{t as n}from"./badge-CT2dDAGA.js";import{t as r}from"./StatusPill-DYejnVUU.js";import{r as i}from"./domain-model-BLOrIAIx.js";var a=t();function o(e){switch(e){case`success`:return`success`;case`warning`:case`processing`:return`warning`;case`error`:return`error`;default:return`neutral`}}function s({status:t,record:s,label:c,mode:l=`tag`}){let{t:u}=e(),d=i(s||{status:t}),f=c||u(`status.${d.key}`);return l===`badge`?(0,a.jsx)(n,{status:d.color==="default"?`default`:d.color,text:f}):(0,a.jsx)(r,{tone:o(d.color),children:f})}export{s as t};

+ 1 - 1
public/assets/ThunderboltOutlined-CB3gq9nZ.js → public/assets/ThunderboltOutlined-Bnk5EjOs.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-Bw5pObCZ.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M848 359.3H627.7L825.8 109c4.1-5.3.4-13-6.3-13H436c-2.8 0-5.5 1.5-6.9 4L170 547.5c-3.1 5.3.7 12 6.9 12h174.4l-89.4 357.6c-1.9 7.8 7.5 13.3 13.3 7.7L853.5 373c5.2-4.9 1.7-13.7-5.5-13.7zM378.2 732.5l60.3-241H281.1l189.6-327.4h224.6L487 427.4h211L378.2 732.5z`}}]},name:`thunderbolt`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M848 359.3H627.7L825.8 109c4.1-5.3.4-13-6.3-13H436c-2.8 0-5.5 1.5-6.9 4L170 547.5c-3.1 5.3.7 12 6.9 12h174.4l-89.4 357.6c-1.9 7.8 7.5 13.3 13.3 7.7L853.5 373c5.2-4.9 1.7-13.7-5.5-13.7zM378.2 732.5l60.3-241H281.1l189.6-327.4h224.6L487 427.4h211L378.2 732.5z`}}]},name:`thunderbolt`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/Webhooks-BfQuuVxm.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 1
public/assets/form-DcO_GhUr.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 1
public/assets/grid-CbrYQCAq.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 1
public/assets/index-8y_yP12y.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/list-BBhECcFB.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/login-BiJl_nHK.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/login-Cu9iKgzv.js


+ 1 - 1
public/assets/modal-wyG3ngKb.js → public/assets/modal-tQWt4gNJ.js

@@ -1 +1 @@
-import{a as e,n as t}from"./react-dom-stZKkA6x.js";import{C as n,D as r,S as i,T as a,_ as o,b as s,f as c,g as l,h as u,m as d,p as f,v as p,w as m,x as h,y as g}from"./theme-Bw5pObCZ.js";import{Mr as _,Rn as v,cn as y,rt as b}from"./jsx-runtime-dm82oUI8.js";var x=e(t()),S=e(_()),C=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(e!=null&&typeof Object.getOwnPropertySymbols==`function`)for(var i=0,r=Object.getOwnPropertySymbols(e);i<r.length;i++)t.indexOf(r[i])<0&&Object.prototype.propertyIsEnumerable.call(e,r[i])&&(n[r[i]]=e[r[i]]);return n},w=b(e=>{let{prefixCls:t,className:i,closeIcon:o,closable:s,type:c,title:l,children:u,footer:d}=e,f=C(e,[`prefixCls`,`className`,`closeIcon`,`closable`,`type`,`title`,`children`,`footer`]),{getPrefixCls:p}=x.useContext(v),g=p(),_=t||p(`modal`),b=y(g),[w,T,E]=n(_,b),D=`${_}-confirm`,O={};return O=c?{closable:s??!1,title:``,footer:``,children:x.createElement(h,Object.assign({},e,{prefixCls:_,confirmPrefixCls:D,rootPrefixCls:g,content:u}))}:{closable:s??!0,title:l,footer:d!==null&&x.createElement(m,Object.assign({},e)),children:u},w(x.createElement(r,Object.assign({prefixCls:_,className:(0,S.default)(T,`${_}-pure-panel`,c&&D,c&&`${D}-${c}`,i,E,b)},f,{closeIcon:a(_,o),closable:s},O)))});function T(e){return f(g(e))}var E=i;E.useModal=c,E.info=function(e){return f(o(e))},E.success=function(e){return f(p(e))},E.error=function(e){return f(l(e))},E.warning=T,E.warn=T,E.confirm=function(e){return f(u(e))},E.destroyAll=function(){for(;s.length;){let e=s.pop();e&&e()}},E.config=d,E._InternalPanelDoNotUseOrYouWillBeFired=w;export{E as t};
+import{a as e,n as t}from"./react-dom-stZKkA6x.js";import{C as n,D as r,S as i,T as a,_ as o,b as s,f as c,g as l,h as u,m as d,p as f,v as p,w as m,x as h,y as g}from"./theme-DI8XQWlV.js";import{Mr as _,Rn as v,cn as y,rt as b}from"./jsx-runtime-dm82oUI8.js";var x=e(t()),S=e(_()),C=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(e!=null&&typeof Object.getOwnPropertySymbols==`function`)for(var i=0,r=Object.getOwnPropertySymbols(e);i<r.length;i++)t.indexOf(r[i])<0&&Object.prototype.propertyIsEnumerable.call(e,r[i])&&(n[r[i]]=e[r[i]]);return n},w=b(e=>{let{prefixCls:t,className:i,closeIcon:o,closable:s,type:c,title:l,children:u,footer:d}=e,f=C(e,[`prefixCls`,`className`,`closeIcon`,`closable`,`type`,`title`,`children`,`footer`]),{getPrefixCls:p}=x.useContext(v),g=p(),_=t||p(`modal`),b=y(g),[w,T,E]=n(_,b),D=`${_}-confirm`,O={};return O=c?{closable:s??!1,title:``,footer:``,children:x.createElement(h,Object.assign({},e,{prefixCls:_,confirmPrefixCls:D,rootPrefixCls:g,content:u}))}:{closable:s??!0,title:l,footer:d!==null&&x.createElement(m,Object.assign({},e)),children:u},w(x.createElement(r,Object.assign({prefixCls:_,className:(0,S.default)(T,`${_}-pure-panel`,c&&D,c&&`${D}-${c}`,i,E,b)},f,{closeIcon:a(_,o),closable:s},O)))});function T(e){return f(g(e))}var E=i;E.useModal=c,E.info=function(e){return f(o(e))},E.success=function(e){return f(p(e))},E.error=function(e){return f(l(e))},E.warning=T,E.warn=T,E.confirm=function(e){return f(u(e))},E.destroyAll=function(){for(;s.length;){let e=s.pop();e&&e()}},E.config=d,E._InternalPanelDoNotUseOrYouWillBeFired=w;export{E as t};

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/popconfirm-C40LmrYU.js


+ 1 - 0
public/assets/row-CYL70Wz3.js

@@ -0,0 +1 @@
+import{n as e,r as t}from"./grid-CbrYQCAq.js";var n=t,r=e;export{n,r as t};

+ 0 - 1
public/assets/row-IaWgwDT6.js

@@ -1 +0,0 @@
-import{n as e,r as t}from"./grid-CgtYChfA.js";var n=t,r=e;export{n,r as t};

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/table-Cy9MyhM5.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
public/assets/theme-DI8XQWlV.js


+ 2 - 2
public/index.html

@@ -4,11 +4,11 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-Yrdiy7jh.js"></script>
+    <script type="module" crossorigin src="/assets/index-8y_yP12y.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/react-dom-stZKkA6x.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-dm82oUI8.js">
-    <link rel="modulepreload" crossorigin href="/assets/theme-Bw5pObCZ.js">
+    <link rel="modulepreload" crossorigin href="/assets/theme-DI8XQWlV.js">
     <link rel="stylesheet" crossorigin href="/assets/theme-e1juCvr1.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>

+ 4 - 4
public/login.html

@@ -4,15 +4,15 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub Auth</title>
-    <script type="module" crossorigin src="/assets/login-BiJl_nHK.js"></script>
+    <script type="module" crossorigin src="/assets/login-Cu9iKgzv.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/react-dom-stZKkA6x.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-dm82oUI8.js">
-    <link rel="modulepreload" crossorigin href="/assets/theme-Bw5pObCZ.js">
-    <link rel="modulepreload" crossorigin href="/assets/grid-CgtYChfA.js">
+    <link rel="modulepreload" crossorigin href="/assets/theme-DI8XQWlV.js">
+    <link rel="modulepreload" crossorigin href="/assets/grid-CbrYQCAq.js">
     <link rel="modulepreload" crossorigin href="/assets/segmented-DUpU-ING.js">
     <link rel="modulepreload" crossorigin href="/assets/useForm-sdDvuRHy.js">
-    <link rel="modulepreload" crossorigin href="/assets/form-D2UigUli.js">
+    <link rel="modulepreload" crossorigin href="/assets/form-DcO_GhUr.js">
     <link rel="modulepreload" crossorigin href="/assets/input-0_Xo22Fl.js">
     <link rel="stylesheet" crossorigin href="/assets/theme-e1juCvr1.css">
   </head>

+ 17 - 4
src/db.js

@@ -895,11 +895,11 @@ export function approveUser(id) {
   return updateUserStatus(id, 'active');
 }
 
-export function markUserEmailVerified(id) {
+export function markUserEmailVerified(id, { requiresApproval = false } = {}) {
   const user = getUser(id);
   if (!user) return null;
   if (user.status !== 'pending_email') return user;
-  return updateUserStatus(id, 'pending_review');
+  return updateUserStatus(id, requiresApproval ? 'pending_review' : 'active');
 }
 
 export function getAdminUser() {
@@ -4173,19 +4173,31 @@ export function createAccountToken(userId, purpose, { ttlMinutes } = {}) {
   };
 }
 
-export function consumeAccountToken(token, purpose) {
+export function consumeAccountToken(token, purpose, { allowedUserStatuses } = {}) {
   const rawToken = String(token || '');
   const cleanPurpose = String(purpose || '').trim();
   if (!rawToken || !cleanPurpose) return null;
+  const statuses = Array.isArray(allowedUserStatuses)
+    ? [...new Set(allowedUserStatuses.map(String).filter((status) => USER_STATUSES.has(status)))]
+    : null;
+  if (Array.isArray(allowedUserStatuses) && !statuses.length) return null;
   const tokenDigest = tokenHash(rawToken);
   const usedAt = now();
+  const statusFilter = statuses
+    ? `AND EXISTS (
+        SELECT 1 FROM users
+        WHERE users.id = account_tokens.user_id
+          AND users.status IN (${statuses.map(() => '?').join(', ')})
+      )`
+    : '';
   const result = requireDb()
     .prepare(`
       UPDATE account_tokens
       SET used_at = ?
       WHERE token_hash = ? AND purpose = ? AND used_at IS NULL AND expires_at > ?
+      ${statusFilter}
     `)
-    .run(usedAt, tokenDigest, cleanPurpose, usedAt);
+    .run(usedAt, tokenDigest, cleanPurpose, usedAt, ...(statuses || []));
   if (result.changes === 0) return null;
   const row = requireDb()
     .prepare('SELECT * FROM account_tokens WHERE token_hash = ? AND purpose = ?')
@@ -4274,6 +4286,7 @@ export function saveSettings(patch) {
     'dmarcPolicy',
     'dmarcRua',
     'sendRequiresVerified',
+    'registrationRequiresApproval',
     'engagementTrackingEnabled',
     'listUnsubscribeMailto',
     'listUnsubscribeUrl',

+ 1 - 1
src/frontend/auth/auth-model.js

@@ -3,7 +3,7 @@ export function nextAuthSuccessState(path, data = {}, requestedNext = '') {
     return {
       mode: 'login',
       path: '/login',
-      message: String(data.message || '注册成功,请先验证邮箱,验证后等待管理员审核。'),
+      message: String(data.message || '注册成功,请先验证邮箱。'),
       redirectTo: ''
     };
   }

+ 10 - 0
src/frontend/i18n/index.js

@@ -400,6 +400,11 @@ const messages = {
     'settings.noPermission': '当前账号没有系统设置权限。',
     'settings.save': '保存设置',
     'settings.engagementTracking': '默认启用打开与点击追踪',
+    'settings.registrationPolicy': '注册与账号',
+    'settings.registrationRequiresApproval': '邮箱验证后需要管理员审核',
+    'settings.registrationRequiresApprovalHint': '关闭时,用户验证邮箱后立即启用;开启时,账号进入待审核状态,需管理员批准后才能登录。',
+    'settings.approvalRequired': '需要审核',
+    'settings.approvalNotRequired': '自动启用',
     'admin.title': '管理中心',
     'admin.users': '用户',
     'admin.resources': '资源',
@@ -1024,6 +1029,11 @@ const messages = {
     'settings.noPermission': 'This account cannot access system settings.',
     'settings.save': 'Save settings',
     'settings.engagementTracking': 'Enable open and click tracking by default',
+    'settings.registrationPolicy': 'Registration and accounts',
+    'settings.registrationRequiresApproval': 'Require administrator approval after email verification',
+    'settings.registrationRequiresApprovalHint': 'When off, verifying an email activates the account immediately. When on, the account remains pending until an administrator approves it.',
+    'settings.approvalRequired': 'Review required',
+    'settings.approvalNotRequired': 'Auto-activate',
     'admin.title': 'Admin center',
     'admin.users': 'Users',
     'admin.resources': 'Resources',

+ 1 - 0
src/frontend/types.ts

@@ -46,6 +46,7 @@ export interface RuntimeConfig {
   defaultSpfMechanisms: string;
   dmarcPolicy: string;
   dmarcRua: string;
+  registrationRequiresApproval: boolean;
   sendRequiresVerified: boolean;
   engagementTrackingEnabled: boolean;
   listUnsubscribeMailto: string;

+ 16 - 0
src/pages/Settings.tsx

@@ -186,6 +186,22 @@ export default function Settings() {
               </div>
             </SectionCard>
 
+            <SectionCard title={t('settings.registrationPolicy')}>
+              <Form.Item
+                name="registrationRequiresApproval"
+                label={t('settings.registrationRequiresApproval')}
+                extra={t('settings.registrationRequiresApprovalHint')}
+                valuePropName="checked"
+                style={{ marginBottom: 0 }}
+              >
+                <Switch
+                  aria-label={t('settings.registrationRequiresApproval')}
+                  checkedChildren={t('settings.approvalRequired')}
+                  unCheckedChildren={t('settings.approvalNotRequired')}
+                />
+              </Form.Item>
+            </SectionCard>
+
             <SectionCard title={locale.startsWith('en') ? 'Message and tracking defaults' : '邮件与跟踪默认值'}>
               <div className="form-grid two">
                 <Form.Item name="listUnsubscribeMailto" label={copy.unsubscribeEmail} extra={envExtra('LIST_UNSUBSCRIBE_MAILTO')}><Input aria-label="LIST_UNSUBSCRIBE_MAILTO" placeholder="unsubscribe@example.com" /></Form.Item>

+ 17 - 4
src/server.js

@@ -215,6 +215,8 @@ const defaultSettings = {
   dmarcPolicy: process.env.DMARC_POLICY || 'none',
   dmarcRua: process.env.DMARC_RUA || '',
   sendRequiresVerified: String(process.env.SEND_REQUIRES_VERIFIED || '').toLowerCase() === 'true' ? 'true' : 'false',
+  registrationRequiresApproval:
+    String(process.env.REGISTRATION_REQUIRES_APPROVAL || '').toLowerCase() === 'true' ? 'true' : 'false',
   engagementTrackingEnabled:
     String(process.env.ENGAGEMENT_TRACKING_ENABLED || '').toLowerCase() === 'true' ? 'true' : 'false',
   listUnsubscribeMailto: process.env.LIST_UNSUBSCRIBE_MAILTO || '',
@@ -1679,11 +1681,18 @@ async function handleVerifyEmail(req, res, url) {
   if ((req.method || 'GET') !== 'GET') return sendJson(res, 404, { error: 'Not found.' });
   const token = String(url.searchParams.get('token') || '').trim();
   if (!token) return sendVerifyEmailError(req, res, '验证链接无效或已过期。');
-  const consumed = consumeAccountToken(token, emailVerificationPurpose);
+  const consumed = consumeAccountToken(token, emailVerificationPurpose, {
+    allowedUserStatuses: ['pending_email', 'pending_review', 'active']
+  });
   if (!consumed) return sendVerifyEmailError(req, res, '验证链接无效或已过期。');
-  const user = markUserEmailVerified(consumed.userId);
+  const settings = runtimeSettings();
+  const user = markUserEmailVerified(consumed.userId, {
+    requiresApproval: settings.registrationRequiresApproval
+  });
   if (!user) return sendVerifyEmailError(req, res, '验证链接无效或已过期。');
-  const message = '邮箱验证成功,请等待管理员审核。';
+  const message = user.status === 'pending_review'
+    ? '邮箱验证成功,请等待管理员审核。'
+    : '邮箱验证成功,现在可以登录。';
   if (wantsHtmlRedirect(req)) return redirect(res, `/login?message=${encodeURIComponent(message)}`, 303);
   return sendJson(res, 200, {
     user,
@@ -1785,7 +1794,7 @@ async function handleLogin(req, res) {
 
 function sendRegisterSuccess(req, res, user, emailResult = { ok: false }) {
   const message = emailResult.ok
-    ? '注册成功,验证邮件已发送,请先验证邮箱,验证后等待管理员审核。'
+    ? '注册成功,验证邮件已发送,请先验证邮箱。'
     : '注册成功,请先验证邮箱;验证邮件暂未发送,请联系管理员或稍后重试。';
   if (wantsHtmlRedirect(req)) return redirect(res, `/login?error=${encodeURIComponent(message)}`, 303);
   return sendJson(res, 201, {
@@ -1972,6 +1981,7 @@ function runtimeSettings() {
     dmarcPolicy: normalizeDmarcPolicy(settings.dmarcPolicy),
     dmarcRua: settings.dmarcRua,
     sendRequiresVerified: String(settings.sendRequiresVerified).toLowerCase() === 'true',
+    registrationRequiresApproval: String(settings.registrationRequiresApproval).toLowerCase() === 'true',
     engagementTrackingEnabled: String(settings.engagementTrackingEnabled).toLowerCase() === 'true',
     listUnsubscribeMailto: settings.listUnsubscribeMailto,
     listUnsubscribeUrl: settings.listUnsubscribeUrl,
@@ -2385,6 +2395,9 @@ function settingsPatchFromBody(body) {
   }
   if (Object.hasOwn(body, 'dmarcPolicy')) patch.dmarcPolicy = normalizeDmarcPolicy(body.dmarcPolicy);
   if (Object.hasOwn(body, 'sendRequiresVerified')) patch.sendRequiresVerified = boolString(body.sendRequiresVerified);
+  if (Object.hasOwn(body, 'registrationRequiresApproval')) {
+    patch.registrationRequiresApproval = boolString(body.registrationRequiresApproval);
+  }
   if (Object.hasOwn(body, 'engagementTrackingEnabled')) {
     patch.engagementTrackingEnabled = boolString(body.engagementTrackingEnabled);
   }

+ 1 - 1
src/system-mail.js

@@ -16,7 +16,7 @@ export function buildVerificationEmail({ appBaseUrl, to, token, fromEmail, fromN
       '',
       verifyUrl,
       '',
-      '验证后账号会进入管理员审核流程。'
+      '完成验证后,请返回 MailHub 登录页面查看账号状态。'
     ].join('\n')
   };
 }

+ 23 - 3
test/db.test.js

@@ -967,6 +967,19 @@ test('stores account tokens as hashes and enforces token lifecycle', () => {
   assert.equal('token' in consumed, false);
   assert.equal(consumeAccountToken(created.token, purpose), null);
 
+  const statusRestricted = createAccountToken(bob.id, purpose, { ttlMinutes: 30 });
+  updateUserStatus(bob.id, 'disabled');
+  assert.equal(
+    consumeAccountToken(statusRestricted.token, purpose, { allowedUserStatuses: ['active'] }),
+    null
+  );
+  assert.equal(database.prepare('SELECT used_at FROM account_tokens WHERE id = ?').get(statusRestricted.id).used_at, null);
+  updateUserStatus(bob.id, 'active');
+  assert.equal(
+    consumeAccountToken(statusRestricted.token, purpose, { allowedUserStatuses: ['active'] }).id,
+    statusRestricted.id
+  );
+
   const expired = createAccountToken(alice.id, purpose, { ttlMinutes: 30 });
   database
     .prepare('UPDATE account_tokens SET expires_at = ? WHERE id = ?')
@@ -1067,14 +1080,21 @@ test('moves users through the extended status lifecycle', () => {
     password: 'password123',
     status: 'pending_email'
   });
+  const reviewPendingEmail = createUser({
+    username: 'review-pending',
+    email: 'review-pending@example.com',
+    password: 'password123',
+    status: 'pending_email'
+  });
   const seededAdmin = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
 
   assert.equal(pendingEmail.status, 'pending_email');
   assert.equal(seededAdmin.status, 'active');
-  assert.equal(markUserEmailVerified(pendingEmail.id).status, 'pending_review');
-  assert.equal(markUserEmailVerified(pendingEmail.id).status, 'pending_review');
-  assert.equal(approveUser(pendingEmail.id).status, 'active');
+  assert.equal(markUserEmailVerified(pendingEmail.id, { requiresApproval: false }).status, 'active');
   assert.equal(markUserEmailVerified(pendingEmail.id).status, 'active');
+  assert.equal(markUserEmailVerified(reviewPendingEmail.id, { requiresApproval: true }).status, 'pending_review');
+  assert.equal(markUserEmailVerified(reviewPendingEmail.id, { requiresApproval: false }).status, 'pending_review');
+  assert.equal(approveUser(reviewPendingEmail.id).status, 'active');
   assert.equal(updateUserStatus(pendingEmail.id, 'disabled').status, 'disabled');
   assert.equal(updateUser(pendingEmail.id, { status: 'pending_review' }).status, 'pending_review');
 

+ 9 - 2
test/frontend-auth-model.test.js

@@ -7,17 +7,24 @@ test('registration success returns to login without navigating to the protected
   assert.deepEqual(
     nextAuthSuccessState('/api/register', {
       user: { status: 'pending_email' },
-      message: '注册成功,请先验证邮箱,验证后等待管理员审核。'
+      message: '注册成功,验证邮件已发送,请先验证邮箱。'
     }),
     {
       mode: 'login',
       path: '/login',
-      message: '注册成功,请先验证邮箱,验证后等待管理员审核。',
+      message: '注册成功,验证邮件已发送,请先验证邮箱。',
       redirectTo: ''
     }
   );
 });
 
+test('registration fallback does not assume administrator approval is enabled', () => {
+  assert.equal(
+    nextAuthSuccessState('/api/register', { user: { status: 'pending_email' } }).message,
+    '注册成功,请先验证邮箱。'
+  );
+});
+
 test('login success still redirects to the protected app', () => {
   assert.deepEqual(
     nextAuthSuccessState('/api/login', {

+ 5 - 0
test/frontend-i18n.test.js

@@ -68,8 +68,13 @@ test('translates admin panel labels', () => {
   assert.equal(zh('admin.resources'), '资源');
   assert.equal(zh('admin.auditLogs'), '审计日志');
   assert.equal(zh('settings.deliveryChecks'), '发信环境检查');
+  assert.equal(zh('settings.registrationPolicy'), '注册与账号');
+  assert.equal(zh('settings.registrationRequiresApproval'), '邮箱验证后需要管理员审核');
+  assert.equal(zh('settings.approvalNotRequired'), '自动启用');
   assert.equal(en('admin.title'), 'Admin center');
   assert.equal(en('settings.deliveryChecks'), 'Sending Environment Checks');
+  assert.equal(en('settings.registrationPolicy'), 'Registration and accounts');
+  assert.equal(en('settings.approvalRequired'), 'Review required');
 });
 
 test('translates smtp login credential labels separately from outbound relays', () => {

+ 154 - 8
test/server-admin-api.test.js

@@ -1012,6 +1012,7 @@ test('registration and verification resend use configured system email', async (
     const registerBody = await register.json();
     assert.equal(registerBody.user.status, 'pending_email');
     assert.equal(registerBody.verificationEmailSent, true);
+    assert.equal(registerBody.message, '注册成功,验证邮件已发送,请先验证邮箱。');
     assert.equal(countAccountTokensForUser(dataDir, sessionSecret, 'mailuser', 'email_verification'), 1);
 
     const publicResend = await fetch(`${baseUrl}/api/auth/resend-verification`, {
@@ -1420,7 +1421,7 @@ test('self registration creates a pending email user and verification token with
   }
 });
 
-test('email verification route consumes token and moves user to admin review', async () => {
+test('email verification activates users by default and redirects browsers with an exact success message', async () => {
   const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
 
   try {
@@ -1458,25 +1459,169 @@ test('email verification route consumes token and moves user to admin review', a
       headers: { Accept: 'text/html,application/xhtml+xml' }
     });
     assert.equal(browserResponse.status, 303);
-    assert.match(browserResponse.headers.get('location') || '', /^\/login\?message=/);
+    assert.equal(
+      browserResponse.headers.get('location'),
+      `/login?message=${encodeURIComponent('邮箱验证成功,现在可以登录。')}`
+    );
     assert.equal(sessionCookieFrom(browserResponse), '');
+    const browserLogin = await loginResponse(baseUrl, 'verifybrowser', 'password123');
+    assert.equal(browserLogin.status, 200);
+    assert.ok(sessionCookieFrom(browserLogin));
 
     const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
     assert.equal(response.status, 200);
     assert.equal(sessionCookieFrom(response), '');
     const body = await response.json();
     assert.equal(body.user.id, created.user.id);
-    assert.equal(body.user.status, 'pending_review');
-    assert.match(body.message, /管理员审核/);
+    assert.equal(body.user.status, 'active');
+    assert.equal(body.message, '邮箱验证成功,现在可以登录。');
 
     const reused = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
     assert.equal(reused.status, 400);
     assert.equal(sessionCookieFrom(reused), '');
 
-    const login = await loginResponse(baseUrl, 'verifyme', 'password123');
-    assert.equal(login.status, 403);
-    assert.equal(sessionCookieFrom(login), '');
-    assert.equal((await login.json()).error, '账号正在等待管理员审核。');
+    const verifiedLogin = await loginResponse(baseUrl, 'verifyme', 'password123');
+    assert.equal(verifiedLogin.status, 200);
+    assert.ok(sessionCookieFrom(verifiedLogin));
+
+    const alreadyPendingReview = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
+      username: 'alreadyreview',
+      email: 'alreadyreview@example.com',
+      password: 'password123',
+      status: 'pending_review'
+    });
+    const pendingReviewResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(alreadyPendingReview.token)}`);
+    assert.equal(pendingReviewResponse.status, 200);
+    const pendingReviewBody = await pendingReviewResponse.json();
+    assert.equal(pendingReviewBody.user.status, 'pending_review');
+    assert.equal(pendingReviewBody.message, '邮箱验证成功,请等待管理员审核。');
+
+    const disabled = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
+      username: 'disabledverify',
+      email: 'disabledverify@example.com',
+      password: 'password123',
+      status: 'disabled'
+    });
+    const disabledResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(disabled.token)}`);
+    assert.equal(disabledResponse.status, 400);
+    assert.equal(countUnusedAccountTokensForUser(dataDir, sessionSecret, 'disabledverify', 'email_verification'), 1);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('admin registration approval setting persists and keeps verified users pending review', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    seedUsers(dataDir, sessionSecret, [
+      { username: 'settingsmember', email: 'settingsmember@example.com', password: 'password123', status: 'active' }
+    ]);
+    const adminCookie = await login(baseUrl, 'admin', 'password123');
+    const memberCookie = await login(baseUrl, 'settingsmember', 'password123');
+
+    const defaultsResponse = await fetch(`${baseUrl}/api/admin/settings`, {
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(defaultsResponse.status, 200);
+    assert.equal((await defaultsResponse.json()).settings.registrationRequiresApproval, false);
+
+    const forbiddenRead = await fetch(`${baseUrl}/api/admin/settings`, {
+      headers: { Cookie: memberCookie }
+    });
+    assert.equal(forbiddenRead.status, 403);
+
+    const forbidden = await fetch(`${baseUrl}/api/admin/settings`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: memberCookie
+      },
+      body: JSON.stringify({ registrationRequiresApproval: true })
+    });
+    assert.equal(forbidden.status, 403);
+
+    const unchangedResponse = await fetch(`${baseUrl}/api/admin/settings`, {
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal((await unchangedResponse.json()).settings.registrationRequiresApproval, false);
+
+    const enabledResponse = await fetch(`${baseUrl}/api/admin/settings`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: adminCookie
+      },
+      body: JSON.stringify({ registrationRequiresApproval: true })
+    });
+    assert.equal(enabledResponse.status, 200);
+    assert.equal((await enabledResponse.json()).settings.registrationRequiresApproval, true);
+
+    const persistedResponse = await fetch(`${baseUrl}/api/admin/settings`, {
+      headers: { Cookie: adminCookie }
+    });
+    assert.equal(persistedResponse.status, 200);
+    assert.equal((await persistedResponse.json()).settings.registrationRequiresApproval, true);
+
+    const register = await fetch(`${baseUrl}/api/register`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        username: 'reviewregistration',
+        email: 'reviewregistration@example.com',
+        password: 'password123'
+      })
+    });
+    assert.equal(register.status, 201);
+    const registerBody = await register.json();
+    assert.equal(registerBody.user.status, 'pending_email');
+    assert.doesNotMatch(registerBody.message, /管理员审核|即可登录/);
+
+    const created = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
+      username: 'reviewrequired',
+      email: 'reviewrequired@example.com',
+      password: 'password123',
+      status: 'pending_email'
+    });
+    const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
+    assert.equal(response.status, 200);
+    assert.equal(sessionCookieFrom(response), '');
+    const body = await response.json();
+    assert.equal(body.user.status, 'pending_review');
+    assert.equal(body.message, '邮箱验证成功,请等待管理员审核。');
+
+    const browserCreated = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
+      username: 'reviewbrowser',
+      email: 'reviewbrowser@example.com',
+      password: 'password123',
+      status: 'pending_email'
+    });
+    const browserResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(browserCreated.token)}`, {
+      redirect: 'manual',
+      headers: { Accept: 'text/html,application/xhtml+xml' }
+    });
+    assert.equal(browserResponse.status, 303);
+    assert.equal(
+      browserResponse.headers.get('location'),
+      `/login?message=${encodeURIComponent('邮箱验证成功,请等待管理员审核。')}`
+    );
+    assert.equal(sessionCookieFrom(browserResponse), '');
+
+    const alreadyActive = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
+      username: 'alreadyactive',
+      email: 'alreadyactive@example.com',
+      password: 'password123',
+      status: 'active'
+    });
+    const activeResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(alreadyActive.token)}`);
+    assert.equal(activeResponse.status, 200);
+    const activeBody = await activeResponse.json();
+    assert.equal(activeBody.user.status, 'active');
+    assert.equal(activeBody.message, '邮箱验证成功,现在可以登录。');
+
+    await assertLoginDeniedByStatus(baseUrl, 'reviewrequired', '账号正在等待管理员审核。');
+    await assertLoginDeniedByStatus(baseUrl, 'reviewbrowser', '账号正在等待管理员审核。');
   } finally {
     child.kill('SIGTERM');
     await waitForExit(child, 1000);
@@ -1640,6 +1785,7 @@ async function startTestServer() {
       DATA_DIR: dataDir,
       ADMIN_PASSWORD: 'password123',
       SESSION_SECRET: sessionSecret,
+      REGISTRATION_REQUIRES_APPROVAL: 'false',
       DNS_AUTO_CHECK_ENABLED: 'false',
       SUBMISSION_ENABLED: 'false',
       IMAP_ENABLED: 'false',

+ 2 - 0
test/system-mail.test.js

@@ -19,6 +19,8 @@ test('builds verification email with configured sender and verification url', ()
   assert.equal(message.to, 'alice@example.com');
   assert.match(message.subject, /验证邮箱/);
   assert.match(message.text, /https:\/\/mail\.example\.com\/api\/auth\/verify-email\?token=verify-token/);
+  assert.match(message.text, /登录页面查看账号状态/);
+  assert.doesNotMatch(message.text, /管理员审核/);
 });
 
 test('builds password reset email with reset url', () => {

+ 1 - 0
test/ui/add-domain-drawer.test.tsx

@@ -53,6 +53,7 @@ const runtimeConfig: RuntimeConfig = {
   defaultSpfMechanisms: '',
   dmarcPolicy: 'none',
   dmarcRua: '',
+  registrationRequiresApproval: false,
   sendRequiresVerified: true,
   engagementTrackingEnabled: true,
   listUnsubscribeMailto: '',

+ 1 - 0
test/ui/admin-layout.test.tsx

@@ -86,6 +86,7 @@ function renderShell(path: string, { role }: { role: 'admin' | 'user' }) {
       defaultSpfMechanisms: '',
       dmarcPolicy: 'none',
       dmarcRua: '',
+      registrationRequiresApproval: false,
       sendRequiresVerified: true,
       engagementTrackingEnabled: true,
       listUnsubscribeMailto: '',

+ 1 - 0
test/ui/api-token-secret.test.tsx

@@ -175,6 +175,7 @@ const config: RuntimeConfig = {
   defaultSpfMechanisms: '',
   dmarcPolicy: 'none',
   dmarcRua: '',
+  registrationRequiresApproval: false,
   sendRequiresVerified: true,
   engagementTrackingEnabled: true,
   listUnsubscribeMailto: '',

+ 1 - 0
test/ui/dashboard.test.tsx

@@ -180,6 +180,7 @@ const runtimeConfig: RuntimeConfig = {
   defaultSpfMechanisms: '',
   dmarcPolicy: 'none',
   dmarcRua: '',
+  registrationRequiresApproval: false,
   sendRequiresVerified: true,
   engagementTrackingEnabled: true,
   listUnsubscribeMailto: '',

+ 1 - 0
test/ui/domains-delete.test.tsx

@@ -71,6 +71,7 @@ const config = {
   defaultSpfMechanisms: '',
   dmarcPolicy: 'none',
   dmarcRua: '',
+  registrationRequiresApproval: false,
   sendRequiresVerified: true,
   engagementTrackingEnabled: true,
   listUnsubscribeMailto: '',

+ 1 - 0
test/ui/domains-workflow.test.tsx

@@ -352,6 +352,7 @@ const context: AppContextValue = {
     defaultSpfMechanisms: '',
     dmarcPolicy: 'none',
     dmarcRua: '',
+    registrationRequiresApproval: false,
     sendRequiresVerified: true,
     engagementTrackingEnabled: true,
     listUnsubscribeMailto: '',

+ 1 - 0
test/ui/inbox-navigation.test.tsx

@@ -416,6 +416,7 @@ const runtimeConfig: RuntimeConfig = {
   defaultSpfMechanisms: '',
   dmarcPolicy: 'none',
   dmarcRua: '',
+  registrationRequiresApproval: false,
   sendRequiresVerified: true,
   engagementTrackingEnabled: true,
   listUnsubscribeMailto: '',

+ 1 - 0
test/ui/integration-resource-resilience.test.tsx

@@ -402,6 +402,7 @@ const runtimeConfig: RuntimeConfig = {
   defaultSpfMechanisms: '',
   dmarcPolicy: 'none',
   dmarcRua: '',
+  registrationRequiresApproval: false,
   sendRequiresVerified: true,
   engagementTrackingEnabled: true,
   listUnsubscribeMailto: '',

+ 1 - 0
test/ui/operations-navigation.test.tsx

@@ -247,6 +247,7 @@ const runtimeConfig: RuntimeConfig = {
   defaultSpfMechanisms: '',
   dmarcPolicy: 'none',
   dmarcRua: '',
+  registrationRequiresApproval: false,
   sendRequiresVerified: true,
   engagementTrackingEnabled: true,
   listUnsubscribeMailto: '',

+ 22 - 0
test/ui/settings-navigation-guard.test.tsx

@@ -109,6 +109,27 @@ describe('Settings navigation guard', () => {
     await user.click(screen.getByRole('button', { name: /保存设置/ }));
     await waitFor(() => expect(saveSettings).toHaveBeenCalledTimes(1));
   });
+
+  it('saves the registration approval policy with clear state and helper copy', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'adminSettings').mockResolvedValue({ settings: runtimeConfig });
+    const saveSettings = vi.spyOn(api, 'saveAdminSettings').mockImplementation(async (values) => ({
+      settings: { ...runtimeConfig, ...values }
+    }));
+    renderSettingsRouter();
+
+    const approvalSwitch = await screen.findByRole('switch', { name: '邮箱验证后需要管理员审核' });
+    expect(approvalSwitch.getAttribute('aria-checked')).toBe('false');
+    expect(screen.getByText('关闭时,用户验证邮箱后立即启用;开启时,账号进入待审核状态,需管理员批准后才能登录。')).not.toBeNull();
+
+    await user.click(approvalSwitch);
+    expect(approvalSwitch.getAttribute('aria-checked')).toBe('true');
+    await user.click(screen.getByRole('button', { name: /保存设置/ }));
+
+    await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({
+      registrationRequiresApproval: true
+    })));
+  });
 });
 
 function renderSettingsRouter(config = runtimeConfig) {
@@ -163,6 +184,7 @@ const runtimeConfig: RuntimeConfig = {
   defaultSpfMechanisms: '',
   dmarcPolicy: 'none',
   dmarcRua: '',
+  registrationRequiresApproval: false,
   sendRequiresVerified: true,
   engagementTrackingEnabled: true,
   listUnsubscribeMailto: '',

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff