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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/PKG-INFO +34 -6
  2. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/README.md +28 -0
  3. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/package.json +3 -3
  4. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/pyproject.toml +6 -6
  5. simple_module_users-0.0.36/tests/_demo_support.py +33 -0
  6. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/_middleware_support.py +0 -2
  7. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/conftest.py +1 -1
  8. simple_module_users-0.0.36/tests/test_demo_account.py +182 -0
  9. simple_module_users-0.0.36/tests/test_demo_guard.py +178 -0
  10. simple_module_users-0.0.36/tests/test_demo_login.py +137 -0
  11. simple_module_users-0.0.36/tests/test_legacy_cached_tenant.py +54 -0
  12. simple_module_users-0.0.36/tests/test_login_redirect_root_mount.py +39 -0
  13. simple_module_users-0.0.36/tests/test_revocation_broadcast.py +167 -0
  14. simple_module_users-0.0.36/tests/test_session_revocation.py +167 -0
  15. simple_module_users-0.0.36/tests/test_session_version_failopen.py +157 -0
  16. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_user_model.py +0 -1
  17. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_user_role_model.py +17 -6
  18. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_users_middleware.py +0 -1
  19. simple_module_users-0.0.36/tests/test_users_setup_lock.py +63 -0
  20. simple_module_users-0.0.36/tests/test_users_setup_wizard.py +187 -0
  21. simple_module_users-0.0.36/tests/test_users_shared_props.py +126 -0
  22. simple_module_users-0.0.36/tests-js/LoginForm.test.tsx +31 -0
  23. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/components/UserRow.tsx +28 -8
  24. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/queries.py +9 -9
  25. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/views.py +1 -1
  26. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/api.py +1 -1
  27. simple_module_users-0.0.36/users/auth_local/components/DemoSignIn.tsx +97 -0
  28. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/LoginForm.tsx +5 -1
  29. simple_module_users-0.0.36/users/auth_local/demo_api.py +94 -0
  30. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/self_account.py +10 -8
  31. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/views.py +19 -1
  32. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/bootstrap.py +22 -12
  33. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/constants.py +7 -0
  34. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/contracts/schemas.py +0 -1
  35. simple_module_users-0.0.36/users/demo.py +233 -0
  36. simple_module_users-0.0.36/users/demo_guard.py +122 -0
  37. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/deps.py +2 -2
  38. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/locales/en.json +15 -2
  39. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/manager.py +8 -0
  40. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/models/refresh_token.py +7 -1
  41. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/models/role.py +16 -0
  42. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/models/user.py +0 -1
  43. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/module.py +40 -81
  44. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/AcceptInvite.tsx +3 -3
  45. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Login.tsx +53 -3
  46. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Profile.tsx +1 -1
  47. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/ResetPassword.tsx +3 -2
  48. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/AddPeople.tsx +104 -97
  49. simple_module_users-0.0.36/users/pages/Users/Edit.tsx +129 -0
  50. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/Index.tsx +70 -67
  51. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/provider.py +19 -1
  52. simple_module_users-0.0.36/users/session_revocation.py +89 -0
  53. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/session_version_cache.py +34 -11
  54. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/settings.py +58 -0
  55. simple_module_users-0.0.36/users/setup.py +101 -0
  56. simple_module_users-0.0.36/users/setup_action.py +129 -0
  57. simple_module_users-0.0.36/users/shared_props.py +55 -0
  58. simple_module_users-0.0.36/users/startup.py +155 -0
  59. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/state.py +6 -0
  60. simple_module_users-0.0.34/tests/test_users_shared_props.py +0 -52
  61. simple_module_users-0.0.34/users/pages/Users/Edit.tsx +0 -126
  62. simple_module_users-0.0.34/users/setup.py +0 -51
  63. simple_module_users-0.0.34/users/shared_props.py +0 -29
  64. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/.gitignore +0 -0
  65. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/LICENSE +0 -0
  66. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/.gitkeep +0 -0
  67. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/_users_app_builders.py +0 -0
  68. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_access_token_model.py +0 -0
  69. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_activity_summary.py +0 -0
  70. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_admin_resend_invite.py +0 -0
  71. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_api_admin.py +0 -0
  72. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_api_admin_crud.py +0 -0
  73. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_api_admin_filters.py +0 -0
  74. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_api_auth.py +0 -0
  75. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_auth_screens_props.py +0 -0
  76. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_backend.py +0 -0
  77. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_bearer_token_age.py +0 -0
  78. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_bearer_token_expiry.py +0 -0
  79. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_bootstrap.py +0 -0
  80. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_bootstrap_resolution.py +0 -0
  81. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_bulk_invite_message.py +0 -0
  82. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_cli.py +0 -0
  83. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_constants.py +0 -0
  84. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_db_adapter.py +0 -0
  85. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_edit_recent_activity.py +0 -0
  86. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_external_users.py +0 -0
  87. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_invite_flow.py +0 -0
  88. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_invite_reuse.py +0 -0
  89. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_legacy_session_expiry.py +0 -0
  90. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_mailer.py +0 -0
  91. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_negative_authz.py +0 -0
  92. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_oauth.py +0 -0
  93. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_oauth_routes.py +0 -0
  94. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_profile_password.py +0 -0
  95. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_rate_limit.py +0 -0
  96. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_remember_me.py +0 -0
  97. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_role_model.py +0 -0
  98. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_service_admin.py +0 -0
  99. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_service_admin_crud.py +0 -0
  100. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_session_expiry.py +0 -0
  101. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_session_version_cache.py +0 -0
  102. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_session_version_hardening.py +0 -0
  103. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_sessions_revoke.py +0 -0
  104. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_settings.py +0 -0
  105. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_token_api.py +0 -0
  106. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_user_manager.py +0 -0
  107. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_user_service.py +0 -0
  108. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_user_state.py +0 -0
  109. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_users_bulk_invite.py +0 -0
  110. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_users_deps.py +0 -0
  111. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_users_list_invites.py +0 -0
  112. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_users_login_deep_link.py +0 -0
  113. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_users_middleware_public_paths.py +0 -0
  114. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_users_middleware_resolvers.py +0 -0
  115. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_users_provider.py +0 -0
  116. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_views.py +0 -0
  117. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests/test_views_admin.py +0 -0
  118. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests-js/EmailChipInput.test.tsx +0 -0
  119. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests-js/deriveState.test.ts +0 -0
  120. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tests-js/invite-emails.test.ts +0 -0
  121. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/tsconfig.json +0 -0
  122. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/__init__.py +0 -0
  123. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/__init__.py +0 -0
  124. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/api.py +0 -0
  125. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/bulk_invite.py +0 -0
  126. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/components/IndexFilters.tsx +0 -0
  127. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/components/RolesTab.tsx +0 -0
  128. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/components/StatusPill.tsx +0 -0
  129. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/components/UserCards.tsx +0 -0
  130. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/components/UserRowMenu.tsx +0 -0
  131. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/components/UsersEmpty.tsx +0 -0
  132. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/components/UsersTable.tsx +0 -0
  133. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/components/useUserRowActions.ts +0 -0
  134. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/components/user-list-item.ts +0 -0
  135. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/recent_activity.py +0 -0
  136. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/resend_invite.py +0 -0
  137. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/service.py +0 -0
  138. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/admin/user_state.py +0 -0
  139. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/audit.py +0 -0
  140. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/__init__.py +0 -0
  141. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/AuthField.tsx +0 -0
  142. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/AuthIntro.tsx +0 -0
  143. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/AuthStateCard.tsx +0 -0
  144. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/InviteSummary.tsx +0 -0
  145. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/PasswordCard.tsx +0 -0
  146. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/PasswordFields.tsx +0 -0
  147. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/PreferencesCard.tsx +0 -0
  148. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
  149. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/ResendVerification.tsx +0 -0
  150. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/ResetLinkSent.tsx +0 -0
  151. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/SessionsCard.tsx +0 -0
  152. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/WaitingOnYou.tsx +0 -0
  153. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/components/profile-user.ts +0 -0
  154. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/invite_preview.py +0 -0
  155. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/rate_limit.py +0 -0
  156. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/token_api.py +0 -0
  157. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/auth_local/token_preview.py +0 -0
  158. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/backend.py +0 -0
  159. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/cli.py +0 -0
  160. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/contracts/__init__.py +0 -0
  161. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/contracts/events.py +0 -0
  162. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/db_adapter.py +0 -0
  163. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/exceptions.py +0 -0
  164. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/health.py +0 -0
  165. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/mailer/__init__.py +0 -0
  166. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/mailer/console.py +0 -0
  167. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/mailer/smtp.py +0 -0
  168. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/mailer/templates/.gitkeep +0 -0
  169. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/mailer/templates/invite.txt +0 -0
  170. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/mailer/templates/reset_password.txt +0 -0
  171. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/mailer/templates/verify_email.txt +0 -0
  172. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/middleware.py +0 -0
  173. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/models/__init__.py +0 -0
  174. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/models/_base.py +0 -0
  175. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/models/access_token.py +0 -0
  176. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/models/oauth_account.py +0 -0
  177. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/models/user_role.py +0 -0
  178. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/oauth/__init__.py +0 -0
  179. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/oauth/api.py +0 -0
  180. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/oauth/providers.py +0 -0
  181. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/.gitkeep +0 -0
  182. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/ForgotPassword.tsx +0 -0
  183. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Register.tsx +0 -0
  184. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/AccountCard.tsx +0 -0
  185. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/CreateUserFields.tsx +0 -0
  186. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/DangerZone.tsx +0 -0
  187. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/DetailsCard.tsx +0 -0
  188. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
  189. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/EmailChipInput.tsx +0 -0
  190. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/InviteFields.tsx +0 -0
  191. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/InviteResults.tsx +0 -0
  192. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
  193. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/RolePicker.tsx +0 -0
  194. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/UserStats.tsx +0 -0
  195. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/useEditUserForm.ts +0 -0
  196. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/components/useUserActions.ts +0 -0
  197. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/Users/invite-emails.ts +0 -0
  198. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/pages/VerifyEmail.tsx +0 -0
  199. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/py.typed +0 -0
  200. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/roles_cache.py +0 -0
  201. {simple_module_users-0.0.34 → simple_module_users-0.0.36}/users/token_strategy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_users
3
- Version: 0.0.34
3
+ Version: 0.0.36
4
4
  Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -24,11 +24,11 @@ Requires-Python: >=3.12
24
24
  Requires-Dist: aiosmtplib>=3.0
25
25
  Requires-Dist: cachetools>=5.3
26
26
  Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
27
- Requires-Dist: simple-module-auth==0.0.34
28
- Requires-Dist: simple-module-core==0.0.34
29
- Requires-Dist: simple-module-db==0.0.34
30
- Requires-Dist: simple-module-hosting==0.0.34
31
- Requires-Dist: simple-module-settings==0.0.34
27
+ Requires-Dist: simple-module-auth==0.0.36
28
+ Requires-Dist: simple-module-core==0.0.36
29
+ Requires-Dist: simple-module-db==0.0.36
30
+ Requires-Dist: simple-module-hosting==0.0.36
31
+ Requires-Dist: simple-module-settings==0.0.36
32
32
  Requires-Dist: typer>=0.12
33
33
  Description-Content-Type: text/markdown
34
34
 
@@ -53,6 +53,7 @@ Pre-wired into any app scaffolded with `smpy new`.
53
53
  - `smpy users create-admin` CLI for ad-hoc admin creation.
54
54
  - Inertia pages for login/register/invite-accept/admin-invite.
55
55
  - Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
56
+ - Demo mode — one-click, read-only shared **admin** and **standard user** accounts on the sign-in card, for public showcase instances.
56
57
 
57
58
  ## Usage
58
59
 
@@ -85,6 +86,33 @@ async def profile(user: CurrentUser):
85
86
  - `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_settings`, `simple_module_auth`
86
87
  - `fastapi-users[sqlalchemy,oauth]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
87
88
 
89
+ ## Demo mode
90
+
91
+ For hosting a public showcase instance. Set under **/admin/settings/ → Users**:
92
+
93
+ ```
94
+ demo_mode = true
95
+ demo_admin_email = demo-admin@example.com
96
+ demo_user_email = demo-user@example.com
97
+ demo_read_only = true # default — keep it on
98
+ ```
99
+
100
+ The sign-in card grows one button per configured account — **Explore as an
101
+ administrator** and **Explore as a standard user** — each posting to
102
+ `POST /api/users/auth/demo/{role}`. Neither account's password reaches the
103
+ browser. Two accounts because the admin surface is what the framework is for
104
+ and the end-user app is what you build with it; a visitor can switch between
105
+ them without signing out. Blank either email to offer only the other; that
106
+ closes its route too, not just its button.
107
+
108
+ Both accounts are seeded (and reconciled) at boot and on every settings
109
+ reload, and while `demo_read_only` is on, `DemoReadOnlyMiddleware` refuses
110
+ every unsafe HTTP method from either demo session — everyone else on the
111
+ instance is unaffected. A standing banner tells the visitor they are in a demo.
112
+
113
+ Full detail, including the `demo_read_only = false` warning, is in
114
+ [docs/modules/users.md](../../docs/modules/users.md#demo-mode-hosting-a-showcase-instance).
115
+
88
116
  ## Social sign-in (Google, GitHub, Microsoft, OIDC)
89
117
 
90
118
  OAuth providers are configured in the admin UI at **/settings/modules → Users**
@@ -19,6 +19,7 @@ Pre-wired into any app scaffolded with `smpy new`.
19
19
  - `smpy users create-admin` CLI for ad-hoc admin creation.
20
20
  - Inertia pages for login/register/invite-accept/admin-invite.
21
21
  - Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
22
+ - Demo mode — one-click, read-only shared **admin** and **standard user** accounts on the sign-in card, for public showcase instances.
22
23
 
23
24
  ## Usage
24
25
 
@@ -51,6 +52,33 @@ async def profile(user: CurrentUser):
51
52
  - `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_settings`, `simple_module_auth`
52
53
  - `fastapi-users[sqlalchemy,oauth]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
53
54
 
55
+ ## Demo mode
56
+
57
+ For hosting a public showcase instance. Set under **/admin/settings/ → Users**:
58
+
59
+ ```
60
+ demo_mode = true
61
+ demo_admin_email = demo-admin@example.com
62
+ demo_user_email = demo-user@example.com
63
+ demo_read_only = true # default — keep it on
64
+ ```
65
+
66
+ The sign-in card grows one button per configured account — **Explore as an
67
+ administrator** and **Explore as a standard user** — each posting to
68
+ `POST /api/users/auth/demo/{role}`. Neither account's password reaches the
69
+ browser. Two accounts because the admin surface is what the framework is for
70
+ and the end-user app is what you build with it; a visitor can switch between
71
+ them without signing out. Blank either email to offer only the other; that
72
+ closes its route too, not just its button.
73
+
74
+ Both accounts are seeded (and reconciled) at boot and on every settings
75
+ reload, and while `demo_read_only` is on, `DemoReadOnlyMiddleware` refuses
76
+ every unsafe HTTP method from either demo session — everyone else on the
77
+ instance is unaffected. A standing banner tells the visitor they are in a demo.
78
+
79
+ Full detail, including the `demo_read_only = false` warning, is in
80
+ [docs/modules/users.md](../../docs/modules/users.md#demo-mode-hosting-a-showcase-instance).
81
+
54
82
  ## Social sign-in (Google, GitHub, Microsoft, OIDC)
55
83
 
56
84
  OAuth providers are configured in the admin UI at **/settings/modules → Users**
@@ -6,14 +6,14 @@
6
6
  "peerDependencies": {
7
7
  "react": "^19.0.0",
8
8
  "react-dom": "^19.0.0",
9
- "@inertiajs/react": "^2.0.0",
9
+ "@inertiajs/react": "^3.7.0",
10
10
  "@simple-module-py/ui": "*"
11
11
  },
12
12
  "devDependencies": {
13
13
  "@simple-module-py/tsconfig": "*"
14
14
  },
15
15
  "dependencies": {
16
- "lucide-react": "^1.28.0",
17
- "sonner": "^2.0.7"
16
+ "lucide-react": "^1.43.0",
17
+ "sonner": "^2.0.8"
18
18
  }
19
19
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_users"
3
- version = "0.0.34"
3
+ version = "0.0.36"
4
4
  description = "Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,11 +21,11 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.34",
25
- "simple_module_db==0.0.34",
26
- "simple_module_hosting==0.0.34",
27
- "simple_module_settings==0.0.34",
28
- "simple_module_auth==0.0.34",
24
+ "simple_module_core==0.0.36",
25
+ "simple_module_db==0.0.36",
26
+ "simple_module_hosting==0.0.36",
27
+ "simple_module_settings==0.0.36",
28
+ "simple_module_auth==0.0.36",
29
29
  # Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
30
30
  # fields after construction (see reconfigure_cookie_transport in backend.py).
31
31
  # Bumping the major version requires re-checking those field names.
@@ -0,0 +1,33 @@
1
+ """Fixtures for the demo-account tests, shared by the seeding and guard suites.
2
+
3
+ Loaded as a pytest plugin from ``conftest.py`` — same mechanism as
4
+ ``_middleware_support``. Not named ``test_*`` so pytest does not collect it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import httpx
10
+ import pytest
11
+ from _users_app_builders import _build_users_app
12
+ from users.demo import ensure_demo_users
13
+
14
+ DEMO_ADMIN_EMAIL = "demo-admin@example.com"
15
+ DEMO_USER_EMAIL = "demo-user@example.com"
16
+
17
+
18
+ @pytest.fixture
19
+ async def demo_app(monkeypatch):
20
+ """A users app with demo mode on and both demo accounts seeded."""
21
+ application, ctx = await _build_users_app(monkeypatch, allow_signup=False)
22
+ application.state.users.settings.demo_mode = True
23
+ await ensure_demo_users(application)
24
+ yield application
25
+ await ctx.__aexit__(None, None, None)
26
+
27
+
28
+ @pytest.fixture
29
+ async def demo_client(demo_app):
30
+ """Anonymous client against ``demo_app`` — it signs itself in via a button."""
31
+ transport = httpx.ASGITransport(app=demo_app)
32
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
33
+ yield client
@@ -49,7 +49,6 @@ async def _build_app(db_state, inner_handler=None, *, principal_resolvers=None):
49
49
  "email": user.email,
50
50
  "name": user.name,
51
51
  "roles": user.roles,
52
- "tenant_id": user.tenant_id,
53
52
  }
54
53
  if user is not None
55
54
  else None
@@ -106,7 +105,6 @@ async def mw_active_user(db_session, _mw_seed_roles):
106
105
  is_superuser=False,
107
106
  is_verified=True,
108
107
  full_name="Middleware Tester",
109
- tenant_id="acme",
110
108
  )
111
109
  link = UserRole(user_id=user_id, role_id=ADMIN_ROLE_ID)
112
110
  db_session.add_all([user, link])
@@ -162,4 +162,4 @@ async def users_db(users_app) -> AsyncGenerator[AsyncSession, None]:
162
162
 
163
163
  # Fixtures consumed by the users.middleware unit tests live in
164
164
  # _middleware_support.py (imported as a pytest plugin below).
165
- pytest_plugins = ["_middleware_support"]
165
+ pytest_plugins = ["_middleware_support", "_demo_support"]
@@ -0,0 +1,182 @@
1
+ """Demo accounts: how they are configured, seeded, and entered.
2
+
3
+ Two of the ways the feature can fail open live here — buttons that appear when
4
+ no account is configured, and an endpoint that signs someone in when the
5
+ feature is off. The third, a demo session that is allowed to write, is
6
+ ``test_demo_guard``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from _demo_support import DEMO_ADMIN_EMAIL, DEMO_USER_EMAIL
12
+ from sqlalchemy import select
13
+ from users.constants import ADMIN_ROLE_NAME, USER_ROLE_NAME
14
+ from users.demo import (
15
+ DemoAccount,
16
+ ensure_demo_users,
17
+ reconcile_demo_user,
18
+ resolve_demo_account,
19
+ resolve_demo_accounts,
20
+ )
21
+ from users.models import Role, User, UserRole
22
+ from users.settings import UsersSettings
23
+
24
+
25
+ def _settings(**overrides) -> UsersSettings:
26
+ base = {
27
+ "reset_password_token_secret": "test-reset-secret-32-bytes-xxxxx",
28
+ "verification_token_secret": "test-verify-secret-32-bytes-xxxxx",
29
+ }
30
+ return UsersSettings(**base, **overrides)
31
+
32
+
33
+ # ── resolve_demo_accounts ───────────────────────────────────────────────────
34
+
35
+
36
+ def test_demo_is_off_by_default():
37
+ assert resolve_demo_accounts(_settings()) == ()
38
+
39
+
40
+ def test_both_accounts_are_offered_admin_first():
41
+ """Admin first: it is the surface someone evaluating the framework came
42
+ to see, and button order is the only thing that says so."""
43
+ accounts = resolve_demo_accounts(_settings(demo_mode=True))
44
+
45
+ assert [a.role for a in accounts] == [ADMIN_ROLE_NAME, USER_ROLE_NAME]
46
+ assert [a.email for a in accounts] == [DEMO_ADMIN_EMAIL, DEMO_USER_EMAIL]
47
+
48
+
49
+ def test_blanking_one_email_offers_only_the_other():
50
+ accounts = resolve_demo_accounts(_settings(demo_mode=True, demo_admin_email=" "))
51
+
52
+ assert [a.role for a in accounts] == [USER_ROLE_NAME]
53
+
54
+
55
+ def test_blanking_both_emails_reads_as_off_not_as_an_error():
56
+ """A half-finished edit in the admin UI must not break the sign-in page."""
57
+ assert (
58
+ resolve_demo_accounts(_settings(demo_mode=True, demo_admin_email="", demo_user_email=""))
59
+ == ()
60
+ )
61
+
62
+
63
+ def test_resolve_by_role_finds_each_account():
64
+ settings = _settings(demo_mode=True)
65
+
66
+ assert resolve_demo_account(settings, ADMIN_ROLE_NAME).email == DEMO_ADMIN_EMAIL
67
+ assert resolve_demo_account(settings, USER_ROLE_NAME).email == DEMO_USER_EMAIL
68
+ assert resolve_demo_account(settings, "superuser") is None
69
+
70
+
71
+ def test_read_only_is_the_default_posture():
72
+ assert _settings(demo_mode=True).demo_read_only is True
73
+
74
+
75
+ # ── seeding ─────────────────────────────────────────────────────────────────
76
+
77
+
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
+ async def _role_names(app, user_id) -> list[str]:
84
+ async with app.state.sm.db.session_factory() as session:
85
+ return list(
86
+ (
87
+ await session.execute(
88
+ select(Role.name)
89
+ .join(UserRole, UserRole.role_id == Role.id)
90
+ .where(UserRole.user_id == user_id)
91
+ )
92
+ )
93
+ .scalars()
94
+ .all()
95
+ )
96
+
97
+
98
+ async def test_ensure_demo_users_seeds_both_accounts(users_app):
99
+ users_app.state.users.settings.demo_mode = True
100
+
101
+ ids = await ensure_demo_users(users_app)
102
+
103
+ assert len(ids) == 2
104
+ 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)
107
+ assert admin is not None and admin.is_superuser
108
+ assert user is not None and not user.is_superuser
109
+ assert all(row.is_active and row.is_verified for row in (admin, user))
110
+
111
+
112
+ async def test_each_account_gets_its_own_role(users_app):
113
+ users_app.state.users.settings.demo_mode = True
114
+ await ensure_demo_users(users_app)
115
+
116
+ admin = await _row(users_app, DEMO_ADMIN_EMAIL)
117
+ user = await _row(users_app, DEMO_USER_EMAIL)
118
+ assert await _role_names(users_app, admin.id) == [ADMIN_ROLE_NAME]
119
+ assert await _role_names(users_app, user.id) == [USER_ROLE_NAME]
120
+
121
+
122
+ async def test_a_blank_password_still_produces_usable_accounts(users_app):
123
+ """The buttons do not need passwords; the rows still need hashes."""
124
+ users_app.state.users.settings.demo_mode = True
125
+ await ensure_demo_users(users_app)
126
+
127
+ assert (await _row(users_app, DEMO_ADMIN_EMAIL)).hashed_password
128
+ assert (await _row(users_app, DEMO_USER_EMAIL)).hashed_password
129
+
130
+
131
+ async def test_a_generated_password_is_not_re_rolled_on_every_boot(users_app):
132
+ """Re-hashing a secret nobody can use would write an audit entry per
133
+ worker per restart, for no gain."""
134
+ users_app.state.users.settings.demo_mode = True
135
+ await ensure_demo_users(users_app)
136
+ first = (await _row(users_app, DEMO_ADMIN_EMAIL)).hashed_password
137
+
138
+ await ensure_demo_users(users_app)
139
+
140
+ assert (await _row(users_app, DEMO_ADMIN_EMAIL)).hashed_password == first
141
+
142
+
143
+ async def test_a_configured_password_is_reapplied(users_app):
144
+ """Changing a demo password in the admin UI has to take effect."""
145
+ users_app.state.users.settings.demo_mode = True
146
+ await ensure_demo_users(users_app)
147
+ first = (await _row(users_app, DEMO_USER_EMAIL)).hashed_password
148
+
149
+ users_app.state.users.settings.demo_user_password = "a-published-demo-password"
150
+ await ensure_demo_users(users_app)
151
+
152
+ assert (await _row(users_app, DEMO_USER_EMAIL)).hashed_password != first
153
+
154
+
155
+ async def test_demo_off_clears_the_cached_ids(users_app):
156
+ users_app.state.users.settings.demo_mode = True
157
+ await ensure_demo_users(users_app)
158
+ users_app.state.users.settings.demo_mode = False
159
+
160
+ assert await ensure_demo_users(users_app) == ()
161
+ assert users_app.state.users.demo_user_ids == ()
162
+
163
+
164
+ async def test_repointing_an_email_demotes_the_account_that_held_it(users_app):
165
+ """An address that served as the demo admin and is then configured as the
166
+ demo user must lose the admin grant, or the demotion is cosmetic."""
167
+ shared = "demo@example.com"
168
+ async with users_app.state.sm.db.session_factory() as session:
169
+ user = await reconcile_demo_user(
170
+ session,
171
+ DemoAccount(role=ADMIN_ROLE_NAME, email=shared, password="x", full_name="Demo"),
172
+ )
173
+ assert user.is_superuser
174
+ await reconcile_demo_user(
175
+ session,
176
+ DemoAccount(role=USER_ROLE_NAME, email=shared, password="x", full_name="Demo"),
177
+ )
178
+
179
+ assert await _role_names(users_app, user.id) == [USER_ROLE_NAME]
180
+ async with users_app.state.sm.db.session_factory() as session:
181
+ refreshed = await session.get(User, user.id)
182
+ assert not refreshed.is_superuser
@@ -0,0 +1,178 @@
1
+ """``DemoReadOnlyMiddleware`` — what a shared demo session may and may not do.
2
+
3
+ Split from the configuration and entry suites because this is the security
4
+ boundary: every test here is a way the guard could fail open.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import httpx
10
+ from users.constants import ADMIN_ROLE_NAME, SESSION_USER_ID_KEY, USER_ROLE_NAME
11
+ from users.demo import SESSION_DEMO_KEY
12
+ from users.demo_guard import DEMO_READ_ONLY_DETAIL
13
+
14
+
15
+ def _demo_route(role: str) -> str:
16
+ return f"/api/users/auth/demo/{role}"
17
+
18
+
19
+ def _client(app, session_payload: dict) -> httpx.AsyncClient:
20
+ """A client carrying a forged session cookie for ``session_payload``."""
21
+ from simple_module_test import forge_session_cookie
22
+
23
+ cookie = forge_session_cookie(str(app.state.sm.settings.secret_key), session_payload)
24
+ return httpx.AsyncClient(
25
+ transport=httpx.ASGITransport(app=app),
26
+ base_url="http://testserver",
27
+ cookies={"session": cookie},
28
+ )
29
+
30
+
31
+ # ── both demo sessions are read-only ────────────────────────────────────────
32
+
33
+
34
+ async def test_the_admin_demo_cannot_write(demo_client):
35
+ await demo_client.post(_demo_route(ADMIN_ROLE_NAME))
36
+
37
+ res = await demo_client.patch("/api/users/me", json={"full_name": "Owned"})
38
+ assert res.status_code == 403
39
+ assert res.json()["detail"] == DEMO_READ_ONLY_DETAIL
40
+
41
+
42
+ async def test_the_user_demo_cannot_write(demo_client):
43
+ await demo_client.post(_demo_route(USER_ROLE_NAME))
44
+
45
+ res = await demo_client.patch("/api/users/me", json={"full_name": "Owned"})
46
+ assert res.status_code == 403
47
+ assert res.json()["detail"] == DEMO_READ_ONLY_DETAIL
48
+
49
+
50
+ async def test_the_admin_demo_cannot_reach_the_admin_write_routes(demo_app):
51
+ """The showcase admin may browse /admin/*, never mutate through it — that
52
+ is the whole reason an admin demo is safe to publish."""
53
+ target = demo_app.state.users.demo_user_ids[0]
54
+ async with _client(
55
+ demo_app, {SESSION_USER_ID_KEY: str(target), SESSION_DEMO_KEY: True}
56
+ ) as client:
57
+ res = await client.delete(f"/api/users/admin/{target}")
58
+
59
+ assert res.status_code == 403
60
+ assert res.json()["detail"] == DEMO_READ_ONLY_DETAIL
61
+
62
+
63
+ async def test_a_demo_session_can_still_read(demo_client):
64
+ await demo_client.post(_demo_route(ADMIN_ROLE_NAME))
65
+ assert (await demo_client.get("/api/users/me")).status_code == 200
66
+
67
+
68
+ async def test_a_demo_session_can_still_sign_out(demo_client):
69
+ await demo_client.post(_demo_route(ADMIN_ROLE_NAME))
70
+ assert (await demo_client.post("/api/users/auth/logout")).status_code == 204
71
+
72
+
73
+ async def test_an_unstamped_session_on_a_demo_account_still_blocks(demo_app):
74
+ """The stamp is sufficient, not necessary.
75
+
76
+ Matching on the user ids too is what catches a visitor who signed in with
77
+ a published demo password through the ordinary form — that session never
78
+ passes through a demo endpoint, so it carries no stamp.
79
+ """
80
+ payload = {SESSION_USER_ID_KEY: str(demo_app.state.users.demo_user_ids[1])}
81
+ assert SESSION_DEMO_KEY not in payload
82
+
83
+ async with _client(demo_app, payload) as client:
84
+ res = await client.patch("/api/users/me", json={"full_name": "Owned"})
85
+
86
+ assert res.status_code == 403
87
+
88
+
89
+ async def test_an_inertia_write_gets_a_hard_redirect_not_a_raw_403(demo_client):
90
+ """Inertia renders a non-Inertia body as a modal — on a showcase instance
91
+ that reads as a crash. The protocol's own answer is 409 + Location."""
92
+ await demo_client.post(_demo_route(ADMIN_ROLE_NAME))
93
+
94
+ res = await demo_client.patch(
95
+ "/api/users/me",
96
+ json={"full_name": "Owned"},
97
+ headers={"X-Inertia": "true", "Referer": "http://testserver/users/me"},
98
+ )
99
+
100
+ assert res.status_code == 409
101
+ assert res.headers["X-Inertia-Location"] == "http://testserver/users/me"
102
+
103
+
104
+ # ── it leaves everyone else alone ───────────────────────────────────────────
105
+
106
+
107
+ async def test_writes_are_allowed_when_read_only_is_off(demo_app, demo_client):
108
+ demo_app.state.users.settings.demo_read_only = False
109
+ await demo_client.post(_demo_route(USER_ROLE_NAME))
110
+
111
+ res = await demo_client.patch("/api/users/me", json={"full_name": "Explorer"})
112
+ assert res.status_code == 200
113
+
114
+
115
+ async def test_the_guard_leaves_real_accounts_alone(demo_app):
116
+ """A real administrator on a demo instance is doing real work.
117
+
118
+ Goes through an admin route rather than ``/api/users/me``: that one
119
+ authenticates off the fastapi-users cookie, which a forged session cookie
120
+ does not carry, so a 401 there would prove nothing about the guard.
121
+ """
122
+ from _demo_support import DEMO_USER_EMAIL
123
+ from _users_app_builders import _make_admin_user
124
+
125
+ admin = await _make_admin_user(demo_app)
126
+ async with _client(demo_app, {SESSION_USER_ID_KEY: str(admin.id)}) as client:
127
+ res = await client.patch(
128
+ f"/api/users/admin/{demo_app.state.users.demo_user_ids[1]}",
129
+ json={"email": DEMO_USER_EMAIL, "full_name": "Renamed By A Real Admin"},
130
+ )
131
+
132
+ assert res.status_code == 200
133
+
134
+
135
+ # ── ways out of a demo session ──────────────────────────────────────────────
136
+
137
+
138
+ async def test_a_demo_session_can_sign_in_as_a_real_user_and_gets_its_writes_back(
139
+ demo_app, demo_client
140
+ ):
141
+ """Signing in as yourself is how you stop being the demo.
142
+
143
+ Two failures met here. Refusing the sign-in POST stranded anyone whose
144
+ browser still carried the stamp — including a lapsed demo session, which
145
+ ``_forget`` leaves anonymous but still marked — with no reachable way back
146
+ in short of clearing cookies. And a stamp that survived the sign-in would
147
+ hand the guard to the real account that replaced it.
148
+ """
149
+ from _users_app_builders import _make_admin_user
150
+
151
+ admin = await _make_admin_user(demo_app)
152
+ await demo_client.post(_demo_route(ADMIN_ROLE_NAME))
153
+
154
+ signed_in = await demo_client.post(
155
+ "/api/users/auth/login",
156
+ data={"username": admin.email, "password": "AdminPass1!", "remember": "false"},
157
+ )
158
+ assert signed_in.status_code == 204
159
+
160
+ res = await demo_client.patch("/api/users/me", json={"full_name": "A Real Admin"})
161
+ assert res.status_code == 200
162
+
163
+
164
+ async def test_demo_sessions_stay_read_only_after_demo_mode_is_switched_off(demo_app, demo_client):
165
+ """Withdrawing the offer must not promote the sessions already handed out.
166
+
167
+ ``demo_mode = False`` stops the buttons; it does not expire the cookies, so
168
+ a guard that keyed on it would hand every live demo visitor a writable
169
+ superuser on the way out.
170
+ """
171
+ await demo_client.post(_demo_route(ADMIN_ROLE_NAME))
172
+ demo_app.state.users.settings.demo_mode = False
173
+ demo_app.state.users.demo_user_ids = ()
174
+
175
+ res = await demo_client.patch("/api/users/me", json={"full_name": "Owned"})
176
+
177
+ assert res.status_code == 403
178
+ assert res.json()["detail"] == DEMO_READ_ONLY_DETAIL