create-bubbles 0.1.26 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (496) hide show
  1. package/package.json +1 -1
  2. package/template-vp-monorepo-react-nestjs/.agents/rules//345/205/261/344/272/253/344/273/243/347/240/201.md +2 -2
  3. package/template-vp-monorepo-react-nestjs/.agents/rules//351/234/200/346/261/202/344/270/216/350/256/276/350/256/241/346/226/207/346/241/243.md +50 -0
  4. package/template-vp-monorepo-react-nestjs/.codegraph/.gitignore +5 -0
  5. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/PRD.md +121 -0
  6. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/cleanup-empty-preview.png +0 -0
  7. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/company-created.png +0 -0
  8. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/login-desktop.png +0 -0
  9. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/member-role-assigned.png +0 -0
  10. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/permission-revoked.png +0 -0
  11. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/platform-menu-recovery.png +0 -0
  12. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/project-created.png +0 -0
  13. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/project-member-home.png +0 -0
  14. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/replacement-administrator.png +0 -0
  15. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/workspace-empty.png +0 -0
  16. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//345/206/263/347/255/226/350/256/260/345/275/225.md +29 -0
  17. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//345/211/215/347/253/257/344/272/244/346/216/245.md +74 -0
  18. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//345/220/216/347/253/257/346/265/213/350/257/225/346/212/245/345/221/212.md +115 -0
  19. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//346/212/200/346/234/257/345/245/221/347/272/246.md +578 -0
  20. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//346/265/213/350/257/225/346/212/245/345/221/212.md +137 -0
  21. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//346/265/213/350/257/225/350/257/201/346/215/256/browser.json +84 -0
  22. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//346/265/213/350/257/225/350/257/201/346/215/256/http.json +108 -0
  23. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//346/265/213/350/257/225/350/257/201/346/215/256/legacy-http.json +18 -0
  24. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//346/265/213/350/257/225/350/257/201/346/215/256/legacy.json +24 -0
  25. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//350/201/224/350/260/203/350/256/260/345/275/225.md +76 -0
  26. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//350/277/220/350/241/214/350/257/264/346/230/216.md +117 -0
  27. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//351/234/200/346/261/202/346/226/207/346/241/243.md +275 -0
  28. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//347/273/204/347/273/207/346/236/266/346/236/204//345/206/263/347/255/226/350/256/260/345/275/225.md +35 -0
  29. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//347/273/204/347/273/207/346/236/266/346/236/204//346/212/200/346/234/257/345/245/221/347/272/246.md +744 -0
  30. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//347/273/204/347/273/207/346/236/266/346/236/204//346/265/213/350/257/225/346/212/245/345/221/212.md +174 -0
  31. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//347/273/204/347/273/207/346/236/266/346/236/204//350/201/224/350/260/203/350/256/260/345/275/225.md +137 -0
  32. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//347/273/204/347/273/207/346/236/266/346/236/204//350/256/276/350/256/241/346/226/271/346/241/210.md +215 -0
  33. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//347/273/204/347/273/207/346/236/266/346/236/204//351/234/200/346/261/202/346/226/207/346/241/243.md +421 -0
  34. package/template-vp-monorepo-react-nestjs/.spaces/{server/05./344/270/212/344/274/240/346/224/271/351/200/240/346/226/271/346/241/210/README.md → 02./344/270/212/344/274/240/344/270/216/346/226/207/344/273/266//344/270/212/344/274/240/346/224/271/351/200/240//350/256/276/350/256/241/346/226/271/346/241/210.md} +13 -20
  35. package/template-vp-monorepo-react-nestjs/.spaces/{server/06./345/205/254/345/205/261/351/230/237/345/210/227/346/216/245/345/205/245/346/226/271/346/241/210//344/270/212/344/274/240/346/270/205/347/220/206.md → 02./344/270/212/344/274/240/344/270/216/346/226/207/344/273/266//344/270/212/344/274/240/346/270/205/347/220/206//350/256/276/350/256/241/346/226/271/346/241/210.md} +13 -8
  36. package/template-vp-monorepo-react-nestjs/.spaces/{server/04.upload.md → 02./344/270/212/344/274/240/344/270/216/346/226/207/344/273/266//345/210/206/347/211/207/344/270/212/344/274/240//345/256/236/346/226/275/346/225/231/347/250/213.md} +21 -18
  37. package/template-vp-monorepo-react-nestjs/.spaces/{server/06./345/205/254/345/205/261/351/230/237/345/210/227/346/216/245/345/205/245/346/226/271/346/241/210/README.md → 03./344/273/273/345/212/241/351/230/237/345/210/227//345/205/254/345/205/261/351/230/237/345/210/227//350/256/276/350/256/241/346/226/271/346/241/210.md} +13 -6
  38. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/accounts-mobile-320.png +0 -0
  39. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/accounts-mobile-360.png +0 -0
  40. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/accounts-mobile-390.png +0 -0
  41. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/companies-desktop-data.png +0 -0
  42. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/companies-desktop-empty.png +0 -0
  43. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/companies-mobile-320.png +0 -0
  44. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/companies-mobile-360.png +0 -0
  45. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/companies-mobile-390.png +0 -0
  46. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/company-create-dialog.png +0 -0
  47. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/company-projects-1440.png +0 -0
  48. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/company-projects-390.png +0 -0
  49. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/login-1440.png +0 -0
  50. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/login-390.png +0 -0
  51. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/project-home-1440.png +0 -0
  52. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/project-home-390.png +0 -0
  53. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/register-1440.png +0 -0
  54. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/register-390.png +0 -0
  55. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/workspaces-1440.png +0 -0
  56. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/workspaces-390.png +0 -0
  57. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/workspaces-empty.png +0 -0
  58. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273//345/267/245/345/205/267/fixtures.mjs +132 -0
  59. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273//345/267/245/345/205/267/visual-check.mjs +274 -0
  60. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273//346/265/213/350/257/225/350/257/201/346/215/256/visual-report.json +104 -0
  61. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273//351/252/214/350/257/201/350/257/264/346/230/216.md +25 -0
  62. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//345/233/275/351/231/205/345/214/226//344/272/244/344/273/230/350/256/260/345/275/225.md +12 -0
  63. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//345/233/275/351/231/205/345/214/226//346/265/213/350/257/225/346/212/245/345/221/212.md +19 -0
  64. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//345/233/275/351/231/205/345/214/226//350/256/276/350/256/241/346/226/271/346/241/210.md +18 -0
  65. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//345/233/275/351/231/205/345/214/226//351/234/200/346/261/202/346/226/207/346/241/243.md +24 -0
  66. package/template-vp-monorepo-react-nestjs/.spaces/05./350/277/220/350/241/214/347/273/264/346/212/244//345/271/263/345/217/260/346/223/215/344/275/234/346/211/213/345/206/214.md +397 -0
  67. package/template-vp-monorepo-react-nestjs/.spaces/05./350/277/220/350/241/214/347/273/264/346/212/244//346/225/260/346/215/256/345/272/223/350/277/201/347/247/273//345/244/207/344/273/275/backup.dump +0 -0
  68. package/template-vp-monorepo-react-nestjs/.spaces/05./350/277/220/350/241/214/347/273/264/346/212/244//346/225/260/346/215/256/345/272/223/350/277/201/347/247/273//345/244/207/344/273/275/export-dump.ps1 +34 -0
  69. package/template-vp-monorepo-react-nestjs/.spaces/05./350/277/220/350/241/214/347/273/264/346/212/244//346/225/260/346/215/256/345/272/223/350/277/201/347/247/273//345/244/207/344/273/275/restore-dump.ps1 +73 -0
  70. package/template-vp-monorepo-react-nestjs/.spaces/05./350/277/220/350/241/214/347/273/264/346/212/244//346/225/260/346/215/256/345/272/223/350/277/201/347/247/273//346/223/215/344/275/234/346/214/207/345/215/227.md +227 -0
  71. package/template-vp-monorepo-react-nestjs/.spaces/05./350/277/220/350/241/214/347/273/264/346/212/244//346/225/260/346/215/256/345/272/223/350/277/201/347/247/273//351/234/200/346/261/202/346/226/207/346/241/243.md +31 -0
  72. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/274/232/350/257/235/347/256/241/347/220/206//346/212/200/346/234/257/345/245/221/347/272/246.md +93 -0
  73. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/274/232/350/257/235/347/256/241/347/220/206//351/234/200/346/261/202/346/226/207/346/241/243.md +87 -0
  74. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/277/256/346/224/271/345/257/206/347/240/201/PRD.md +55 -0
  75. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/277/256/346/224/271/345/257/206/347/240/201//346/212/200/346/234/257/345/245/221/347/272/246.md +92 -0
  76. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/277/256/346/224/271/345/257/206/347/240/201//346/265/213/350/257/225/346/212/245/345/221/212.md +38 -0
  77. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/277/256/346/224/271/345/257/206/347/240/201//350/201/224/350/260/203/350/256/260/345/275/225.md +26 -0
  78. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/277/256/346/224/271/345/257/206/347/240/201//350/256/276/350/256/241/346/226/271/346/241/210.md +53 -0
  79. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/277/256/346/224/271/345/257/206/347/240/201//351/234/200/346/261/202/346/226/207/346/241/243.md +63 -0
  80. package/template-vp-monorepo-react-nestjs/.spaces/07.API/344/270/216/351/224/231/350/257/257//346/212/200/346/234/257/345/245/221/347/272/246.md +95 -0
  81. package/template-vp-monorepo-react-nestjs/.spaces/README.md +85 -0
  82. package/template-vp-monorepo-react-nestjs/.spaces//346/226/207/346/241/243/350/247/204/350/214/203.md +97 -0
  83. package/template-vp-monorepo-react-nestjs/AGENTS.md +12 -0
  84. package/template-vp-monorepo-react-nestjs/README.md +3 -27
  85. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0002_enterprise_access.sql +182 -0
  86. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0003_cute_killmonger.sql +357 -0
  87. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0004_company-member-invitations.sql +25 -0
  88. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0005_menu_svg_icons.sql +54 -0
  89. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0002_snapshot.json +1801 -0
  90. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0003_snapshot.json +3511 -0
  91. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0004_snapshot.json +3713 -0
  92. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/_journal.json +29 -1
  93. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/rollback/0003_organization_backfill.sql +32 -0
  94. package/template-vp-monorepo-react-nestjs/apps/server/package.json +6 -4
  95. package/template-vp-monorepo-react-nestjs/apps/server/src/access-init.ts +45 -0
  96. package/template-vp-monorepo-react-nestjs/apps/server/src/app.controller.ts +3 -0
  97. package/template-vp-monorepo-react-nestjs/apps/server/src/app.module.ts +28 -5
  98. package/template-vp-monorepo-react-nestjs/apps/server/src/app.service.ts +3 -0
  99. package/template-vp-monorepo-react-nestjs/apps/server/src/common/adapters/fastify.adapter.ts +11 -0
  100. package/template-vp-monorepo-react-nestjs/apps/server/src/common/constants/session.constants.ts +9 -0
  101. package/template-vp-monorepo-react-nestjs/apps/server/src/common/decorators/access-policy.decorator.ts +18 -0
  102. package/template-vp-monorepo-react-nestjs/apps/server/src/common/decorators/current-auth.decorator.ts +4 -0
  103. package/template-vp-monorepo-react-nestjs/apps/server/src/common/decorators/public.decorator.ts +3 -0
  104. package/template-vp-monorepo-react-nestjs/apps/server/src/common/exceptions/app.exception.ts +9 -0
  105. package/template-vp-monorepo-react-nestjs/apps/server/src/common/filters/global-exception.filter.ts +38 -5
  106. package/template-vp-monorepo-react-nestjs/apps/server/src/common/security/password-security-event.ts +98 -0
  107. package/template-vp-monorepo-react-nestjs/apps/server/src/config/app.config.ts +3 -0
  108. package/template-vp-monorepo-react-nestjs/apps/server/src/config/database.config.ts +3 -0
  109. package/template-vp-monorepo-react-nestjs/apps/server/src/config/llm.config.ts +3 -0
  110. package/template-vp-monorepo-react-nestjs/apps/server/src/config/queue.config.ts +14 -0
  111. package/template-vp-monorepo-react-nestjs/apps/server/src/config/redis.config.ts +3 -0
  112. package/template-vp-monorepo-react-nestjs/apps/server/src/config/session.config.ts +7 -0
  113. package/template-vp-monorepo-react-nestjs/apps/server/src/config/storage.config.ts +9 -0
  114. package/template-vp-monorepo-react-nestjs/apps/server/src/database/access.schema.ts +394 -0
  115. package/template-vp-monorepo-react-nestjs/apps/server/src/database/db.module.ts +3 -0
  116. package/template-vp-monorepo-react-nestjs/apps/server/src/database/organization.schema.ts +453 -0
  117. package/template-vp-monorepo-react-nestjs/apps/server/src/database/schema.ts +6 -0
  118. package/template-vp-monorepo-react-nestjs/apps/server/src/main.ts +4 -0
  119. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access-http.ts +29 -0
  120. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access.errors.ts +130 -0
  121. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access.guard.ts +63 -0
  122. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access.module.ts +27 -0
  123. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access.service.ts +441 -0
  124. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access.store.ts +68 -0
  125. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access.validation.ts +44 -0
  126. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/maintenance/permissions-cleanup.controller.ts +27 -0
  127. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/maintenance/permissions-cleanup.service.ts +260 -0
  128. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/maintenance/permissions-cleanup.validation.ts +7 -0
  129. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/roles/roles.controller.ts +80 -0
  130. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/roles/roles.service.ts +249 -0
  131. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/roles/roles.validation.ts +24 -0
  132. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/seed/access-seed.service.ts +183 -0
  133. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/workspaces/workspaces.controller.ts +28 -0
  134. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/workspaces/workspaces.service.ts +94 -0
  135. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/workspaces/workspaces.validation.ts +5 -0
  136. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/audit/audit.controller.ts +26 -0
  137. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/audit/audit.module.ts +11 -0
  138. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/audit/audit.service.ts +74 -0
  139. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/audit/audit.validation.ts +11 -0
  140. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.controller.ts +25 -0
  141. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.errors.ts +10 -0
  142. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.module.ts +1 -1
  143. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.repository.ts +60 -0
  144. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.service.ts +69 -11
  145. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/dto/register.dto.ts +1 -1
  146. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/guards/session-auth.guard.ts +8 -0
  147. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/password/change-password.dto.ts +17 -0
  148. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/password.service.ts +6 -0
  149. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session-store.service.ts +46 -0
  150. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session-token.service.ts +12 -0
  151. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session.script.ts +7 -1
  152. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/companies/companies.controller.ts +105 -0
  153. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/companies/companies.module.ts +12 -0
  154. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/companies/companies.service.ts +523 -0
  155. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/accounts/accounts.controller.ts +72 -0
  156. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/accounts/accounts.service.ts +154 -0
  157. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/accounts/accounts.validation.ts +8 -0
  158. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/accounts/password-reset/password-reset.service.ts +81 -0
  159. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/accounts/password-reset/reset-password.validation.ts +13 -0
  160. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/administrators/administrators.service.ts +116 -0
  161. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/administrators/administrators.validation.ts +1 -0
  162. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/invitations/company-member-invitations.controller.ts +69 -0
  163. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/invitations/member-invitation-acceptance.controller.ts +23 -0
  164. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/invitations/member-invitations.service.ts +344 -0
  165. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/invitations/member-invitations.validation.ts +23 -0
  166. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/members.controller.ts +88 -0
  167. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/members.module.ts +31 -0
  168. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/members.service.ts +573 -0
  169. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/members.validation.ts +19 -0
  170. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/menus/menus.controller.ts +56 -0
  171. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/menus/menus.module.ts +11 -0
  172. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/menus/menus.service.ts +230 -0
  173. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/menus/menus.validation.ts +30 -0
  174. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/candidates/global-account/global-account-candidates.controller.ts +35 -0
  175. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/candidates/global-account/global-account-candidates.service.ts +109 -0
  176. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/candidates/member-candidates.controller.ts +60 -0
  177. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/candidates/member-candidates.service.ts +939 -0
  178. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/initialization/project-organization-initialization.service.ts +189 -0
  179. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/organization.module.ts +34 -0
  180. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/organization.store.ts +150 -0
  181. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/organization.validation.ts +23 -0
  182. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/positions/positions.controller.ts +160 -0
  183. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/positions/positions.service.ts +636 -0
  184. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/templates/organization-templates.controller.ts +77 -0
  185. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/templates/organization-templates.service.ts +501 -0
  186. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/templates/organization-templates.store.ts +163 -0
  187. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/units/organization-units.controller.ts +187 -0
  188. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/units/organization-units.service.ts +947 -0
  189. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/projects/projects.controller.ts +94 -0
  190. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/projects/projects.module.ts +13 -0
  191. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/projects/projects.service.ts +369 -0
  192. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/queue-worker.bootstrap.ts +3 -0
  193. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.contracts.ts +4 -0
  194. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.module.ts +5 -0
  195. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.processor.ts +13 -0
  196. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.service.ts +9 -0
  197. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/db/db.controller.ts +9 -0
  198. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/db/db.service.ts +9 -0
  199. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/redis/redis.controller.ts +9 -0
  200. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/redis/redis.service.ts +18 -0
  201. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/dto/upload-params.dto.ts +1 -1
  202. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/exact-size.transform.ts +14 -3
  203. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/minio-storage.adapter.ts +37 -0
  204. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/storage.port.ts +21 -0
  205. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.constants.ts +9 -0
  206. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.controller.ts +25 -0
  207. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.reponsitory.ts +27 -0
  208. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.service.ts +78 -0
  209. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/utils/object-key.ts +4 -0
  210. package/template-vp-monorepo-react-nestjs/apps/server/src/utils/server-address.ts +6 -0
  211. package/template-vp-monorepo-react-nestjs/apps/server/test/access/access-policy.spec.ts +95 -0
  212. package/template-vp-monorepo-react-nestjs/apps/server/test/access/access.integration.spec.ts +2250 -0
  213. package/template-vp-monorepo-react-nestjs/apps/server/test/access/member-onboarding.validation.spec.ts +78 -0
  214. package/template-vp-monorepo-react-nestjs/apps/server/test/auth/password-operations.spec.ts +433 -0
  215. package/template-vp-monorepo-react-nestjs/apps/server/test/auth/password.integration.spec.ts +300 -0
  216. package/template-vp-monorepo-react-nestjs/apps/server/test/error-catalog.spec.ts +6 -1
  217. package/template-vp-monorepo-react-nestjs/apps/server/test/menus.validation.spec.ts +28 -0
  218. package/template-vp-monorepo-react-nestjs/apps/server/test/organization/organization-domain.spec.ts +260 -0
  219. package/template-vp-monorepo-react-nestjs/apps/server/tsconfig.scripts.json +5 -0
  220. package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//347/273/204/344/273/266/345/244/215/347/224/250.md +7 -0
  221. package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//350/241/250/345/215/225/345/233/236/345/241/253.md +4 -0
  222. package/template-vp-monorepo-react-nestjs/apps/web/AGENTS.md +17 -8
  223. package/template-vp-monorepo-react-nestjs/apps/web/README.MD +36 -8
  224. package/template-vp-monorepo-react-nestjs/apps/web/index.html +2 -2
  225. package/template-vp-monorepo-react-nestjs/apps/web/package.json +1 -0
  226. package/template-vp-monorepo-react-nestjs/apps/web/src/App.tsx +19 -8
  227. package/template-vp-monorepo-react-nestjs/apps/web/src/api/auth.ts +14 -1
  228. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/accounts.svg +4 -0
  229. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/audit-log.svg +4 -0
  230. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/companies.svg +5 -0
  231. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/dashboard.svg +6 -0
  232. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/home.svg +4 -0
  233. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/members.svg +4 -0
  234. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/menu-item.svg +4 -0
  235. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/menus.svg +6 -0
  236. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/organization-template.svg +4 -0
  237. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/organization.svg +6 -0
  238. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/positions.svg +4 -0
  239. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/profile.svg +5 -0
  240. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/projects.svg +4 -0
  241. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/roles.svg +4 -0
  242. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Brand/Brand.module.css +125 -0
  243. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Brand/Brand.tsx +56 -0
  244. package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/DraftProTable.tsx +11 -5
  245. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/SvgAsset/IconSelector.tsx +38 -0
  246. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/SvgAsset/SvgAssetIcon.tsx +14 -0
  247. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/SvgAsset/SvgAssetRegistry.ts +32 -0
  248. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/SvgAsset/index.ts +10 -0
  249. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/svg-icon/SvgIcon.tsx +0 -2
  250. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Loading/PageLoading.tsx +5 -1
  251. package/template-vp-monorepo-react-nestjs/apps/web/src/components/LocaleSwitch/LocaleSwitch.tsx +52 -0
  252. package/template-vp-monorepo-react-nestjs/apps/web/src/components/PasswordActions/ChangePasswordDialog.tsx +90 -0
  253. package/template-vp-monorepo-react-nestjs/apps/web/src/components/PasswordActions/PasswordFields.module.css +12 -0
  254. package/template-vp-monorepo-react-nestjs/apps/web/src/components/PasswordActions/PasswordFields.tsx +54 -0
  255. package/template-vp-monorepo-react-nestjs/apps/web/src/components/PasswordActions/ResetAccountPasswordDialog.tsx +100 -0
  256. package/template-vp-monorepo-react-nestjs/apps/web/src/components/PasswordActions/change-password-session.ts +13 -0
  257. package/template-vp-monorepo-react-nestjs/apps/web/src/components/RouteTransition/RouteTransition.css +60 -0
  258. package/template-vp-monorepo-react-nestjs/apps/web/src/components/RouteTransition/RouteTransition.tsx +44 -0
  259. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/BasicTableSelector/BasicTableSelector.tsx +218 -0
  260. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/BasicTableSelector/BasicTableSelectorSelection.ts +91 -0
  261. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/BasicTableSelector/BasicTableSelectorTypes.ts +60 -0
  262. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/BasicTableSelector/README.md +110 -0
  263. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/BasicTableSelector/index.ts +7 -0
  264. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/GlobalAccountSelect/GlobalAccountSelect.tsx +211 -0
  265. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/GlobalAccountSelect/GlobalAccountSelectTypes.ts +75 -0
  266. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/GlobalAccountSelect/README.md +35 -0
  267. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/GlobalAccountSelect/index.ts +9 -0
  268. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/OrganizationMemberBrowser.tsx +146 -0
  269. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/OrganizationMemberCandidateColumns.tsx +137 -0
  270. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/OrganizationMemberFilterPanel.tsx +209 -0
  271. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/OrganizationMemberSelector.tsx +196 -0
  272. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/OrganizationMemberSelectorTypes.ts +103 -0
  273. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/README.md +8 -0
  274. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/index.ts +13 -0
  275. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/README.md +13 -0
  276. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/index.ts +30 -0
  277. package/template-vp-monorepo-react-nestjs/apps/web/src/config/theme.ts +41 -9
  278. package/template-vp-monorepo-react-nestjs/apps/web/src/hooks/useLatestDialogRequest.ts +71 -0
  279. package/template-vp-monorepo-react-nestjs/apps/web/src/i18n/config.ts +40 -0
  280. package/template-vp-monorepo-react-nestjs/apps/web/src/i18n/index.ts +10 -0
  281. package/template-vp-monorepo-react-nestjs/apps/web/src/i18n/instance.ts +36 -0
  282. package/template-vp-monorepo-react-nestjs/apps/web/src/i18n/ui-locale.ts +30 -0
  283. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/{BasicLayout.tsx → BasicLayout/index.tsx} +42 -35
  284. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/WorkspaceLayout/components/WorkspaceNavigationBoundary.tsx +50 -0
  285. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/WorkspaceLayout/index.tsx +196 -0
  286. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/WorkspaceLayout/workspace-landing.css +119 -0
  287. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/WorkspaceLayout/workspace-surfaces.css +110 -0
  288. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/WorkspaceLayout/workspace.css +223 -0
  289. package/template-vp-monorepo-react-nestjs/apps/web/src/locales/en_US.json +639 -1
  290. package/template-vp-monorepo-react-nestjs/apps/web/src/locales/zh_CN.json +621 -1
  291. package/template-vp-monorepo-react-nestjs/apps/web/src/main.tsx +8 -2
  292. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/api.ts +226 -0
  293. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/audit.tsx +169 -0
  294. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/AdministratorDialog.tsx +128 -0
  295. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/CleanupDialog.tsx +140 -0
  296. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/CompanyHierarchyDialog.tsx +244 -0
  297. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/EntityFormDialog.tsx +285 -0
  298. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/EntitySelectors/index.ts +6 -0
  299. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/EntitySelectors/selector-requests.ts +59 -0
  300. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberAffiliations/MemberOrganizationsDialog.tsx +188 -0
  301. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberAffiliations/MemberPositionsDialog.tsx +116 -0
  302. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberAffiliations/affiliation-loaders.ts +18 -0
  303. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberAffiliations/index.ts +5 -0
  304. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/AddProjectMemberDialog.tsx +75 -0
  305. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/CompanyMemberInvitation/CompanyMemberInvitationDialog.tsx +246 -0
  306. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/CompanyMemberInvitation/InvitationRecordsTable.tsx +152 -0
  307. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/CompanyMemberInvitation/invitation-link.ts +9 -0
  308. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/MemberOnboardingActions.tsx +79 -0
  309. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/RegisterCompanyMember/RegisterCompanyMemberDialog.module.css +12 -0
  310. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/RegisterCompanyMember/RegisterCompanyMemberDialog.tsx +122 -0
  311. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberRolesDialog.tsx +80 -0
  312. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberTable/MemberTableColumns.tsx +234 -0
  313. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberTable/index.ts +2 -0
  314. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberTable/member-candidate-cache.ts +12 -0
  315. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MenuFormDialog.tsx +251 -0
  316. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/RoleFormDialog.tsx +73 -0
  317. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/RolePermissionsDialog.tsx +185 -0
  318. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/entities.tsx +268 -0
  319. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/home.tsx +67 -0
  320. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/members.tsx +310 -0
  321. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/menus/api.ts +74 -0
  322. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/menus/index.tsx +325 -0
  323. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/profile.tsx +160 -0
  324. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/roles.tsx +173 -0
  325. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/use-access.ts +35 -0
  326. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/error/404.tsx +20 -0
  327. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/error/error.tsx +42 -0
  328. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/i18n/index.tsx +20 -38
  329. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/components/ProjectFormDialog.tsx +46 -41
  330. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/config/columns.tsx +20 -15
  331. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/config/index.ts +32 -22
  332. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/components/DraftProjectFormDialog.tsx +41 -34
  333. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/columns.tsx +27 -20
  334. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/projects.ts +21 -13
  335. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/index.tsx +47 -27
  336. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/index.tsx +51 -36
  337. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/home/index.tsx +9 -6
  338. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/api.ts +3 -0
  339. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/index.tsx +62 -82
  340. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/login-form.css +199 -0
  341. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/login.css +146 -124
  342. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/member-invitations/accept.css +47 -0
  343. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/member-invitations/accept.tsx +143 -0
  344. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/member-invitations/api.ts +20 -0
  345. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/api.ts +91 -0
  346. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/MoveOrganizationUnitDialog.tsx +82 -0
  347. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/OrganizationMemberColumns.tsx +87 -0
  348. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/OrganizationMemberHeader.tsx +102 -0
  349. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/OrganizationTreeModel.tsx +59 -0
  350. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/OrganizationTreePanel.tsx +88 -0
  351. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/index.ts +11 -0
  352. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/organization-loaders.ts +47 -0
  353. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/organization-member-filter-loaders.ts +187 -0
  354. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationMembersDialog.tsx +207 -0
  355. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationUnitDialog.tsx +110 -0
  356. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/index.tsx +258 -0
  357. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization-templates/api.ts +53 -0
  358. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization-templates/components/OrganizationTemplateDialog/TemplateDefinitionTabs.tsx +189 -0
  359. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization-templates/components/OrganizationTemplateDialog/index.tsx +135 -0
  360. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization-templates/components/OrganizationTemplateDialog/types.ts +25 -0
  361. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization-templates/index.tsx +201 -0
  362. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/positions/api.ts +81 -0
  363. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/positions/components/PositionFormDialog.tsx +86 -0
  364. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/positions/components/PositionMembersDialog.tsx +102 -0
  365. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/positions/index.tsx +198 -0
  366. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/positions/loaders.ts +35 -0
  367. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/register/api.ts +14 -0
  368. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/register/index.tsx +157 -0
  369. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/workspaces/api.ts +32 -0
  370. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/workspaces/index.tsx +146 -0
  371. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/workspaces/state.ts +24 -0
  372. package/template-vp-monorepo-react-nestjs/apps/web/src/router/AGENTS.MD +6 -0
  373. package/template-vp-monorepo-react-nestjs/apps/web/src/router/index.tsx +2 -46
  374. package/template-vp-monorepo-react-nestjs/apps/web/src/router/lazy-load/index.tsx +26 -0
  375. package/template-vp-monorepo-react-nestjs/apps/web/src/router/middleware/README.md +23 -0
  376. package/template-vp-monorepo-react-nestjs/apps/web/src/router/middleware/auth.ts +6 -0
  377. package/template-vp-monorepo-react-nestjs/apps/web/src/router/middleware/index.ts +4 -0
  378. package/template-vp-monorepo-react-nestjs/apps/web/src/router/middleware/workspace/access.ts +23 -0
  379. package/template-vp-monorepo-react-nestjs/apps/web/src/router/middleware/workspace/data.ts +55 -0
  380. package/template-vp-monorepo-react-nestjs/apps/web/src/router/middleware/workspace/entry.ts +52 -0
  381. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/access/page.tsx +20 -0
  382. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/auth.tsx +17 -0
  383. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/company.tsx +30 -0
  384. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/entry.tsx +20 -0
  385. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/examples.tsx +23 -0
  386. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/fallback.tsx +13 -0
  387. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/index.tsx +32 -0
  388. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/platform.tsx +27 -0
  389. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/project.tsx +28 -0
  390. package/template-vp-monorepo-react-nestjs/apps/web/src/router/page-registry.tsx +94 -0
  391. package/template-vp-monorepo-react-nestjs/apps/web/src/styles/dream.css +34 -0
  392. package/template-vp-monorepo-react-nestjs/apps/web/src/styles/index.css +6 -0
  393. package/template-vp-monorepo-react-nestjs/apps/web/src/types/auto-imports.d.ts +26 -0
  394. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/organization-assignment.ts +66 -0
  395. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/core/index.ts +48 -13
  396. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/core/utils.ts +5 -0
  397. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/index.ts +7 -2
  398. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/workspace.ts +82 -0
  399. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/safe-next.ts +36 -0
  400. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/storage/cookie.ts +1 -0
  401. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/storage/session.ts +1 -0
  402. package/template-vp-monorepo-react-nestjs/apps/web/test/i18n-locale.spec.ts +22 -0
  403. package/template-vp-monorepo-react-nestjs/apps/web/test/icon-selector.spec.tsx +23 -0
  404. package/template-vp-monorepo-react-nestjs/apps/web/test/latest-dialog-request.spec.ts +89 -0
  405. package/template-vp-monorepo-react-nestjs/apps/web/test/member-invitation-navigation.spec.ts +34 -0
  406. package/template-vp-monorepo-react-nestjs/apps/web/test/organization-assignment.spec.ts +137 -0
  407. package/template-vp-monorepo-react-nestjs/apps/web/test/password-actions.spec.ts +40 -0
  408. package/template-vp-monorepo-react-nestjs/apps/web/test/password-reset-action.spec.ts +64 -0
  409. package/template-vp-monorepo-react-nestjs/apps/web/test/request.spec.ts +1 -0
  410. package/template-vp-monorepo-react-nestjs/apps/web/test/router/access.spec.ts +136 -0
  411. package/template-vp-monorepo-react-nestjs/apps/web/test/router/config.ts +140 -0
  412. package/template-vp-monorepo-react-nestjs/apps/web/test/router/entry.spec.ts +179 -0
  413. package/template-vp-monorepo-react-nestjs/apps/web/test/router/workspace-data.spec.ts +112 -0
  414. package/template-vp-monorepo-react-nestjs/apps/web/test/selector/global-account-select.spec.ts +311 -0
  415. package/template-vp-monorepo-react-nestjs/apps/web/test/selector/organization-member-selector.spec.ts +454 -0
  416. package/template-vp-monorepo-react-nestjs/apps/web/test/selector/selector-selection.spec.ts +132 -0
  417. package/template-vp-monorepo-react-nestjs/apps/web/test/storage.spec.ts +1 -0
  418. package/template-vp-monorepo-react-nestjs/apps/web/test/tsconfig.json +2 -2
  419. package/template-vp-monorepo-react-nestjs/apps/web/test/workspace-navigation-boundary.spec.tsx +169 -0
  420. package/template-vp-monorepo-react-nestjs/apps/web/test/workspace-navigation.spec.ts +105 -0
  421. package/template-vp-monorepo-react-nestjs/apps/web/test/workspace-requests.spec.ts +174 -0
  422. package/template-vp-monorepo-react-nestjs/apps/web/test/workspaces-password-entry.spec.tsx +71 -0
  423. package/template-vp-monorepo-react-nestjs/apps/web/vite.config.ts +84 -35
  424. package/template-vp-monorepo-react-nestjs/apps/web-vue/package.json +2 -0
  425. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/api/index.ts +1 -0
  426. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/hooks/chart/useEcharts.ts +13 -1
  427. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/layout/default/header/index.vue +1 -0
  428. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/layout/default/index.vue +6 -2
  429. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/guard/index.ts +1 -0
  430. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/guard/permissionGuard.ts +1 -0
  431. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/index.ts +1 -0
  432. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/store/index.ts +1 -0
  433. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/store/modules/user.ts +1 -0
  434. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/types/auto-import.d.ts +2 -0
  435. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/request/core/index.ts +30 -0
  436. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/request/core/utils.ts +5 -0
  437. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/request/index.ts +2 -0
  438. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/views/example/echart/config.ts +1 -0
  439. package/template-vp-monorepo-react-nestjs/apps/web-vue/tsconfig.tsbuildinfo +1 -1
  440. package/template-vp-monorepo-react-nestjs/package.json +1 -8
  441. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/command.ts +22 -2
  442. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/config.ts +1 -0
  443. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/files.ts +1 -0
  444. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/load-config.ts +8 -0
  445. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/report.ts +2 -0
  446. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/scanner.ts +11 -0
  447. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/sync.ts +12 -0
  448. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/command.test.ts +1 -0
  449. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/temporary-directory.ts +5 -0
  450. package/template-vp-monorepo-react-nestjs/packages/i18n/core/src/format.ts +1 -0
  451. package/template-vp-monorepo-react-nestjs/packages/i18n/core/src/i18n.ts +14 -0
  452. package/template-vp-monorepo-react-nestjs/packages/i18n/core/src/storage.ts +8 -0
  453. package/template-vp-monorepo-react-nestjs/packages/i18n/core/src/store.ts +9 -0
  454. package/template-vp-monorepo-react-nestjs/packages/i18n/react/src/index.tsx +5 -0
  455. package/template-vp-monorepo-react-nestjs/packages/i18n/react/src/use-store.ts +3 -0
  456. package/template-vp-monorepo-react-nestjs/packages/i18n/vue/src/index.ts +6 -0
  457. package/template-vp-monorepo-react-nestjs/packages/i18n/vue/src/use-store.ts +3 -0
  458. package/template-vp-monorepo-react-nestjs/packages/shared/package.json +5 -1
  459. package/template-vp-monorepo-react-nestjs/packages/shared/src/index.ts +2 -13
  460. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/access-maintenance.ts +68 -0
  461. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/access-menus.ts +94 -0
  462. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/access-resources.ts +168 -0
  463. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/access.ts +71 -0
  464. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/auth.ts +23 -0
  465. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/index.ts +9 -0
  466. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/organization/models.ts +361 -0
  467. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/access-catalog.ts +304 -0
  468. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/access-scope.ts +97 -0
  469. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/account.ts +1 -0
  470. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/index.ts +65 -0
  471. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/organization/contracts.ts +237 -0
  472. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/record.ts +10 -0
  473. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/session-terminal.ts +1 -0
  474. package/template-vp-monorepo-react-nestjs/packages/shared/test/access.spec.ts +194 -0
  475. package/template-vp-monorepo-react-nestjs/packages/shared/test/organization.spec.ts +168 -0
  476. package/template-vp-monorepo-react-nestjs/pnpm-workspace.yaml +103 -69
  477. package/template-vp-monorepo-react-nestjs/scripts/enterprise-qa/browser.mjs +696 -0
  478. package/template-vp-monorepo-react-nestjs/scripts/enterprise-qa/http-support.mjs +105 -0
  479. package/template-vp-monorepo-react-nestjs/scripts/enterprise-qa/http.mjs +1091 -0
  480. package/template-vp-monorepo-react-nestjs/scripts/enterprise-qa/legacy-http.mjs +140 -0
  481. package/template-vp-monorepo-react-nestjs/scripts/enterprise-qa/legacy.mjs +196 -0
  482. package/template-vp-monorepo-react-nestjs/scripts/enterprise-qa/runtime.mjs +110 -0
  483. package/template-vp-monorepo-react-nestjs/scripts/with-enterprise-test-env.mjs +80 -0
  484. package/template-vp-monorepo-react-nestjs/src/types/auto-imports.d.ts +60 -0
  485. package/template-vp-react/.vscode/settings.json +8 -1
  486. package/template-vp-react/package.json +10 -10
  487. package/template-vp-react/src/types/auto-imports.d.ts +2 -0
  488. package/template-vp-vue-eslint-vapor/package.json +20 -20
  489. package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//351/234/200/346/261/202/346/226/207/346/241/243.md +0 -177
  490. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/BasicLayout.module.css +0 -17
  491. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/.gitkeep +0 -0
  492. package/template-vp-react/.agents/rules/SVG/344/275/277/347/224/250.md +0 -8
  493. package/template-vp-react/AGENTS.md +0 -11
  494. /package/template-vp-monorepo-react-nestjs/apps/web/src/assets/{hero.png → image/hero.png} +0 -0
  495. /package/template-vp-monorepo-react-nestjs/apps/web/src/assets/{react.svg → svg/react.svg} +0 -0
  496. /package/template-vp-monorepo-react-nestjs/apps/web/src/assets/{vite.svg → svg/vite.svg} +0 -0
@@ -0,0 +1,2250 @@
1
+ import 'reflect-metadata'
2
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vite-plus/test'
3
+ import { Pool } from 'pg'
4
+ import { drizzle } from 'drizzle-orm/node-postgres'
5
+ import { migrate } from 'drizzle-orm/node-postgres/migrator'
6
+ import { and, eq, inArray } from 'drizzle-orm'
7
+ import Redis from 'ioredis'
8
+ import { ConfigService } from '@nestjs/config'
9
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { createHash, randomBytes } from 'node:crypto'
13
+ import type { CompanyDetail, MemberRecord, PermissionDefinition, ProjectDetail } from 'shared/types'
14
+ import * as schema from '@/database/schema'
15
+ import { AccessService } from '@/modules/access/access.service'
16
+ import { AccessSeedService } from '@/modules/access/seed/access-seed.service'
17
+ import { MembersService } from '@/modules/members/members.service'
18
+ import {
19
+ COMPANY_MEMBER_INVITATION_TTL_MS,
20
+ MemberInvitationsService,
21
+ } from '@/modules/members/invitations/member-invitations.service'
22
+ import { CompaniesService } from '@/modules/companies/companies.service'
23
+ import { ProjectsService } from '@/modules/projects/projects.service'
24
+ import { ProjectOrganizationInitializationService } from '@/modules/organization/initialization/project-organization-initialization.service'
25
+ import { OrganizationTemplatesService } from '@/modules/organization/templates/organization-templates.service'
26
+ import { OrganizationUnitsService } from '@/modules/organization/units/organization-units.service'
27
+ import { MemberCandidatesService } from '@/modules/organization/candidates/member-candidates.service'
28
+ import { GlobalAccountCandidatesService } from '@/modules/organization/candidates/global-account/global-account-candidates.service'
29
+ import { AdministratorsService } from '@/modules/members/administrators/administrators.service'
30
+ import { AccountsService } from '@/modules/members/accounts/accounts.service'
31
+ import { WorkspacesService } from '@/modules/access/workspaces/workspaces.service'
32
+ import { RolesService } from '@/modules/access/roles/roles.service'
33
+ import { MenusService } from '@/modules/menus/menus.service'
34
+ import { PermissionsCleanupService } from '@/modules/access/maintenance/permissions-cleanup.service'
35
+ import { SessionStoreService } from '@/modules/auth/session/session-store.service'
36
+ import { AuthRepository } from '@/modules/auth/auth.repository'
37
+ import { PasswordService } from '@/modules/auth/password.service'
38
+ import { lockAccess } from '@/modules/access/access.store'
39
+ import { ACCESS_CATALOG_VERSION, ACCESS_PERMISSION_CATALOG } from 'shared/utils'
40
+
41
+ // 仅测试模块扩展目录;生产首版没有废弃功能,不加入虚构的业务菜单。
42
+ vi.mock('shared/utils', async (original) => {
43
+ const actual = await original<typeof import('shared/utils')>()
44
+ const fixture: PermissionDefinition[] = [
45
+ {
46
+ key: 'company.fixture.read',
47
+ scopeType: 'company',
48
+ kind: 'page',
49
+ title: '测试发布功能',
50
+ routeKey: 'company.fixture',
51
+ pagePermissionKey: null,
52
+ adminOnly: false,
53
+ deprecated: false,
54
+ },
55
+ {
56
+ key: 'company.fixture.update',
57
+ scopeType: 'company',
58
+ kind: 'operation',
59
+ title: '测试发布操作',
60
+ routeKey: 'company.fixture',
61
+ pagePermissionKey: 'company.fixture.read',
62
+ adminOnly: false,
63
+ deprecated: false,
64
+ },
65
+ ]
66
+ // 只在当前测试模块修改内存目录,保留真实 getBuiltinPermissionKeys 的升级规则。
67
+ ;(actual.ACCESS_PERMISSION_CATALOG as PermissionDefinition[]).push(...fixture)
68
+ return actual
69
+ })
70
+
71
+ const enabled = process.env.RUN_ACCESS_INTEGRATION === 'true'
72
+ const databaseName = `access_it_${crypto.randomUUID().replaceAll('-', '')}`
73
+ const actor = (userId: string) => ({ userId, requestId: crypto.randomUUID() })
74
+ /** 创建可从外部释放的 Promise,用于控制并发测试中事务的进入与完成顺序。 */
75
+ const deferred = () => {
76
+ let resolve!: () => void
77
+ const promise = new Promise<void>((done) => {
78
+ resolve = done
79
+ })
80
+ return { promise, resolve }
81
+ }
82
+
83
+ describe.skipIf(!enabled)('企业权限真实 PostgreSQL / Redis 集成', () => {
84
+ let adminPool: Pool
85
+ let pool: Pool
86
+ let redis: Redis
87
+ let db: ReturnType<typeof drizzle<typeof schema>>
88
+ let access: AccessService
89
+ let seed: AccessSeedService
90
+ let members: MembersService
91
+ let invitations: MemberInvitationsService
92
+ let companies: CompaniesService
93
+ let projects: ProjectsService
94
+ let organizationTemplates: OrganizationTemplatesService
95
+ let organizationUnits: OrganizationUnitsService
96
+ let memberCandidates: MemberCandidatesService
97
+ let globalAccountCandidates: GlobalAccountCandidatesService
98
+ let accounts: AccountsService
99
+ let workspaces: WorkspacesService
100
+ let roles: RolesService
101
+ let menus: MenusService
102
+ let cleanup: PermissionsCleanupService
103
+ let sessions: SessionStoreService
104
+ let platformId: string
105
+ let companyAdminId: string
106
+ let memberId: string
107
+ let replacementId: string
108
+ let company: CompanyDetail
109
+ let project: ProjectDetail
110
+ let member: MemberRecord
111
+ let temporaryDirectory: string
112
+ let companyScope: { type: 'company'; companyId: string }
113
+
114
+ beforeAll(async () => {
115
+ if (!process.env.DATABASE_URL) throw new Error('真实测试必须提供隔离容器 DATABASE_URL')
116
+ adminPool = new Pool({ connectionString: process.env.DATABASE_URL })
117
+ await adminPool.query(`CREATE DATABASE "${databaseName}"`)
118
+ const connection = new URL(process.env.DATABASE_URL)
119
+ connection.pathname = `/${databaseName}`
120
+ pool = new Pool({ connectionString: connection.toString(), max: 12 })
121
+ db = drizzle(pool, { schema })
122
+ await migrate(db, { migrationsFolder: './drizzle' })
123
+ await migrate(db, { migrationsFolder: './drizzle' })
124
+ redis = new Redis({
125
+ protocol: 2,
126
+ host: process.env.REDIS_HOST,
127
+ port: Number(process.env.REDIS_PORT),
128
+ password: process.env.REDIS_PASSWORD || undefined,
129
+ db: Number(process.env.REDIS_DB ?? 0),
130
+ lazyConnect: true,
131
+ })
132
+ await redis.connect()
133
+ sessions = new SessionStoreService(
134
+ redis,
135
+ new ConfigService({ session: { idleTtlMs: 60_000, absoluteTtlMs: 120_000 } }),
136
+ )
137
+ access = new AccessService(db)
138
+ seed = new AccessSeedService(db)
139
+ members = new MembersService(access, seed, new PasswordService())
140
+ invitations = new MemberInvitationsService(access, members)
141
+ const administrators = new AdministratorsService(access, members, seed)
142
+ companies = new CompaniesService(access, members, administrators)
143
+ organizationTemplates = new OrganizationTemplatesService(access)
144
+ organizationUnits = new OrganizationUnitsService(access)
145
+ memberCandidates = new MemberCandidatesService(access)
146
+ globalAccountCandidates = new GlobalAccountCandidatesService(access)
147
+ projects = new ProjectsService(
148
+ access,
149
+ members,
150
+ administrators,
151
+ new ProjectOrganizationInitializationService(access, organizationTemplates),
152
+ )
153
+ accounts = new AccountsService(access, members, sessions)
154
+ workspaces = new WorkspacesService(access)
155
+ roles = new RolesService(access)
156
+ menus = new MenusService(access)
157
+ cleanup = new PermissionsCleanupService(access)
158
+ const created = await db
159
+ .insert(schema.users)
160
+ .values(
161
+ ['platform_it', 'company_admin_it', 'ordinary_it', 'replacement_it'].map((account) => ({
162
+ account,
163
+ name: account,
164
+ passwordHash: 'test-only-no-login',
165
+ })),
166
+ )
167
+ .returning()
168
+ platformId = created[0]!.id
169
+ companyAdminId = created[1]!.id
170
+ memberId = created[2]!.id
171
+ replacementId = created[3]!.id
172
+ await seed.initialize('platform_it')
173
+ temporaryDirectory = await mkdtemp(join(tmpdir(), 'codex-access-integration-'))
174
+ }, 30_000)
175
+ afterAll(async () => {
176
+ const results = await Promise.allSettled([
177
+ (async () => {
178
+ if (!redis) return
179
+ try {
180
+ for (const id of [platformId, companyAdminId, memberId, replacementId].filter(Boolean))
181
+ await sessions.revokeAllForUser(id)
182
+ } finally {
183
+ redis.disconnect()
184
+ }
185
+ })(),
186
+ (async () => {
187
+ try {
188
+ if (pool) await pool.end()
189
+ } finally {
190
+ if (adminPool) {
191
+ try {
192
+ await adminPool.query(`DROP DATABASE IF EXISTS "${databaseName}" WITH (FORCE)`)
193
+ } finally {
194
+ await adminPool.end()
195
+ }
196
+ }
197
+ }
198
+ })(),
199
+ temporaryDirectory
200
+ ? rm(temporaryDirectory, { recursive: true, force: true })
201
+ : Promise.resolve(),
202
+ ])
203
+ delete process.env.ACCESS_DEPLOYMENT_PROOF_FILE
204
+ const failed = results.find((result) => result.status === 'rejected')
205
+ if (failed?.status === 'rejected') throw failed.reason
206
+ }, 30_000)
207
+
208
+ /** 轮询测试数据库中等待权限咨询锁的连接,确认管理写操作已排队;两秒内未出现则断言失败。 */
209
+ const waitForQueuedManagementWrite = () =>
210
+ expect
211
+ .poll(
212
+ async () => {
213
+ const result = await pool.query<{ count: number }>(
214
+ "SELECT count(*)::int AS count FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid WHERE l.locktype = 'advisory' AND NOT l.granted AND l.classid = 7421 AND l.objid = 1 AND a.datname = $1",
215
+ [databaseName],
216
+ )
217
+ return result.rows[0]!.count
218
+ },
219
+ { timeout: 2000, interval: 5 },
220
+ )
221
+ .toBeGreaterThan(0)
222
+
223
+ /** 通过生产邀请流程把已有全局账号加入企业,供后续集成场景复用。 */
224
+ const joinCompanyByInvitation = async (input: {
225
+ companyId: string
226
+ inviterUserId: string
227
+ inviteeUserId: string
228
+ }) => {
229
+ const issued = await invitations.create({
230
+ actor: actor(input.inviterUserId),
231
+ scope: { type: 'company', companyId: input.companyId },
232
+ body: {},
233
+ })
234
+ return (
235
+ await invitations.accept({
236
+ actor: actor(input.inviteeUserId),
237
+ body: { token: issued.token },
238
+ })
239
+ ).member
240
+ }
241
+
242
+ it('幂等初始化不提权其他账号,公开新账号没有工作空间', async () => {
243
+ await expect(seed.initialize('platform_it')).resolves.toMatchObject({
244
+ alreadyInitialized: true,
245
+ })
246
+ await expect(seed.initialize('ordinary_it')).rejects.toMatchObject({
247
+ definition: { code: 'ACCESS.ALREADY_INITIALIZED' },
248
+ })
249
+ expect((await workspaces.workspaces(actor(memberId))).workspaces).toEqual([])
250
+ expect(
251
+ await db
252
+ .select()
253
+ .from(schema.auditLogs)
254
+ .where(eq(schema.auditLogs.action, 'access.initialize')),
255
+ ).toHaveLength(1)
256
+ })
257
+ it('创建企业、添加成员、创建项目完整事务;平台身份不等于企业成员', async () => {
258
+ company = (await companies.create({
259
+ actor: actor(platformId),
260
+ body: { name: '集成企业', code: 'integration', administratorUserId: companyAdminId },
261
+ })) as CompanyDetail
262
+ companyScope = { type: 'company', companyId: company.id }
263
+ member = await joinCompanyByInvitation({
264
+ companyId: company.id,
265
+ inviterUserId: companyAdminId,
266
+ inviteeUserId: memberId,
267
+ })
268
+ project = (await projects.create({
269
+ actor: actor(companyAdminId),
270
+ companyId: company.id,
271
+ body: {
272
+ name: '集成项目',
273
+ code: 'project_it',
274
+ administratorUserId: memberId,
275
+ organizationInitialization: { mode: 'blank' },
276
+ },
277
+ })) as ProjectDetail
278
+ const blankTrees = await db
279
+ .select()
280
+ .from(schema.organizationTrees)
281
+ .where(
282
+ and(
283
+ eq(schema.organizationTrees.scopeType, 'project'),
284
+ eq(schema.organizationTrees.companyId, company.id),
285
+ eq(schema.organizationTrees.projectId, project.id),
286
+ ),
287
+ )
288
+ expect(blankTrees).toHaveLength(1)
289
+ expect(
290
+ await db
291
+ .select()
292
+ .from(schema.organizationUnits)
293
+ .where(eq(schema.organizationUnits.treeId, blankTrees[0]!.id)),
294
+ ).toHaveLength(0)
295
+ expect(
296
+ await db.select().from(schema.positions).where(eq(schema.positions.projectId, project.id)),
297
+ ).toHaveLength(0)
298
+ await expect(
299
+ access.read({ actor: actor(platformId), scope: companyScope }, async () => true),
300
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.NOT_FOUND' } })
301
+ const context = await access.read(
302
+ {
303
+ actor: actor(companyAdminId),
304
+ scope: { type: 'project', companyId: company.id, projectId: project.id },
305
+ },
306
+ async (_tx, result) => access.context(result),
307
+ )
308
+ expect(context.administrator).toBe('company')
309
+ expect(
310
+ await db
311
+ .select()
312
+ .from(schema.projectMembers)
313
+ .where(eq(schema.projectMembers.userId, companyAdminId)),
314
+ ).toHaveLength(0)
315
+ await expect(
316
+ projects.create({
317
+ actor: actor(memberId),
318
+ companyId: company.id,
319
+ body: {
320
+ name: '不能创建',
321
+ code: 'forbidden',
322
+ administratorUserId: memberId,
323
+ organizationInitialization: { mode: 'blank' },
324
+ },
325
+ }),
326
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.FORBIDDEN' } })
327
+ })
328
+ it('企业邀请仅保存摘要,并覆盖并发幂等、轮换、撤销和过期边界', async () => {
329
+ const [idempotentUser, otherUser, expiredUser, revokedUser] = await db
330
+ .insert(schema.users)
331
+ .values(
332
+ [
333
+ ['invite_idempotent', '邀请幂等账号'],
334
+ ['invite_other', '邀请复用账号'],
335
+ ['invite_expired', '邀请过期账号'],
336
+ ['invite_revoked', '邀请撤销账号'],
337
+ ].map(([account, name]) => ({ account: account!, name: name!, passwordHash: 'test-only' })),
338
+ )
339
+ .returning()
340
+ const beforeIssue = Date.now()
341
+ const issued = await invitations.create({
342
+ actor: actor(companyAdminId),
343
+ scope: companyScope,
344
+ body: {},
345
+ })
346
+ const afterIssue = Date.now()
347
+ const expiresAt = new Date(issued.invitation.expiresAt).getTime()
348
+ expect(expiresAt).toBeGreaterThanOrEqual(beforeIssue + COMPANY_MEMBER_INVITATION_TTL_MS)
349
+ expect(expiresAt).toBeLessThanOrEqual(afterIssue + COMPANY_MEMBER_INVITATION_TTL_MS)
350
+ const [stored] = await db
351
+ .select()
352
+ .from(schema.companyMemberInvitations)
353
+ .where(eq(schema.companyMemberInvitations.id, issued.invitation.id))
354
+ expect(stored?.tokenDigest).toBe(
355
+ createHash('sha256').update(issued.token, 'utf8').digest('hex'),
356
+ )
357
+ expect(stored?.tokenDigest).not.toBe(issued.token)
358
+ const listed = await invitations.list({
359
+ actor: actor(companyAdminId),
360
+ scope: companyScope,
361
+ query: { pageSize: 100 },
362
+ })
363
+ const listedInvitation = listed.items.find((item) => item.id === issued.invitation.id)
364
+ expect(listedInvitation).toBeDefined()
365
+ expect(listedInvitation).not.toHaveProperty('token')
366
+ expect(listedInvitation).not.toHaveProperty('tokenDigest')
367
+ expect(
368
+ await db
369
+ .select()
370
+ .from(schema.companyMembers)
371
+ .where(eq(schema.companyMembers.userId, idempotentUser!.id)),
372
+ ).toHaveLength(0)
373
+
374
+ const concurrentAccepted = await Promise.all([
375
+ invitations.accept({
376
+ actor: actor(idempotentUser!.id),
377
+ body: { token: issued.token },
378
+ }),
379
+ invitations.accept({
380
+ actor: actor(idempotentUser!.id),
381
+ body: { token: issued.token },
382
+ }),
383
+ ])
384
+ expect(concurrentAccepted[0].member.id).toBe(concurrentAccepted[1].member.id)
385
+ expect(concurrentAccepted[0].member.roleNames).toEqual(['内置普通成员'])
386
+ await expect(
387
+ invitations.accept({ actor: actor(idempotentUser!.id), body: { token: issued.token } }),
388
+ ).resolves.toMatchObject({ member: { id: concurrentAccepted[0].member.id } })
389
+ await expect(
390
+ invitations.accept({ actor: actor(otherUser!.id), body: { token: issued.token } }),
391
+ ).rejects.toMatchObject({
392
+ definition: { code: 'ACCESS.MEMBER_INVITATION_ALREADY_ACCEPTED' },
393
+ })
394
+
395
+ const expired = await invitations.create({
396
+ actor: actor(companyAdminId),
397
+ scope: companyScope,
398
+ body: {},
399
+ })
400
+ await db
401
+ .update(schema.companyMemberInvitations)
402
+ .set({ expiresAt: new Date(Date.now() - 1000) })
403
+ .where(eq(schema.companyMemberInvitations.id, expired.invitation.id))
404
+ await expect(
405
+ invitations.accept({ actor: actor(expiredUser!.id), body: { token: expired.token } }),
406
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.MEMBER_INVITATION_EXPIRED' } })
407
+ const expiredList = await invitations.list({
408
+ actor: actor(companyAdminId),
409
+ scope: companyScope,
410
+ query: { status: 'expired', pageSize: 100 },
411
+ })
412
+ expect(expiredList.items.map(({ id }) => id)).toContain(expired.invitation.id)
413
+ const resent = await invitations.resend({
414
+ actor: actor(companyAdminId),
415
+ scope: companyScope,
416
+ invitationId: expired.invitation.id,
417
+ body: { expectedVersion: expired.invitation.version },
418
+ })
419
+ expect(resent.token).not.toBe(expired.token)
420
+ await expect(
421
+ invitations.accept({ actor: actor(expiredUser!.id), body: { token: expired.token } }),
422
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.MEMBER_INVITATION_NOT_FOUND' } })
423
+ await expect(
424
+ invitations.accept({ actor: actor(expiredUser!.id), body: { token: resent.token } }),
425
+ ).resolves.toMatchObject({ member: { userId: expiredUser!.id } })
426
+
427
+ const revoked = await invitations.create({
428
+ actor: actor(companyAdminId),
429
+ scope: companyScope,
430
+ body: {},
431
+ })
432
+ await invitations.revoke({
433
+ actor: actor(companyAdminId),
434
+ scope: companyScope,
435
+ invitationId: revoked.invitation.id,
436
+ body: { expectedVersion: revoked.invitation.version },
437
+ })
438
+ await expect(
439
+ invitations.accept({ actor: actor(revokedUser!.id), body: { token: revoked.token } }),
440
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.MEMBER_INVITATION_REVOKED' } })
441
+ const existingMemberInvitation = await invitations.create({
442
+ actor: actor(companyAdminId),
443
+ scope: companyScope,
444
+ body: {},
445
+ })
446
+ await expect(
447
+ invitations.accept({
448
+ actor: actor(memberId),
449
+ body: { token: existingMemberInvitation.token },
450
+ }),
451
+ ).rejects.toMatchObject({
452
+ definition: { code: 'ACCESS.MEMBER_INVITATION_MEMBER_EXISTS' },
453
+ })
454
+
455
+ const invitationAudits = await db
456
+ .select()
457
+ .from(schema.auditLogs)
458
+ .where(
459
+ inArray(schema.auditLogs.action, [
460
+ 'member.invitation.create',
461
+ 'member.invitation.accept',
462
+ 'member.invitation.resend',
463
+ 'member.invitation.revoke',
464
+ ]),
465
+ )
466
+ expect(invitationAudits.map(({ action }) => action)).toEqual(
467
+ expect.arrayContaining([
468
+ 'member.invitation.create',
469
+ 'member.invitation.accept',
470
+ 'member.invitation.resend',
471
+ 'member.invitation.revoke',
472
+ ]),
473
+ )
474
+ for (const audit of invitationAudits) {
475
+ expect(audit.summary).not.toHaveProperty('token')
476
+ expect(audit.summary).not.toHaveProperty('tokenDigest')
477
+ for (const token of [issued.token, expired.token, resent.token, revoked.token])
478
+ expect(JSON.stringify(audit.summary)).not.toContain(token)
479
+ }
480
+ })
481
+ it('企业直接注册的审计调用和落库摘要均不携带明文密码', async () => {
482
+ const password = 'Audit-only-password_123'
483
+ const audit = vi.spyOn(access, 'audit')
484
+ const registered = await members.registerCompanyMember({
485
+ actor: actor(companyAdminId),
486
+ scope: companyScope,
487
+ body: { name: '审计注册成员', account: 'audit_direct_member', password },
488
+ })
489
+ const auditCall = audit.mock.calls.find(
490
+ ([, input]) => input.action === 'member.add' && input.objectId === registered.id,
491
+ )
492
+ audit.mockRestore()
493
+ expect(auditCall?.[1]).not.toHaveProperty('body')
494
+ expect(JSON.stringify(auditCall?.[1])).not.toContain(password)
495
+ const [storedUser] = await db
496
+ .select()
497
+ .from(schema.users)
498
+ .where(eq(schema.users.id, registered.userId))
499
+ expect(storedUser?.passwordHash).not.toBe(password)
500
+ const [storedAudit] = await db
501
+ .select()
502
+ .from(schema.auditLogs)
503
+ .where(eq(schema.auditLogs.objectId, registered.id))
504
+ expect(JSON.stringify(storedAudit?.summary)).not.toContain(password)
505
+ })
506
+ it('项目成员候选强制排除失效账号、失效企业成员和已有项目成员', async () => {
507
+ const [eligibleUser, disabledMemberUser, lockedUser] = await db
508
+ .insert(schema.users)
509
+ .values([
510
+ { account: 'project_candidate_valid', name: '有效项目候选', passwordHash: 'test-only' },
511
+ {
512
+ account: 'project_candidate_member_off',
513
+ name: '停用企业成员候选',
514
+ passwordHash: 'test-only',
515
+ },
516
+ {
517
+ account: 'project_candidate_account_off',
518
+ name: '停用账号候选',
519
+ passwordHash: 'test-only',
520
+ },
521
+ ])
522
+ .returning()
523
+ for (const user of [eligibleUser!, disabledMemberUser!, lockedUser!])
524
+ await joinCompanyByInvitation({
525
+ companyId: company.id,
526
+ inviterUserId: companyAdminId,
527
+ inviteeUserId: user.id,
528
+ })
529
+ await db
530
+ .update(schema.companyMembers)
531
+ .set({ status: 'disabled' })
532
+ .where(
533
+ and(
534
+ eq(schema.companyMembers.companyId, company.id),
535
+ eq(schema.companyMembers.userId, disabledMemberUser!.id),
536
+ ),
537
+ )
538
+ await db
539
+ .update(schema.users)
540
+ .set({ status: 'locked' })
541
+ .where(eq(schema.users.id, lockedUser!.id))
542
+ const projectScope = {
543
+ type: 'project' as const,
544
+ companyId: company.id,
545
+ projectId: project.id,
546
+ }
547
+ const resolved = await memberCandidates.resolve({
548
+ actor: actor(companyAdminId),
549
+ scope: projectScope,
550
+ body: {
551
+ purpose: 'addProjectMember',
552
+ userIds: [lockedUser!.id, eligibleUser!.id, disabledMemberUser!.id, memberId],
553
+ },
554
+ })
555
+ expect(resolved.map(({ userId }) => userId)).toEqual([eligibleUser!.id])
556
+ for (const account of [disabledMemberUser!.account, lockedUser!.account, 'ordinary_it']) {
557
+ const result = await memberCandidates.search({
558
+ actor: actor(companyAdminId),
559
+ scope: projectScope,
560
+ query: { purpose: 'addProjectMember', query: account, includeDisabled: true },
561
+ })
562
+ expect(result.items).toHaveLength(0)
563
+ }
564
+ await expect(
565
+ members.addProjectMember({
566
+ actor: actor(companyAdminId),
567
+ scope: projectScope,
568
+ body: { userId: disabledMemberUser!.id },
569
+ }),
570
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.INVALID_MEMBER_ACCOUNT' } })
571
+ await expect(
572
+ members.addProjectMember({
573
+ actor: actor(companyAdminId),
574
+ scope: projectScope,
575
+ body: { userId: lockedUser!.id },
576
+ }),
577
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.INVALID_MEMBER_ACCOUNT' } })
578
+ await members.addProjectMember({
579
+ actor: actor(companyAdminId),
580
+ scope: projectScope,
581
+ body: { userId: eligibleUser!.id },
582
+ })
583
+ await expect(
584
+ memberCandidates.search({
585
+ actor: actor(companyAdminId),
586
+ scope: projectScope,
587
+ query: { purpose: 'addProjectMember', query: eligibleUser!.account },
588
+ }),
589
+ ).resolves.toMatchObject({ items: [], total: 0 })
590
+ await db
591
+ .update(schema.companies)
592
+ .set({ status: 'disabled' })
593
+ .where(eq(schema.companies.id, company.id))
594
+ try {
595
+ await expect(
596
+ memberCandidates.search({
597
+ actor: actor(companyAdminId),
598
+ scope: projectScope,
599
+ query: { purpose: 'addProjectMember', query: eligibleUser!.account },
600
+ }),
601
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.FORBIDDEN' } })
602
+ } finally {
603
+ await db
604
+ .update(schema.companies)
605
+ .set({ status: 'active' })
606
+ .where(eq(schema.companies.id, company.id))
607
+ }
608
+ })
609
+ it('全局账号候选按用途鉴权、稳定区分同名账号并按请求顺序回显', async () => {
610
+ const sharedName = '同名全局候选'
611
+ const [laterAccount, earlierAccount, lockedAccount] = await db
612
+ .insert(schema.users)
613
+ .values([
614
+ {
615
+ account: 'global_candidate_b',
616
+ name: sharedName,
617
+ passwordHash: 'test-only-no-login',
618
+ },
619
+ {
620
+ account: 'global_candidate_a',
621
+ name: sharedName,
622
+ passwordHash: 'test-only-no-login',
623
+ },
624
+ {
625
+ account: 'global_candidate_locked',
626
+ name: sharedName,
627
+ passwordHash: 'test-only-no-login',
628
+ status: 'locked' as const,
629
+ },
630
+ ])
631
+ .returning()
632
+ const read = vi.spyOn(access, 'read')
633
+ const createCandidates = await globalAccountCandidates.search({
634
+ actor: actor(platformId),
635
+ query: { purpose: 'createCompanyAdministrator', query: sharedName, pageSize: 100 },
636
+ })
637
+ const setCandidates = await globalAccountCandidates.search({
638
+ actor: actor(platformId),
639
+ query: { purpose: 'setCompanyAdministrator', query: sharedName, pageSize: 100 },
640
+ })
641
+ expect(read.mock.calls[0]?.[0]).toMatchObject({
642
+ scope: { type: 'platform' },
643
+ permission: 'platform.companies.create',
644
+ adminOnly: true,
645
+ })
646
+ expect(read.mock.calls[1]?.[0]).toMatchObject({
647
+ scope: { type: 'platform' },
648
+ permission: 'platform.companies.administrator',
649
+ adminOnly: true,
650
+ })
651
+ read.mockRestore()
652
+ expect(createCandidates.items.map((item) => item.account)).toEqual([
653
+ 'global_candidate_a',
654
+ 'global_candidate_b',
655
+ 'global_candidate_locked',
656
+ ])
657
+ expect(new Set(createCandidates.items.map((item) => item.userId)).size).toBe(3)
658
+ expect(setCandidates.items).toHaveLength(3)
659
+ expect(createCandidates.items.find((item) => item.userId === lockedAccount!.id)).toMatchObject({
660
+ status: 'locked',
661
+ disabled: true,
662
+ disabledReason: 'accountInactive',
663
+ })
664
+ const resolved = await globalAccountCandidates.resolve({
665
+ actor: actor(platformId),
666
+ body: {
667
+ purpose: 'setCompanyAdministrator',
668
+ userIds: [laterAccount!.id, crypto.randomUUID(), lockedAccount!.id, earlierAccount!.id],
669
+ },
670
+ })
671
+ expect(resolved.map((item) => item.userId)).toEqual([
672
+ laterAccount!.id,
673
+ lockedAccount!.id,
674
+ earlierAccount!.id,
675
+ ])
676
+ expect(resolved[1]).toMatchObject({ disabled: true, disabledReason: 'accountInactive' })
677
+ await expect(
678
+ globalAccountCandidates.search({
679
+ actor: actor(companyAdminId),
680
+ query: { purpose: 'createCompanyAdministrator', query: sharedName },
681
+ }),
682
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.FORBIDDEN' } })
683
+ })
684
+ it('岗位成员候选只接受 URL 当前企业或项目作用域内的岗位 ID', async () => {
685
+ const foreignCompany = await companies.create({
686
+ actor: actor(platformId),
687
+ body: {
688
+ name: '候选隔离企业',
689
+ code: 'candidate_isolation_company',
690
+ administratorUserId: replacementId,
691
+ },
692
+ })
693
+ const foreignProject = await projects.create({
694
+ actor: actor(replacementId),
695
+ companyId: foreignCompany.id,
696
+ body: {
697
+ name: '候选隔离项目',
698
+ code: 'candidate_isolation_project',
699
+ administratorUserId: replacementId,
700
+ organizationInitialization: { mode: 'blank' },
701
+ },
702
+ })
703
+ const [companyPosition, projectPosition, foreignCompanyPosition, foreignProjectPosition] =
704
+ await db
705
+ .insert(schema.positions)
706
+ .values([
707
+ {
708
+ scopeType: 'company',
709
+ companyId: company.id,
710
+ name: '候选企业岗位',
711
+ nameKey: '候选企业岗位',
712
+ code: 'candidate_company_position',
713
+ codeKey: 'candidate_company_position',
714
+ },
715
+ {
716
+ scopeType: 'project',
717
+ companyId: company.id,
718
+ projectId: project.id,
719
+ name: '候选项目岗位',
720
+ nameKey: '候选项目岗位',
721
+ code: 'candidate_project_position',
722
+ codeKey: 'candidate_project_position',
723
+ },
724
+ {
725
+ scopeType: 'company',
726
+ companyId: foreignCompany.id,
727
+ name: '候选隔离企业岗位',
728
+ nameKey: '候选隔离企业岗位',
729
+ code: 'candidate_foreign_company_pos',
730
+ codeKey: 'candidate_foreign_company_pos',
731
+ },
732
+ {
733
+ scopeType: 'project',
734
+ companyId: foreignCompany.id,
735
+ projectId: foreignProject.id,
736
+ name: '候选隔离项目岗位',
737
+ nameKey: '候选隔离项目岗位',
738
+ code: 'candidate_foreign_project_pos',
739
+ codeKey: 'candidate_foreign_project_pos',
740
+ },
741
+ ])
742
+ .returning()
743
+ await expect(
744
+ memberCandidates.search({
745
+ actor: actor(companyAdminId),
746
+ scope: companyScope,
747
+ query: { purpose: 'assignPositionMembers', positionIds: [companyPosition!.id] },
748
+ }),
749
+ ).resolves.toMatchObject({ page: 1, pageSize: 20 })
750
+ for (const positionId of [projectPosition!.id, foreignCompanyPosition!.id]) {
751
+ await expect(
752
+ memberCandidates.search({
753
+ actor: actor(companyAdminId),
754
+ scope: companyScope,
755
+ query: { purpose: 'assignPositionMembers', positionIds: [positionId] },
756
+ }),
757
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.NOT_FOUND' } })
758
+ }
759
+ const projectScope = {
760
+ type: 'project' as const,
761
+ companyId: company.id,
762
+ projectId: project.id,
763
+ }
764
+ await expect(
765
+ memberCandidates.search({
766
+ actor: actor(companyAdminId),
767
+ scope: projectScope,
768
+ query: { purpose: 'assignPositionMembers', positionIds: [projectPosition!.id] },
769
+ }),
770
+ ).resolves.toMatchObject({ page: 1, pageSize: 20 })
771
+ for (const positionId of [companyPosition!.id, foreignProjectPosition!.id]) {
772
+ await expect(
773
+ memberCandidates.search({
774
+ actor: actor(companyAdminId),
775
+ scope: projectScope,
776
+ query: { purpose: 'assignPositionMembers', positionIds: [positionId] },
777
+ }),
778
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.NOT_FOUND' } })
779
+ }
780
+ })
781
+ it('企业与项目组织负责人可原子交接,双负责人请求和数据库写入仍整批失败', async () => {
782
+ const [oldLeader, newLeader] = await db
783
+ .insert(schema.users)
784
+ .values([
785
+ {
786
+ account: 'organization_old_leader',
787
+ name: '原组织负责人',
788
+ passwordHash: 'test-only-no-login',
789
+ },
790
+ {
791
+ account: 'organization_new_leader',
792
+ name: '新组织负责人',
793
+ passwordHash: 'test-only-no-login',
794
+ },
795
+ ])
796
+ .returning()
797
+ for (const user of [oldLeader!, newLeader!]) {
798
+ await joinCompanyByInvitation({
799
+ companyId: company.id,
800
+ inviterUserId: companyAdminId,
801
+ inviteeUserId: user.id,
802
+ })
803
+ await members.addProjectMember({
804
+ actor: actor(companyAdminId),
805
+ scope: { type: 'project', companyId: company.id, projectId: project.id },
806
+ body: { userId: user.id },
807
+ })
808
+ }
809
+ const companyUnit = await organizationUnits.create({
810
+ actor: actor(companyAdminId),
811
+ scope: companyScope,
812
+ body: { name: '企业负责人交接单元', code: 'company_leader_handover' },
813
+ })
814
+ const projectScope = {
815
+ type: 'project' as const,
816
+ companyId: company.id,
817
+ projectId: project.id,
818
+ }
819
+ const projectUnit = await organizationUnits.create({
820
+ actor: actor(companyAdminId),
821
+ scope: projectScope,
822
+ body: { name: '项目负责人交接单元', code: 'project_leader_handover' },
823
+ })
824
+
825
+ for (const target of [
826
+ {
827
+ scope: companyScope,
828
+ unitId: companyUnit.id,
829
+ table: schema.companyOrganizationUnitMembers,
830
+ },
831
+ {
832
+ scope: projectScope,
833
+ unitId: projectUnit.id,
834
+ table: schema.projectOrganizationUnitMembers,
835
+ },
836
+ ] as const) {
837
+ await organizationUnits.replaceUnitMembers({
838
+ actor: actor(companyAdminId),
839
+ scope: target.scope,
840
+ unitId: target.unitId,
841
+ body: {
842
+ // 先插入未来负责人,确保旧实现会按危险顺序先尝试升级该行。
843
+ members: [
844
+ { userId: newLeader!.id, duty: 'member' },
845
+ { userId: oldLeader!.id, duty: 'leader' },
846
+ ],
847
+ },
848
+ })
849
+ const swapped = await organizationUnits.replaceUnitMembers({
850
+ actor: actor(companyAdminId),
851
+ scope: target.scope,
852
+ unitId: target.unitId,
853
+ body: {
854
+ members: [
855
+ { userId: newLeader!.id, duty: 'leader' },
856
+ { userId: oldLeader!.id, duty: 'member' },
857
+ ],
858
+ },
859
+ })
860
+ expect(
861
+ Object.fromEntries(swapped.members.map((item) => [item.member.userId, item.duty])),
862
+ ).toEqual({ [newLeader!.id]: 'leader', [oldLeader!.id]: 'member' })
863
+ await expect(
864
+ organizationUnits.replaceUnitMembers({
865
+ actor: actor(companyAdminId),
866
+ scope: target.scope,
867
+ unitId: target.unitId,
868
+ body: {
869
+ members: [
870
+ { userId: newLeader!.id, duty: 'leader' },
871
+ { userId: oldLeader!.id, duty: 'leader' },
872
+ ],
873
+ },
874
+ }),
875
+ ).rejects.toMatchObject({
876
+ definition: { code: 'ACCESS.ORGANIZATION_LEADER_CONFLICT' },
877
+ })
878
+ await expect(
879
+ db
880
+ .update(target.table)
881
+ .set({ duty: 'leader' })
882
+ .where(
883
+ and(
884
+ eq(target.table.organizationUnitId, target.unitId),
885
+ eq(target.table.userId, oldLeader!.id),
886
+ ),
887
+ ),
888
+ ).rejects.toBeTruthy()
889
+ const persisted = await db
890
+ .select({ userId: target.table.userId, duty: target.table.duty })
891
+ .from(target.table)
892
+ .where(eq(target.table.organizationUnitId, target.unitId))
893
+ expect(Object.fromEntries(persisted.map((item) => [item.userId, item.duty]))).toEqual({
894
+ [newLeader!.id]: 'leader',
895
+ [oldLeader!.id]: 'member',
896
+ })
897
+ }
898
+ })
899
+ it('项目按模板复制组织与岗位快照,不复制成员任职或角色,并校验模板版本、状态和企业归属', async () => {
900
+ const template = await organizationTemplates.create({
901
+ actor: actor(companyAdminId),
902
+ companyId: company.id,
903
+ body: {
904
+ name: '标准项目组织',
905
+ description: '用于验证项目初始化快照',
906
+ isDefault: true,
907
+ units: [
908
+ {
909
+ clientKey: 'management',
910
+ parentClientKey: null,
911
+ name: '项目管理部',
912
+ code: 'management',
913
+ description: '模板根节点',
914
+ sort: 10,
915
+ },
916
+ {
917
+ clientKey: 'finance',
918
+ parentClientKey: 'management',
919
+ name: '项目财务组',
920
+ code: 'finance',
921
+ description: '模板子节点',
922
+ sort: 20,
923
+ },
924
+ ],
925
+ positions: [
926
+ {
927
+ name: '项目经理',
928
+ code: 'project_manager',
929
+ description: '启用岗位',
930
+ status: 'active',
931
+ },
932
+ {
933
+ name: '项目资料员',
934
+ code: 'document_controller',
935
+ description: '停用岗位也应按快照复制',
936
+ status: 'disabled',
937
+ },
938
+ ],
939
+ },
940
+ })
941
+ const templateProject = await projects.create({
942
+ actor: actor(companyAdminId),
943
+ companyId: company.id,
944
+ body: {
945
+ name: '模板初始化项目',
946
+ code: 'template_project',
947
+ administratorUserId: memberId,
948
+ organizationInitialization: {
949
+ mode: 'template',
950
+ templateId: template.id,
951
+ templateVersion: template.version,
952
+ },
953
+ },
954
+ })
955
+ const [projectTree] = await db
956
+ .select()
957
+ .from(schema.organizationTrees)
958
+ .where(eq(schema.organizationTrees.projectId, templateProject.id))
959
+ expect(projectTree).toBeDefined()
960
+ const copiedUnits = await db
961
+ .select()
962
+ .from(schema.organizationUnits)
963
+ .where(eq(schema.organizationUnits.treeId, projectTree!.id))
964
+ const copiedPositions = await db
965
+ .select()
966
+ .from(schema.positions)
967
+ .where(eq(schema.positions.projectId, templateProject.id))
968
+ expect(copiedUnits).toHaveLength(template.units.length)
969
+ expect(copiedPositions).toHaveLength(template.positions.length)
970
+ expect(new Set(copiedUnits.map((item) => item.sourceTemplateUnitId))).toEqual(
971
+ new Set(template.units.map((item) => item.id)),
972
+ )
973
+ expect(new Set(copiedPositions.map((item) => item.sourceTemplatePositionId))).toEqual(
974
+ new Set(template.positions.map((item) => item.id)),
975
+ )
976
+ expect(copiedUnits.find((item) => item.code === 'finance')?.parentId).toBe(
977
+ copiedUnits.find((item) => item.code === 'management')?.id,
978
+ )
979
+ expect(
980
+ await db
981
+ .select()
982
+ .from(schema.projectMembers)
983
+ .where(eq(schema.projectMembers.projectId, templateProject.id)),
984
+ ).toMatchObject([{ userId: memberId }])
985
+ expect(
986
+ await db
987
+ .select()
988
+ .from(schema.projectOrganizationUnitMembers)
989
+ .where(eq(schema.projectOrganizationUnitMembers.projectId, templateProject.id)),
990
+ ).toHaveLength(0)
991
+ expect(
992
+ await db
993
+ .select()
994
+ .from(schema.projectPositionMembers)
995
+ .where(eq(schema.projectPositionMembers.projectId, templateProject.id)),
996
+ ).toHaveLength(0)
997
+ const projectRoles = await db
998
+ .select()
999
+ .from(schema.roles)
1000
+ .where(eq(schema.roles.projectId, templateProject.id))
1001
+ expect(projectRoles).toHaveLength(2)
1002
+ expect(projectRoles.every((item) => item.builtin !== null)).toBe(true)
1003
+
1004
+ const replaced = await organizationTemplates.replace({
1005
+ actor: actor(companyAdminId),
1006
+ companyId: company.id,
1007
+ templateId: template.id,
1008
+ body: {
1009
+ expectedVersion: template.version,
1010
+ name: template.name,
1011
+ description: '模板已产生下一版本',
1012
+ isDefault: true,
1013
+ units: [
1014
+ {
1015
+ clientKey: 'new-root',
1016
+ parentClientKey: null,
1017
+ name: '新版组织节点',
1018
+ code: 'new_root',
1019
+ sort: 1,
1020
+ },
1021
+ ],
1022
+ positions: [],
1023
+ },
1024
+ })
1025
+ expect(
1026
+ await db
1027
+ .select()
1028
+ .from(schema.organizationUnits)
1029
+ .where(eq(schema.organizationUnits.treeId, projectTree!.id)),
1030
+ ).toHaveLength(2)
1031
+ await expect(
1032
+ projects.create({
1033
+ actor: actor(companyAdminId),
1034
+ companyId: company.id,
1035
+ body: {
1036
+ name: '过期模板版本项目',
1037
+ code: 'stale_template_project',
1038
+ administratorUserId: memberId,
1039
+ organizationInitialization: {
1040
+ mode: 'template',
1041
+ templateId: template.id,
1042
+ templateVersion: template.version,
1043
+ },
1044
+ },
1045
+ }),
1046
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.TEMPLATE_VERSION_CONFLICT' } })
1047
+ const disabled = await organizationTemplates.status({
1048
+ actor: actor(companyAdminId),
1049
+ companyId: company.id,
1050
+ templateId: template.id,
1051
+ body: { expectedVersion: replaced.version, status: 'disabled' },
1052
+ })
1053
+ await expect(
1054
+ projects.create({
1055
+ actor: actor(companyAdminId),
1056
+ companyId: company.id,
1057
+ body: {
1058
+ name: '停用模板项目',
1059
+ code: 'disabled_template_project',
1060
+ administratorUserId: memberId,
1061
+ organizationInitialization: {
1062
+ mode: 'template',
1063
+ templateId: disabled.id,
1064
+ templateVersion: disabled.version,
1065
+ },
1066
+ },
1067
+ }),
1068
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.TEMPLATE_UNAVAILABLE' } })
1069
+
1070
+ const otherCompany = await companies.create({
1071
+ actor: actor(platformId),
1072
+ body: {
1073
+ name: '模板隔离企业',
1074
+ code: 'template_isolation',
1075
+ administratorUserId: companyAdminId,
1076
+ },
1077
+ })
1078
+ const foreignTemplate = await organizationTemplates.create({
1079
+ actor: actor(companyAdminId),
1080
+ companyId: otherCompany.id,
1081
+ body: {
1082
+ name: '其他企业模板',
1083
+ units: [],
1084
+ positions: [],
1085
+ },
1086
+ })
1087
+ await expect(
1088
+ projects.create({
1089
+ actor: actor(companyAdminId),
1090
+ companyId: company.id,
1091
+ body: {
1092
+ name: '跨企业模板项目',
1093
+ code: 'foreign_template_project',
1094
+ administratorUserId: memberId,
1095
+ organizationInitialization: {
1096
+ mode: 'template',
1097
+ templateId: foreignTemplate.id,
1098
+ templateVersion: foreignTemplate.version,
1099
+ },
1100
+ },
1101
+ }),
1102
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.NOT_FOUND' } })
1103
+ expect(
1104
+ await db
1105
+ .select()
1106
+ .from(schema.projects)
1107
+ .where(
1108
+ inArray(schema.projects.code, [
1109
+ 'stale_template_project',
1110
+ 'disabled_template_project',
1111
+ 'foreign_template_project',
1112
+ ]),
1113
+ ),
1114
+ ).toHaveLength(0)
1115
+ })
1116
+ it('企业层级按同父规范名称、默认排序和循环规则维护,旧接口不泄漏层级字段', async () => {
1117
+ const parent = await companies.create({
1118
+ actor: actor(platformId),
1119
+ body: {
1120
+ name: '层级父企业',
1121
+ code: 'hierarchy_parent',
1122
+ administratorUserId: replacementId,
1123
+ entityType: 'group',
1124
+ },
1125
+ })
1126
+ const firstChild = await companies.create({
1127
+ actor: actor(platformId),
1128
+ body: {
1129
+ name: 'ABC 子企业',
1130
+ code: 'hierarchy_child_1',
1131
+ administratorUserId: replacementId,
1132
+ parentCompanyId: parent.id,
1133
+ },
1134
+ })
1135
+ const secondChild = await companies.create({
1136
+ actor: actor(platformId),
1137
+ body: {
1138
+ name: '第二子企业',
1139
+ code: 'hierarchy_child_2',
1140
+ administratorUserId: replacementId,
1141
+ parentCompanyId: parent.id,
1142
+ },
1143
+ })
1144
+ expect(parent).toMatchObject({ parentCompanyId: null, entityType: 'group' })
1145
+ expect(firstChild.sort).toBe(1)
1146
+ expect(secondChild.sort).toBe(2)
1147
+ await expect(
1148
+ companies.create({
1149
+ actor: actor(platformId),
1150
+ body: {
1151
+ name: 'ABC 子企业',
1152
+ code: 'hierarchy_duplicate_name',
1153
+ administratorUserId: replacementId,
1154
+ parentCompanyId: parent.id,
1155
+ },
1156
+ }),
1157
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.DUPLICATE_RESOURCE' } })
1158
+ await expect(
1159
+ companies.updateHierarchy({
1160
+ actor: actor(platformId),
1161
+ companyId: parent.id,
1162
+ body: {
1163
+ expectedVersion: parent.version,
1164
+ parentCompanyId: firstChild.id,
1165
+ entityType: parent.entityType,
1166
+ },
1167
+ }),
1168
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.ORGANIZATION_CYCLE' } })
1169
+ await companies.create({
1170
+ actor: actor(platformId),
1171
+ body: {
1172
+ name: '排序上限子企业',
1173
+ code: 'hierarchy_child_max',
1174
+ administratorUserId: replacementId,
1175
+ parentCompanyId: parent.id,
1176
+ sort: 999_999_999,
1177
+ },
1178
+ })
1179
+ await expect(
1180
+ companies.create({
1181
+ actor: actor(platformId),
1182
+ body: {
1183
+ name: '无法自动排序子企业',
1184
+ code: 'hierarchy_sort_exhausted',
1185
+ administratorUserId: replacementId,
1186
+ parentCompanyId: parent.id,
1187
+ },
1188
+ }),
1189
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.ORGANIZATION_SORT_EXHAUSTED' } })
1190
+ const tree = await companies.hierarchyTree({ actor: actor(platformId) })
1191
+ expect(
1192
+ tree
1193
+ .find((item) => item.id === parent.id)
1194
+ ?.children.slice(0, 2)
1195
+ .map((item) => item.id),
1196
+ ).toEqual([firstChild.id, secondChild.id])
1197
+ expect(
1198
+ await db
1199
+ .select()
1200
+ .from(schema.organizationTrees)
1201
+ .where(eq(schema.organizationTrees.companyId, parent.id)),
1202
+ ).toHaveLength(1)
1203
+ const oldListRecord = (
1204
+ await companies.listCompanies({ actor: actor(platformId), query: { pageSize: 100 } })
1205
+ ).items.find((item) => item.id === parent.id)!
1206
+ const oldDetail = await companies.get({
1207
+ actor: actor(platformId),
1208
+ companyId: parent.id,
1209
+ platform: true,
1210
+ })
1211
+ expect(Object.hasOwn(oldListRecord, 'parentCompanyId')).toBe(false)
1212
+ expect(Object.hasOwn(oldDetail, 'parentCompanyId')).toBe(false)
1213
+ const [hierarchyOperator] = await db
1214
+ .insert(schema.users)
1215
+ .values({
1216
+ account: 'hierarchy_operator',
1217
+ name: '企业层级维护员',
1218
+ passwordHash: 'test-only-no-login',
1219
+ })
1220
+ .returning()
1221
+ const hierarchyRole = await roles.create({
1222
+ actor: actor(platformId),
1223
+ scope: { type: 'platform' },
1224
+ body: {
1225
+ name: '企业层级维护角色',
1226
+ permissionKeys: ['platform.companies.read', 'platform.companies.hierarchy'],
1227
+ },
1228
+ })
1229
+ await accounts.accountChange({
1230
+ actor: actor(platformId),
1231
+ userId: hierarchyOperator!.id,
1232
+ body: { roleIds: [hierarchyRole.id] },
1233
+ })
1234
+ await expect(
1235
+ companies.updateHierarchy({
1236
+ actor: actor(hierarchyOperator!.id),
1237
+ companyId: secondChild.id,
1238
+ body: {
1239
+ expectedVersion: secondChild.version,
1240
+ parentCompanyId: null,
1241
+ entityType: 'group',
1242
+ },
1243
+ }),
1244
+ ).resolves.toMatchObject({
1245
+ id: secondChild.id,
1246
+ parentCompanyId: null,
1247
+ entityType: 'group',
1248
+ })
1249
+ })
1250
+ it('项目创建审计失败时项目、角色和成员全部回滚', async () => {
1251
+ const before = {
1252
+ trees: (await db.select().from(schema.organizationTrees)).length,
1253
+ roles: (await db.select().from(schema.roles)).length,
1254
+ members: (await db.select().from(schema.projectMembers)).length,
1255
+ }
1256
+ const failingAudit = vi
1257
+ .spyOn(access, 'audit')
1258
+ .mockRejectedValueOnce(new Error('transaction audit fixture'))
1259
+ await expect(
1260
+ projects.create({
1261
+ actor: actor(companyAdminId),
1262
+ companyId: company.id,
1263
+ body: {
1264
+ name: '回滚项目',
1265
+ code: 'rollback',
1266
+ administratorUserId: memberId,
1267
+ organizationInitialization: { mode: 'blank' },
1268
+ },
1269
+ }),
1270
+ ).rejects.toThrow('transaction audit fixture')
1271
+ failingAudit.mockRestore()
1272
+ expect(
1273
+ await db.select().from(schema.projects).where(eq(schema.projects.code, 'rollback')),
1274
+ ).toHaveLength(0)
1275
+ expect(await db.select().from(schema.organizationTrees)).toHaveLength(before.trees)
1276
+ expect(await db.select().from(schema.roles)).toHaveLength(before.roles)
1277
+ expect(await db.select().from(schema.projectMembers)).toHaveLength(before.members)
1278
+ })
1279
+ it('同范围唯一、内置角色保护、非法授权与跨企业成员约束', async () => {
1280
+ await expect(
1281
+ members.addProjectMember({
1282
+ actor: actor(companyAdminId),
1283
+ scope: { type: 'project', companyId: company.id, projectId: project.id },
1284
+ body: { userId: memberId },
1285
+ }),
1286
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.DUPLICATE_RESOURCE' } })
1287
+ const builtin = (
1288
+ await roles.list({ actor: actor(companyAdminId), scope: companyScope, query: {} })
1289
+ ).items.find((r) => r.builtin === 'administrator')!
1290
+ await expect(
1291
+ roles.change({
1292
+ actor: actor(companyAdminId),
1293
+ scope: companyScope,
1294
+ roleId: builtin.id,
1295
+ action: 'delete',
1296
+ body: { expectedVersion: builtin.version },
1297
+ }),
1298
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.BUILTIN_ROLE_IMMUTABLE' } })
1299
+ await expect(
1300
+ roles.create({
1301
+ actor: actor(companyAdminId),
1302
+ scope: companyScope,
1303
+ body: { name: '非法授权', permissionKeys: ['company.projects.create'] },
1304
+ }),
1305
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.INVALID_PERMISSION_SET' } })
1306
+ const foreignCompany = (await companies.create({
1307
+ actor: actor(platformId),
1308
+ body: { name: '另一企业', code: 'foreign_it', administratorUserId: replacementId },
1309
+ })) as CompanyDetail
1310
+ await expect(
1311
+ db
1312
+ .insert(schema.projectMembers)
1313
+ .values({ companyId: foreignCompany.id, projectId: project.id, userId: replacementId }),
1314
+ ).rejects.toBeTruthy()
1315
+ await expect(
1316
+ members.record(db, { type: 'company', companyId: foreignCompany.id }, member.id),
1317
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.NOT_FOUND' } })
1318
+ })
1319
+ it('公司管理员和自定义角色均受菜单停用,隐藏只影响导航', async () => {
1320
+ const tree = await menus.list({ actor: actor(platformId), scopeType: 'company' })
1321
+ const page = tree.items.find((m) => m.routeKey === 'company.profile')!
1322
+ let changed = await menus.change({
1323
+ actor: actor(platformId),
1324
+ menuId: page.id,
1325
+ action: 'update',
1326
+ body: { expectedVersion: tree.version, hidden: true },
1327
+ })
1328
+ /** 以公司管理员身份重新鉴权并读取当前公司权限上下文,观察菜单修改后的导航和权限变化。 */
1329
+ const getContext = () =>
1330
+ access.read({ actor: actor(companyAdminId), scope: companyScope }, async (_tx, current) =>
1331
+ access.context(current),
1332
+ )
1333
+ expect((await getContext()).permissionKeys).toContain('company.profile.update')
1334
+ expect((await getContext()).menus.some((m) => m.id === page.id)).toBe(false)
1335
+ changed = await menus.change({
1336
+ actor: actor(platformId),
1337
+ menuId: page.id,
1338
+ action: 'update',
1339
+ body: { expectedVersion: changed.version, status: 'disabled' },
1340
+ })
1341
+ expect((await getContext()).permissionKeys).not.toContain('company.profile.update')
1342
+ await menus.change({
1343
+ actor: actor(platformId),
1344
+ menuId: page.id,
1345
+ action: 'update',
1346
+ body: { expectedVersion: changed.version, hidden: false, status: 'active' },
1347
+ })
1348
+ const platformTree = await menus.list({ actor: actor(platformId), scopeType: 'platform' })
1349
+ await expect(
1350
+ menus.change({
1351
+ actor: actor(platformId),
1352
+ menuId: platformTree.items.find((m) => m.routeKey === 'platform.menus')!.id,
1353
+ action: 'update',
1354
+ body: { expectedVersion: platformTree.version, status: 'disabled' },
1355
+ }),
1356
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.PROTECTED_MENU' } })
1357
+ })
1358
+ it('停用保留子级状态,移除企业成员清理项目授权且重新加入不恢复', async () => {
1359
+ let p = await projects.status({
1360
+ actor: actor(companyAdminId),
1361
+ companyId: company.id,
1362
+ projectId: project.id,
1363
+ body: { expectedVersion: project.version, status: 'disabled' },
1364
+ })
1365
+ let c = await companies.status({
1366
+ actor: actor(platformId),
1367
+ companyId: company.id,
1368
+ body: { expectedVersion: company.version, status: 'disabled' },
1369
+ })
1370
+ c = await companies.status({
1371
+ actor: actor(platformId),
1372
+ companyId: company.id,
1373
+ body: { expectedVersion: c.version, status: 'active' },
1374
+ })
1375
+ expect(
1376
+ (await projects.project(db, { companyId: company.id, projectId: project.id })).status,
1377
+ ).toBe('disabled')
1378
+ p = await projects.status({
1379
+ actor: actor(companyAdminId),
1380
+ companyId: company.id,
1381
+ projectId: project.id,
1382
+ body: { expectedVersion: p.version, status: 'active' },
1383
+ })
1384
+ const isolatedCompany = await companies.create({
1385
+ actor: actor(platformId),
1386
+ body: {
1387
+ name: '成员隔离企业',
1388
+ code: 'member_isolation',
1389
+ administratorUserId: memberId,
1390
+ },
1391
+ })
1392
+ const isolatedProject = await projects.create({
1393
+ actor: actor(memberId),
1394
+ companyId: isolatedCompany.id,
1395
+ body: {
1396
+ name: '成员隔离项目',
1397
+ code: 'member_isolation_project',
1398
+ administratorUserId: memberId,
1399
+ organizationInitialization: { mode: 'blank' },
1400
+ },
1401
+ })
1402
+ const trees = await db.select().from(schema.organizationTrees)
1403
+ const companyTree = trees.find(
1404
+ (item) => item.scopeType === 'company' && item.companyId === company.id,
1405
+ )!
1406
+ const projectTree = trees.find((item) => item.projectId === project.id)!
1407
+ const isolatedCompanyTree = trees.find(
1408
+ (item) => item.scopeType === 'company' && item.companyId === isolatedCompany.id,
1409
+ )!
1410
+ const isolatedProjectTree = trees.find((item) => item.projectId === isolatedProject.id)!
1411
+ const [companyUnit, projectUnit, isolatedCompanyUnit, isolatedProjectUnit] = await db
1412
+ .insert(schema.organizationUnits)
1413
+ .values([
1414
+ {
1415
+ treeId: companyTree.id,
1416
+ name: '原企业组织',
1417
+ nameKey: '原企业组织',
1418
+ code: 'source_company_unit',
1419
+ codeKey: 'source_company_unit',
1420
+ sort: 1,
1421
+ },
1422
+ {
1423
+ treeId: projectTree.id,
1424
+ name: '原项目组织',
1425
+ nameKey: '原项目组织',
1426
+ code: 'source_project_unit',
1427
+ codeKey: 'source_project_unit',
1428
+ sort: 1,
1429
+ },
1430
+ {
1431
+ treeId: isolatedCompanyTree.id,
1432
+ name: '隔离企业组织',
1433
+ nameKey: '隔离企业组织',
1434
+ code: 'isolated_company_unit',
1435
+ codeKey: 'isolated_company_unit',
1436
+ sort: 1,
1437
+ },
1438
+ {
1439
+ treeId: isolatedProjectTree.id,
1440
+ name: '隔离项目组织',
1441
+ nameKey: '隔离项目组织',
1442
+ code: 'isolated_project_unit',
1443
+ codeKey: 'isolated_project_unit',
1444
+ sort: 1,
1445
+ },
1446
+ ])
1447
+ .returning()
1448
+ const [companyPosition, projectPosition, isolatedCompanyPosition, isolatedProjectPosition] =
1449
+ await db
1450
+ .insert(schema.positions)
1451
+ .values([
1452
+ {
1453
+ scopeType: 'company',
1454
+ companyId: company.id,
1455
+ name: '原企业岗位',
1456
+ nameKey: '原企业岗位',
1457
+ code: 'source_company_position',
1458
+ codeKey: 'source_company_position',
1459
+ },
1460
+ {
1461
+ scopeType: 'project',
1462
+ companyId: company.id,
1463
+ projectId: project.id,
1464
+ name: '原项目岗位',
1465
+ nameKey: '原项目岗位',
1466
+ code: 'source_project_position',
1467
+ codeKey: 'source_project_position',
1468
+ },
1469
+ {
1470
+ scopeType: 'company',
1471
+ companyId: isolatedCompany.id,
1472
+ name: '隔离企业岗位',
1473
+ nameKey: '隔离企业岗位',
1474
+ code: 'isolated_company_position',
1475
+ codeKey: 'isolated_company_position',
1476
+ },
1477
+ {
1478
+ scopeType: 'project',
1479
+ companyId: isolatedCompany.id,
1480
+ projectId: isolatedProject.id,
1481
+ name: '隔离项目岗位',
1482
+ nameKey: '隔离项目岗位',
1483
+ code: 'isolated_project_position',
1484
+ codeKey: 'isolated_project_position',
1485
+ },
1486
+ ])
1487
+ .returning()
1488
+ await db.insert(schema.companyOrganizationUnitMembers).values([
1489
+ {
1490
+ companyId: company.id,
1491
+ treeId: companyTree.id,
1492
+ organizationUnitId: companyUnit!.id,
1493
+ userId: memberId,
1494
+ duty: 'leader',
1495
+ },
1496
+ {
1497
+ companyId: isolatedCompany.id,
1498
+ treeId: isolatedCompanyTree.id,
1499
+ organizationUnitId: isolatedCompanyUnit!.id,
1500
+ userId: memberId,
1501
+ duty: 'leader',
1502
+ },
1503
+ ])
1504
+ await db.insert(schema.projectOrganizationUnitMembers).values([
1505
+ {
1506
+ companyId: company.id,
1507
+ projectId: project.id,
1508
+ treeId: projectTree.id,
1509
+ organizationUnitId: projectUnit!.id,
1510
+ userId: memberId,
1511
+ duty: 'deputy',
1512
+ },
1513
+ {
1514
+ companyId: isolatedCompany.id,
1515
+ projectId: isolatedProject.id,
1516
+ treeId: isolatedProjectTree.id,
1517
+ organizationUnitId: isolatedProjectUnit!.id,
1518
+ userId: memberId,
1519
+ duty: 'deputy',
1520
+ },
1521
+ ])
1522
+ await db.insert(schema.companyPositionMembers).values([
1523
+ { companyId: company.id, positionId: companyPosition!.id, userId: memberId },
1524
+ {
1525
+ companyId: isolatedCompany.id,
1526
+ positionId: isolatedCompanyPosition!.id,
1527
+ userId: memberId,
1528
+ },
1529
+ ])
1530
+ await db.insert(schema.projectPositionMembers).values([
1531
+ {
1532
+ companyId: company.id,
1533
+ projectId: project.id,
1534
+ positionId: projectPosition!.id,
1535
+ userId: memberId,
1536
+ },
1537
+ {
1538
+ companyId: isolatedCompany.id,
1539
+ projectId: isolatedProject.id,
1540
+ positionId: isolatedProjectPosition!.id,
1541
+ userId: memberId,
1542
+ },
1543
+ ])
1544
+ const isolatedRoleAssignments = await db
1545
+ .select({ roleId: schema.userRoles.roleId })
1546
+ .from(schema.userRoles)
1547
+ .innerJoin(schema.roles, eq(schema.roles.id, schema.userRoles.roleId))
1548
+ .where(
1549
+ and(eq(schema.userRoles.userId, memberId), eq(schema.roles.companyId, isolatedCompany.id)),
1550
+ )
1551
+ await members.change({
1552
+ actor: actor(companyAdminId),
1553
+ scope: companyScope,
1554
+ memberId: member.id,
1555
+ action: 'remove',
1556
+ body: { expectedVersion: member.version },
1557
+ })
1558
+ expect(
1559
+ await db
1560
+ .select()
1561
+ .from(schema.projectMembers)
1562
+ .where(
1563
+ and(
1564
+ eq(schema.projectMembers.companyId, company.id),
1565
+ eq(schema.projectMembers.userId, memberId),
1566
+ ),
1567
+ ),
1568
+ ).toHaveLength(0)
1569
+ expect(
1570
+ await db
1571
+ .select()
1572
+ .from(schema.projectMembers)
1573
+ .where(
1574
+ and(
1575
+ eq(schema.projectMembers.projectId, isolatedProject.id),
1576
+ eq(schema.projectMembers.userId, memberId),
1577
+ ),
1578
+ ),
1579
+ ).toHaveLength(1)
1580
+ expect(
1581
+ await db
1582
+ .select()
1583
+ .from(schema.companyOrganizationUnitMembers)
1584
+ .where(
1585
+ and(
1586
+ eq(schema.companyOrganizationUnitMembers.companyId, company.id),
1587
+ eq(schema.companyOrganizationUnitMembers.userId, memberId),
1588
+ ),
1589
+ ),
1590
+ ).toHaveLength(0)
1591
+ expect(
1592
+ await db
1593
+ .select()
1594
+ .from(schema.projectOrganizationUnitMembers)
1595
+ .where(
1596
+ and(
1597
+ eq(schema.projectOrganizationUnitMembers.companyId, company.id),
1598
+ eq(schema.projectOrganizationUnitMembers.userId, memberId),
1599
+ ),
1600
+ ),
1601
+ ).toHaveLength(0)
1602
+ expect(
1603
+ await db
1604
+ .select()
1605
+ .from(schema.companyPositionMembers)
1606
+ .where(
1607
+ and(
1608
+ eq(schema.companyPositionMembers.companyId, company.id),
1609
+ eq(schema.companyPositionMembers.userId, memberId),
1610
+ ),
1611
+ ),
1612
+ ).toHaveLength(0)
1613
+ expect(
1614
+ await db
1615
+ .select()
1616
+ .from(schema.projectPositionMembers)
1617
+ .where(
1618
+ and(
1619
+ eq(schema.projectPositionMembers.companyId, company.id),
1620
+ eq(schema.projectPositionMembers.userId, memberId),
1621
+ ),
1622
+ ),
1623
+ ).toHaveLength(0)
1624
+ expect(
1625
+ await db
1626
+ .select()
1627
+ .from(schema.userRoles)
1628
+ .innerJoin(schema.roles, eq(schema.roles.id, schema.userRoles.roleId))
1629
+ .where(and(eq(schema.userRoles.userId, memberId), eq(schema.roles.companyId, company.id))),
1630
+ ).toHaveLength(0)
1631
+ expect(
1632
+ await db
1633
+ .select()
1634
+ .from(schema.companyOrganizationUnitMembers)
1635
+ .where(eq(schema.companyOrganizationUnitMembers.companyId, isolatedCompany.id)),
1636
+ ).toHaveLength(1)
1637
+ expect(
1638
+ await db
1639
+ .select()
1640
+ .from(schema.projectOrganizationUnitMembers)
1641
+ .where(eq(schema.projectOrganizationUnitMembers.projectId, isolatedProject.id)),
1642
+ ).toHaveLength(1)
1643
+ expect(
1644
+ await db
1645
+ .select()
1646
+ .from(schema.companyPositionMembers)
1647
+ .where(eq(schema.companyPositionMembers.companyId, isolatedCompany.id)),
1648
+ ).toHaveLength(1)
1649
+ expect(
1650
+ await db
1651
+ .select()
1652
+ .from(schema.projectPositionMembers)
1653
+ .where(eq(schema.projectPositionMembers.projectId, isolatedProject.id)),
1654
+ ).toHaveLength(1)
1655
+ expect(
1656
+ await db
1657
+ .select({ roleId: schema.userRoles.roleId })
1658
+ .from(schema.userRoles)
1659
+ .innerJoin(schema.roles, eq(schema.roles.id, schema.userRoles.roleId))
1660
+ .where(
1661
+ and(
1662
+ eq(schema.userRoles.userId, memberId),
1663
+ eq(schema.roles.companyId, isolatedCompany.id),
1664
+ ),
1665
+ ),
1666
+ ).toEqual(isolatedRoleAssignments)
1667
+ const [removalAudit] = await db
1668
+ .select()
1669
+ .from(schema.auditLogs)
1670
+ .where(
1671
+ and(eq(schema.auditLogs.action, 'member.remove'), eq(schema.auditLogs.objectId, member.id)),
1672
+ )
1673
+ expect(removalAudit?.summary).toMatchObject({
1674
+ removedOrganizationRelationCount: 2,
1675
+ removedPositionAssignmentCount: 2,
1676
+ })
1677
+ member = await joinCompanyByInvitation({
1678
+ companyId: company.id,
1679
+ inviterUserId: companyAdminId,
1680
+ inviteeUserId: memberId,
1681
+ })
1682
+ expect(member.roleNames).toEqual(['内置普通成员'])
1683
+ project = { ...project, ...p }
1684
+ company = { ...company, ...c }
1685
+ })
1686
+ it('管理写先持锁允许提交,随后撤权;撤权先提交则锁内鉴权拒绝且无成功审计', async () => {
1687
+ const custom = await roles.create({
1688
+ actor: actor(companyAdminId),
1689
+ scope: companyScope,
1690
+ body: {
1691
+ name: '资料维护员',
1692
+ permissionKeys: ['company.profile.read', 'company.profile.update'],
1693
+ },
1694
+ })
1695
+ member = (await members.change({
1696
+ actor: actor(companyAdminId),
1697
+ scope: companyScope,
1698
+ memberId: member.id,
1699
+ action: 'roles',
1700
+ body: { expectedVersion: member.version, roleIds: [...member.roleIds, custom.id] },
1701
+ })) as MemberRecord
1702
+ const held = deferred()
1703
+ const release = deferred()
1704
+ const writeActor = actor(memberId)
1705
+ const write = access.write(
1706
+ { actor: writeActor, scope: companyScope, permission: 'company.profile.update' },
1707
+ async (tx, verified) => {
1708
+ held.resolve()
1709
+ await release.promise
1710
+ await tx
1711
+ .update(schema.companies)
1712
+ .set({ description: '先持锁写入' })
1713
+ .where(eq(schema.companies.id, company.id))
1714
+ await access.audit(tx, {
1715
+ actor: writeActor,
1716
+ access: verified,
1717
+ action: 'company.update',
1718
+ objectType: 'company',
1719
+ objectId: company.id,
1720
+ })
1721
+ },
1722
+ )
1723
+ await held.promise
1724
+ const revoke = members.change({
1725
+ actor: actor(companyAdminId),
1726
+ scope: companyScope,
1727
+ memberId: member.id,
1728
+ action: 'roles',
1729
+ body: {
1730
+ expectedVersion: member.version,
1731
+ roleIds: member.roleIds.filter((id) => id !== custom.id),
1732
+ },
1733
+ })
1734
+ try {
1735
+ await waitForQueuedManagementWrite()
1736
+ } finally {
1737
+ release.resolve()
1738
+ }
1739
+ await write
1740
+ member = (await revoke) as MemberRecord
1741
+ expect((await companies.company(db, company.id)).description).toBe('先持锁写入')
1742
+
1743
+ member = (await members.change({
1744
+ actor: actor(companyAdminId),
1745
+ scope: companyScope,
1746
+ memberId: member.id,
1747
+ action: 'roles',
1748
+ body: { expectedVersion: member.version, roleIds: [...member.roleIds, custom.id] },
1749
+ })) as MemberRecord
1750
+ const revocationHeld = deferred()
1751
+ const releaseRevocation = deferred()
1752
+ const revokeActor = actor(companyAdminId)
1753
+ const originalAudit = access.audit.bind(access)
1754
+ const gateAudit = vi.spyOn(access, 'audit').mockImplementation(async (tx, input) => {
1755
+ await originalAudit(tx, input)
1756
+ if (input.actor.requestId === revokeActor.requestId) {
1757
+ revocationHeld.resolve()
1758
+ await releaseRevocation.promise
1759
+ }
1760
+ })
1761
+ const firstRevocation = members.change({
1762
+ actor: revokeActor,
1763
+ scope: companyScope,
1764
+ memberId: member.id,
1765
+ action: 'roles',
1766
+ body: {
1767
+ expectedVersion: member.version,
1768
+ roleIds: member.roleIds.filter((id) => id !== custom.id),
1769
+ },
1770
+ })
1771
+ await revocationHeld.promise
1772
+ const afterRevocation = actor(memberId)
1773
+ const blockedWrite = companies
1774
+ .profile({
1775
+ actor: afterRevocation,
1776
+ companyId: company.id,
1777
+ body: { expectedVersion: company.version, description: '不允许写入' },
1778
+ })
1779
+ .then(
1780
+ (result) => ({ result }),
1781
+ (error) => ({ error }),
1782
+ )
1783
+ try {
1784
+ await waitForQueuedManagementWrite()
1785
+ } finally {
1786
+ releaseRevocation.resolve()
1787
+ }
1788
+ member = (await firstRevocation) as MemberRecord
1789
+ expect(await blockedWrite).toMatchObject({
1790
+ error: { definition: { code: 'ACCESS.FORBIDDEN' } },
1791
+ })
1792
+ gateAudit.mockRestore()
1793
+ expect((await companies.company(db, company.id)).description).toBe('先持锁写入')
1794
+ expect(
1795
+ await db
1796
+ .select()
1797
+ .from(schema.auditLogs)
1798
+ .where(eq(schema.auditLogs.requestId, afterRevocation.requestId)),
1799
+ ).toHaveLength(0)
1800
+ })
1801
+ it('并发停用两位企业管理员只能成功一次', async () => {
1802
+ await companies.setAdministrator({
1803
+ actor: actor(platformId),
1804
+ companyId: company.id,
1805
+ body: { administratorUserId: replacementId },
1806
+ })
1807
+ const currentMembers = (
1808
+ await members.list({ actor: actor(companyAdminId), scope: companyScope, query: {} })
1809
+ ).items
1810
+ const first = currentMembers.find((m) => m.userId === companyAdminId)!
1811
+ const second = currentMembers.find((m) => m.userId === replacementId)!
1812
+ const results = await Promise.allSettled([
1813
+ members.change({
1814
+ actor: actor(companyAdminId),
1815
+ scope: companyScope,
1816
+ memberId: first.id,
1817
+ action: 'status',
1818
+ body: { expectedVersion: first.version, status: 'disabled' },
1819
+ }),
1820
+ members.change({
1821
+ actor: actor(replacementId),
1822
+ scope: companyScope,
1823
+ memberId: second.id,
1824
+ action: 'status',
1825
+ body: { expectedVersion: second.version, status: 'disabled' },
1826
+ }),
1827
+ ])
1828
+ expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)
1829
+ await companies.setAdministrator({
1830
+ actor: actor(platformId),
1831
+ companyId: company.id,
1832
+ body: { administratorUserId: companyAdminId },
1833
+ })
1834
+ await companies.setAdministrator({
1835
+ actor: actor(platformId),
1836
+ companyId: company.id,
1837
+ body: { administratorUserId: replacementId },
1838
+ })
1839
+ })
1840
+ it('账号禁用允许平台补任更换,旧会话撤销、恢复不恢复旧管理员角色', async () => {
1841
+ const tokenDigest = randomBytes(32).toString('hex')
1842
+ await sessions.createOrReplace({
1843
+ tokenDigest,
1844
+ userId: companyAdminId,
1845
+ terminal: 'desktop',
1846
+ loginIp: '127.0.0.1',
1847
+ userAgent: 'test',
1848
+ })
1849
+ expect(await sessions.validateAndTouch(tokenDigest)).toMatchObject({
1850
+ userId: companyAdminId,
1851
+ terminal: 'desktop',
1852
+ })
1853
+ await accounts.accountChange({
1854
+ actor: actor(platformId),
1855
+ userId: companyAdminId,
1856
+ body: { status: 'disabled' },
1857
+ })
1858
+ expect(await sessions.validateAndTouch(tokenDigest)).toBeNull()
1859
+ await companies.setAdministrator({
1860
+ actor: actor(platformId),
1861
+ companyId: company.id,
1862
+ body: { administratorUserId: replacementId, replaceUserId: companyAdminId },
1863
+ })
1864
+ await accounts.accountChange({
1865
+ actor: actor(platformId),
1866
+ userId: companyAdminId,
1867
+ body: { status: 'active' },
1868
+ })
1869
+ expect(await access.isAdministrator(db, companyScope, companyAdminId)).toBe(false)
1870
+ expect(await sessions.validateAndTouch(tokenDigest)).toBeNull()
1871
+ const platformDigest = randomBytes(32).toString('hex')
1872
+ await sessions.createOrReplace({
1873
+ tokenDigest: platformDigest,
1874
+ userId: platformId,
1875
+ terminal: 'desktop',
1876
+ loginIp: '127.0.0.1',
1877
+ userAgent: 'test',
1878
+ })
1879
+ await expect(
1880
+ accounts.accountChange({
1881
+ actor: actor(platformId),
1882
+ userId: platformId,
1883
+ body: { status: 'disabled' },
1884
+ }),
1885
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.LAST_ADMINISTRATOR' } })
1886
+ expect(await sessions.validateAndTouch(platformDigest)).toMatchObject({ userId: platformId })
1887
+ await companies.setAdministrator({
1888
+ actor: actor(platformId),
1889
+ companyId: company.id,
1890
+ body: { administratorUserId: companyAdminId },
1891
+ })
1892
+ })
1893
+ it('账号停用审计失败回滚时保留账号状态和现有会话', async () => {
1894
+ const tokenDigest = randomBytes(32).toString('hex')
1895
+ await sessions.createOrReplace({
1896
+ tokenDigest,
1897
+ userId: memberId,
1898
+ terminal: 'desktop',
1899
+ loginIp: '127.0.0.1',
1900
+ userAgent: 'test',
1901
+ })
1902
+ const auditFailure = vi
1903
+ .spyOn(access, 'audit')
1904
+ .mockRejectedValueOnce(new Error('account audit rollback fixture'))
1905
+ await expect(
1906
+ accounts.accountChange({
1907
+ actor: actor(platformId),
1908
+ userId: memberId,
1909
+ body: { status: 'disabled' },
1910
+ }),
1911
+ ).rejects.toThrow('account audit rollback fixture')
1912
+ auditFailure.mockRestore()
1913
+ expect((await accounts.accountRecord(db, memberId)).status).toBe('active')
1914
+ expect(await sessions.validateAndTouch(tokenDigest)).toMatchObject({ userId: memberId })
1915
+ })
1916
+ it('登录用户行共享锁与禁用排他锁协调,禁用完成后不会残留登录会话', async () => {
1917
+ const repository = new AuthRepository(db)
1918
+ const held = deferred()
1919
+ const release = deferred()
1920
+ const tokenDigest = randomBytes(32).toString('hex')
1921
+ const login = repository.withActiveUserLock(memberId, async () => {
1922
+ held.resolve()
1923
+ await release.promise
1924
+ return sessions.createOrReplace({
1925
+ tokenDigest,
1926
+ userId: memberId,
1927
+ terminal: 'desktop',
1928
+ loginIp: '127.0.0.1',
1929
+ userAgent: 'test',
1930
+ })
1931
+ })
1932
+ await held.promise
1933
+ const disable = accounts.accountChange({
1934
+ actor: actor(platformId),
1935
+ userId: memberId,
1936
+ body: { status: 'disabled' },
1937
+ })
1938
+ release.resolve()
1939
+ await login
1940
+ await disable
1941
+ expect(await sessions.validateAndTouch(tokenDigest)).toBeNull()
1942
+ expect(await repository.withActiveUserLock(memberId, async () => true)).toBeNull()
1943
+ await accounts.accountChange({
1944
+ actor: actor(platformId),
1945
+ userId: memberId,
1946
+ body: { status: 'active' },
1947
+ })
1948
+ })
1949
+ it('清理重新核验部署与子项,审计失败全回滚,幂等/tombstone与种子不复活', async () => {
1950
+ const fixture = ACCESS_PERMISSION_CATALOG.filter((p) => p.routeKey === 'company.fixture')
1951
+ fixture.find((p) => p.kind === 'page')!.deprecated = true
1952
+ expect(
1953
+ (await cleanup.preview(actor(platformId))).items.find(
1954
+ (p) => p.permissionKey === 'company.fixture.read',
1955
+ )?.blockedReasons,
1956
+ ).toContain('存在非废弃子节点')
1957
+ for (const p of fixture) p.deprecated = true
1958
+ await db.transaction(async (tx) => {
1959
+ await lockAccess(tx)
1960
+ await seed.sync(tx)
1961
+ })
1962
+ const proofPath = join(temporaryDirectory, 'deployment-proof.json')
1963
+ process.env.ACCESS_DEPLOYMENT_PROOF_FILE = proofPath
1964
+ /** 根据当前未废弃权限目录生成一分钟内有效的部署证明,供清理接口复核服务依赖。 */
1965
+ const makeProof = () => ({
1966
+ deploymentId: 'integration-deployment',
1967
+ completed: true,
1968
+ verifiedAt: new Date().toISOString(),
1969
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
1970
+ serviceVersions: [
1971
+ {
1972
+ version: process.env.ACCESS_RELEASE_VERSION ?? ACCESS_CATALOG_VERSION,
1973
+ instanceIds: ['isolated-test-instance'],
1974
+ requiredPermissionKeys: ACCESS_PERMISSION_CATALOG.filter((p) => !p.deprecated).map(
1975
+ (p) => p.key,
1976
+ ),
1977
+ },
1978
+ ],
1979
+ })
1980
+ await writeFile(proofPath, JSON.stringify(makeProof()))
1981
+ const activePreview = await cleanup.preview(actor(platformId))
1982
+ expect(activePreview.eligible).toBe(false)
1983
+ const tree = await menus.list({ actor: actor(platformId), scopeType: 'company' })
1984
+ const page = tree.items.find((m) => m.routeKey === 'company.fixture')!
1985
+ let version = tree.version
1986
+ for (const row of [page, ...page.children]) {
1987
+ const result = await menus.change({
1988
+ actor: actor(platformId),
1989
+ menuId: row.id,
1990
+ action: 'update',
1991
+ body: { expectedVersion: version, status: 'disabled' },
1992
+ })
1993
+ version = result.version
1994
+ }
1995
+ let preview = await cleanup.preview(actor(platformId))
1996
+ expect(preview.eligible).toBe(true)
1997
+ const expiredProof = { ...makeProof(), expiresAt: new Date(Date.now() - 1000).toISOString() }
1998
+ await writeFile(proofPath, JSON.stringify(expiredProof))
1999
+ await expect(
2000
+ cleanup.cleanup(actor(platformId), {
2001
+ permissionKeys: fixture.map((p) => p.key),
2002
+ proofDigest: preview.proofDigest!,
2003
+ }),
2004
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.CLEANUP_BLOCKED' } })
2005
+ const dependentProof = makeProof()
2006
+ dependentProof.serviceVersions.push({
2007
+ version: 'still-running-old-version',
2008
+ instanceIds: ['isolated-old-instance'],
2009
+ requiredPermissionKeys: fixture.map((p) => p.key),
2010
+ })
2011
+ await writeFile(proofPath, JSON.stringify(dependentProof))
2012
+ const dependentPreview = await cleanup.preview(actor(platformId))
2013
+ expect(
2014
+ dependentPreview.items.every((p) => p.blockedReasons.includes('在用服务版本仍依赖此权限')),
2015
+ ).toBe(true)
2016
+ await expect(
2017
+ cleanup.cleanup(actor(platformId), {
2018
+ permissionKeys: fixture.map((p) => p.key),
2019
+ proofDigest: dependentPreview.proofDigest!,
2020
+ }),
2021
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.CLEANUP_BLOCKED' } })
2022
+ const validProofText = JSON.stringify(makeProof())
2023
+ await writeFile(proofPath, validProofText)
2024
+ preview = await cleanup.preview(actor(platformId))
2025
+ await writeFile(proofPath, `${validProofText}\n`)
2026
+ await expect(
2027
+ cleanup.cleanup(actor(platformId), {
2028
+ permissionKeys: fixture.map((p) => p.key),
2029
+ proofDigest: preview.proofDigest!,
2030
+ }),
2031
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.CLEANUP_BLOCKED' } })
2032
+ preview = await cleanup.preview(actor(platformId))
2033
+ await expect(
2034
+ cleanup.cleanup(actor(platformId), {
2035
+ permissionKeys: ['company.fixture.read'],
2036
+ proofDigest: preview.proofDigest!,
2037
+ }),
2038
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.RESOURCE_IN_USE' } })
2039
+ const body = { permissionKeys: fixture.map((p) => p.key), proofDigest: preview.proofDigest! }
2040
+ const auditFailure = vi
2041
+ .spyOn(access, 'audit')
2042
+ .mockRejectedValueOnce(new Error('cleanup rollback fixture'))
2043
+ await expect(cleanup.cleanup(actor(platformId), body)).rejects.toThrow(
2044
+ 'cleanup rollback fixture',
2045
+ )
2046
+ auditFailure.mockRestore()
2047
+ expect(
2048
+ await db
2049
+ .select()
2050
+ .from(schema.permissions)
2051
+ .where(eq(schema.permissions.key, 'company.fixture.read')),
2052
+ ).toHaveLength(1)
2053
+ expect(await db.select().from(schema.cleanupTombstones)).toHaveLength(0)
2054
+ const result = await cleanup.cleanup(actor(platformId), body)
2055
+ expect(result.removedPermissionKeys).toHaveLength(2)
2056
+ expect(
2057
+ (await cleanup.cleanup(actor(platformId), body)).alreadyCleanedPermissionKeys,
2058
+ ).toHaveLength(2)
2059
+ await seed.onModuleInit()
2060
+ expect(
2061
+ await db
2062
+ .select()
2063
+ .from(schema.permissions)
2064
+ .where(eq(schema.permissions.key, 'company.fixture.read')),
2065
+ ).toHaveLength(0)
2066
+ expect(
2067
+ await db
2068
+ .select()
2069
+ .from(schema.rolePermissions)
2070
+ .where(eq(schema.rolePermissions.permissionKey, 'company.fixture.read')),
2071
+ ).toHaveLength(0)
2072
+ })
2073
+ it('菜单乐观版本、数据库版本约束和审计白名单保持有效', async () => {
2074
+ const tree = await menus.list({ actor: actor(platformId), scopeType: 'company' })
2075
+ await expect(
2076
+ menus.create({
2077
+ actor: actor(platformId),
2078
+ scopeType: 'company',
2079
+ body: {
2080
+ type: 'directory',
2081
+ parentId: null,
2082
+ name: '过期目录',
2083
+ expectedVersion: tree.version - 1,
2084
+ },
2085
+ }),
2086
+ ).rejects.toMatchObject({ definition: { code: 'ACCESS.VERSION_CONFLICT' } })
2087
+ await expect(
2088
+ db.update(schema.companies).set({ version: 0 }).where(eq(schema.companies.id, company.id)),
2089
+ ).rejects.toBeTruthy()
2090
+ const audits = await db.select().from(schema.auditLogs)
2091
+ expect(audits.length).toBeGreaterThan(15)
2092
+ expect(JSON.stringify(audits)).not.toMatch(/passwordHash|password_hash|accessToken|tokenDigest/)
2093
+ })
2094
+ it('初始化后发布新功能仅扩展同范围内置管理员,保持其他授权与菜单定制', async () => {
2095
+ const upgradePermissions: PermissionDefinition[] = [
2096
+ {
2097
+ key: 'company.upgrade_fixture.read',
2098
+ scopeType: 'company',
2099
+ kind: 'page',
2100
+ title: '升级新增页面',
2101
+ routeKey: 'company.upgrade_fixture',
2102
+ pagePermissionKey: null,
2103
+ adminOnly: false,
2104
+ deprecated: false,
2105
+ },
2106
+ {
2107
+ key: 'company.upgrade_fixture.update',
2108
+ scopeType: 'company',
2109
+ kind: 'operation',
2110
+ title: '升级新增操作',
2111
+ routeKey: 'company.upgrade_fixture',
2112
+ pagePermissionKey: 'company.upgrade_fixture.read',
2113
+ adminOnly: false,
2114
+ deprecated: false,
2115
+ },
2116
+ ]
2117
+ const upgradeKeys = upgradePermissions.map((permission) => permission.key)
2118
+ expect(await db.select().from(schema.accessBootstrap)).toHaveLength(1)
2119
+ expect(
2120
+ ACCESS_PERMISSION_CATALOG.some((permission) => upgradeKeys.includes(permission.key)),
2121
+ ).toBe(false)
2122
+ expect(
2123
+ await db
2124
+ .select()
2125
+ .from(schema.permissions)
2126
+ .where(inArray(schema.permissions.key, upgradeKeys)),
2127
+ ).toHaveLength(0)
2128
+
2129
+ const custom = await roles.create({
2130
+ actor: actor(companyAdminId),
2131
+ scope: companyScope,
2132
+ body: { name: '升级保留角色', permissionKeys: ['company.profile.read'] },
2133
+ })
2134
+ const existingMember = await members.record(db, companyScope, member.id)
2135
+ await members.change({
2136
+ actor: actor(companyAdminId),
2137
+ scope: companyScope,
2138
+ memberId: existingMember.id,
2139
+ action: 'roles',
2140
+ body: {
2141
+ expectedVersion: existingMember.version,
2142
+ roleIds: [...existingMember.roleIds, custom.id],
2143
+ },
2144
+ })
2145
+ const tree = await menus.list({ actor: actor(platformId), scopeType: 'company' })
2146
+ const profile = tree.items.find((item) => item.routeKey === 'company.profile')!
2147
+ const withDirectory = await menus.create({
2148
+ actor: actor(platformId),
2149
+ scopeType: 'company',
2150
+ body: {
2151
+ expectedVersion: tree.version,
2152
+ type: 'directory',
2153
+ parentId: null,
2154
+ name: '升级保留目录',
2155
+ sort: 432,
2156
+ },
2157
+ })
2158
+ const directory = withDirectory.items.find((item) => item.name === '升级保留目录')!
2159
+ await menus.change({
2160
+ actor: actor(platformId),
2161
+ menuId: profile.id,
2162
+ action: 'update',
2163
+ body: {
2164
+ expectedVersion: withDirectory.version,
2165
+ parentId: directory.id,
2166
+ name: '升级前自定义资料页',
2167
+ icon: '',
2168
+ sort: 987,
2169
+ hidden: true,
2170
+ status: 'disabled',
2171
+ },
2172
+ })
2173
+
2174
+ /** 按稳定顺序读取角色、授权、菜单和版本,供验证种子升级保留已有配置且只增加预期数据。 */
2175
+ const snapshot = async () => ({
2176
+ roles: await db.select().from(schema.roles).orderBy(schema.roles.id),
2177
+ grants: await db
2178
+ .select()
2179
+ .from(schema.rolePermissions)
2180
+ .orderBy(schema.rolePermissions.roleId, schema.rolePermissions.permissionKey),
2181
+ assignments: await db
2182
+ .select()
2183
+ .from(schema.userRoles)
2184
+ .orderBy(schema.userRoles.userId, schema.userRoles.roleId),
2185
+ menus: await db.select().from(schema.menus).orderBy(schema.menus.id),
2186
+ menuVersions: await db
2187
+ .select()
2188
+ .from(schema.menuVersions)
2189
+ .orderBy(schema.menuVersions.scopeType),
2190
+ })
2191
+ const before = await snapshot()
2192
+ // 此时数据库和全部内置角色已初始化,之后才模拟新版服务发布目录。
2193
+ ;(ACCESS_PERMISSION_CATALOG as PermissionDefinition[]).push(...upgradePermissions)
2194
+ await seed.onModuleInit()
2195
+ const after = await snapshot()
2196
+
2197
+ expect(after.roles.map((role) => role.id)).toEqual(before.roles.map((role) => role.id))
2198
+ for (const role of before.roles) {
2199
+ const previousKeys = before.grants
2200
+ .filter((grant) => grant.roleId === role.id)
2201
+ .map((grant) => grant.permissionKey)
2202
+ const nextKeys = after.grants
2203
+ .filter((grant) => grant.roleId === role.id)
2204
+ .map((grant) => grant.permissionKey)
2205
+ const receivesUpgrade = role.scopeType === 'company' && role.builtin === 'administrator'
2206
+ expect(nextKeys).toEqual([...previousKeys, ...(receivesUpgrade ? upgradeKeys : [])].sort())
2207
+ const nextRole = after.roles.find((candidate) => candidate.id === role.id)!
2208
+ if (receivesUpgrade) expect(nextRole.version).toBe(role.version + 1)
2209
+ else expect(nextRole).toEqual(role)
2210
+ }
2211
+ expect(after.assignments).toEqual(before.assignments)
2212
+ expect(
2213
+ after.menus.filter((item) => before.menus.some((previous) => previous.id === item.id)),
2214
+ ).toEqual(before.menus)
2215
+ const addedMenus = after.menus.filter(
2216
+ (item) => item.permissionKey && upgradeKeys.includes(item.permissionKey),
2217
+ )
2218
+ expect(addedMenus).toHaveLength(2)
2219
+ const addedPage = addedMenus.find((item) => item.type === 'page')!
2220
+ expect(addedPage.permissionKey).toBe('company.upgrade_fixture.read')
2221
+ expect(addedMenus.find((item) => item.type === 'operation')).toMatchObject({
2222
+ permissionKey: 'company.upgrade_fixture.update',
2223
+ parentId: addedPage.id,
2224
+ })
2225
+ for (const previous of before.menuVersions) {
2226
+ expect(
2227
+ after.menuVersions.find((version) => version.scopeType === previous.scopeType)?.version,
2228
+ ).toBe(previous.version + (previous.scopeType === 'company' ? 2 : 0))
2229
+ }
2230
+ expect(
2231
+ await db
2232
+ .select()
2233
+ .from(schema.permissions)
2234
+ .where(inArray(schema.permissions.key, upgradeKeys)),
2235
+ ).toHaveLength(2)
2236
+ const adminContext = await access.read(
2237
+ { actor: actor(companyAdminId), scope: companyScope },
2238
+ async (_tx, current) => access.context(current),
2239
+ )
2240
+ const memberContext = await access.read(
2241
+ { actor: actor(memberId), scope: companyScope },
2242
+ async (_tx, current) => access.context(current),
2243
+ )
2244
+ expect(adminContext.permissionKeys).toEqual(expect.arrayContaining(upgradeKeys))
2245
+ expect(memberContext.permissionKeys.some((key) => upgradeKeys.includes(key))).toBe(false)
2246
+
2247
+ await seed.onModuleInit()
2248
+ expect(await snapshot()).toEqual(after)
2249
+ })
2250
+ })