simple-module-users 0.0.41__tar.gz → 0.0.42__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 (277) hide show
  1. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/PKG-INFO +7 -7
  2. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/package.json +4 -4
  3. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/pyproject.toml +7 -7
  4. simple_module_users-0.0.42/tests/invites/test_users_bulk_invite_error_privacy.py +24 -0
  5. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_revocation_broadcast.py +7 -5
  6. simple_module_users-0.0.42/tests/test_users_admin_create_commit.py +77 -0
  7. simple_module_users-0.0.42/tests/test_users_data_compatibility.py +66 -0
  8. simple_module_users-0.0.42/tests/test_users_mailer_templates.py +148 -0
  9. simple_module_users-0.0.42/tests/test_users_reset_credentials.py +118 -0
  10. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/api.py +21 -15
  11. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/self_account.py +3 -43
  12. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/constants.py +8 -0
  13. simple_module_users-0.0.42/users/credentials.py +52 -0
  14. simple_module_users-0.0.42/users/data_compatibility.py +62 -0
  15. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/invites/bulk.py +2 -10
  16. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/mailer/__init__.py +9 -2
  17. simple_module_users-0.0.42/users/mailer/templated.py +111 -0
  18. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/manager.py +11 -2
  19. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/module.py +7 -1
  20. simple_module_users-0.0.42/users/reset_revocation.py +53 -0
  21. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/settings.py +9 -0
  22. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/startup.py +1 -1
  23. simple_module_users-0.0.41/users/mailer/templated.py +0 -77
  24. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/.gitignore +0 -0
  25. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/LICENSE +0 -0
  26. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/README.md +0 -0
  27. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/.gitkeep +0 -0
  28. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/_demo_support.py +0 -0
  29. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/_middleware_support.py +0 -0
  30. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/_users_app_builders.py +0 -0
  31. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/_users_oauth_support.py +0 -0
  32. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/conftest.py +0 -0
  33. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/demo/test_users_demo_account.py +0 -0
  34. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/demo/test_users_demo_credentials.py +0 -0
  35. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/demo/test_users_demo_guard.py +0 -0
  36. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/demo/test_users_demo_login.py +0 -0
  37. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/invites/test_users_admin_resend_invite.py +0 -0
  38. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/invites/test_users_bulk_invite.py +0 -0
  39. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/invites/test_users_bulk_invite_message.py +0 -0
  40. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/invites/test_users_invite_flow.py +0 -0
  41. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/invites/test_users_invite_reuse.py +0 -0
  42. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/invites/test_users_list_invites.py +0 -0
  43. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/lockout/test_users_admin_unlock.py +0 -0
  44. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/lockout/test_users_lockout_checkpoint.py +0 -0
  45. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/lockout/test_users_lockout_columns.py +0 -0
  46. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/lockout/test_users_lockout_enumeration.py +0 -0
  47. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/lockout/test_users_lockout_service.py +0 -0
  48. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/lockout/test_users_login_lockout.py +0 -0
  49. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_access_token_model.py +0 -0
  50. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_activity_summary.py +0 -0
  51. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_api_admin.py +0 -0
  52. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_api_admin_crud.py +0 -0
  53. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_api_admin_filters.py +0 -0
  54. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_api_admin_pagination_bounds.py +0 -0
  55. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_api_auth.py +0 -0
  56. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_auth_screens_props.py +0 -0
  57. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_backend.py +0 -0
  58. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_bearer_token_age.py +0 -0
  59. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_bearer_token_expiry.py +0 -0
  60. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_bootstrap.py +0 -0
  61. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_bootstrap_resolution.py +0 -0
  62. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_cli.py +0 -0
  63. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_constants.py +0 -0
  64. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_db_adapter.py +0 -0
  65. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_edit_recent_activity.py +0 -0
  66. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_external_users.py +0 -0
  67. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_legacy_cached_tenant.py +0 -0
  68. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_legacy_session_expiry.py +0 -0
  69. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_login_redirect_root_mount.py +0 -0
  70. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_mailer.py +0 -0
  71. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_negative_authz.py +0 -0
  72. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_oauth.py +0 -0
  73. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_oauth_routes.py +0 -0
  74. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_profile_password.py +0 -0
  75. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_rate_limit.py +0 -0
  76. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_remember_me.py +0 -0
  77. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_role_model.py +0 -0
  78. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_service_admin.py +0 -0
  79. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_service_admin_crud.py +0 -0
  80. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_session_expiry.py +0 -0
  81. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_session_revocation.py +0 -0
  82. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_session_version_atomic.py +0 -0
  83. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_session_version_cache.py +0 -0
  84. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_session_version_failopen.py +0 -0
  85. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_session_version_hardening.py +0 -0
  86. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_sessions_revoke.py +0 -0
  87. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_settings.py +0 -0
  88. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_token_api.py +0 -0
  89. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_user_model.py +0 -0
  90. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_user_role_model.py +0 -0
  91. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_user_service.py +0 -0
  92. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_user_state.py +0 -0
  93. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_admin_effective_permissions.py +0 -0
  94. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_admin_privilege_guards.py +0 -0
  95. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_admin_role_grant_rules.py +0 -0
  96. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_admin_session_revocation.py +0 -0
  97. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_bearer_scheme.py +0 -0
  98. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_default_role.py +0 -0
  99. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_deletion_events.py +0 -0
  100. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_deps.py +0 -0
  101. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_directory.py +0 -0
  102. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_domain_errors.py +0 -0
  103. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_login_deep_link.py +0 -0
  104. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_login_session_rotation.py +0 -0
  105. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_mailer_core_sender.py +0 -0
  106. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_manager.py +0 -0
  107. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_me_credentials.py +0 -0
  108. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_middleware.py +0 -0
  109. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_middleware_public_paths.py +0 -0
  110. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_middleware_resolvers.py +0 -0
  111. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_oauth_email_claim.py +0 -0
  112. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_oauth_error_responses.py +0 -0
  113. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_oauth_login_errors.py +0 -0
  114. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_oauth_provider_identity.py +0 -0
  115. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_oauth_signup_gate.py +0 -0
  116. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_oauth_unverified_link.py +0 -0
  117. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_password_change_keeps_api.py +0 -0
  118. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_provider.py +0 -0
  119. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_refresh_family_max_age.py +0 -0
  120. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_refresh_reuse.py +0 -0
  121. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_register_role_durable.py +0 -0
  122. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_register_single_connection.py +0 -0
  123. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_require_verification.py +0 -0
  124. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_reset_link_origin.py +0 -0
  125. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_reset_password_retry.py +0 -0
  126. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_seed_role_migration.py +0 -0
  127. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_setup_lock.py +0 -0
  128. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_setup_wizard.py +0 -0
  129. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_shared_props.py +0 -0
  130. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_startup_role.py +0 -0
  131. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_token_hash_migration.py +0 -0
  132. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_token_pruning.py +0 -0
  133. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_token_storage.py +0 -0
  134. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_users_update_schema.py +0 -0
  135. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_views.py +0 -0
  136. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests/test_views_admin.py +0 -0
  137. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/AccountCardLocked.test.tsx +0 -0
  138. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/EmailChipInput.test.tsx +0 -0
  139. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/LoginForm.test.tsx +0 -0
  140. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/deriveState.test.ts +0 -0
  141. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/invite-emails.test.ts +0 -0
  142. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/registerErrors.test.ts +0 -0
  143. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/users_Login.test.tsx +0 -0
  144. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/users_Register.test.tsx +0 -0
  145. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/users_ResetPassword.test.tsx +0 -0
  146. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/users_VerifyEmail.test.tsx +0 -0
  147. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/users_actions.test.tsx +0 -0
  148. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/users_admin_error_details.test.tsx +0 -0
  149. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/users_fixtures.ts +0 -0
  150. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tests-js/users_profile_cards.test.tsx +0 -0
  151. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/tsconfig.json +0 -0
  152. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/__init__.py +0 -0
  153. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/__init__.py +0 -0
  154. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/components/IndexFilters.tsx +0 -0
  155. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/components/RolesTab.tsx +0 -0
  156. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/components/StatusPill.tsx +0 -0
  157. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/components/UserCards.tsx +0 -0
  158. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/components/UserRow.tsx +0 -0
  159. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/components/UserRowMenu.tsx +0 -0
  160. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/components/UsersEmpty.tsx +0 -0
  161. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/components/UsersTable.tsx +0 -0
  162. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/components/useUserRowActions.ts +0 -0
  163. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/components/user-list-item.ts +0 -0
  164. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/events.py +0 -0
  165. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/links.py +0 -0
  166. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/queries.py +0 -0
  167. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/recent_activity.py +0 -0
  168. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/safety.py +0 -0
  169. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/service.py +0 -0
  170. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/user_state.py +0 -0
  171. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/admin/views.py +0 -0
  172. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/audit.py +0 -0
  173. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/__init__.py +0 -0
  174. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/api.py +0 -0
  175. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/AuthField.tsx +0 -0
  176. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/AuthIntro.tsx +0 -0
  177. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/AuthStateCard.tsx +0 -0
  178. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/DemoSignIn.tsx +0 -0
  179. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/InviteSummary.tsx +0 -0
  180. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/LoginForm.tsx +0 -0
  181. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/PasswordCard.tsx +0 -0
  182. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/PasswordFields.tsx +0 -0
  183. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/PreferencesCard.tsx +0 -0
  184. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
  185. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/ResendVerification.tsx +0 -0
  186. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/ResetLinkSent.tsx +0 -0
  187. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/SessionsCard.tsx +0 -0
  188. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/WaitingOnYou.tsx +0 -0
  189. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/components/profile-user.ts +0 -0
  190. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/lockout/__init__.py +0 -0
  191. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/lockout/attempt.py +0 -0
  192. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/rate_limit.py +0 -0
  193. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/registerErrors.ts +0 -0
  194. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/resetPasswordError.ts +0 -0
  195. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/token_api.py +0 -0
  196. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/token_preview.py +0 -0
  197. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/auth_local/views.py +0 -0
  198. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/backend.py +0 -0
  199. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/bootstrap.py +0 -0
  200. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/cli.py +0 -0
  201. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/contracts/__init__.py +0 -0
  202. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/contracts/events.py +0 -0
  203. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/contracts/roles.py +0 -0
  204. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/contracts/schemas.py +0 -0
  205. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/db_adapter.py +0 -0
  206. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/demo/__init__.py +0 -0
  207. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/demo/api.py +0 -0
  208. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/demo/guard.py +0 -0
  209. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/deps.py +0 -0
  210. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/directory.py +0 -0
  211. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/errors.py +0 -0
  212. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/exceptions.py +0 -0
  213. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/invites/__init__.py +0 -0
  214. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/invites/preview.py +0 -0
  215. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/invites/resend.py +0 -0
  216. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/locales/de.json +0 -0
  217. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/locales/en.json +0 -0
  218. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/mailer/templates/.gitkeep +0 -0
  219. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/mailer/templates/invite.txt +0 -0
  220. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/mailer/templates/reset_password.txt +0 -0
  221. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/mailer/templates/verify_email.txt +0 -0
  222. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/middleware.py +0 -0
  223. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/models/__init__.py +0 -0
  224. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/models/_base.py +0 -0
  225. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/models/access_token.py +0 -0
  226. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/models/oauth_account.py +0 -0
  227. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/models/refresh_token.py +0 -0
  228. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/models/role.py +0 -0
  229. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/models/user.py +0 -0
  230. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/models/user_role.py +0 -0
  231. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/oauth/__init__.py +0 -0
  232. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/oauth/api.py +0 -0
  233. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/oauth/errors.py +0 -0
  234. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/oauth/providers.py +0 -0
  235. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/.gitkeep +0 -0
  236. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/AcceptInvite.tsx +0 -0
  237. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/ForgotPassword.tsx +0 -0
  238. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Login.tsx +0 -0
  239. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Profile.tsx +0 -0
  240. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Register.tsx +0 -0
  241. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/ResetPassword.tsx +0 -0
  242. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/AddPeople.tsx +0 -0
  243. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/Edit.tsx +0 -0
  244. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/Index.tsx +0 -0
  245. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/AccountCard.tsx +0 -0
  246. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/CreateUserFields.tsx +0 -0
  247. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/DangerZone.tsx +0 -0
  248. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/DetailsCard.tsx +0 -0
  249. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
  250. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/EmailChipInput.tsx +0 -0
  251. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/InviteFields.tsx +0 -0
  252. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/InviteResults.tsx +0 -0
  253. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/MailerBanner.tsx +0 -0
  254. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
  255. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/RolePicker.tsx +0 -0
  256. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/UserStats.tsx +0 -0
  257. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/useAddPeople.ts +0 -0
  258. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/useEditUserForm.ts +0 -0
  259. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/components/useUserActions.ts +0 -0
  260. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/Users/invite-emails.ts +0 -0
  261. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/VerifyEmail.tsx +0 -0
  262. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/api.generated.ts +0 -0
  263. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/api.ts +0 -0
  264. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/failureMessage.ts +0 -0
  265. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/pages/signIn.ts +0 -0
  266. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/provider.py +0 -0
  267. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/py.typed +0 -0
  268. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/registration.py +0 -0
  269. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/roles_cache.py +0 -0
  270. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/session_revocation.py +0 -0
  271. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/session_version_cache.py +0 -0
  272. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/setup.py +0 -0
  273. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/setup_action.py +0 -0
  274. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/shared_props.py +0 -0
  275. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/state.py +0 -0
  276. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/token_storage.py +0 -0
  277. {simple_module_users-0.0.41 → simple_module_users-0.0.42}/users/token_strategy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_users
3
- Version: 0.0.41
3
+ Version: 0.0.42
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
@@ -23,12 +23,12 @@ Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: cachetools<8,>=5.3
25
25
  Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
26
- Requires-Dist: simple-module-auth==0.0.41
27
- Requires-Dist: simple-module-core==0.0.41
28
- Requires-Dist: simple-module-db==0.0.41
29
- Requires-Dist: simple-module-hosting==0.0.41
30
- Requires-Dist: simple-module-mail==0.0.41
31
- Requires-Dist: simple-module-settings==0.0.41
26
+ Requires-Dist: simple-module-auth==0.0.42
27
+ Requires-Dist: simple-module-core==0.0.42
28
+ Requires-Dist: simple-module-db==0.0.42
29
+ Requires-Dist: simple-module-hosting==0.0.42
30
+ Requires-Dist: simple-module-mail==0.0.42
31
+ Requires-Dist: simple-module-settings==0.0.42
32
32
  Requires-Dist: typer<0.28,>=0.12
33
33
  Description-Content-Type: text/markdown
34
34
 
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@simple-module-py/users",
3
- "version": "0.0.41",
3
+ "version": "0.0.42",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the Users module",
6
6
  "peerDependencies": {
7
+ "@inertiajs/react": "^3.9.1",
8
+ "@simple-module-py/ui": "*",
7
9
  "react": "^19.0.0",
8
- "react-dom": "^19.0.0",
9
- "@inertiajs/react": "^3.7.0",
10
- "@simple-module-py/ui": "*"
10
+ "react-dom": "^19.3.1"
11
11
  },
12
12
  "devDependencies": {
13
13
  "@simple-module-py/tsconfig": "*"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_users"
3
- version = "0.0.41"
3
+ version = "0.0.42"
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,14 +21,14 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.41",
25
- "simple_module_db==0.0.41",
26
- "simple_module_hosting==0.0.41",
27
- "simple_module_settings==0.0.41",
28
- "simple_module_auth==0.0.41",
24
+ "simple_module_core==0.0.42",
25
+ "simple_module_db==0.0.42",
26
+ "simple_module_hosting==0.0.42",
27
+ "simple_module_settings==0.0.42",
28
+ "simple_module_auth==0.0.42",
29
29
  # Sends through the framework mail sender; `mail` owns the SMTP transport
30
30
  # (GH #496), so an upgrade keeps delivering mail.
31
- "simple_module_mail==0.0.41",
31
+ "simple_module_mail==0.0.42",
32
32
  # Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
33
33
  # fields after construction (see reconfigure_cookie_transport in backend.py).
34
34
  # Bumping the major version requires re-checking those field names.
@@ -0,0 +1,24 @@
1
+ """Invite responses never expose database statements or exception details (#698)."""
2
+
3
+ import pytest
4
+ from fastapi_users.exceptions import UserAlreadyExists
5
+ from sqlalchemy.exc import IntegrityError
6
+ from users.invites.bulk import _failure_detail
7
+
8
+
9
+ @pytest.mark.parametrize(
10
+ "error",
11
+ [
12
+ IntegrityError(
13
+ "INSERT INTO users VALUES (:password)", {"password": "secret"}, Exception("db")
14
+ ),
15
+ RuntimeError("private backend details"),
16
+ Exception(),
17
+ ],
18
+ )
19
+ def test_users_bulk_invite_unknown_error_is_generic(error):
20
+ assert _failure_detail(error) == "The invitation could not be created. Please try again."
21
+
22
+
23
+ def test_users_bulk_invite_already_exists_has_safe_message():
24
+ assert _failure_detail(UserAlreadyExists()) == "Already a member of this workspace"
@@ -133,18 +133,20 @@ class TestRollbackDoesNotBroadcast:
133
133
  ):
134
134
  """Fail the endpoint *after* it has registered the on-commit callback.
135
135
 
136
- ``delete`` is called for the access-token rows, which happens after the
137
- bump and after ``db.on_commit(...)``. Making it raise reproduces "the
138
- transaction did not commit" without needing to break the database.
136
+ Let the shared helper finish, then fail before the response commits.
137
+ This exercises rollback with an invalidation callback already queued.
139
138
  """
140
139
  import users.auth_local.self_account as self_account
141
140
 
142
141
  await _sign_in(anon_client)
143
142
 
144
- def _explode(*args, **kwargs):
143
+ revoke = self_account.revoke_all_credentials
144
+
145
+ async def _explode(*args, **kwargs):
146
+ await revoke(*args, **kwargs)
145
147
  raise RuntimeError("simulated failure after the session_version bump")
146
148
 
147
- monkeypatch.setattr(self_account, "delete", _explode)
149
+ monkeypatch.setattr(self_account, "revoke_all_credentials", _explode)
148
150
 
149
151
  with pytest.raises(RuntimeError, match="simulated failure"):
150
152
  await anon_client.post(_REVOKE)
@@ -0,0 +1,77 @@
1
+ """Single admin creation/invitation side effects follow the commit (#692, #627)."""
2
+
3
+ import pytest
4
+ from background_tasks.models import OutboxMessage
5
+ from simple_module_db import transaction
6
+ from sqlalchemy import event
7
+ from sqlmodel import select
8
+ from test_users_deletion_events import PausedUserTransport, record_user_event
9
+ from users.contracts.events import UserCreated, UserInvited
10
+ from users.models import User
11
+
12
+
13
+ @pytest.mark.parametrize("operation,event_type", [("invite", UserInvited), ("create", UserCreated)])
14
+ @pytest.mark.parametrize("durable", [False, True])
15
+ @pytest.mark.parametrize("fail_commit", [True, False])
16
+ async def test_admin_creation_side_effects_wait_for_commit(
17
+ admin_client, users_app, captured_mail, monkeypatch, operation, event_type, fail_commit, durable
18
+ ):
19
+ observed = []
20
+ before_commit = []
21
+
22
+ async def observe(message):
23
+ observed.append(message)
24
+
25
+ bus = users_app.state.sm.event_bus
26
+ bus.subscribe(event_type, observe)
27
+ if durable:
28
+ bus.durable_transport = PausedUserTransport()
29
+ bus.subscribe_durable(event_type, record_user_event)
30
+ email = "commit-boundary@example.com"
31
+
32
+ def fail(session):
33
+ raise RuntimeError("forced commit failure")
34
+
35
+ finalize = transaction.finalize_session
36
+
37
+ async def check_commit(session):
38
+ before_commit.append((list(captured_mail.messages), list(observed)))
39
+ if fail_commit:
40
+ event.listen(session.sync_session, "before_commit", fail, once=True)
41
+ await finalize(session)
42
+
43
+ monkeypatch.setattr(transaction, "finalize_session", check_commit)
44
+ db = users_app.state.sm.db
45
+ body = {"email": email, "role_names": []}
46
+ if operation == "create":
47
+ body["password"] = "CompliantPass1!"
48
+ response = await admin_client.post(
49
+ "/api/users/admin/invite" if operation == "invite" else "/api/users/admin", json=body
50
+ )
51
+
52
+ assert response.status_code == (500 if fail_commit else 201), response.text
53
+ assert before_commit == [([], [])]
54
+ async with db.session_factory() as session:
55
+ user = (await session.execute(select(User).where(User.email == email))).scalar_one_or_none()
56
+ outbox = (await session.execute(select(OutboxMessage))).scalars().all()
57
+ assert len(outbox) == (1 if durable and not fail_commit else 0)
58
+ if fail_commit:
59
+ assert captured_mail.messages == []
60
+ assert observed == []
61
+ else:
62
+ assert user is not None
63
+ assert len(observed) == 1
64
+ assert observed[0].user_id == user.id
65
+ assert observed[0].email == email
66
+ if operation == "invite":
67
+ assert any("/users/invite/accept?token=" in m.text for m in captured_mail.to(email))
68
+
69
+
70
+ async def test_admin_invite_existing_email_is_a_conflict(admin_client, captured_mail):
71
+ body = {"email": "dup-invite@example.com", "role_names": []}
72
+ first = await admin_client.post("/api/users/admin/invite", json=body)
73
+ assert first.status_code == 201, first.text
74
+ sent = len(captured_mail.messages)
75
+ again = await admin_client.post("/api/users/admin/invite", json=body)
76
+ assert again.status_code == 409, again.text
77
+ assert len(captured_mail.messages) == sent
@@ -0,0 +1,66 @@
1
+ """Semantic token checks accept current baselines and equivalent ports (#711)."""
2
+
3
+ import pytest
4
+ from sqlalchemy import text
5
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
6
+ from users.module import UsersModule
7
+
8
+
9
+ @pytest.mark.parametrize(
10
+ ("access_type", "refresh_type", "token", "incompatible"),
11
+ [
12
+ ("VARCHAR(64)", "VARCHAR(64)", None, False),
13
+ ("VARCHAR(64)", "VARCHAR(64)", "a" * 64, False),
14
+ ("VARCHAR(43)", "CHAR(32)", None, True),
15
+ ("VARCHAR(64)", "VARCHAR(64)", "plaintext", True),
16
+ ("VARCHAR(64)", "VARCHAR(64)", "z" * 64, True),
17
+ ],
18
+ )
19
+ async def test_users_semantic_token_compatibility(access_type, refresh_type, token, incompatible):
20
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
21
+ try:
22
+ async with engine.begin() as conn:
23
+ for name, kind in [("access", access_type), ("refresh", refresh_type)]:
24
+ await conn.execute(text(f"CREATE TABLE users_{name}_token (token {kind})"))
25
+ if token:
26
+ await conn.execute(
27
+ text(f"INSERT INTO users_{name}_token VALUES (:token)"), {"token": token}
28
+ )
29
+ async with async_sessionmaker(engine)() as db:
30
+ issues = await UsersModule().check_data_compatibility(db)
31
+ assert bool(issues) is incompatible
32
+ if incompatible:
33
+ assert issues[0].upgrade_id == "users.hashed_bearer_tokens_v1"
34
+ assert issues[0].severity == "error"
35
+ if token:
36
+ assert await db.scalar(text("SELECT token FROM users_access_token")) == token
37
+ finally:
38
+ await engine.dispose()
39
+
40
+
41
+ async def test_missing_token_tables_are_not_a_finding():
42
+ # An unmigrated database is the migration check's problem; this must not
43
+ # refuse a production boot on its own.
44
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
45
+ try:
46
+ async with async_sessionmaker(engine)() as db:
47
+ assert await UsersModule().check_data_compatibility(db) == []
48
+ finally:
49
+ await engine.dispose()
50
+
51
+
52
+ async def test_plaintext_rows_behind_hashed_ones_are_found():
53
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
54
+ try:
55
+ async with engine.begin() as conn:
56
+ for name in ("access", "refresh"):
57
+ await conn.execute(text(f"CREATE TABLE users_{name}_token (token VARCHAR(64))"))
58
+ for i in range(150):
59
+ await conn.execute(
60
+ text(f"INSERT INTO users_{name}_token VALUES (:t)"), {"t": f"{i:064x}"}
61
+ )
62
+ await conn.execute(text("INSERT INTO users_access_token VALUES ('plaintext')"))
63
+ async with async_sessionmaker(engine)() as db:
64
+ assert await UsersModule().check_data_compatibility(db)
65
+ finally:
66
+ await engine.dispose()
@@ -0,0 +1,148 @@
1
+ """Host template precedence, optional HTML, and startup configuration (#712)."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+ from fastapi import FastAPI
9
+ from jinja2 import TemplateNotFound, TemplateSyntaxError
10
+ from jinja2.exceptions import SecurityError
11
+ from simple_module_core.settings_hydrate import build_settings
12
+ from users.mailer import build_mailer
13
+ from users.mailer.templated import TemplatedMailer
14
+ from users.settings import UsersSettings
15
+ from users.startup import _build_mailer
16
+
17
+
18
+ @pytest.mark.parametrize(
19
+ ("stem", "method", "args", "route"),
20
+ [
21
+ ("verify_email", "send_verification", (), "verify"),
22
+ ("reset_password", "send_password_reset", (), "reset-password"),
23
+ ("invite", "send_invite", ("<Alice>", " <note> "), "invite/accept"),
24
+ ],
25
+ )
26
+ async def test_html_only_override_keeps_bundled_text(
27
+ tmp_path, captured_mail, stem, method, args, route
28
+ ):
29
+ (tmp_path / f"{stem}.html").write_text(
30
+ "<p>{{ app_name }}|{{ link }}|{{ invited_by_name }}|{{ message }}</p>"
31
+ )
32
+ mailer = TemplatedMailer("https://example.com/", lambda: "<Acme>", template_dirs=[tmp_path])
33
+ await getattr(mailer, method)("a@example.com", "tok&x", *args)
34
+
35
+ (msg,) = captured_mail.messages
36
+ assert f"https://example.com/users/{route}?token=tok&x" in msg.text
37
+ assert "<Acme>" in msg.text
38
+ assert f"https://example.com/users/{route}?token=tok&amp;x" in msg.html
39
+ assert "&lt;Acme&gt;" in msg.html
40
+ if stem == "invite":
41
+ assert "&lt;Alice&gt;|&lt;note&gt;" in msg.html
42
+ assert "<Alice>" in msg.text and "<note>" in msg.text
43
+
44
+
45
+ async def test_directory_order_and_independent_file_fallback(tmp_path, captured_mail):
46
+ first, second = tmp_path / "first", tmp_path / "second"
47
+ first.mkdir()
48
+ second.mkdir()
49
+ (first / "verify_email.txt").write_text("first {{ app_name }}")
50
+ (second / "verify_email.txt").write_text("second")
51
+ (second / "verify_email.html").write_text("<b>second {{ app_name }}</b>")
52
+ mailer = build_mailer(UsersSettings(), lambda: "<Acme>", template_dirs=[str(first), second])
53
+ await mailer.send_verification("a@example.com", "tok")
54
+ await mailer.send_password_reset("a@example.com", "reset")
55
+ verify, reset = captured_mail.messages
56
+ assert verify.text == "first <Acme>"
57
+ assert verify.html == "<b>second &lt;Acme&gt;</b>"
58
+ assert "/users/reset-password?token=reset" in reset.text
59
+ assert reset.html is None
60
+
61
+
62
+ async def test_mailer_environments_are_isolated(tmp_path, captured_mail):
63
+ (tmp_path / "verify_email.txt").write_text("custom")
64
+ custom = TemplatedMailer("https://example.com", template_dirs=[tmp_path])
65
+ default = TemplatedMailer("https://example.com")
66
+ await custom.send_verification("a@example.com", "tok")
67
+ await default.send_verification("a@example.com", "tok")
68
+ await custom.send_verification("a@example.com", "tok")
69
+ assert [m.text for m in captured_mail.messages][::2] == ["custom", "custom"]
70
+ assert "Welcome to SimpleModule!" in captured_mail.messages[1].text
71
+ assert all(m.html is None for m in captured_mail.messages)
72
+
73
+
74
+ @pytest.mark.parametrize(
75
+ ("source", "error"),
76
+ [
77
+ ("{% if %}", TemplateSyntaxError),
78
+ ('{% include "missing.html" %}', TemplateNotFound),
79
+ ('{% extends "missing.html" %}', TemplateNotFound),
80
+ ("{{ 1 / 0 }}", ZeroDivisionError),
81
+ ],
82
+ )
83
+ async def test_broken_html_prevents_delivery(tmp_path, captured_mail, source, error):
84
+ (tmp_path / "verify_email.html").write_text(source)
85
+ mailer = TemplatedMailer("https://example.com", template_dirs=[tmp_path])
86
+ with pytest.raises(error):
87
+ await mailer.send_verification("a@example.com", "tok")
88
+ assert not captured_mail.messages
89
+
90
+
91
+ async def test_broken_text_is_not_hidden_by_bundled_fallback(tmp_path, captured_mail):
92
+ (tmp_path / "verify_email.txt").write_text("{% if %}")
93
+ mailer = TemplatedMailer("https://example.com", template_dirs=[tmp_path])
94
+ with pytest.raises(TemplateSyntaxError):
95
+ await mailer.send_verification("a@example.com", "tok")
96
+ assert not captured_mail.messages
97
+
98
+
99
+ @pytest.mark.parametrize("kind", ["relative", "missing", "file"])
100
+ def test_invalid_template_directory_is_skipped_with_warning(tmp_path, kind, caplog):
101
+ path = tmp_path / kind
102
+ if kind == "file":
103
+ path.write_text("not a directory")
104
+ if kind == "relative":
105
+ path = Path("relative")
106
+ settings = UsersSettings(mail_template_dirs=[str(path)])
107
+ state = SimpleNamespace()
108
+ _build_mailer(FastAPI(), state, settings)
109
+ assert state.mailer is not None
110
+ assert "Ignoring mail template path" in caplog.text
111
+
112
+
113
+ async def test_startup_forwards_db_setting_and_keeps_branding_live(tmp_path, captured_mail):
114
+ (tmp_path / "verify_email.html").write_text("<p>{{ app_name }}</p>")
115
+ settings = build_settings(
116
+ UsersSettings, {"mail_template_dirs": (json.dumps([str(tmp_path)]), "json")}
117
+ )
118
+ app = FastAPI()
119
+ app.state.branding = SimpleNamespace(settings=SimpleNamespace(app_name="First"))
120
+ state = SimpleNamespace()
121
+ _build_mailer(app, state, settings)
122
+ app.state.branding.settings.app_name = "<Second>"
123
+ await state.mailer.send_verification("a@example.com", "tok")
124
+ (msg,) = captured_mail.messages
125
+ assert msg.html == "<p>&lt;Second&gt;</p>"
126
+ assert msg.subject == "Verify your email for <Second>"
127
+ assert "<Second>" in msg.text
128
+
129
+
130
+ def test_template_setting_is_restart_required_and_defaults_are_independent(monkeypatch):
131
+ monkeypatch.setenv("SM_USERS_MAIL_TEMPLATE_DIRS", '["/ignored"]')
132
+ first, second = UsersSettings(), UsersSettings()
133
+ assert first.mail_template_dirs == []
134
+ first.mail_template_dirs.append("/trusted/templates")
135
+ assert second.mail_template_dirs == []
136
+ assert UsersSettings.model_fields["mail_template_dirs"].json_schema_extra == {
137
+ "requires_restart": True
138
+ }
139
+
140
+
141
+ async def test_host_templates_render_sandboxed(tmp_path, captured_mail):
142
+ # The directory list is admin-editable, so a template must not reach Python
143
+ # internals (the classic ``__class__.__mro__`` escape).
144
+ (tmp_path / "verify_email.txt").write_text("{{ ''.__class__.__mro__[1].__subclasses__() }}")
145
+ mailer = TemplatedMailer("https://example.com", template_dirs=[tmp_path])
146
+ with pytest.raises(SecurityError):
147
+ await mailer.send_verification("a@example.com", "tok")
148
+ assert captured_mail.messages == []
@@ -0,0 +1,118 @@
1
+ """A mailbox password reset strands every previously issued credential (#686)."""
2
+
3
+ from urllib.parse import parse_qs, urlsplit
4
+
5
+ import httpx
6
+ import pytest
7
+ from sqlmodel import select
8
+ from users.models import RefreshToken, UserAccessToken
9
+ from users.session_revocation import CHANNEL
10
+
11
+
12
+ @pytest.mark.parametrize("credential", ["refresh", "session", "access"])
13
+ async def test_reset_revokes_old_credentials(admin_client, anon_client, users_app, credential):
14
+ login = await anon_client.post(
15
+ "/api/users/auth/login",
16
+ data={"username": "admin@example.com", "password": "AdminPass1!"},
17
+ )
18
+ assert login.status_code == 204, login.text
19
+ pair = await anon_client.post(
20
+ "/api/users/auth/token",
21
+ json={"email": "admin@example.com", "password": "AdminPass1!"},
22
+ )
23
+ assert pair.status_code == 200, pair.text
24
+ tokens = pair.json()
25
+ me = await anon_client.get("/api/users/me")
26
+ assert me.status_code == 200, me.text
27
+ # Warm the session-version cache before resetting in a separate client.
28
+ assert (await anon_client.get("/admin/users/")).status_code == 200
29
+ link = await admin_client.post(f"/api/users/admin/{me.json()['id']}/reset-password-link")
30
+ assert link.status_code == 200, link.text
31
+ token = parse_qs(urlsplit(link.json()["link"]).query)["token"][0]
32
+ invalidations = []
33
+ users_app.state.sm.invalidation.subscribe(CHANNEL, invalidations.append)
34
+ async with httpx.AsyncClient(
35
+ transport=httpx.ASGITransport(app=users_app), base_url="http://testserver"
36
+ ) as fresh:
37
+ reset = await fresh.post(
38
+ "/api/users/auth/reset-password",
39
+ json={"token": token, "password": "NewCompliantPass1!"},
40
+ )
41
+ assert reset.status_code == 200, reset.text
42
+ assert [message.key for message in invalidations] == [me.json()["id"]]
43
+ async with users_app.state.sm.db.session_factory() as session:
44
+ assert (await session.execute(select(UserAccessToken))).scalars().all() == []
45
+ refresh_rows = (await session.execute(select(RefreshToken))).scalars().all()
46
+ assert refresh_rows and all(row.revoked_at is not None for row in refresh_rows)
47
+ if credential == "refresh":
48
+ response = await fresh.post(
49
+ "/api/users/auth/token/refresh", json={"refresh_token": tokens["refresh_token"]}
50
+ )
51
+ assert response.status_code == 401, response.text
52
+ elif credential == "access":
53
+ response = await fresh.get(
54
+ "/api/users/me", headers={"Authorization": f"Bearer {tokens['access_token']}"}
55
+ )
56
+ assert response.status_code == 401, response.text
57
+ else:
58
+ response = await anon_client.get("/admin/users/", follow_redirects=False)
59
+ assert response.status_code == 302, response.text
60
+ assert "/users/login" in response.headers["location"]
61
+
62
+
63
+ async def test_reset_revokes_in_the_password_commit(
64
+ admin_client, anon_client, users_app, monkeypatch
65
+ ):
66
+ """A failure after the password is stored must not leave old credentials valid."""
67
+ from users.manager import UserManager
68
+
69
+ pair = await anon_client.post(
70
+ "/api/users/auth/token",
71
+ json={"email": "admin@example.com", "password": "AdminPass1!"},
72
+ )
73
+ assert pair.status_code == 200, pair.text
74
+ me = await anon_client.get(
75
+ "/api/users/me", headers={"Authorization": f"Bearer {pair.json()['access_token']}"}
76
+ )
77
+ link = await admin_client.post(f"/api/users/admin/{me.json()['id']}/reset-password-link")
78
+ token = parse_qs(urlsplit(link.json()["link"]).query)["token"][0]
79
+
80
+ async def explode(self, user, request=None):
81
+ raise RuntimeError("after-reset hook failed")
82
+
83
+ monkeypatch.setattr(UserManager, "on_after_reset_password", explode)
84
+ async with httpx.AsyncClient(
85
+ transport=httpx.ASGITransport(app=users_app, raise_app_exceptions=False),
86
+ base_url="http://testserver",
87
+ ) as fresh:
88
+ reset = await fresh.post(
89
+ "/api/users/auth/reset-password",
90
+ json={"token": token, "password": "NewCompliantPass1!"},
91
+ )
92
+ assert reset.status_code == 500
93
+ refreshed = await fresh.post(
94
+ "/api/users/auth/token/refresh",
95
+ json={"refresh_token": pair.json()["refresh_token"]},
96
+ )
97
+ assert refreshed.status_code == 401, refreshed.text
98
+ async with users_app.state.sm.db.session_factory() as session:
99
+ assert (await session.execute(select(UserAccessToken))).scalars().all() == []
100
+
101
+
102
+ async def test_rejected_reset_password_revokes_nothing(admin_client, anon_client, users_app):
103
+ login = await anon_client.post(
104
+ "/api/users/auth/login",
105
+ data={"username": "admin@example.com", "password": "AdminPass1!"},
106
+ )
107
+ assert login.status_code == 204, login.text
108
+ me = await anon_client.get("/api/users/me")
109
+ link = await admin_client.post(f"/api/users/admin/{me.json()['id']}/reset-password-link")
110
+ token = parse_qs(urlsplit(link.json()["link"]).query)["token"][0]
111
+ async with httpx.AsyncClient(
112
+ transport=httpx.ASGITransport(app=users_app), base_url="http://testserver"
113
+ ) as fresh:
114
+ reset = await fresh.post(
115
+ "/api/users/auth/reset-password", json={"token": token, "password": "12345678"}
116
+ )
117
+ assert reset.status_code == 400, reset.text
118
+ assert (await anon_client.get("/admin/users/", follow_redirects=False)).status_code == 200
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
8
8
  from fastapi import status as http_status
9
9
  from fastapi_users import exceptions as fa_exceptions
10
10
  from simple_module_core.events import EventBus
11
+ from simple_module_db import RequestSession
11
12
  from simple_module_db.deps import get_db
12
13
  from simple_module_db.pagination import PageParams
13
14
  from simple_module_hosting.pagination import page_params
@@ -42,6 +43,8 @@ from users.exceptions import (
42
43
  from users.invites.bulk import bulk_router
43
44
  from users.invites.resend import resend_router
44
45
 
46
+ _DUPLICATE_EMAIL = "A user with this email already exists."
47
+
45
48
  admin_router = APIRouter(
46
49
  prefix="/admin",
47
50
  dependencies=[Depends(RequiresPermission(PERM_USERS_MANAGE))],
@@ -91,20 +94,26 @@ async def admin_invite_user(
91
94
  bus: EventBus = Depends(get_event_bus),
92
95
  service: UserService = Depends(get_user_service),
93
96
  mailer=Depends(get_mailer),
97
+ db: RequestSession = Depends(get_db),
94
98
  ):
95
99
  """Invite a new user by email, optionally assigning roles."""
96
100
  invited_by = getattr(request.state, "user", None)
97
101
  invited_by_name = invited_by.name if invited_by else "Administrator"
98
- user, token = await service.invite(
99
- data.email, data.full_name, data.role_names, invited_by=invited_by
100
- )
101
- await mailer.send_invite(user.email, token, invited_by_name)
102
- await bus.publish(
102
+ try:
103
+ user, token = await service.invite(
104
+ data.email, data.full_name, data.role_names, invited_by=invited_by
105
+ )
106
+ except fa_exceptions.UserAlreadyExists:
107
+ raise HTTPException(status_code=409, detail=_DUPLICATE_EMAIL) from None
108
+ db.on_commit(lambda: mailer.send_invite(user.email, token, invited_by_name))
109
+ await publish_admin_event(
110
+ bus,
103
111
  UserInvited(
104
112
  user_id=user.id,
105
113
  email=user.email,
106
114
  invited_by=(str(invited_by.id) if invited_by else None),
107
- )
115
+ ),
116
+ db,
108
117
  )
109
118
  return service.to_list_item(user)
110
119
 
@@ -119,6 +128,7 @@ async def admin_create_user(
119
128
  request: Request,
120
129
  bus: EventBus = Depends(get_event_bus),
121
130
  service: UserService = Depends(get_user_service),
131
+ db: AsyncSession = Depends(get_db),
122
132
  ):
123
133
  """Create an active+verified user with an admin-set password."""
124
134
  creator = getattr(request.state, "user", None)
@@ -132,13 +142,12 @@ async def admin_create_user(
132
142
  created_by=created_by,
133
143
  )
134
144
  except fa_exceptions.UserAlreadyExists:
135
- raise HTTPException(
136
- status_code=409,
137
- detail="A user with this email already exists.",
138
- ) from None
145
+ raise HTTPException(status_code=409, detail=_DUPLICATE_EMAIL) from None
139
146
  except fa_exceptions.InvalidPasswordException as exc:
140
147
  raise HTTPException(status_code=400, detail=exc.reason) from None
141
- await bus.publish(UserCreated(user_id=user.id, email=user.email, created_by=created_by))
148
+ await publish_admin_event(
149
+ bus, UserCreated(user_id=user.id, email=user.email, created_by=created_by), db
150
+ )
142
151
  return service.to_list_item(user)
143
152
 
144
153
 
@@ -154,10 +163,7 @@ async def admin_update_user(
154
163
  except UserNotFoundError:
155
164
  raise HTTPException(status_code=404, detail="User not found") from None
156
165
  except EmailAlreadyExistsError:
157
- raise HTTPException(
158
- status_code=409,
159
- detail="A user with this email already exists.",
160
- ) from None
166
+ raise HTTPException(status_code=409, detail=_DUPLICATE_EMAIL) from None
161
167
  return service.to_list_item(user)
162
168
 
163
169