simple-module-users 0.0.37__tar.gz → 0.0.39__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 (250) hide show
  1. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/.gitignore +1 -2
  2. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/PKG-INFO +12 -12
  3. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/README.md +3 -3
  4. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/package.json +1 -1
  5. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/pyproject.toml +12 -9
  6. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/_middleware_support.py +6 -0
  7. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/_users_app_builders.py +58 -7
  8. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/conftest.py +8 -1
  9. simple_module_users-0.0.37/tests/test_demo_account.py → simple_module_users-0.0.39/tests/demo/test_users_demo_account.py +11 -15
  10. simple_module_users-0.0.39/tests/demo/test_users_demo_credentials.py +139 -0
  11. simple_module_users-0.0.37/tests/test_demo_guard.py → simple_module_users-0.0.39/tests/demo/test_users_demo_guard.py +1 -1
  12. simple_module_users-0.0.37/tests/test_admin_resend_invite.py → simple_module_users-0.0.39/tests/invites/test_users_admin_resend_invite.py +9 -7
  13. {simple_module_users-0.0.37/tests → simple_module_users-0.0.39/tests/invites}/test_users_bulk_invite.py +2 -2
  14. simple_module_users-0.0.37/tests/test_bulk_invite_message.py → simple_module_users-0.0.39/tests/invites/test_users_bulk_invite_message.py +10 -8
  15. simple_module_users-0.0.37/tests/test_invite_flow.py → simple_module_users-0.0.39/tests/invites/test_users_invite_flow.py +27 -26
  16. simple_module_users-0.0.37/tests/test_invite_reuse.py → simple_module_users-0.0.39/tests/invites/test_users_invite_reuse.py +18 -18
  17. simple_module_users-0.0.39/tests/lockout/test_users_admin_unlock.py +100 -0
  18. simple_module_users-0.0.39/tests/lockout/test_users_lockout_checkpoint.py +114 -0
  19. simple_module_users-0.0.39/tests/lockout/test_users_lockout_columns.py +31 -0
  20. simple_module_users-0.0.39/tests/lockout/test_users_lockout_enumeration.py +125 -0
  21. simple_module_users-0.0.39/tests/lockout/test_users_lockout_service.py +202 -0
  22. simple_module_users-0.0.39/tests/lockout/test_users_login_lockout.py +149 -0
  23. simple_module_users-0.0.39/tests/test_api_admin_pagination_bounds.py +31 -0
  24. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_auth_screens_props.py +1 -1
  25. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_bearer_token_expiry.py +7 -3
  26. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_bootstrap.py +31 -3
  27. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_edit_recent_activity.py +12 -10
  28. simple_module_users-0.0.39/tests/test_mailer.py +69 -0
  29. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_profile_password.py +2 -2
  30. simple_module_users-0.0.39/tests/test_rate_limit.py +28 -0
  31. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_session_revocation.py +1 -1
  32. simple_module_users-0.0.39/tests/test_session_version_atomic.py +35 -0
  33. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_settings.py +14 -43
  34. simple_module_users-0.0.39/tests/test_users_admin_session_revocation.py +106 -0
  35. simple_module_users-0.0.39/tests/test_users_default_role.py +75 -0
  36. simple_module_users-0.0.39/tests/test_users_directory.py +131 -0
  37. simple_module_users-0.0.39/tests/test_users_domain_errors.py +40 -0
  38. simple_module_users-0.0.39/tests/test_users_mailer_core_sender.py +100 -0
  39. simple_module_users-0.0.37/tests/test_user_manager.py → simple_module_users-0.0.39/tests/test_users_manager.py +0 -33
  40. simple_module_users-0.0.39/tests/test_users_password_change_keeps_api.py +98 -0
  41. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_provider.py +10 -5
  42. simple_module_users-0.0.39/tests/test_users_register_single_connection.py +55 -0
  43. simple_module_users-0.0.39/tests/test_users_reset_link_origin.py +48 -0
  44. simple_module_users-0.0.39/tests/test_users_startup_role.py +83 -0
  45. simple_module_users-0.0.39/tests-js/AccountCardLocked.test.tsx +50 -0
  46. simple_module_users-0.0.39/tests-js/users_Login.test.tsx +94 -0
  47. simple_module_users-0.0.39/tests-js/users_actions.test.tsx +88 -0
  48. simple_module_users-0.0.39/tests-js/users_fixtures.ts +23 -0
  49. simple_module_users-0.0.39/tests-js/users_profile_cards.test.tsx +56 -0
  50. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/api.py +22 -8
  51. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/StatusPill.tsx +18 -0
  52. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/UserCards.tsx +1 -0
  53. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/UserRow.tsx +2 -1
  54. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/useUserRowActions.ts +26 -14
  55. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/user-list-item.ts +2 -0
  56. simple_module_users-0.0.39/users/admin/links.py +39 -0
  57. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/queries.py +52 -24
  58. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/recent_activity.py +28 -59
  59. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/service.py +28 -7
  60. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/views.py +15 -14
  61. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/audit.py +12 -32
  62. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/api.py +34 -41
  63. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/AuthIntro.tsx +2 -2
  64. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/PasswordCard.tsx +19 -10
  65. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/ProfileDetailsCard.tsx +11 -9
  66. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/ResendVerification.tsx +3 -6
  67. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/SessionsCard.tsx +26 -13
  68. simple_module_users-0.0.39/users/auth_local/lockout/__init__.py +209 -0
  69. simple_module_users-0.0.39/users/auth_local/lockout/attempt.py +87 -0
  70. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/rate_limit.py +6 -34
  71. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/self_account.py +50 -22
  72. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/token_api.py +49 -17
  73. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/token_preview.py +1 -1
  74. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/views.py +1 -1
  75. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/backend.py +30 -5
  76. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/bootstrap.py +38 -27
  77. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/constants.py +13 -0
  78. simple_module_users-0.0.39/users/contracts/roles.py +11 -0
  79. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/contracts/schemas.py +3 -1
  80. simple_module_users-0.0.37/users/demo.py → simple_module_users-0.0.39/users/demo/__init__.py +7 -4
  81. simple_module_users-0.0.37/users/auth_local/demo_api.py → simple_module_users-0.0.39/users/demo/api.py +1 -1
  82. simple_module_users-0.0.37/users/demo_guard.py → simple_module_users-0.0.39/users/demo/guard.py +39 -13
  83. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/deps.py +2 -1
  84. simple_module_users-0.0.39/users/directory.py +108 -0
  85. simple_module_users-0.0.39/users/errors.py +19 -0
  86. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/exceptions.py +19 -4
  87. simple_module_users-0.0.39/users/invites/__init__.py +1 -0
  88. simple_module_users-0.0.37/users/admin/bulk_invite.py → simple_module_users-0.0.39/users/invites/bulk.py +23 -49
  89. simple_module_users-0.0.37/users/admin/resend_invite.py → simple_module_users-0.0.39/users/invites/resend.py +5 -5
  90. simple_module_users-0.0.39/users/locales/de.json +419 -0
  91. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/locales/en.json +11 -5
  92. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/mailer/__init__.py +4 -19
  93. simple_module_users-0.0.39/users/mailer/templated.py +77 -0
  94. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/manager.py +13 -0
  95. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/user.py +15 -1
  96. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/module.py +31 -23
  97. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/AcceptInvite.tsx +16 -18
  98. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/ForgotPassword.tsx +7 -11
  99. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Login.tsx +36 -49
  100. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Profile.tsx +2 -4
  101. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Register.tsx +9 -6
  102. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/ResetPassword.tsx +19 -32
  103. simple_module_users-0.0.39/users/pages/Users/AddPeople.tsx +136 -0
  104. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/Edit.tsx +18 -13
  105. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/Index.tsx +9 -10
  106. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/AccountCard.tsx +24 -1
  107. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/DangerZone.tsx +22 -19
  108. simple_module_users-0.0.39/users/pages/Users/components/MailerBanner.tsx +37 -0
  109. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/RecentActivityCard.tsx +3 -3
  110. simple_module_users-0.0.39/users/pages/Users/components/useAddPeople.ts +157 -0
  111. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/useEditUserForm.ts +24 -31
  112. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/useUserActions.ts +49 -14
  113. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/VerifyEmail.tsx +11 -22
  114. simple_module_users-0.0.39/users/pages/api.generated.ts +1923 -0
  115. simple_module_users-0.0.39/users/pages/api.ts +9 -0
  116. simple_module_users-0.0.39/users/pages/failureMessage.ts +16 -0
  117. simple_module_users-0.0.39/users/pages/signIn.ts +25 -0
  118. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/provider.py +3 -17
  119. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/registration.py +17 -11
  120. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/session_revocation.py +25 -2
  121. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/settings.py +31 -16
  122. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/setup_action.py +1 -1
  123. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/shared_props.py +1 -1
  124. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/startup.py +24 -43
  125. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/state.py +1 -2
  126. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/token_strategy.py +6 -5
  127. simple_module_users-0.0.37/tests/test_mailer.py +0 -130
  128. simple_module_users-0.0.37/tests/test_rate_limit.py +0 -132
  129. simple_module_users-0.0.37/tests/test_users_default_role.py +0 -82
  130. simple_module_users-0.0.37/users/health.py +0 -47
  131. simple_module_users-0.0.37/users/mailer/console.py +0 -61
  132. simple_module_users-0.0.37/users/mailer/smtp.py +0 -123
  133. simple_module_users-0.0.37/users/pages/Users/AddPeople.tsx +0 -298
  134. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/LICENSE +0 -0
  135. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/.gitkeep +0 -0
  136. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/_demo_support.py +0 -0
  137. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/_users_oauth_support.py +0 -0
  138. /simple_module_users-0.0.37/tests/test_demo_login.py → /simple_module_users-0.0.39/tests/demo/test_users_demo_login.py +0 -0
  139. {simple_module_users-0.0.37/tests → simple_module_users-0.0.39/tests/invites}/test_users_list_invites.py +0 -0
  140. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_access_token_model.py +0 -0
  141. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_activity_summary.py +0 -0
  142. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_api_admin.py +0 -0
  143. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_api_admin_crud.py +0 -0
  144. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_api_admin_filters.py +0 -0
  145. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_api_auth.py +0 -0
  146. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_backend.py +0 -0
  147. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_bearer_token_age.py +0 -0
  148. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_bootstrap_resolution.py +0 -0
  149. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_cli.py +0 -0
  150. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_constants.py +0 -0
  151. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_db_adapter.py +0 -0
  152. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_external_users.py +0 -0
  153. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_legacy_cached_tenant.py +0 -0
  154. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_legacy_session_expiry.py +0 -0
  155. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_login_redirect_root_mount.py +0 -0
  156. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_negative_authz.py +0 -0
  157. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_oauth.py +0 -0
  158. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_oauth_routes.py +0 -0
  159. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_remember_me.py +0 -0
  160. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_revocation_broadcast.py +0 -0
  161. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_role_model.py +0 -0
  162. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_service_admin.py +0 -0
  163. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_service_admin_crud.py +0 -0
  164. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_session_expiry.py +0 -0
  165. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_session_version_cache.py +0 -0
  166. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_session_version_failopen.py +0 -0
  167. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_session_version_hardening.py +0 -0
  168. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_sessions_revoke.py +0 -0
  169. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_token_api.py +0 -0
  170. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_user_model.py +0 -0
  171. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_user_role_model.py +0 -0
  172. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_user_service.py +0 -0
  173. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_user_state.py +0 -0
  174. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_bearer_scheme.py +0 -0
  175. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_deps.py +0 -0
  176. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_login_deep_link.py +0 -0
  177. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_middleware.py +0 -0
  178. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_middleware_public_paths.py +0 -0
  179. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_middleware_resolvers.py +0 -0
  180. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_oauth_signup_gate.py +0 -0
  181. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_oauth_unverified_link.py +0 -0
  182. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_register_role_durable.py +0 -0
  183. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_require_verification.py +0 -0
  184. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_seed_role_migration.py +0 -0
  185. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_setup_lock.py +0 -0
  186. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_setup_wizard.py +0 -0
  187. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_shared_props.py +0 -0
  188. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_views.py +0 -0
  189. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_views_admin.py +0 -0
  190. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests-js/EmailChipInput.test.tsx +0 -0
  191. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests-js/LoginForm.test.tsx +0 -0
  192. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests-js/deriveState.test.ts +0 -0
  193. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests-js/invite-emails.test.ts +0 -0
  194. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests-js/registerErrors.test.ts +0 -0
  195. /simple_module_users-0.0.37/tests-js/Register.test.tsx → /simple_module_users-0.0.39/tests-js/users_Register.test.tsx +0 -0
  196. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tsconfig.json +0 -0
  197. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/__init__.py +0 -0
  198. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/__init__.py +0 -0
  199. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/IndexFilters.tsx +0 -0
  200. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/RolesTab.tsx +0 -0
  201. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/UserRowMenu.tsx +0 -0
  202. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/UsersEmpty.tsx +0 -0
  203. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/UsersTable.tsx +0 -0
  204. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/user_state.py +0 -0
  205. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/__init__.py +0 -0
  206. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/AuthField.tsx +0 -0
  207. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/AuthStateCard.tsx +0 -0
  208. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/DemoSignIn.tsx +0 -0
  209. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/InviteSummary.tsx +0 -0
  210. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/LoginForm.tsx +0 -0
  211. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/PasswordFields.tsx +0 -0
  212. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/PreferencesCard.tsx +0 -0
  213. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/ResetLinkSent.tsx +0 -0
  214. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/WaitingOnYou.tsx +0 -0
  215. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/profile-user.ts +0 -0
  216. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/registerErrors.ts +0 -0
  217. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/cli.py +0 -0
  218. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/contracts/__init__.py +0 -0
  219. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/contracts/events.py +0 -0
  220. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/db_adapter.py +0 -0
  221. /simple_module_users-0.0.37/users/auth_local/invite_preview.py → /simple_module_users-0.0.39/users/invites/preview.py +0 -0
  222. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/mailer/templates/.gitkeep +0 -0
  223. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/mailer/templates/invite.txt +0 -0
  224. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/mailer/templates/reset_password.txt +0 -0
  225. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/mailer/templates/verify_email.txt +0 -0
  226. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/middleware.py +0 -0
  227. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/__init__.py +0 -0
  228. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/_base.py +0 -0
  229. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/access_token.py +0 -0
  230. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/oauth_account.py +0 -0
  231. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/refresh_token.py +0 -0
  232. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/role.py +0 -0
  233. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/user_role.py +0 -0
  234. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/oauth/__init__.py +0 -0
  235. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/oauth/api.py +0 -0
  236. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/oauth/providers.py +0 -0
  237. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/.gitkeep +0 -0
  238. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/CreateUserFields.tsx +0 -0
  239. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/DetailsCard.tsx +0 -0
  240. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
  241. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/EmailChipInput.tsx +0 -0
  242. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/InviteFields.tsx +0 -0
  243. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/InviteResults.tsx +0 -0
  244. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/RolePicker.tsx +0 -0
  245. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/UserStats.tsx +0 -0
  246. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/invite-emails.ts +0 -0
  247. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/py.typed +0 -0
  248. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/roles_cache.py +0 -0
  249. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/session_version_cache.py +0 -0
  250. {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/setup.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.39
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,15 +21,15 @@ 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
- Requires-Dist: cachetools>=5.3
24
+ Requires-Dist: cachetools<8,>=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
32
- Requires-Dist: typer>=0.12
26
+ Requires-Dist: simple-module-auth==0.0.39
27
+ Requires-Dist: simple-module-core==0.0.39
28
+ Requires-Dist: simple-module-db==0.0.39
29
+ Requires-Dist: simple-module-hosting==0.0.39
30
+ Requires-Dist: simple-module-mail==0.0.39
31
+ Requires-Dist: simple-module-settings==0.0.39
32
+ Requires-Dist: typer<0.28,>=0.12
33
33
  Description-Content-Type: text/markdown
34
34
 
35
35
  # simple_module_users
@@ -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.39",
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.39"
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,18 +21,20 @@ 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.39",
25
+ "simple_module_db==0.0.39",
26
+ "simple_module_hosting==0.0.39",
27
+ "simple_module_settings==0.0.39",
28
+ "simple_module_auth==0.0.39",
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.39",
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
- "cachetools>=5.3",
35
- "typer>=0.12",
36
+ "cachetools>=5.3,<8",
37
+ "typer>=0.12,<0.28",
36
38
  ]
37
39
 
38
40
  [project.entry-points.simple_module]
@@ -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.
@@ -82,9 +84,16 @@ async def _seed_roles(application) -> None:
82
84
  if "user" not in existing:
83
85
  session.add(Role(id=USER_ROLE_ID, name="user", description="Standard user"))
84
86
  await session.commit()
87
+ # Direct inserts bypass the role service that would refresh the cache; startup
88
+ # already warmed it (with the role it seeds), so refresh like a real write would.
89
+ from users.roles_cache import refresh_roles_cache
90
+
91
+ await refresh_roles_cache(application)
85
92
 
86
93
 
87
- async def _seed_users_settings(application, *, allow_signup: bool) -> None:
94
+ async def _seed_users_settings(
95
+ application, *, allow_signup: bool, overrides: dict[str, tuple[str, str]] | None = None
96
+ ) -> None:
88
97
  """Write the test UsersSettings overrides to the DB before hydrate runs.
89
98
 
90
99
  Hydrate fires inside the lifespan ``__aenter__``, so this needs to land
@@ -95,7 +104,8 @@ async def _seed_users_settings(application, *, allow_signup: bool) -> None:
95
104
 
96
105
  async with application.state.sm.db.session_factory() as session:
97
106
  store = SettingsStore(SettingService(session))
98
- for field, (raw, vtype) in _users_overrides(allow_signup=allow_signup).items():
107
+ rows = _users_overrides(allow_signup=allow_signup) | (overrides or {})
108
+ for field, (raw, vtype) in rows.items():
99
109
  await store.set_override("users", field, raw, vtype)
100
110
  await session.commit()
101
111
 
@@ -136,19 +146,33 @@ async def _make_standard_user(app, email: str = "user@example.com"):
136
146
  return user
137
147
 
138
148
 
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."""
149
+ async def _build_users_app(
150
+ monkeypatch,
151
+ *,
152
+ allow_signup: bool,
153
+ seed_admin: bool = True,
154
+ database_url: str = "sqlite+aiosqlite:///:memory:",
155
+ overrides: dict[str, tuple[str, str]] | None = None,
156
+ ):
157
+ """Build a test FastAPI app with DB created, settings seeded, lifespan started.
158
+
159
+ ``database_url`` lets several apps share one SQLite file, standing in for
160
+ workers of one deployment; ``overrides`` adds ``UsersSettings`` rows.
161
+ """
141
162
  from simple_module_hosting.app_builder import create_app
142
163
 
143
164
  settings = Settings(
144
- database_url="sqlite+aiosqlite:///:memory:",
165
+ database_url=database_url,
145
166
  environment="testing",
146
167
  secret_key="test-secret-key",
147
168
  multi_tenant=False,
169
+ # Explicit: a developer's .env pointing at the shared Redis would make
170
+ # login-lockout counters outlive the test that wrote them.
171
+ redis_url=None,
148
172
  )
149
173
  application = create_app(settings)
150
174
  await _setup_app_db(application)
151
- await _seed_users_settings(application, allow_signup=allow_signup)
175
+ await _seed_users_settings(application, allow_signup=allow_signup, overrides=overrides)
152
176
 
153
177
  ctx = application.router.lifespan_context(application)
154
178
  await ctx.__aenter__()
@@ -161,3 +185,30 @@ async def _build_users_app(monkeypatch, *, allow_signup: bool, seed_admin: bool
161
185
  if seed_admin:
162
186
  await _make_admin_user(application)
163
187
  return application, ctx
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Request and row helpers for the login-lockout tests
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ def _client(app, ip: str) -> httpx.AsyncClient:
196
+ """A client whose requests arrive from *ip*."""
197
+ transport = httpx.ASGITransport(app=app, client=(ip, 1))
198
+ return httpx.AsyncClient(transport=transport, base_url="http://testserver")
199
+
200
+
201
+ async def _login(app, ip: str, username: str, password: str = "wrong") -> httpx.Response:
202
+ async with _client(app, ip) as c:
203
+ return await c.post(
204
+ "/api/users/auth/login", data={"username": username, "password": password}
205
+ )
206
+
207
+
208
+ async def user_row(app, email: str):
209
+ """The ``User`` row for *email*, or ``None``, read in a fresh session."""
210
+ from sqlalchemy import select
211
+ from users.models import User
212
+
213
+ async with app.state.sm.db.session_factory() as session:
214
+ 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
  # ---------------------------------------------------------------------------
@@ -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):
@@ -0,0 +1,139 @@
1
+ """Demo read-only mode follows identities authenticated without demo cookies."""
2
+
3
+ import httpx
4
+ import pytest
5
+ from api_tokens.constants import TokenKind
6
+ from api_tokens.service import ApiTokenService
7
+ from simple_module_test import forge_session_cookie
8
+ from users.auth_local.token_api import _create_token_pair
9
+ from users.demo import ensure_demo_users
10
+
11
+
12
+ @pytest.fixture
13
+ async def demo_credentials(demo_app):
14
+ settings = demo_app.state.users.settings
15
+ settings.demo_admin_password = "DemoPass1!"
16
+ await ensure_demo_users(demo_app)
17
+ user_id = demo_app.state.users.demo_user_ids[0]
18
+ async with demo_app.state.sm.db.session_factory() as db:
19
+ pair = await _create_token_pair(db, user_id, settings)
20
+ _, pat = await ApiTokenService(db).create(
21
+ kind=TokenKind.PERSONAL,
22
+ name="pre-existing demo PAT",
23
+ scopes=["users.manage", "api_tokens.use"],
24
+ expires_in_days=1,
25
+ max_lifetime_days=30,
26
+ owner_user_id=user_id,
27
+ )
28
+ await db.commit()
29
+ return {"bearer": pair.access_token, "pat": pat, "refresh": pair.refresh_token}
30
+
31
+
32
+ @pytest.mark.parametrize("credential", ["bearer", "pat"])
33
+ @pytest.mark.parametrize("read_only", [True, False])
34
+ async def test_demo_token_writes_follow_read_only_setting(
35
+ demo_app, demo_client, demo_credentials, credential, read_only
36
+ ):
37
+ demo_app.state.users.settings.demo_read_only = read_only
38
+ headers = {"Authorization": f"Bearer {demo_credentials[credential]}"}
39
+ # A successful read proves this credential actually resolves the demo identity.
40
+ target = demo_app.state.users.demo_user_ids[1]
41
+ assert (await demo_client.get("/api/users/admin", headers=headers)).status_code == 200
42
+ response = await demo_client.patch(
43
+ f"/api/users/admin/{target}",
44
+ json={"email": demo_app.state.users.settings.demo_user_email, "full_name": "Changed"},
45
+ headers=headers,
46
+ )
47
+ assert response.status_code == (403 if read_only else 200), response.text
48
+ if read_only:
49
+ assert response.json() == {"detail": "DEMO_READ_ONLY"}
50
+ else:
51
+ assert response.json()["full_name"] == "Changed"
52
+
53
+
54
+ @pytest.mark.parametrize("credential", ["bearer", "pat"])
55
+ async def test_demo_token_inertia_write_uses_existing_refusal(
56
+ demo_app, demo_client, demo_credentials, credential
57
+ ):
58
+ response = await demo_client.patch(
59
+ f"/api/users/admin/{demo_app.state.users.demo_user_ids[1]}",
60
+ json={"email": demo_app.state.users.settings.demo_user_email, "full_name": "Changed"},
61
+ headers={
62
+ "Authorization": f"Bearer {demo_credentials[credential]}",
63
+ "X-Inertia": "true",
64
+ "Referer": "http://testserver/admin/users/",
65
+ },
66
+ )
67
+ assert response.status_code == 409
68
+ assert response.headers["X-Inertia-Location"] == "http://testserver/admin/users/"
69
+
70
+
71
+ @pytest.mark.parametrize("operation", ["login", "refresh"])
72
+ async def test_demo_cannot_mint_token_pair_without_session(
73
+ demo_app, demo_client, demo_credentials, operation
74
+ ):
75
+ if operation == "login":
76
+ path = "/api/users/auth/token"
77
+ body = {"email": demo_app.state.users.settings.demo_admin_email, "password": "DemoPass1!"}
78
+ else:
79
+ path = "/api/users/auth/token/refresh"
80
+ body = {"refresh_token": demo_credentials["refresh"]}
81
+ response = await demo_client.post(path, json=body)
82
+ assert response.status_code == 403, response.text
83
+ assert response.json() == {"detail": "DEMO_READ_ONLY"}
84
+ # Refusal must not consume the refresh token or leave a login lockout slot.
85
+ demo_app.state.users.settings.demo_read_only = False
86
+ assert (await demo_client.post(path, json=body)).status_code == 200
87
+
88
+
89
+ async def test_demo_session_cannot_create_pat(demo_app):
90
+ cookie = forge_session_cookie(
91
+ str(demo_app.state.sm.settings.secret_key),
92
+ {"user_id": str(demo_app.state.users.demo_user_ids[0]), "sm_csrf_token": "csrf"},
93
+ )
94
+ async with httpx.AsyncClient(
95
+ transport=httpx.ASGITransport(app=demo_app),
96
+ base_url="http://testserver",
97
+ cookies={"session": cookie},
98
+ ) as client:
99
+ response = await client.post(
100
+ "/api/api_tokens/tokens/",
101
+ json={"name": "demo", "scopes": ["users.manage"], "expires_in_days": 1},
102
+ headers={"X-CSRF-Token": "csrf"},
103
+ )
104
+ assert response.status_code == 403
105
+ assert response.json() == {"detail": "DEMO_READ_ONLY"}
106
+
107
+
108
+ async def test_real_bearer_account_can_still_write_on_demo_instance(demo_app, demo_client):
109
+ response = await demo_client.post(
110
+ "/api/users/auth/token", json={"email": "admin@example.com", "password": "AdminPass1!"}
111
+ )
112
+ assert response.status_code == 200
113
+ response = await demo_client.patch(
114
+ f"/api/users/admin/{demo_app.state.users.demo_user_ids[1]}",
115
+ json={
116
+ "email": demo_app.state.users.settings.demo_user_email,
117
+ "full_name": "Real admin write",
118
+ },
119
+ headers={"Authorization": f"Bearer {response.json()['access_token']}"},
120
+ )
121
+ assert response.status_code == 200
122
+ assert response.json()["full_name"] == "Real admin write"
123
+
124
+
125
+ async def test_demo_token_login_still_checks_password(demo_app, demo_client, demo_credentials):
126
+ response = await demo_client.post(
127
+ "/api/users/auth/token",
128
+ json={"email": demo_app.state.users.settings.demo_admin_email, "password": "wrong"},
129
+ )
130
+ assert response.status_code == 401
131
+
132
+
133
+ @pytest.mark.parametrize("credential", ["bearer", "pat"])
134
+ async def test_demo_token_can_still_logout(demo_client, demo_credentials, credential):
135
+ response = await demo_client.post(
136
+ "/api/users/auth/logout",
137
+ headers={"Authorization": f"Bearer {demo_credentials[credential]}"},
138
+ )
139
+ assert response.status_code == 204
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  import httpx
10
10
  from users.constants import ADMIN_ROLE_NAME, SESSION_USER_ID_KEY, USER_ROLE_NAME
11
11
  from users.demo import SESSION_DEMO_KEY
12
- from users.demo_guard import DEMO_READ_ONLY_DETAIL
12
+ from users.demo.guard import DEMO_READ_ONLY_DETAIL
13
13
 
14
14
 
15
15
  def _demo_route(role: str) -> str:
@@ -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,
@@ -200,7 +202,7 @@ class TestMailerFailureIsNotEchoed:
200
202
  async def test_the_admin_gets_the_link_and_a_generic_reason(
201
203
  self, admin_client, users_app, caplog
202
204
  ):
203
- from users.admin.bulk_invite import MAILER_FAILURE_DETAIL
205
+ from users.invites.bulk import MAILER_FAILURE_DETAIL
204
206
 
205
207
  class _BrokenMailer(_SpyMailer):
206
208
  async def send_invite(self, email, token, invited_by_name, message=None):
@@ -210,7 +212,7 @@ class TestMailerFailureIsNotEchoed:
210
212
  original = users_app.state.users.mailer
211
213
  users_app.state.users.mailer = _BrokenMailer()
212
214
  try:
213
- with caplog.at_level(logging.ERROR, logger="users.admin.resend_invite"):
215
+ with caplog.at_level(logging.ERROR, logger="users.invites.resend"):
214
216
  resp = await admin_client.post(f"/api/users/admin/{user_id}/resend-invite")
215
217
  finally:
216
218
  users_app.state.users.mailer = original
@@ -139,7 +139,7 @@ class TestBulkInvite:
139
139
 
140
140
  async def test_address_count_is_capped(self, authenticated_client: httpx.AsyncClient):
141
141
  """One submit must not be able to mint unbounded live invite tokens."""
142
- from users.admin.bulk_invite import MAX_ADDRESSES
142
+ from users.invites.bulk import MAX_ADDRESSES
143
143
 
144
144
  emails = [f"bulk{i}@example.com" for i in range(MAX_ADDRESSES + 5)]
145
145
  resp = await authenticated_client.post(_URL, json={"emails": emails, "role_names": []})
@@ -151,7 +151,7 @@ class TestBulkInvite:
151
151
  ):
152
152
  """Silent truncation reports "100 invites sent" while 5 people are
153
153
  never contacted, with nothing on screen saying so."""
154
- from users.admin.bulk_invite import MAX_ADDRESSES
154
+ from users.invites.bulk import MAX_ADDRESSES
155
155
 
156
156
  emails = [f"over{i}@example.com" for i in range(MAX_ADDRESSES + 5)]
157
157
  resp = await authenticated_client.post(_URL, json={"emails": emails, "role_names": []})
@@ -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