simple-module-users 0.0.37__tar.gz → 0.0.38__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 (241) hide show
  1. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/.gitignore +1 -2
  2. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/PKG-INFO +10 -10
  3. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/README.md +3 -3
  4. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/package.json +1 -1
  5. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/pyproject.toml +10 -7
  6. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/_middleware_support.py +6 -0
  7. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/_users_app_builders.py +53 -7
  8. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/conftest.py +8 -1
  9. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_admin_resend_invite.py +7 -5
  10. simple_module_users-0.0.38/tests/test_api_admin_pagination_bounds.py +31 -0
  11. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_auth_screens_props.py +1 -1
  12. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_bearer_token_expiry.py +7 -3
  13. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_bootstrap.py +31 -3
  14. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_bulk_invite_message.py +10 -8
  15. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_demo_account.py +11 -15
  16. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_edit_recent_activity.py +12 -10
  17. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_invite_flow.py +27 -26
  18. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_invite_reuse.py +18 -18
  19. simple_module_users-0.0.38/tests/test_mailer.py +69 -0
  20. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_profile_password.py +2 -2
  21. simple_module_users-0.0.38/tests/test_rate_limit.py +28 -0
  22. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_session_revocation.py +1 -1
  23. simple_module_users-0.0.38/tests/test_session_version_atomic.py +35 -0
  24. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_settings.py +14 -43
  25. simple_module_users-0.0.38/tests/test_users_admin_session_revocation.py +106 -0
  26. simple_module_users-0.0.38/tests/test_users_admin_unlock.py +100 -0
  27. simple_module_users-0.0.38/tests/test_users_directory.py +131 -0
  28. simple_module_users-0.0.38/tests/test_users_lockout_columns.py +31 -0
  29. simple_module_users-0.0.38/tests/test_users_lockout_enumeration.py +125 -0
  30. simple_module_users-0.0.38/tests/test_users_lockout_service.py +202 -0
  31. simple_module_users-0.0.38/tests/test_users_login_lockout.py +149 -0
  32. simple_module_users-0.0.38/tests/test_users_mailer_core_sender.py +100 -0
  33. simple_module_users-0.0.38/tests/test_users_password_change_keeps_api.py +98 -0
  34. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_provider.py +10 -5
  35. simple_module_users-0.0.38/tests/test_users_reset_link_origin.py +48 -0
  36. simple_module_users-0.0.38/tests-js/AccountCardLocked.test.tsx +50 -0
  37. simple_module_users-0.0.38/tests-js/users_Login.test.tsx +94 -0
  38. simple_module_users-0.0.38/tests-js/users_actions.test.tsx +88 -0
  39. simple_module_users-0.0.38/tests-js/users_fixtures.ts +23 -0
  40. simple_module_users-0.0.38/tests-js/users_profile_cards.test.tsx +56 -0
  41. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/api.py +20 -6
  42. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/bulk_invite.py +4 -10
  43. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/components/StatusPill.tsx +18 -0
  44. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/components/UserCards.tsx +1 -0
  45. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/components/UserRow.tsx +2 -1
  46. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/components/useUserRowActions.ts +26 -14
  47. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/components/user-list-item.ts +2 -0
  48. simple_module_users-0.0.38/users/admin/links.py +38 -0
  49. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/queries.py +51 -24
  50. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/recent_activity.py +28 -59
  51. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/service.py +27 -7
  52. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/views.py +15 -14
  53. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/audit.py +12 -32
  54. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/api.py +31 -41
  55. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/AuthIntro.tsx +2 -2
  56. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/PasswordCard.tsx +19 -10
  57. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/ProfileDetailsCard.tsx +11 -9
  58. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/ResendVerification.tsx +3 -6
  59. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/SessionsCard.tsx +26 -13
  60. simple_module_users-0.0.38/users/auth_local/lockout.py +289 -0
  61. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/rate_limit.py +6 -34
  62. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/self_account.py +49 -22
  63. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/token_api.py +27 -10
  64. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/backend.py +30 -5
  65. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/bootstrap.py +13 -6
  66. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/constants.py +13 -0
  67. simple_module_users-0.0.38/users/contracts/roles.py +11 -0
  68. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/contracts/schemas.py +3 -1
  69. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/demo.py +3 -3
  70. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/deps.py +2 -1
  71. simple_module_users-0.0.38/users/directory.py +118 -0
  72. simple_module_users-0.0.38/users/locales/de.json +419 -0
  73. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/locales/en.json +11 -5
  74. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/mailer/__init__.py +4 -19
  75. simple_module_users-0.0.38/users/mailer/templated.py +77 -0
  76. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/manager.py +13 -0
  77. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/models/user.py +15 -1
  78. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/module.py +22 -15
  79. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/AcceptInvite.tsx +16 -18
  80. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/ForgotPassword.tsx +7 -11
  81. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Login.tsx +36 -49
  82. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Profile.tsx +2 -4
  83. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Register.tsx +9 -6
  84. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/ResetPassword.tsx +19 -32
  85. simple_module_users-0.0.38/users/pages/Users/AddPeople.tsx +136 -0
  86. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/Edit.tsx +18 -13
  87. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/Index.tsx +9 -10
  88. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/AccountCard.tsx +24 -1
  89. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/DangerZone.tsx +22 -19
  90. simple_module_users-0.0.38/users/pages/Users/components/MailerBanner.tsx +37 -0
  91. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/RecentActivityCard.tsx +3 -3
  92. simple_module_users-0.0.38/users/pages/Users/components/useAddPeople.ts +157 -0
  93. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/useEditUserForm.ts +22 -15
  94. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/useUserActions.ts +49 -14
  95. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/VerifyEmail.tsx +11 -22
  96. simple_module_users-0.0.38/users/pages/api.generated.ts +1923 -0
  97. simple_module_users-0.0.38/users/pages/api.ts +9 -0
  98. simple_module_users-0.0.38/users/pages/failureMessage.ts +16 -0
  99. simple_module_users-0.0.38/users/pages/signIn.ts +25 -0
  100. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/provider.py +2 -17
  101. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/registration.py +1 -1
  102. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/session_revocation.py +22 -1
  103. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/settings.py +31 -16
  104. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/setup_action.py +1 -1
  105. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/startup.py +6 -37
  106. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/state.py +1 -2
  107. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/token_strategy.py +5 -5
  108. simple_module_users-0.0.37/tests/test_mailer.py +0 -130
  109. simple_module_users-0.0.37/tests/test_rate_limit.py +0 -132
  110. simple_module_users-0.0.37/users/health.py +0 -47
  111. simple_module_users-0.0.37/users/mailer/console.py +0 -61
  112. simple_module_users-0.0.37/users/mailer/smtp.py +0 -123
  113. simple_module_users-0.0.37/users/pages/Users/AddPeople.tsx +0 -298
  114. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/LICENSE +0 -0
  115. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/.gitkeep +0 -0
  116. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/_demo_support.py +0 -0
  117. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/_users_oauth_support.py +0 -0
  118. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_access_token_model.py +0 -0
  119. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_activity_summary.py +0 -0
  120. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_api_admin.py +0 -0
  121. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_api_admin_crud.py +0 -0
  122. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_api_admin_filters.py +0 -0
  123. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_api_auth.py +0 -0
  124. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_backend.py +0 -0
  125. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_bearer_token_age.py +0 -0
  126. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_bootstrap_resolution.py +0 -0
  127. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_cli.py +0 -0
  128. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_constants.py +0 -0
  129. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_db_adapter.py +0 -0
  130. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_demo_guard.py +0 -0
  131. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_demo_login.py +0 -0
  132. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_external_users.py +0 -0
  133. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_legacy_cached_tenant.py +0 -0
  134. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_legacy_session_expiry.py +0 -0
  135. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_login_redirect_root_mount.py +0 -0
  136. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_negative_authz.py +0 -0
  137. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_oauth.py +0 -0
  138. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_oauth_routes.py +0 -0
  139. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_remember_me.py +0 -0
  140. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_revocation_broadcast.py +0 -0
  141. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_role_model.py +0 -0
  142. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_service_admin.py +0 -0
  143. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_service_admin_crud.py +0 -0
  144. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_session_expiry.py +0 -0
  145. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_session_version_cache.py +0 -0
  146. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_session_version_failopen.py +0 -0
  147. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_session_version_hardening.py +0 -0
  148. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_sessions_revoke.py +0 -0
  149. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_token_api.py +0 -0
  150. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_user_manager.py +0 -0
  151. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_user_model.py +0 -0
  152. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_user_role_model.py +0 -0
  153. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_user_service.py +0 -0
  154. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_user_state.py +0 -0
  155. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_bearer_scheme.py +0 -0
  156. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_bulk_invite.py +0 -0
  157. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_default_role.py +0 -0
  158. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_deps.py +0 -0
  159. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_list_invites.py +0 -0
  160. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_login_deep_link.py +0 -0
  161. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_middleware.py +0 -0
  162. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_middleware_public_paths.py +0 -0
  163. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_middleware_resolvers.py +0 -0
  164. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_oauth_signup_gate.py +0 -0
  165. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_oauth_unverified_link.py +0 -0
  166. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_register_role_durable.py +0 -0
  167. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_require_verification.py +0 -0
  168. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_seed_role_migration.py +0 -0
  169. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_setup_lock.py +0 -0
  170. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_setup_wizard.py +0 -0
  171. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_users_shared_props.py +0 -0
  172. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_views.py +0 -0
  173. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests/test_views_admin.py +0 -0
  174. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests-js/EmailChipInput.test.tsx +0 -0
  175. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests-js/LoginForm.test.tsx +0 -0
  176. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests-js/deriveState.test.ts +0 -0
  177. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests-js/invite-emails.test.ts +0 -0
  178. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tests-js/registerErrors.test.ts +0 -0
  179. /simple_module_users-0.0.37/tests-js/Register.test.tsx → /simple_module_users-0.0.38/tests-js/users_Register.test.tsx +0 -0
  180. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/tsconfig.json +0 -0
  181. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/__init__.py +0 -0
  182. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/__init__.py +0 -0
  183. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/components/IndexFilters.tsx +0 -0
  184. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/components/RolesTab.tsx +0 -0
  185. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/components/UserRowMenu.tsx +0 -0
  186. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/components/UsersEmpty.tsx +0 -0
  187. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/components/UsersTable.tsx +0 -0
  188. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/resend_invite.py +0 -0
  189. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/admin/user_state.py +0 -0
  190. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/__init__.py +0 -0
  191. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/AuthField.tsx +0 -0
  192. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/AuthStateCard.tsx +0 -0
  193. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/DemoSignIn.tsx +0 -0
  194. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/InviteSummary.tsx +0 -0
  195. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/LoginForm.tsx +0 -0
  196. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/PasswordFields.tsx +0 -0
  197. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/PreferencesCard.tsx +0 -0
  198. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/ResetLinkSent.tsx +0 -0
  199. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/WaitingOnYou.tsx +0 -0
  200. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/components/profile-user.ts +0 -0
  201. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/demo_api.py +0 -0
  202. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/invite_preview.py +0 -0
  203. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/registerErrors.ts +0 -0
  204. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/token_preview.py +0 -0
  205. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/auth_local/views.py +0 -0
  206. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/cli.py +0 -0
  207. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/contracts/__init__.py +0 -0
  208. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/contracts/events.py +0 -0
  209. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/db_adapter.py +0 -0
  210. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/demo_guard.py +0 -0
  211. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/exceptions.py +0 -0
  212. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/mailer/templates/.gitkeep +0 -0
  213. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/mailer/templates/invite.txt +0 -0
  214. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/mailer/templates/reset_password.txt +0 -0
  215. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/mailer/templates/verify_email.txt +0 -0
  216. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/middleware.py +0 -0
  217. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/models/__init__.py +0 -0
  218. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/models/_base.py +0 -0
  219. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/models/access_token.py +0 -0
  220. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/models/oauth_account.py +0 -0
  221. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/models/refresh_token.py +0 -0
  222. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/models/role.py +0 -0
  223. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/models/user_role.py +0 -0
  224. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/oauth/__init__.py +0 -0
  225. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/oauth/api.py +0 -0
  226. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/oauth/providers.py +0 -0
  227. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/.gitkeep +0 -0
  228. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/CreateUserFields.tsx +0 -0
  229. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/DetailsCard.tsx +0 -0
  230. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
  231. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/EmailChipInput.tsx +0 -0
  232. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/InviteFields.tsx +0 -0
  233. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/InviteResults.tsx +0 -0
  234. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/RolePicker.tsx +0 -0
  235. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/components/UserStats.tsx +0 -0
  236. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/pages/Users/invite-emails.ts +0 -0
  237. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/py.typed +0 -0
  238. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/roles_cache.py +0 -0
  239. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/session_version_cache.py +0 -0
  240. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/setup.py +0 -0
  241. {simple_module_users-0.0.37 → simple_module_users-0.0.38}/users/shared_props.py +0 -0
@@ -7,8 +7,6 @@ build/
7
7
  .venv/
8
8
  *.egg
9
9
 
10
- # UV
11
- uv.lock
12
10
 
13
11
  # Node
14
12
  node_modules/
@@ -65,3 +63,4 @@ host/client_app/.playwright-cli/*
65
63
  .qa/
66
64
  .verify/
67
65
  /openapi.json
66
+ qa-shots/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_users
3
- Version: 0.0.37
3
+ Version: 0.0.38
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
@@ -21,14 +21,14 @@ Classifier: Topic :: Internet :: WWW/HTTP
21
21
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: aiosmtplib>=3.0
25
24
  Requires-Dist: cachetools>=5.3
26
25
  Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
27
- Requires-Dist: simple-module-auth==0.0.37
28
- Requires-Dist: simple-module-core==0.0.37
29
- Requires-Dist: simple-module-db==0.0.37
30
- Requires-Dist: simple-module-hosting==0.0.37
31
- Requires-Dist: simple-module-settings==0.0.37
26
+ Requires-Dist: simple-module-auth==0.0.38
27
+ Requires-Dist: simple-module-core==0.0.38
28
+ Requires-Dist: simple-module-db==0.0.38
29
+ Requires-Dist: simple-module-hosting==0.0.38
30
+ Requires-Dist: simple-module-mail==0.0.38
31
+ Requires-Dist: simple-module-settings==0.0.38
32
32
  Requires-Dist: typer>=0.12
33
33
  Description-Content-Type: text/markdown
34
34
 
@@ -52,7 +52,7 @@ Pre-wired into any app scaffolded with `smpy new`.
52
52
  - Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
53
53
  - `smpy users create-admin` CLI for ad-hoc admin creation.
54
54
  - Inertia pages for login/register/invite-accept/admin-invite.
55
- - Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
55
+ - Sends verification, reset and invite emails through the framework mail sender (configure the transport in the `mail` module).
56
56
  - Demo mode — one-click, read-only shared **admin** and **standard user** accounts on the sign-in card, for public showcase instances.
57
57
 
58
58
  ## Usage
@@ -73,7 +73,7 @@ SM_USERS_BOOTSTRAP_PASSWORD=change-me
73
73
  Program:
74
74
 
75
75
  ```python
76
- from auth.deps import CurrentUser # type: ignore[import-not-found]
76
+ from auth.deps import CurrentUser
77
77
 
78
78
 
79
79
  @router.get("/profile")
@@ -84,7 +84,7 @@ async def profile(user: CurrentUser):
84
84
  ## Depends on
85
85
 
86
86
  - `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_settings`, `simple_module_auth`
87
- - `fastapi-users[sqlalchemy,oauth]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
87
+ - `fastapi-users[sqlalchemy,oauth]>=15,<16`, `cachetools`, `typer`
88
88
 
89
89
  ## Demo mode
90
90
 
@@ -18,7 +18,7 @@ Pre-wired into any app scaffolded with `smpy new`.
18
18
  - Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
19
19
  - `smpy users create-admin` CLI for ad-hoc admin creation.
20
20
  - Inertia pages for login/register/invite-accept/admin-invite.
21
- - Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
21
+ - Sends verification, reset and invite emails through the framework mail sender (configure the transport in the `mail` module).
22
22
  - Demo mode — one-click, read-only shared **admin** and **standard user** accounts on the sign-in card, for public showcase instances.
23
23
 
24
24
  ## Usage
@@ -39,7 +39,7 @@ SM_USERS_BOOTSTRAP_PASSWORD=change-me
39
39
  Program:
40
40
 
41
41
  ```python
42
- from auth.deps import CurrentUser # type: ignore[import-not-found]
42
+ from auth.deps import CurrentUser
43
43
 
44
44
 
45
45
  @router.get("/profile")
@@ -50,7 +50,7 @@ async def profile(user: CurrentUser):
50
50
  ## Depends on
51
51
 
52
52
  - `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_settings`, `simple_module_auth`
53
- - `fastapi-users[sqlalchemy,oauth]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
53
+ - `fastapi-users[sqlalchemy,oauth]>=15,<16`, `cachetools`, `typer`
54
54
 
55
55
  ## Demo mode
56
56
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-module-py/users",
3
- "version": "0.1.0",
3
+ "version": "0.0.38",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the Users module",
6
6
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_users"
3
- version = "0.0.37"
3
+ version = "0.0.38"
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,16 +21,18 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.37",
25
- "simple_module_db==0.0.37",
26
- "simple_module_hosting==0.0.37",
27
- "simple_module_settings==0.0.37",
28
- "simple_module_auth==0.0.37",
24
+ "simple_module_core==0.0.38",
25
+ "simple_module_db==0.0.38",
26
+ "simple_module_hosting==0.0.38",
27
+ "simple_module_settings==0.0.38",
28
+ "simple_module_auth==0.0.38",
29
+ # Sends through the framework mail sender; `mail` owns the SMTP transport
30
+ # (GH #496), so an upgrade keeps delivering mail.
31
+ "simple_module_mail==0.0.38",
29
32
  # Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
30
33
  # fields after construction (see reconfigure_cookie_transport in backend.py).
31
34
  # Bumping the major version requires re-checking those field names.
32
35
  "fastapi-users[sqlalchemy,oauth]>=15,<16",
33
- "aiosmtplib>=3.0",
34
36
  "cachetools>=5.3",
35
37
  "typer>=0.12",
36
38
  ]
@@ -63,3 +65,4 @@ simple_module_db = { workspace = true }
63
65
  simple_module_hosting = { workspace = true }
64
66
  simple_module_settings = { workspace = true }
65
67
  simple_module_auth = { workspace = true }
68
+ simple_module_mail = { workspace = true }
@@ -60,8 +60,14 @@ async def _build_app(db_state, inner_handler=None, *, principal_resolvers=None):
60
60
 
61
61
  app = FastAPI()
62
62
  app.state.sm = SimpleNamespace(db=db_state)
63
+ from simple_module_core.public_routes import PublicRouteRegistry
64
+ from users.module import UsersModule
63
65
  from users.provider import UsersAuthProvider
64
66
 
67
+ # The host fills this from every module's register_public_routes at boot.
68
+ app.state.public_routes = PublicRouteRegistry()
69
+ UsersModule().register_public_routes(app.state.public_routes)
70
+
65
71
  app.state.auth = AuthState(
66
72
  auth_provider=UsersAuthProvider(),
67
73
  principal_resolvers=list(principal_resolvers or []),
@@ -7,9 +7,12 @@ wiring. Not named ``test_*`` so pytest does not collect it.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import httpx
10
11
  from simple_module_hosting.settings import Settings
11
12
  from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID
12
13
 
14
+ ALICE = "alice@example.com"
15
+
13
16
  # ---------------------------------------------------------------------------
14
17
 
15
18
 
@@ -17,7 +20,6 @@ def _users_overrides(allow_signup: bool = False) -> dict[str, tuple[str, str]]:
17
20
  """Values seeded into the ``SettingsStore`` before the app lifespan runs."""
18
21
  return {
19
22
  "allow_signup": (str(allow_signup).lower(), "bool"),
20
- "mailer": ("console", "string"),
21
23
  "base_url": ("http://testserver", "string"),
22
24
  "cookie_secure": ("false", "bool"),
23
25
  # 32+ bytes to clear pyjwt's InsecureKeyLengthWarning for HMAC-SHA256.
@@ -84,7 +86,9 @@ async def _seed_roles(application) -> None:
84
86
  await session.commit()
85
87
 
86
88
 
87
- async def _seed_users_settings(application, *, allow_signup: bool) -> None:
89
+ async def _seed_users_settings(
90
+ application, *, allow_signup: bool, overrides: dict[str, tuple[str, str]] | None = None
91
+ ) -> None:
88
92
  """Write the test UsersSettings overrides to the DB before hydrate runs.
89
93
 
90
94
  Hydrate fires inside the lifespan ``__aenter__``, so this needs to land
@@ -95,7 +99,8 @@ async def _seed_users_settings(application, *, allow_signup: bool) -> None:
95
99
 
96
100
  async with application.state.sm.db.session_factory() as session:
97
101
  store = SettingsStore(SettingService(session))
98
- for field, (raw, vtype) in _users_overrides(allow_signup=allow_signup).items():
102
+ rows = _users_overrides(allow_signup=allow_signup) | (overrides or {})
103
+ for field, (raw, vtype) in rows.items():
99
104
  await store.set_override("users", field, raw, vtype)
100
105
  await session.commit()
101
106
 
@@ -136,19 +141,33 @@ async def _make_standard_user(app, email: str = "user@example.com"):
136
141
  return user
137
142
 
138
143
 
139
- async def _build_users_app(monkeypatch, *, allow_signup: bool, seed_admin: bool = True):
140
- """Build a test FastAPI app with DB created, settings seeded, lifespan started."""
144
+ async def _build_users_app(
145
+ monkeypatch,
146
+ *,
147
+ allow_signup: bool,
148
+ seed_admin: bool = True,
149
+ database_url: str = "sqlite+aiosqlite:///:memory:",
150
+ overrides: dict[str, tuple[str, str]] | None = None,
151
+ ):
152
+ """Build a test FastAPI app with DB created, settings seeded, lifespan started.
153
+
154
+ ``database_url`` lets several apps share one SQLite file, standing in for
155
+ workers of one deployment; ``overrides`` adds ``UsersSettings`` rows.
156
+ """
141
157
  from simple_module_hosting.app_builder import create_app
142
158
 
143
159
  settings = Settings(
144
- database_url="sqlite+aiosqlite:///:memory:",
160
+ database_url=database_url,
145
161
  environment="testing",
146
162
  secret_key="test-secret-key",
147
163
  multi_tenant=False,
164
+ # Explicit: a developer's .env pointing at the shared Redis would make
165
+ # login-lockout counters outlive the test that wrote them.
166
+ redis_url=None,
148
167
  )
149
168
  application = create_app(settings)
150
169
  await _setup_app_db(application)
151
- await _seed_users_settings(application, allow_signup=allow_signup)
170
+ await _seed_users_settings(application, allow_signup=allow_signup, overrides=overrides)
152
171
 
153
172
  ctx = application.router.lifespan_context(application)
154
173
  await ctx.__aenter__()
@@ -161,3 +180,30 @@ async def _build_users_app(monkeypatch, *, allow_signup: bool, seed_admin: bool
161
180
  if seed_admin:
162
181
  await _make_admin_user(application)
163
182
  return application, ctx
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Request and row helpers for the login-lockout tests
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ def _client(app, ip: str) -> httpx.AsyncClient:
191
+ """A client whose requests arrive from *ip*."""
192
+ transport = httpx.ASGITransport(app=app, client=(ip, 1))
193
+ return httpx.AsyncClient(transport=transport, base_url="http://testserver")
194
+
195
+
196
+ async def _login(app, ip: str, username: str, password: str = "wrong") -> httpx.Response:
197
+ async with _client(app, ip) as c:
198
+ return await c.post(
199
+ "/api/users/auth/login", data={"username": username, "password": password}
200
+ )
201
+
202
+
203
+ async def user_row(app, email: str):
204
+ """The ``User`` row for *email*, or ``None``, read in a fresh session."""
205
+ from sqlalchemy import select
206
+ from users.models import User
207
+
208
+ async with app.state.sm.db.session_factory() as session:
209
+ return (await session.execute(select(User).where(User.email == email))).scalar_one_or_none()
@@ -2,7 +2,7 @@
2
2
 
3
3
  The ``users_app`` fixture builds a full FastAPI app via ``create_app`` but
4
4
  with an in-memory SQLite database, seeded roles, and test-friendly settings
5
- (ConsoleMailer, signup disabled by default, short secrets).
5
+ (no mail sender installed, signup disabled by default, short secrets).
6
6
 
7
7
  The ``anon_client`` gives a plain httpx client.
8
8
  The ``admin_client`` gives a client with a signed local-user session cookie
@@ -21,6 +21,7 @@ import pytest
21
21
  # top-level modules on pytest's sys.path — same mechanism as the
22
22
  # _middleware_support plugin loaded at the bottom of this file.
23
23
  from _users_app_builders import (
24
+ ALICE,
24
25
  _build_users_app,
25
26
  _make_admin_user,
26
27
  _make_standard_user,
@@ -148,6 +149,12 @@ async def user_client(users_app) -> AsyncGenerator[httpx.AsyncClient, None]:
148
149
  yield c
149
150
 
150
151
 
152
+ @pytest.fixture
153
+ async def alice(users_app):
154
+ """A standard user, ``ALICE``, for the login-lockout tests."""
155
+ return await _make_standard_user(users_app, ALICE)
156
+
157
+
151
158
  # ---------------------------------------------------------------------------
152
159
  # DB session fixture scoped to users_app
153
160
  # ---------------------------------------------------------------------------
@@ -10,6 +10,7 @@ message nobody received.
10
10
  from __future__ import annotations
11
11
 
12
12
  import logging
13
+ import re
13
14
  from datetime import UTC, datetime, timedelta
14
15
 
15
16
  import pytest
@@ -174,14 +175,15 @@ class TestResendInvite:
174
175
 
175
176
  class TestInviteTokenClaims:
176
177
  @pytest.mark.anyio
177
- async def test_invite_token_names_the_inviter(self, admin_client, users_app, caplog):
178
+ async def test_invite_token_names_the_inviter(self, admin_client, users_app, captured_mail):
178
179
  """The accept-invite card says who invited you; the claim carries it."""
179
180
  import jwt
180
181
 
181
- with caplog.at_level(logging.INFO, logger="users.mailer"):
182
- await _invite(admin_client, "whosent@example.com")
183
- link = next(r for r in caplog.records if r.getMessage() == "users.invite.email").link
184
- token = link.split("token=", 1)[1]
182
+ await _invite(admin_client, "whosent@example.com")
183
+ text = next(
184
+ m.text for m in captured_mail.to("whosent@example.com") if "invited you" in m.subject
185
+ )
186
+ token = re.search(r"token=(\S+)", text).group(1)
185
187
 
186
188
  payload = jwt.decode(
187
189
  token,
@@ -0,0 +1,31 @@
1
+ """``GET /api/users/admin`` validates paging like every other JSON list (#513)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from simple_module_db.pagination import MAX_PAGE, MAX_PER_PAGE
7
+
8
+ ADMIN_URL = "/api/users/admin"
9
+
10
+
11
+ @pytest.mark.parametrize(
12
+ "params",
13
+ [
14
+ {"page": 0},
15
+ {"page": -3},
16
+ {"page": MAX_PAGE + 1},
17
+ {"per_page": 0},
18
+ {"per_page": -1},
19
+ {"per_page": MAX_PER_PAGE + 1},
20
+ {"per_page": 100000},
21
+ ],
22
+ )
23
+ async def test_rejects_out_of_range(params: dict, admin_client, users_db) -> None:
24
+ resp = await admin_client.get(ADMIN_URL, params=params)
25
+ assert resp.status_code == 422
26
+
27
+
28
+ async def test_accepts_bounds(admin_client, users_db) -> None:
29
+ resp = await admin_client.get(ADMIN_URL, params={"page": MAX_PAGE, "per_page": MAX_PER_PAGE})
30
+ assert resp.status_code == 200
31
+ assert resp.json() == []
@@ -62,7 +62,7 @@ class TestForgotPasswordProps:
62
62
 
63
63
  @pytest.mark.anyio
64
64
  async def test_console_mailer_does_not_deliver(self, anon_client):
65
- """The test app runs the ConsoleMailer, so the amber callout must show."""
65
+ """The test app has no delivering mail sender, so the amber callout must show."""
66
66
  props = _props(await anon_client.get("/users/forgot-password", headers=_INERTIA_HEADERS))
67
67
  assert props["mailer_delivers"] is False
68
68
 
@@ -192,16 +192,20 @@ class TestAPasswordChangeStrandsBearerTokens:
192
192
  async def test_changing_the_password_deletes_the_rows(self, users_app, anon_client):
193
193
  """The stamped counter already strands them; deleting is what stops a
194
194
  stolen token resolving a row at all, and stops the refresh tokens
195
- minting fresh ones behind it."""
195
+ minting fresh ones behind it. The one row left is the changing
196
+ browser's reissued ``sm_auth`` credential, minted under the new counter."""
196
197
  assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
197
- assert await _tokens(users_app)
198
+ before = {row.token for row in await _tokens(users_app)}
199
+ assert before
198
200
 
199
201
  resp = await anon_client.post(
200
202
  "/api/users/me/password",
201
203
  json={"current_password": "AdminPass1!", "new_password": "NewAdminPass1!"},
202
204
  )
203
205
  assert resp.status_code == 204, resp.text
204
- assert await _tokens(users_app) == []
206
+ reissued = await _only_token(users_app)
207
+ assert reissued.token not in before
208
+ assert reissued.session_version == 1
205
209
 
206
210
 
207
211
  class TestTheMintedCounterIsCurrent:
@@ -187,7 +187,7 @@ async def test_bootstrap_from_env_noop_when_unset(users_app_empty) -> None:
187
187
  bootstrap_password="",
188
188
  )
189
189
  # Patch fake_app.state onto a real app-like object
190
- await bootstrap_admin_from_env(fake_app) # type: ignore[arg-type]
190
+ await bootstrap_admin_from_env(fake_app)
191
191
 
192
192
  # No users should have been created
193
193
  async with users_app_empty.state.sm.db.session_factory() as s:
@@ -217,7 +217,7 @@ async def test_bootstrap_from_env_noop_when_table_nonempty(users_app_empty) -> N
217
217
  bootstrap_email="admin@test.example",
218
218
  bootstrap_password="AdminPass1!",
219
219
  )
220
- await bootstrap_admin_from_env(fake_app) # type: ignore[arg-type]
220
+ await bootstrap_admin_from_env(fake_app)
221
221
 
222
222
  # Only the original dummy user should exist
223
223
  async with users_app_empty.state.sm.db.session_factory() as s:
@@ -236,7 +236,7 @@ async def test_bootstrap_from_env_creates_admin_when_empty_and_configured(users_
236
236
  bootstrap_email="bootstrap@test.example",
237
237
  bootstrap_password="BootPass1!",
238
238
  )
239
- await bootstrap_admin_from_env(fake_app) # type: ignore[arg-type]
239
+ await bootstrap_admin_from_env(fake_app)
240
240
 
241
241
  async with users_app_empty.state.sm.db.session_factory() as s:
242
242
  user = (
@@ -247,3 +247,31 @@ async def test_bootstrap_from_env_creates_admin_when_empty_and_configured(users_
247
247
  assert user.is_superuser is True
248
248
  assert user.is_verified is True
249
249
  assert user.is_active is True
250
+
251
+
252
+ @pytest.mark.parametrize("generated", [False, True])
253
+ async def test_bootstrap_logs_generated_password_only_once(
254
+ users_app_empty, monkeypatch, caplog, generated
255
+ ):
256
+ if generated:
257
+ monkeypatch.setenv("SM_USERS_BOOTSTRAP_PASSWORD_GENERATED", "1")
258
+ fake_app = _make_fake_app(
259
+ users_app_empty.state.sm.db.session_factory,
260
+ bootstrap_email="first@test.example",
261
+ bootstrap_password="GeneratedPass1!",
262
+ )
263
+ with caplog.at_level("WARNING", logger="users.bootstrap"):
264
+ await bootstrap_admin_from_env(fake_app)
265
+ first_messages = list(caplog.messages)
266
+ caplog.clear()
267
+ # A restart generates a different unused password for an existing install.
268
+ fake_app.state.users.settings.bootstrap_password = "UnusedPass2!"
269
+ await bootstrap_admin_from_env(fake_app)
270
+ assert caplog.messages == []
271
+ if generated:
272
+ assert first_messages == [
273
+ "first-boot admin created; store this password securely: "
274
+ "first@test.example / GeneratedPass1!"
275
+ ]
276
+ else:
277
+ assert first_messages == []
@@ -8,8 +8,6 @@ rather than stopping at the API boundary.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- import logging
12
-
13
11
  import pytest
14
12
 
15
13
  _URL = "/api/users/admin/invite/bulk"
@@ -111,11 +109,15 @@ class TestBulkInviteMessage:
111
109
  assert row["state"] == "invited"
112
110
 
113
111
 
114
- class TestConsoleMailerMessage:
112
+ class TestTemplatedMailerMessage:
113
+ @pytest.mark.anyio
114
+ async def test_mailer_quotes_the_message(self, users_app, captured_mail):
115
+ mailer = users_app.state.users.mailer
116
+ await mailer.send_invite("x@example.com", "tok", "Alex Doyle", "See you Monday.")
117
+ assert "See you Monday." in captured_mail.to("x@example.com")[0].text
118
+
115
119
  @pytest.mark.anyio
116
- async def test_console_mailer_logs_the_message(self, users_app, caplog):
120
+ async def test_blank_message_leaves_no_quote(self, users_app, captured_mail):
117
121
  mailer = users_app.state.users.mailer
118
- with caplog.at_level(logging.INFO, logger="users.mailer"):
119
- await mailer.send_invite("x@example.com", "tok", "Alex Doyle", "See you Monday.")
120
- record = next(r for r in caplog.records if r.getMessage() == "users.invite.email")
121
- assert record.message_body == "See you Monday."
122
+ await mailer.send_invite("x@example.com", "tok", "Alex Doyle", " \n ")
123
+ assert "See you" not in captured_mail.to("x@example.com")[0].text
@@ -9,6 +9,7 @@ feature is off. The third, a demo session that is allowed to write, is
9
9
  from __future__ import annotations
10
10
 
11
11
  from _demo_support import DEMO_ADMIN_EMAIL, DEMO_USER_EMAIL
12
+ from _users_app_builders import user_row
12
13
  from sqlalchemy import select
13
14
  from users.constants import ADMIN_ROLE_NAME, USER_ROLE_NAME
14
15
  from users.demo import (
@@ -75,11 +76,6 @@ def test_read_only_is_the_default_posture():
75
76
  # ── seeding ─────────────────────────────────────────────────────────────────
76
77
 
77
78
 
78
- async def _row(app, email: str) -> User | None:
79
- async with app.state.sm.db.session_factory() as session:
80
- return (await session.execute(select(User).where(User.email == email))).scalar_one_or_none()
81
-
82
-
83
79
  async def _role_names(app, user_id) -> list[str]:
84
80
  async with app.state.sm.db.session_factory() as session:
85
81
  return list(
@@ -102,8 +98,8 @@ async def test_ensure_demo_users_seeds_both_accounts(users_app):
102
98
 
103
99
  assert len(ids) == 2
104
100
  assert users_app.state.users.demo_user_ids == ids
105
- admin = await _row(users_app, DEMO_ADMIN_EMAIL)
106
- user = await _row(users_app, DEMO_USER_EMAIL)
101
+ admin = await user_row(users_app, DEMO_ADMIN_EMAIL)
102
+ user = await user_row(users_app, DEMO_USER_EMAIL)
107
103
  assert admin is not None and admin.is_superuser
108
104
  assert user is not None and not user.is_superuser
109
105
  assert all(row.is_active and row.is_verified for row in (admin, user))
@@ -113,8 +109,8 @@ async def test_each_account_gets_its_own_role(users_app):
113
109
  users_app.state.users.settings.demo_mode = True
114
110
  await ensure_demo_users(users_app)
115
111
 
116
- admin = await _row(users_app, DEMO_ADMIN_EMAIL)
117
- user = await _row(users_app, DEMO_USER_EMAIL)
112
+ admin = await user_row(users_app, DEMO_ADMIN_EMAIL)
113
+ user = await user_row(users_app, DEMO_USER_EMAIL)
118
114
  assert await _role_names(users_app, admin.id) == [ADMIN_ROLE_NAME]
119
115
  assert await _role_names(users_app, user.id) == [USER_ROLE_NAME]
120
116
 
@@ -124,8 +120,8 @@ async def test_a_blank_password_still_produces_usable_accounts(users_app):
124
120
  users_app.state.users.settings.demo_mode = True
125
121
  await ensure_demo_users(users_app)
126
122
 
127
- assert (await _row(users_app, DEMO_ADMIN_EMAIL)).hashed_password
128
- assert (await _row(users_app, DEMO_USER_EMAIL)).hashed_password
123
+ assert (await user_row(users_app, DEMO_ADMIN_EMAIL)).hashed_password
124
+ assert (await user_row(users_app, DEMO_USER_EMAIL)).hashed_password
129
125
 
130
126
 
131
127
  async def test_a_generated_password_is_not_re_rolled_on_every_boot(users_app):
@@ -133,23 +129,23 @@ async def test_a_generated_password_is_not_re_rolled_on_every_boot(users_app):
133
129
  worker per restart, for no gain."""
134
130
  users_app.state.users.settings.demo_mode = True
135
131
  await ensure_demo_users(users_app)
136
- first = (await _row(users_app, DEMO_ADMIN_EMAIL)).hashed_password
132
+ first = (await user_row(users_app, DEMO_ADMIN_EMAIL)).hashed_password
137
133
 
138
134
  await ensure_demo_users(users_app)
139
135
 
140
- assert (await _row(users_app, DEMO_ADMIN_EMAIL)).hashed_password == first
136
+ assert (await user_row(users_app, DEMO_ADMIN_EMAIL)).hashed_password == first
141
137
 
142
138
 
143
139
  async def test_a_configured_password_is_reapplied(users_app):
144
140
  """Changing a demo password in the admin UI has to take effect."""
145
141
  users_app.state.users.settings.demo_mode = True
146
142
  await ensure_demo_users(users_app)
147
- first = (await _row(users_app, DEMO_USER_EMAIL)).hashed_password
143
+ first = (await user_row(users_app, DEMO_USER_EMAIL)).hashed_password
148
144
 
149
145
  users_app.state.users.settings.demo_user_password = "a-published-demo-password"
150
146
  await ensure_demo_users(users_app)
151
147
 
152
- assert (await _row(users_app, DEMO_USER_EMAIL)).hashed_password != first
148
+ assert (await user_row(users_app, DEMO_USER_EMAIL)).hashed_password != first
153
149
 
154
150
 
155
151
  async def test_demo_off_clears_the_cached_ids(users_app):
@@ -102,7 +102,7 @@ class TestRecentActivity:
102
102
  settings.
103
103
  """
104
104
  from audit_log.models import AuditEntry
105
- from settings.models import Setting
105
+ from simple_module_db.stored_settings import Setting
106
106
 
107
107
  user_id = await _admin_id(users_app)
108
108
  async with users_app.state.sm.db.session_factory() as session:
@@ -147,17 +147,19 @@ class TestRecentActivity:
147
147
 
148
148
  @pytest.mark.anyio
149
149
  async def test_prop_is_none_when_audit_log_is_not_installed(self, admin_client, users_app):
150
- """Absent module, absent card — not an empty one claiming no activity."""
151
- sm = users_app.state.sm
152
- loaded = sm.modules
153
- trimmed = type(loaded)(m for m in loaded if getattr(m.meta, "name", "") != "AuditLog")
154
- assert len(trimmed) < len(loaded), "audit_log was not loaded, so this proves nothing"
155
- # ``sm`` is a frozen dataclass, so this is the only way to model an app
156
- # built without the module short of building a second one.
157
- object.__setattr__(sm, "modules", trimmed)
150
+ """No activity feed, absent card — not an empty one claiming no activity.
151
+
152
+ The card reads the core activity feed that ``audit_log`` installs
153
+ (GH #501); an app built without the module has none.
154
+ """
155
+ from simple_module_core.activity_feed import get_activity_feed, set_activity_feed
156
+
157
+ feed = get_activity_feed(users_app)
158
+ assert feed is not None, "audit_log installed no feed, so this proves nothing"
159
+ set_activity_feed(users_app, None)
158
160
  try:
159
161
  user_id = await _admin_id(users_app)
160
162
  props = await _edit_props(admin_client, user_id)
161
163
  assert props["recent_activity"] is None
162
164
  finally:
163
- object.__setattr__(sm, "modules", loaded)
165
+ set_activity_feed(users_app, feed)