simple-module-users 0.0.35__tar.gz → 0.0.36__tar.gz

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 (199) hide show
  1. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/PKG-INFO +6 -6
  2. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/pyproject.toml +6 -6
  3. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/_middleware_support.py +0 -2
  4. simple_module_users-0.0.36/tests/test_legacy_cached_tenant.py +54 -0
  5. simple_module_users-0.0.36/tests/test_login_redirect_root_mount.py +39 -0
  6. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_user_model.py +0 -1
  7. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_middleware.py +0 -1
  8. simple_module_users-0.0.36/tests/test_users_setup_lock.py +63 -0
  9. simple_module_users-0.0.36/tests/test_users_setup_wizard.py +187 -0
  10. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_shared_props.py +15 -0
  11. simple_module_users-0.0.36/tests-js/LoginForm.test.tsx +31 -0
  12. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/queries.py +9 -9
  13. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/api.py +1 -1
  14. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/LoginForm.tsx +5 -1
  15. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/bootstrap.py +22 -12
  16. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/contracts/schemas.py +0 -1
  17. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/deps.py +2 -2
  18. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/locales/en.json +5 -1
  19. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/role.py +16 -0
  20. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/user.py +0 -1
  21. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/AcceptInvite.tsx +3 -3
  22. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/ResetPassword.tsx +3 -2
  23. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/AddPeople.tsx +103 -96
  24. simple_module_users-0.0.36/users/pages/Users/Edit.tsx +129 -0
  25. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/Index.tsx +69 -66
  26. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/provider.py +3 -0
  27. simple_module_users-0.0.36/users/setup.py +101 -0
  28. simple_module_users-0.0.36/users/setup_action.py +129 -0
  29. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/shared_props.py +8 -2
  30. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/startup.py +2 -6
  31. simple_module_users-0.0.35/users/pages/Users/Edit.tsx +0 -126
  32. simple_module_users-0.0.35/users/setup.py +0 -51
  33. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/.gitignore +0 -0
  34. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/LICENSE +0 -0
  35. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/README.md +0 -0
  36. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/package.json +0 -0
  37. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/.gitkeep +0 -0
  38. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/_demo_support.py +0 -0
  39. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/_users_app_builders.py +0 -0
  40. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/conftest.py +0 -0
  41. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_access_token_model.py +0 -0
  42. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_activity_summary.py +0 -0
  43. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_admin_resend_invite.py +0 -0
  44. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_api_admin.py +0 -0
  45. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_api_admin_crud.py +0 -0
  46. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_api_admin_filters.py +0 -0
  47. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_api_auth.py +0 -0
  48. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_auth_screens_props.py +0 -0
  49. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_backend.py +0 -0
  50. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_bearer_token_age.py +0 -0
  51. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_bearer_token_expiry.py +0 -0
  52. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_bootstrap.py +0 -0
  53. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_bootstrap_resolution.py +0 -0
  54. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_bulk_invite_message.py +0 -0
  55. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_cli.py +0 -0
  56. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_constants.py +0 -0
  57. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_db_adapter.py +0 -0
  58. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_demo_account.py +0 -0
  59. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_demo_guard.py +0 -0
  60. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_demo_login.py +0 -0
  61. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_edit_recent_activity.py +0 -0
  62. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_external_users.py +0 -0
  63. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_invite_flow.py +0 -0
  64. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_invite_reuse.py +0 -0
  65. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_legacy_session_expiry.py +0 -0
  66. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_mailer.py +0 -0
  67. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_negative_authz.py +0 -0
  68. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_oauth.py +0 -0
  69. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_oauth_routes.py +0 -0
  70. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_profile_password.py +0 -0
  71. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_rate_limit.py +0 -0
  72. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_remember_me.py +0 -0
  73. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_revocation_broadcast.py +0 -0
  74. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_role_model.py +0 -0
  75. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_service_admin.py +0 -0
  76. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_service_admin_crud.py +0 -0
  77. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_session_expiry.py +0 -0
  78. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_session_revocation.py +0 -0
  79. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_session_version_cache.py +0 -0
  80. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_session_version_failopen.py +0 -0
  81. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_session_version_hardening.py +0 -0
  82. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_sessions_revoke.py +0 -0
  83. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_settings.py +0 -0
  84. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_token_api.py +0 -0
  85. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_user_manager.py +0 -0
  86. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_user_role_model.py +0 -0
  87. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_user_service.py +0 -0
  88. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_user_state.py +0 -0
  89. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_bulk_invite.py +0 -0
  90. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_deps.py +0 -0
  91. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_list_invites.py +0 -0
  92. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_login_deep_link.py +0 -0
  93. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_middleware_public_paths.py +0 -0
  94. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_middleware_resolvers.py +0 -0
  95. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_provider.py +0 -0
  96. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_views.py +0 -0
  97. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_views_admin.py +0 -0
  98. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests-js/EmailChipInput.test.tsx +0 -0
  99. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests-js/deriveState.test.ts +0 -0
  100. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests-js/invite-emails.test.ts +0 -0
  101. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tsconfig.json +0 -0
  102. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/__init__.py +0 -0
  103. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/__init__.py +0 -0
  104. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/api.py +0 -0
  105. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/bulk_invite.py +0 -0
  106. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/IndexFilters.tsx +0 -0
  107. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/RolesTab.tsx +0 -0
  108. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/StatusPill.tsx +0 -0
  109. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/UserCards.tsx +0 -0
  110. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/UserRow.tsx +0 -0
  111. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/UserRowMenu.tsx +0 -0
  112. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/UsersEmpty.tsx +0 -0
  113. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/UsersTable.tsx +0 -0
  114. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/useUserRowActions.ts +0 -0
  115. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/user-list-item.ts +0 -0
  116. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/recent_activity.py +0 -0
  117. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/resend_invite.py +0 -0
  118. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/service.py +0 -0
  119. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/user_state.py +0 -0
  120. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/views.py +0 -0
  121. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/audit.py +0 -0
  122. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/__init__.py +0 -0
  123. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/AuthField.tsx +0 -0
  124. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/AuthIntro.tsx +0 -0
  125. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/AuthStateCard.tsx +0 -0
  126. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/DemoSignIn.tsx +0 -0
  127. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/InviteSummary.tsx +0 -0
  128. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/PasswordCard.tsx +0 -0
  129. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/PasswordFields.tsx +0 -0
  130. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/PreferencesCard.tsx +0 -0
  131. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
  132. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/ResendVerification.tsx +0 -0
  133. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/ResetLinkSent.tsx +0 -0
  134. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/SessionsCard.tsx +0 -0
  135. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/WaitingOnYou.tsx +0 -0
  136. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/profile-user.ts +0 -0
  137. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/demo_api.py +0 -0
  138. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/invite_preview.py +0 -0
  139. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/rate_limit.py +0 -0
  140. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/self_account.py +0 -0
  141. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/token_api.py +0 -0
  142. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/token_preview.py +0 -0
  143. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/views.py +0 -0
  144. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/backend.py +0 -0
  145. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/cli.py +0 -0
  146. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/constants.py +0 -0
  147. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/contracts/__init__.py +0 -0
  148. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/contracts/events.py +0 -0
  149. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/db_adapter.py +0 -0
  150. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/demo.py +0 -0
  151. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/demo_guard.py +0 -0
  152. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/exceptions.py +0 -0
  153. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/health.py +0 -0
  154. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/__init__.py +0 -0
  155. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/console.py +0 -0
  156. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/smtp.py +0 -0
  157. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/templates/.gitkeep +0 -0
  158. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/templates/invite.txt +0 -0
  159. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/templates/reset_password.txt +0 -0
  160. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/templates/verify_email.txt +0 -0
  161. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/manager.py +0 -0
  162. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/middleware.py +0 -0
  163. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/__init__.py +0 -0
  164. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/_base.py +0 -0
  165. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/access_token.py +0 -0
  166. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/oauth_account.py +0 -0
  167. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/refresh_token.py +0 -0
  168. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/user_role.py +0 -0
  169. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/module.py +0 -0
  170. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/oauth/__init__.py +0 -0
  171. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/oauth/api.py +0 -0
  172. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/oauth/providers.py +0 -0
  173. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/.gitkeep +0 -0
  174. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/ForgotPassword.tsx +0 -0
  175. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Login.tsx +0 -0
  176. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Profile.tsx +0 -0
  177. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Register.tsx +0 -0
  178. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/AccountCard.tsx +0 -0
  179. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/CreateUserFields.tsx +0 -0
  180. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/DangerZone.tsx +0 -0
  181. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/DetailsCard.tsx +0 -0
  182. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
  183. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/EmailChipInput.tsx +0 -0
  184. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/InviteFields.tsx +0 -0
  185. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/InviteResults.tsx +0 -0
  186. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
  187. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/RolePicker.tsx +0 -0
  188. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/UserStats.tsx +0 -0
  189. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/useEditUserForm.ts +0 -0
  190. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/useUserActions.ts +0 -0
  191. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/invite-emails.ts +0 -0
  192. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/VerifyEmail.tsx +0 -0
  193. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/py.typed +0 -0
  194. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/roles_cache.py +0 -0
  195. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/session_revocation.py +0 -0
  196. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/session_version_cache.py +0 -0
  197. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/settings.py +0 -0
  198. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/state.py +0 -0
  199. {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/token_strategy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_users
3
- Version: 0.0.35
3
+ Version: 0.0.36
4
4
  Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -24,11 +24,11 @@ Requires-Python: >=3.12
24
24
  Requires-Dist: aiosmtplib>=3.0
25
25
  Requires-Dist: cachetools>=5.3
26
26
  Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
27
- Requires-Dist: simple-module-auth==0.0.35
28
- Requires-Dist: simple-module-core==0.0.35
29
- Requires-Dist: simple-module-db==0.0.35
30
- Requires-Dist: simple-module-hosting==0.0.35
31
- Requires-Dist: simple-module-settings==0.0.35
27
+ Requires-Dist: simple-module-auth==0.0.36
28
+ Requires-Dist: simple-module-core==0.0.36
29
+ Requires-Dist: simple-module-db==0.0.36
30
+ Requires-Dist: simple-module-hosting==0.0.36
31
+ Requires-Dist: simple-module-settings==0.0.36
32
32
  Requires-Dist: typer>=0.12
33
33
  Description-Content-Type: text/markdown
34
34
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_users"
3
- version = "0.0.35"
3
+ version = "0.0.36"
4
4
  description = "Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,11 +21,11 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.35",
25
- "simple_module_db==0.0.35",
26
- "simple_module_hosting==0.0.35",
27
- "simple_module_settings==0.0.35",
28
- "simple_module_auth==0.0.35",
24
+ "simple_module_core==0.0.36",
25
+ "simple_module_db==0.0.36",
26
+ "simple_module_hosting==0.0.36",
27
+ "simple_module_settings==0.0.36",
28
+ "simple_module_auth==0.0.36",
29
29
  # Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
30
30
  # fields after construction (see reconfigure_cookie_transport in backend.py).
31
31
  # Bumping the major version requires re-checking those field names.
@@ -49,7 +49,6 @@ async def _build_app(db_state, inner_handler=None, *, principal_resolvers=None):
49
49
  "email": user.email,
50
50
  "name": user.name,
51
51
  "roles": user.roles,
52
- "tenant_id": user.tenant_id,
53
52
  }
54
53
  if user is not None
55
54
  else None
@@ -106,7 +105,6 @@ async def mw_active_user(db_session, _mw_seed_roles):
106
105
  is_superuser=False,
107
106
  is_verified=True,
108
107
  full_name="Middleware Tester",
109
- tenant_id="acme",
110
108
  )
111
109
  link = UserRole(user_id=user_id, role_id=ADMIN_ROLE_ID)
112
110
  db_session.add_all([user, link])
@@ -0,0 +1,54 @@
1
+ """A pre-#381 session cookie's cached ``tenant_id`` is ignored.
2
+
3
+ ``users_user.tenant_id`` is gone and the active tenant is resolved per request
4
+ from memberships, but cookies signed before the upgrade still carry the old
5
+ value inside ``user_ctx``. Honouring it would keep acting for a tenant the
6
+ user may have left.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+
13
+ import pytest
14
+ from auth.contracts.schemas import UserContext
15
+ from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY
16
+ from sqlalchemy import select
17
+ from starlette.requests import Request
18
+ from users.models import User
19
+ from users.provider import UsersAuthProvider
20
+
21
+
22
+ async def _admin_id(users_app) -> str:
23
+ async with users_app.state.sm.db.session_factory() as session:
24
+ stmt = select(User.id).where(User.email == "admin@example.com")
25
+ return str((await session.execute(stmt)).scalar_one())
26
+
27
+
28
+ @pytest.mark.anyio
29
+ async def test_old_cookie_tenant_id_is_dropped_on_the_cached_path(users_app):
30
+ user_id = await _admin_id(users_app)
31
+ legacy_ctx = UserContext(
32
+ id=user_id, email="admin@example.com", name="Admin", roles=["admin"], tenant_id="acme"
33
+ ).to_session_dict()
34
+ session = {
35
+ "user_id": user_id,
36
+ "user_ctx": legacy_ctx,
37
+ "session_version": 0,
38
+ SESSION_EXPIRES_AT_KEY: int(time.time()) + 3600,
39
+ }
40
+ scope = {
41
+ "type": "http",
42
+ "method": "GET",
43
+ "path": "/",
44
+ "headers": [],
45
+ "app": users_app,
46
+ "session": session,
47
+ }
48
+
49
+ ctx = await UsersAuthProvider().resolve_user(Request(scope))
50
+
51
+ assert ctx is not None
52
+ assert ctx.id == user_id
53
+ assert ctx.name == "Admin" # the cached context was used, not a DB reload
54
+ assert ctx.tenant_id is None
@@ -0,0 +1,39 @@
1
+ """Login fallback with a module mounted at the root (#435)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+
8
+ def _fallback_url(app, prefixes: dict[str, str]) -> str:
9
+ """Run the login fallback against a Dashboard-less module set (#435)."""
10
+ from users.startup import apply_login_redirect_fallback
11
+
12
+ sm = app.state.sm
13
+ saved_mods, saved_prefixes = sm.modules, dict(sm.view_prefixes)
14
+ settings = app.state.users.settings
15
+ saved_url = settings.login_redirect_url
16
+ object.__setattr__(sm, "modules", tuple(m for m in saved_mods if m.meta.name != "Dashboard"))
17
+ sm.view_prefixes.clear()
18
+ sm.view_prefixes.update(prefixes)
19
+ settings.login_redirect_url = "/dashboard/"
20
+ try:
21
+ apply_login_redirect_fallback(app, settings, "Users", "/dashboard/")
22
+ return settings.login_redirect_url
23
+ finally:
24
+ object.__setattr__(sm, "modules", saved_mods)
25
+ sm.view_prefixes.clear()
26
+ sm.view_prefixes.update(saved_prefixes)
27
+ settings.login_redirect_url = saved_url
28
+
29
+
30
+ @pytest.mark.anyio
31
+ async def test_login_redirect_fallback_prefers_root_mounted_module(users_app):
32
+ """#435: a module that serves views at ``/`` (view_prefix == "") wins."""
33
+ assert _fallback_url(users_app, {"Users": "/users", "Home": "", "Other": "/o"}) == "/"
34
+
35
+
36
+ @pytest.mark.anyio
37
+ async def test_login_redirect_fallback_skips_api_only_module(users_app):
38
+ """An API-only module also has view_prefix == "" but is not in the map."""
39
+ assert _fallback_url(users_app, {"Users": "/users", "Other": "/o"}) == "/o/"
@@ -35,7 +35,6 @@ class TestUserTableShape:
35
35
  "is_superuser",
36
36
  "is_verified",
37
37
  "full_name",
38
- "tenant_id",
39
38
  "disabled_at",
40
39
  "last_login_at",
41
40
  # AuditMixin columns
@@ -81,7 +81,6 @@ async def test_authenticated_request_sets_user_context(db_state, mw_active_user)
81
81
  assert data["user"]["email"] == "middleware-test@example.com"
82
82
  assert data["user"]["name"] == "Middleware Tester"
83
83
  assert data["user"]["roles"] == ["admin"]
84
- assert data["user"]["tenant_id"] == "acme"
85
84
 
86
85
 
87
86
  # ---------------------------------------------------------------------------
@@ -0,0 +1,63 @@
1
+ """The database lock behind the first-admin wizard action, across connections.
2
+
3
+ ``test_users_setup_wizard`` drives concurrency through one app, where the
4
+ in-process lock already serializes requests (and an in-memory SQLite engine
5
+ shares one connection anyway). Two workers share neither, so the database lock
6
+ is the real guarantee — exercised here on two real connections.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ import pytest
14
+ import sqlalchemy as sa
15
+ from simple_module_db import init_db
16
+ from sqlalchemy.exc import OperationalError
17
+ from users.models import User
18
+ from users.setup_action import _lock_admin_creation
19
+
20
+ pytestmark = pytest.mark.anyio
21
+
22
+
23
+ async def test_sqlite_lock_holds_off_a_second_writer(tmp_path) -> None:
24
+ state = init_db(f"sqlite+aiosqlite:///{tmp_path / 'lock.db'}", sqlite_busy_timeout_ms=100)
25
+ try:
26
+ async with state.engine.begin() as conn:
27
+ await conn.run_sync(lambda sync: User.__table__.create(sync))
28
+
29
+ async with state.session_factory() as first, state.session_factory() as second:
30
+ await _lock_admin_creation(first)
31
+
32
+ with pytest.raises(OperationalError, match="locked"):
33
+ await _lock_admin_creation(second)
34
+ await second.rollback()
35
+
36
+ # Released at commit: the next worker gets through and re-checks.
37
+ await first.commit()
38
+ await _lock_admin_creation(second)
39
+ await second.rollback()
40
+ finally:
41
+ await state.engine.dispose()
42
+
43
+
44
+ @pytest.mark.skipif(
45
+ not os.environ.get("SM_TEST_DATABASE_URL", "").startswith("postgresql"),
46
+ reason="needs SM_TEST_DATABASE_URL pointing at Postgres",
47
+ )
48
+ async def test_postgres_advisory_lock_is_held_for_the_transaction() -> None:
49
+ state = init_db(os.environ["SM_TEST_DATABASE_URL"])
50
+ try:
51
+ async with state.session_factory() as first, state.session_factory() as second:
52
+ await _lock_admin_creation(first)
53
+ probe = sa.text("SELECT pg_try_advisory_xact_lock(:key)")
54
+ from users.setup_action import _PG_LOCK_KEY
55
+
56
+ assert await second.scalar(probe, {"key": _PG_LOCK_KEY}) is False
57
+ await second.rollback()
58
+
59
+ await first.commit()
60
+ assert await second.scalar(probe, {"key": _PG_LOCK_KEY}) is True
61
+ await second.rollback()
62
+ finally:
63
+ await state.engine.dispose()
@@ -0,0 +1,187 @@
1
+ """The ``users.administrator`` wizard action: anonymous creation of the first admin.
2
+
3
+ What is pinned here, in the order the action's docstring argues it:
4
+
5
+ * it completes the step and releases the gate;
6
+ * it is gated on *its own step*, so an install that re-enters setup mode with
7
+ administrators intact (schema behind head) refuses it;
8
+ * it re-checks under a lock inside the inserting transaction, so concurrent
9
+ requests mint exactly one superuser;
10
+ * the password goes through the module's real policy.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from types import SimpleNamespace
17
+
18
+ import pytest
19
+ from fastapi import HTTPException
20
+ from simple_module_test.setup_wizard import post_step, wizard_client, wizard_headers
21
+ from sqlalchemy import func, select
22
+ from users.models import User
23
+ from users.setup import STEP_ADMINISTRATOR
24
+ from users.setup_action import create_first_administrator
25
+
26
+ pytestmark = pytest.mark.anyio
27
+
28
+ _BEHIND = {
29
+ "current_revision": "abc123",
30
+ "head_revision": "def456",
31
+ "is_current": False,
32
+ "pending_count": 1,
33
+ }
34
+
35
+
36
+ async def _superusers(app) -> list[str]:
37
+ async with app.state.sm.db.session_factory() as session:
38
+ rows = await session.scalars(
39
+ select(User.email).where(User.is_superuser.is_(True), User.is_active.is_(True))
40
+ )
41
+ return list(rows)
42
+
43
+
44
+ def _schema_behind(app, monkeypatch) -> None:
45
+ """Put the install back into setup mode the way a deploy-before-migrate does."""
46
+ app.state.migration = dict(_BEHIND)
47
+
48
+ # The gate re-reads a behind-head verdict from the database rather than
49
+ # trusting the boot snapshot, so the stub has to keep saying "behind".
50
+ async def _still_behind(*_args, **_kwargs):
51
+ return dict(_BEHIND)
52
+
53
+ monkeypatch.setattr(
54
+ "simple_module_hosting.migrations.migration_status", _still_behind, raising=True
55
+ )
56
+
57
+
58
+ async def test_creating_an_admin_completes_setup(setup_pending_app) -> None:
59
+ async with wizard_client(setup_pending_app) as client:
60
+ resp = await post_step(
61
+ client,
62
+ STEP_ADMINISTRATOR,
63
+ {"email": "root@example.com", "password": "SetupPass1!", "full_name": None},
64
+ )
65
+ assert resp.status_code == 200, resp.text
66
+ assert resp.json()["result"] == {"created": True, "email": "root@example.com"}
67
+
68
+ # The gate must now release for ordinary routes, and the wizard close.
69
+ after = await client.get("/", follow_redirects=False)
70
+ wizard = await client.get("/setup")
71
+
72
+ assert after.status_code != 302
73
+ assert wizard.status_code == 404
74
+ assert await _superusers(setup_pending_app) == ["root@example.com"]
75
+
76
+
77
+ async def test_refused_when_setup_mode_reopens_with_admins_intact(app, monkeypatch) -> None:
78
+ """A behind-head schema reopens the wizard, not admin creation.
79
+
80
+ ``create_app`` registers ``host.migrations`` for every install, so a live
81
+ deployment that ships code ahead of its migration job re-enters setup mode
82
+ with its administrators intact. Gated on "setup mode" this action would
83
+ hand an anonymous request a fresh superuser there.
84
+ """
85
+ _schema_behind(app, monkeypatch)
86
+
87
+ async with wizard_client(app) as client:
88
+ resp = await post_step(
89
+ client, STEP_ADMINISTRATOR, {"email": "intruder@example.com", "password": "Whatever1!"}
90
+ )
91
+
92
+ assert resp.status_code == 409
93
+ assert "intruder@example.com" not in await _superusers(app)
94
+
95
+
96
+ async def test_handler_rechecks_inside_its_transaction(app) -> None:
97
+ """The race loser: it passed the wizard's step check, then someone else
98
+ committed an admin. The in-transaction re-check must refuse it."""
99
+ request = SimpleNamespace(app=app)
100
+
101
+ with pytest.raises(HTTPException) as exc:
102
+ await create_first_administrator(
103
+ request, {"email": "late@example.com", "password": "LatePass1!"}
104
+ )
105
+
106
+ assert exc.value.status_code == 409
107
+ assert "late@example.com" not in await _superusers(app)
108
+
109
+
110
+ async def test_concurrent_submissions_create_one_admin(setup_pending_app) -> None:
111
+ clients = [wizard_client(setup_pending_app) for _ in range(4)]
112
+ try:
113
+ # Every client loads the wizard first, as concurrent operators would —
114
+ # once the winner commits, the wizard is gone for anyone arriving later.
115
+ headers = [await wizard_headers(c) for c in clients]
116
+ responses = await asyncio.gather(
117
+ *(
118
+ c.post(
119
+ f"/setup/steps/{STEP_ADMINISTRATOR}",
120
+ json={"email": f"root{n}@example.com", "password": "RacePass1!"},
121
+ headers=h,
122
+ )
123
+ for n, (c, h) in enumerate(zip(clients, headers, strict=True))
124
+ )
125
+ )
126
+ finally:
127
+ for c in clients:
128
+ await c.aclose()
129
+
130
+ codes = sorted(r.status_code for r in responses)
131
+ assert codes.count(200) == 1, codes
132
+ # Losers are refused by the step gate or the in-transaction re-check (409),
133
+ # or — once the winner has closed the wizard entirely — by its absence (404).
134
+ assert all(code in (200, 404, 409) for code in codes), codes
135
+ async with setup_pending_app.state.sm.db.session_factory() as session:
136
+ count = await session.scalar(
137
+ select(func.count()).select_from(User).where(User.is_superuser.is_(True))
138
+ )
139
+ assert count == 1
140
+
141
+
142
+ async def test_existing_non_admin_address_is_refused(setup_pending_app) -> None:
143
+ """create_admin leaves an existing account untouched; reporting success for
144
+ a step that is still pending would leave the operator stuck."""
145
+ from users.bootstrap import create_standard_user
146
+
147
+ async with setup_pending_app.state.sm.db.session_factory() as session:
148
+ await create_standard_user(session, email="user@example.com", password="UserPass1!")
149
+
150
+ async with wizard_client(setup_pending_app) as client:
151
+ resp = await post_step(
152
+ client, STEP_ADMINISTRATOR, {"email": "user@example.com", "password": "Another1!"}
153
+ )
154
+
155
+ assert resp.status_code == 409
156
+ assert await _superusers(setup_pending_app) == []
157
+
158
+
159
+ @pytest.mark.parametrize(
160
+ "password,why",
161
+ [
162
+ (" ", "whitespace-only, exactly eight characters"),
163
+ (" a ", "one real character padded to eight"),
164
+ ("short", "under the minimum"),
165
+ ("", "empty"),
166
+ ("12345678", "all digits — the policy rejects these"),
167
+ ],
168
+ )
169
+ async def test_weak_passwords_are_refused(setup_pending_app, password: str, why: str) -> None:
170
+ """``create_admin`` writes the hash directly, bypassing ``UserManager``."""
171
+ async with wizard_client(setup_pending_app) as client:
172
+ resp = await post_step(
173
+ client, STEP_ADMINISTRATOR, {"email": "root@example.com", "password": password}
174
+ )
175
+
176
+ assert resp.status_code == 422, f"accepted a password that is {why}: {resp.text[:120]}"
177
+ # The wizard renders this straight into its error line.
178
+ detail = resp.json().get("detail")
179
+ assert isinstance(detail, str) and detail, f"unusable error body: {resp.text[:200]}"
180
+
181
+
182
+ async def test_malformed_payload_is_422(setup_pending_app) -> None:
183
+ async with wizard_client(setup_pending_app) as client:
184
+ resp = await post_step(client, STEP_ADMINISTRATOR, {"email": "not-an-address"})
185
+
186
+ assert resp.status_code == 422
187
+ assert isinstance(resp.json()["detail"], str)
@@ -109,3 +109,18 @@ class TestProviderIsRegistered:
109
109
  settings module, and a stub would pass while the wiring was broken."""
110
110
  providers = getattr(app.state, "inertia_shared_providers", [])
111
111
  assert users_shared_props in providers
112
+
113
+
114
+ class TestHomeUrlSharedProp:
115
+ def test_reflects_login_redirect_url(self) -> None:
116
+ request = _request(SimpleNamespace(settings=SimpleNamespace(login_redirect_url="/home/")))
117
+ assert users_shared_props(request)["homeUrl"] == "/home/"
118
+
119
+ def test_tracks_runtime_edits(self) -> None:
120
+ settings = SimpleNamespace(login_redirect_url="/a/")
121
+ request = _request(SimpleNamespace(settings=settings))
122
+ settings.login_redirect_url = "/b/"
123
+ assert users_shared_props(request)["homeUrl"] == "/b/"
124
+
125
+ def test_omitted_when_settings_are_missing(self) -> None:
126
+ assert "homeUrl" not in users_shared_props(_request(SimpleNamespace()))
@@ -0,0 +1,31 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { describe, expect, test, vi } from 'vitest';
4
+
5
+ import { LoginForm } from '../users/auth_local/components/LoginForm';
6
+
7
+ const baseProps = {
8
+ email: '',
9
+ onEmailChange: vi.fn(),
10
+ password: '',
11
+ onPasswordChange: vi.fn(),
12
+ remember: false,
13
+ onRememberChange: vi.fn(),
14
+ rememberDays: 30,
15
+ loading: false,
16
+ onSubmit: vi.fn(),
17
+ allowSignup: false,
18
+ oauthProviders: [],
19
+ };
20
+
21
+ describe('LoginForm', () => {
22
+ test('announces a sign-in error as an alert', () => {
23
+ render(<LoginForm {...baseProps} error="Invalid credentials" />);
24
+ expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
25
+ });
26
+
27
+ test('renders no alert without an error', () => {
28
+ render(<LoginForm {...baseProps} error={null} />);
29
+ expect(screen.queryByRole('alert')).toBeNull();
30
+ });
31
+ });
@@ -226,16 +226,16 @@ class _UserServiceBase:
226
226
  deck labels "Pending invites" means outstanding *invitations*: a
227
227
  self-registered account that never clicked its verification mail is
228
228
  nobody's to chase.
229
+
230
+ One scan with ``COUNT(*) FILTER (WHERE …)`` per state rather than a
231
+ round-trip each (Postgres and SQLite >= 3.30 both support it).
229
232
  """
230
- counts: dict[str, int] = {}
231
- for key, condition in (
232
- ("active", STATUS_CONDITIONS["active"]),
233
- ("unverified", STATUS_CONDITIONS["unverified"]),
234
- ("invited", STATUS_CONDITIONS["invited"]),
235
- ):
236
- stmt = select(func.count()).select_from(User).where(condition())
237
- counts[key] = int((await self._db.execute(stmt)).scalar_one())
238
- return counts
233
+ keys = ("active", "unverified", "invited")
234
+ stmt = select(*(func.count().filter(STATUS_CONDITIONS[k]()) for k in keys)).select_from(
235
+ User
236
+ )
237
+ row = (await self._db.execute(stmt)).one()
238
+ return {key: int(value) for key, value in zip(keys, row, strict=True)}
239
239
 
240
240
  async def get_with_roles(self, user_id: uuid.UUID) -> User | None:
241
241
  return await self._get_user_with_roles(user_id)
@@ -53,7 +53,7 @@ __all__ = ["enforce_auth_throughput_limit", "router"]
53
53
  # ── Rate limit ───────────────────────────────────────────────────────────────
54
54
 
55
55
 
56
- def get_rate_limiter(request: Request) -> LoginRateLimiter:
56
+ async def get_rate_limiter(request: Request) -> LoginRateLimiter:
57
57
  """Return the per-app LoginRateLimiter built in UsersModule.on_startup."""
58
58
  return request.app.state.users.rate_limiter
59
59
 
@@ -105,7 +105,11 @@ export function LoginForm({
105
105
  </Label>
106
106
  </div>
107
107
 
108
- {error && <p className="text-sm text-destructive">{error}</p>}
108
+ {error && (
109
+ <p role="alert" className="text-sm text-destructive">
110
+ {error}
111
+ </p>
112
+ )}
109
113
 
110
114
  <Button type="submit" size="lg" className="w-full" disabled={loading}>
111
115
  {loading ? t(keys.users.login.submitting) : t(keys.users.login.submit)}
@@ -134,6 +134,27 @@ async def create_admin(
134
134
  return CreateAdminResult(user=existing, created=False)
135
135
 
136
136
 
137
+ async def ensure_user_role(db: AsyncSession) -> Role:
138
+ """The standard ``user`` Role row, created (and flushed) if it is missing.
139
+
140
+ The seed migration normally inserts it; tests build the schema with
141
+ ``create_all`` and get no seed rows, so callers that attach the role must
142
+ not assume it exists.
143
+ """
144
+ user_role = (
145
+ await db.execute(select(Role).where(Role.name == USER_ROLE_NAME))
146
+ ).scalar_one_or_none()
147
+ if user_role is None:
148
+ user_role = (
149
+ await db.execute(select(Role).where(Role.id == USER_ROLE_ID))
150
+ ).scalar_one_or_none()
151
+ if user_role is None:
152
+ user_role = Role(id=USER_ROLE_ID, name=USER_ROLE_NAME, description=USER_ROLE_DESCRIPTION)
153
+ db.add(user_role)
154
+ await db.flush()
155
+ return user_role
156
+
157
+
137
158
  async def create_standard_user(
138
159
  db: AsyncSession,
139
160
  *,
@@ -153,18 +174,7 @@ async def create_standard_user(
153
174
  logger.info(_EVT_USER_NOOP, extra={"email": email, "id": str(existing.id)})
154
175
  return CreateAdminResult(user=existing, created=False)
155
176
 
156
- user_role = (
157
- await db.execute(select(Role).where(Role.name == USER_ROLE_NAME))
158
- ).scalar_one_or_none()
159
- if user_role is None:
160
- user_role = (
161
- await db.execute(select(Role).where(Role.id == USER_ROLE_ID))
162
- ).scalar_one_or_none()
163
- if user_role is None:
164
- # Safety net — the seed migration normally inserts this row.
165
- user_role = Role(id=USER_ROLE_ID, name=USER_ROLE_NAME, description=USER_ROLE_DESCRIPTION)
166
- db.add(user_role)
167
- await db.flush()
177
+ user_role = await ensure_user_role(db)
168
178
 
169
179
  user = User(
170
180
  email=email,
@@ -47,7 +47,6 @@ class UserRead(CreateUpdateDictModel, SQLModel):
47
47
  is_verified: bool = False
48
48
  is_external: bool = False
49
49
  full_name: str | None = None
50
- tenant_id: str | None = None
51
50
  disabled_at: datetime | None = None
52
51
  last_login_at: datetime | None = None
53
52
 
@@ -48,12 +48,12 @@ current_active_user = fastapi_users.current_user(active=True)
48
48
  current_superuser = fastapi_users.current_user(active=True, superuser=True)
49
49
 
50
50
 
51
- def get_mailer(request: Request):
51
+ async def get_mailer(request: Request):
52
52
  """Return the mailer from app.state.users (built in UsersModule.on_startup)."""
53
53
  return request.app.state.users.mailer
54
54
 
55
55
 
56
- def get_event_bus(request: Request) -> EventBus:
56
+ async def get_event_bus(request: Request) -> EventBus:
57
57
  """Return the event bus from app.state.sm."""
58
58
  return request.app.state.sm.event_bus
59
59
 
@@ -356,7 +356,11 @@
356
356
  "setup": {
357
357
  "administrator": {
358
358
  "title": "Create an administrator",
359
- "description": "An account that can sign in and manage this install."
359
+ "description": "An account that can sign in and manage this install.",
360
+ "email": "Email",
361
+ "password": "Password",
362
+ "full_name": "Full name",
363
+ "submit": "Create administrator"
360
364
  }
361
365
  },
362
366
  "user_row": {
@@ -6,7 +6,9 @@
6
6
  import uuid
7
7
 
8
8
  from fastapi_users_db_sqlalchemy.generics import GUID
9
+ from simple_module_core.tenancy import TENANT_ROLE_PREFIX
9
10
  from simple_module_db.mixins import AuditMixin
11
+ from sqlalchemy.orm import validates
10
12
  from sqlmodel import Field, Relationship
11
13
 
12
14
  from users.models._base import Base
@@ -32,3 +34,17 @@ class Role(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
32
34
  back_populates="roles",
33
35
  sa_relationship_kwargs={"lazy": "noload"},
34
36
  )
37
+
38
+ @validates("name")
39
+ def _reject_tenant_namespace(self, _key: str, value: str) -> str:
40
+ """``tenant:*`` is the synthetic per-tenant principal namespace (#377).
41
+
42
+ A platform ``Role`` row with that prefix would be indistinguishable from
43
+ the active tenant's membership role on a principal, so the name is
44
+ refused at the model, whichever path (service, CLI, seed) sets it.
45
+ """
46
+ if isinstance(value, str) and value.casefold().startswith(TENANT_ROLE_PREFIX):
47
+ raise ValueError(
48
+ f"Role names starting with {TENANT_ROLE_PREFIX!r} are reserved for tenant roles"
49
+ )
50
+ return value
@@ -56,7 +56,6 @@ class User(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
56
56
  )
57
57
 
58
58
  full_name: str | None = Field(default=None, max_length=255)
59
- tenant_id: str | None = Field(default=None, max_length=50, index=True)
60
59
  disabled_at: datetime | None = Field(
61
60
  default=None,
62
61
  sa_type=DateTime(timezone=True),