simple-module-users 0.0.33__tar.gz → 0.0.35__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 (195) hide show
  1. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/PKG-INFO +34 -6
  2. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/README.md +28 -0
  3. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/package.json +3 -3
  4. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/pyproject.toml +6 -6
  5. simple_module_users-0.0.35/tests/_demo_support.py +33 -0
  6. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/conftest.py +1 -1
  7. simple_module_users-0.0.35/tests/test_activity_summary.py +111 -0
  8. simple_module_users-0.0.35/tests/test_bearer_token_expiry.py +265 -0
  9. simple_module_users-0.0.35/tests/test_demo_account.py +182 -0
  10. simple_module_users-0.0.35/tests/test_demo_guard.py +178 -0
  11. simple_module_users-0.0.35/tests/test_demo_login.py +137 -0
  12. simple_module_users-0.0.35/tests/test_legacy_session_expiry.py +159 -0
  13. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_remember_me.py +6 -2
  14. simple_module_users-0.0.35/tests/test_revocation_broadcast.py +167 -0
  15. simple_module_users-0.0.35/tests/test_session_revocation.py +167 -0
  16. simple_module_users-0.0.35/tests/test_session_version_failopen.py +157 -0
  17. simple_module_users-0.0.35/tests/test_session_version_hardening.py +242 -0
  18. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_user_manager.py +1 -1
  19. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_user_role_model.py +17 -6
  20. simple_module_users-0.0.35/tests/test_user_state.py +48 -0
  21. simple_module_users-0.0.35/tests/test_users_shared_props.py +111 -0
  22. simple_module_users-0.0.35/tests-js/deriveState.test.ts +43 -0
  23. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/UserRow.tsx +28 -8
  24. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/recent_activity.py +73 -25
  25. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/views.py +4 -2
  26. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/audit.py +9 -0
  27. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/api.py +2 -2
  28. simple_module_users-0.0.35/users/auth_local/components/DemoSignIn.tsx +97 -0
  29. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/LoginForm.tsx +2 -9
  30. simple_module_users-0.0.35/users/auth_local/demo_api.py +94 -0
  31. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/self_account.py +38 -13
  32. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/token_api.py +22 -3
  33. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/views.py +19 -1
  34. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/backend.py +46 -19
  35. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/constants.py +7 -0
  36. simple_module_users-0.0.35/users/demo.py +233 -0
  37. simple_module_users-0.0.35/users/demo_guard.py +122 -0
  38. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/locales/en.json +20 -2
  39. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/manager.py +8 -0
  40. simple_module_users-0.0.35/users/models/access_token.py +69 -0
  41. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/refresh_token.py +7 -1
  42. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/module.py +40 -76
  43. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/AcceptInvite.tsx +1 -6
  44. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/ForgotPassword.tsx +1 -1
  45. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Login.tsx +53 -3
  46. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Profile.tsx +1 -1
  47. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Register.tsx +1 -1
  48. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/ResetPassword.tsx +1 -6
  49. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/AddPeople.tsx +1 -1
  50. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/Edit.tsx +1 -1
  51. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/Index.tsx +1 -1
  52. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/provider.py +64 -39
  53. simple_module_users-0.0.35/users/session_revocation.py +89 -0
  54. simple_module_users-0.0.35/users/session_version_cache.py +152 -0
  55. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/settings.py +66 -0
  56. simple_module_users-0.0.35/users/shared_props.py +49 -0
  57. simple_module_users-0.0.35/users/startup.py +159 -0
  58. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/state.py +6 -0
  59. simple_module_users-0.0.35/users/token_strategy.py +164 -0
  60. simple_module_users-0.0.33/tests/test_users_shared_props.py +0 -52
  61. simple_module_users-0.0.33/users/models/access_token.py +0 -33
  62. simple_module_users-0.0.33/users/session_version_cache.py +0 -84
  63. simple_module_users-0.0.33/users/shared_props.py +0 -29
  64. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/.gitignore +0 -0
  65. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/LICENSE +0 -0
  66. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/.gitkeep +0 -0
  67. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/_middleware_support.py +0 -0
  68. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/_users_app_builders.py +0 -0
  69. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_access_token_model.py +0 -0
  70. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_admin_resend_invite.py +0 -0
  71. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_api_admin.py +0 -0
  72. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_api_admin_crud.py +0 -0
  73. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_api_admin_filters.py +0 -0
  74. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_api_auth.py +0 -0
  75. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_auth_screens_props.py +0 -0
  76. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_backend.py +0 -0
  77. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_bearer_token_age.py +0 -0
  78. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_bootstrap.py +0 -0
  79. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_bootstrap_resolution.py +0 -0
  80. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_bulk_invite_message.py +0 -0
  81. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_cli.py +0 -0
  82. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_constants.py +0 -0
  83. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_db_adapter.py +0 -0
  84. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_edit_recent_activity.py +0 -0
  85. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_external_users.py +0 -0
  86. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_invite_flow.py +0 -0
  87. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_invite_reuse.py +0 -0
  88. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_mailer.py +0 -0
  89. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_negative_authz.py +0 -0
  90. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_oauth.py +0 -0
  91. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_oauth_routes.py +0 -0
  92. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_profile_password.py +0 -0
  93. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_rate_limit.py +0 -0
  94. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_role_model.py +0 -0
  95. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_service_admin.py +0 -0
  96. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_service_admin_crud.py +0 -0
  97. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_session_expiry.py +0 -0
  98. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_session_version_cache.py +0 -0
  99. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_sessions_revoke.py +0 -0
  100. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_settings.py +0 -0
  101. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_token_api.py +0 -0
  102. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_user_model.py +0 -0
  103. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_user_service.py +0 -0
  104. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_bulk_invite.py +0 -0
  105. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_deps.py +0 -0
  106. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_list_invites.py +0 -0
  107. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_login_deep_link.py +0 -0
  108. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_middleware.py +0 -0
  109. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_middleware_public_paths.py +0 -0
  110. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_middleware_resolvers.py +0 -0
  111. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_users_provider.py +0 -0
  112. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_views.py +0 -0
  113. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests/test_views_admin.py +0 -0
  114. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests-js/EmailChipInput.test.tsx +0 -0
  115. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tests-js/invite-emails.test.ts +0 -0
  116. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/tsconfig.json +0 -0
  117. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/__init__.py +0 -0
  118. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/__init__.py +0 -0
  119. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/api.py +0 -0
  120. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/bulk_invite.py +0 -0
  121. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/IndexFilters.tsx +0 -0
  122. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/RolesTab.tsx +0 -0
  123. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/StatusPill.tsx +0 -0
  124. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/UserCards.tsx +0 -0
  125. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/UserRowMenu.tsx +0 -0
  126. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/UsersEmpty.tsx +0 -0
  127. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/UsersTable.tsx +0 -0
  128. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/useUserRowActions.ts +0 -0
  129. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/components/user-list-item.ts +0 -0
  130. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/queries.py +0 -0
  131. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/resend_invite.py +0 -0
  132. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/service.py +0 -0
  133. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/admin/user_state.py +0 -0
  134. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/__init__.py +0 -0
  135. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/AuthField.tsx +0 -0
  136. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/AuthIntro.tsx +0 -0
  137. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/AuthStateCard.tsx +0 -0
  138. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/InviteSummary.tsx +0 -0
  139. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/PasswordCard.tsx +0 -0
  140. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/PasswordFields.tsx +0 -0
  141. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/PreferencesCard.tsx +0 -0
  142. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
  143. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/ResendVerification.tsx +0 -0
  144. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/ResetLinkSent.tsx +0 -0
  145. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/SessionsCard.tsx +0 -0
  146. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/WaitingOnYou.tsx +0 -0
  147. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/components/profile-user.ts +0 -0
  148. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/invite_preview.py +0 -0
  149. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/rate_limit.py +0 -0
  150. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/auth_local/token_preview.py +0 -0
  151. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/bootstrap.py +0 -0
  152. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/cli.py +0 -0
  153. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/contracts/__init__.py +0 -0
  154. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/contracts/events.py +0 -0
  155. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/contracts/schemas.py +0 -0
  156. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/db_adapter.py +0 -0
  157. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/deps.py +0 -0
  158. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/exceptions.py +0 -0
  159. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/health.py +0 -0
  160. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/__init__.py +0 -0
  161. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/console.py +0 -0
  162. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/smtp.py +0 -0
  163. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/templates/.gitkeep +0 -0
  164. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/templates/invite.txt +0 -0
  165. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/templates/reset_password.txt +0 -0
  166. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/mailer/templates/verify_email.txt +0 -0
  167. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/middleware.py +0 -0
  168. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/__init__.py +0 -0
  169. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/_base.py +0 -0
  170. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/oauth_account.py +0 -0
  171. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/role.py +0 -0
  172. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/user.py +0 -0
  173. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/models/user_role.py +0 -0
  174. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/oauth/__init__.py +0 -0
  175. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/oauth/api.py +0 -0
  176. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/oauth/providers.py +0 -0
  177. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/.gitkeep +0 -0
  178. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/AccountCard.tsx +0 -0
  179. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/CreateUserFields.tsx +0 -0
  180. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/DangerZone.tsx +0 -0
  181. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/DetailsCard.tsx +0 -0
  182. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
  183. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/EmailChipInput.tsx +0 -0
  184. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/InviteFields.tsx +0 -0
  185. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/InviteResults.tsx +0 -0
  186. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
  187. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/RolePicker.tsx +0 -0
  188. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/UserStats.tsx +0 -0
  189. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/useEditUserForm.ts +0 -0
  190. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/components/useUserActions.ts +0 -0
  191. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/Users/invite-emails.ts +0 -0
  192. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/pages/VerifyEmail.tsx +0 -0
  193. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/py.typed +0 -0
  194. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/roles_cache.py +0 -0
  195. {simple_module_users-0.0.33 → simple_module_users-0.0.35}/users/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_users
3
- Version: 0.0.33
3
+ Version: 0.0.35
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.33
28
- Requires-Dist: simple-module-core==0.0.33
29
- Requires-Dist: simple-module-db==0.0.33
30
- Requires-Dist: simple-module-hosting==0.0.33
31
- Requires-Dist: simple-module-settings==0.0.33
27
+ Requires-Dist: simple-module-auth==0.0.35
28
+ Requires-Dist: simple-module-core==0.0.35
29
+ Requires-Dist: simple-module-db==0.0.35
30
+ Requires-Dist: simple-module-hosting==0.0.35
31
+ Requires-Dist: simple-module-settings==0.0.35
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.33"
3
+ version = "0.0.35"
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.33",
25
- "simple_module_db==0.0.33",
26
- "simple_module_hosting==0.0.33",
27
- "simple_module_settings==0.0.33",
28
- "simple_module_auth==0.0.33",
24
+ "simple_module_core==0.0.35",
25
+ "simple_module_db==0.0.35",
26
+ "simple_module_hosting==0.0.35",
27
+ "simple_module_settings==0.0.35",
28
+ "simple_module_auth==0.0.35",
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
@@ -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,111 @@
1
+ """Every activity line comes out of a catalog, not out of an f-string.
2
+
3
+ The card used to build its sentences in Python — ``f"{verb} {kind} {label}"``
4
+ from an English verb table — so the "Recent activity" list read as English in
5
+ every locale and no translation could reach it. Neither lint check could see
6
+ it: they only parse ``.tsx``.
7
+
8
+ These tests translate with a stub that echoes ``key(args)`` instead of copy, so
9
+ an assertion here fails the moment a sentence is assembled locally again — an
10
+ f-string would produce prose where the stub produces a key.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from simple_module_core import AuditLink, AuditLinkRegistry
18
+ from users.admin.recent_activity import _kind_of, _summarise
19
+
20
+
21
+ class _EchoTranslator:
22
+ """Stands in for ``Translator``, rendering the key and its arguments."""
23
+
24
+ def t(self, key: str, **params: Any) -> str:
25
+ rendered = ", ".join(f"{name}={value}" for name, value in sorted(params.items()))
26
+ return f"{key}({rendered})"
27
+
28
+
29
+ class _KeylessTranslator(_EchoTranslator):
30
+ """A translator with an empty catalog: ``t`` echoes the key back."""
31
+
32
+ def t(self, key: str, **params: Any) -> str:
33
+ return key
34
+
35
+
36
+ T = _EchoTranslator()
37
+
38
+
39
+ class TestSummaryKeys:
40
+ def test_a_create_reads_from_the_created_key(self) -> None:
41
+ assert _summarise(T, "create", "setting", "smtp_host", []) == (
42
+ "users.recent_activity.summary.created(kind=setting, label=smtp_host)"
43
+ )
44
+
45
+ def test_every_spelling_of_one_action_shares_a_key(self) -> None:
46
+ """Writers disagree on "delete"/"deleted"; the reader should not."""
47
+ assert _summarise(T, "delete", "file", "x", []) == _summarise(T, "deleted", "file", "x", [])
48
+
49
+ def test_an_unknown_action_keeps_its_verb_as_an_argument(self) -> None:
50
+ """Inventing a translation for a verb only one writer uses is a guess."""
51
+ assert _summarise(T, "archive", "file", "x", []) == (
52
+ "users.recent_activity.summary.other(action=Archive, kind=file, label=x)"
53
+ )
54
+
55
+ def test_named_fields_are_one_argument_not_a_locally_joined_sentence(self) -> None:
56
+ summary = _summarise(T, "update", "user", "Ada", [{"field": "email"}, {"field": "roles"}])
57
+ assert summary == (
58
+ "users.recent_activity.summary.changed_fields(fields=email, roles, label=Ada)"
59
+ )
60
+
61
+ def test_a_long_field_list_becomes_a_count(self) -> None:
62
+ changes = [{"field": name} for name in ("a", "b", "c", "d")]
63
+ assert _summarise(T, "update", "user", "Ada", changes) == (
64
+ "users.recent_activity.summary.changed_count(count=4, label=Ada)"
65
+ )
66
+
67
+ def test_an_update_with_no_recorded_fields_falls_back_to_the_plain_verb(self) -> None:
68
+ assert _summarise(T, "update", "user", "Ada", None) == (
69
+ "users.recent_activity.summary.updated(kind=user, label=Ada)"
70
+ )
71
+
72
+ def test_a_missing_catalog_entry_leaves_no_trailing_space(self) -> None:
73
+ """The key echoes back verbatim; nothing here appends a stray gap."""
74
+ assert _summarise(_KeylessTranslator(), "create", "setting", "x", []) == (
75
+ "users.recent_activity.summary.created"
76
+ )
77
+
78
+
79
+ class TestKindOf:
80
+ def _registry(self, link: AuditLink) -> AuditLinkRegistry:
81
+ registry = AuditLinkRegistry()
82
+ registry.register(link)
83
+ return registry
84
+
85
+ def test_the_links_label_key_is_translated(self) -> None:
86
+ registry = self._registry(
87
+ AuditLink(
88
+ entity_type="Setting",
89
+ url_template="/s/{id}",
90
+ label="Setting",
91
+ label_key="settings.audit.setting",
92
+ )
93
+ )
94
+ assert _kind_of(T, registry, "Setting") == "settings.audit.setting()"
95
+
96
+ def test_an_unresolved_key_falls_back_to_the_english_label(self) -> None:
97
+ registry = self._registry(
98
+ AuditLink(
99
+ entity_type="Setting",
100
+ url_template="/s/{id}",
101
+ label="Setting",
102
+ label_key="settings.audit.setting",
103
+ )
104
+ )
105
+ assert _kind_of(_KeylessTranslator(), registry, "Setting") == "setting"
106
+
107
+ def test_a_type_no_module_claims_reads_as_its_class_name(self) -> None:
108
+ assert _kind_of(T, AuditLinkRegistry(), "Unclaimed") == "unclaimed"
109
+
110
+ def test_no_registry_at_all_is_survivable(self) -> None:
111
+ assert _kind_of(T, None, "Unclaimed") == "unclaimed"
@@ -0,0 +1,265 @@
1
+ """A bearer credential lasts as long as it was issued for — not thirty days.
2
+
3
+ ``_TOKEN_LIFETIME_SECONDS`` is one number for the whole process, because
4
+ ``current_user`` resolves its strategy from the shared backend and cannot vary
5
+ it per request. It has to be the widest window any sign-in asks for, so every
6
+ other credential inherited it: an ordinary sign-in wrote an ``sm_auth`` cookie
7
+ whose ``Max-Age`` said fourteen days while the row behind it was accepted for
8
+ thirty, and ``/api/users/auth/token`` returned ``expires_in=900`` for a row good
9
+ for a month. ``Max-Age`` is browser-enforced only — a cookie lifted off disk is
10
+ replayed without one.
11
+
12
+ Each row now carries its own deadline and the ``session_version`` it was minted
13
+ under, and both bearer read paths enforce them: ``ExpiringDatabaseStrategy``
14
+ (which backs ``fastapi_users.current_user``) and ``UsersAuthProvider
15
+ ._resolve_bearer`` (which backs ``AuthMiddleware``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import uuid
21
+ from datetime import UTC, datetime, timedelta
22
+
23
+ import httpx
24
+ import pytest
25
+ from sqlalchemy import select
26
+ from users.models import User, UserAccessToken
27
+
28
+ _LOGIN = "/api/users/auth/login"
29
+ _TOKEN = "/api/users/auth/token"
30
+ _ME = "/api/users/me"
31
+ _CREDS = {"username": "admin@example.com", "password": "AdminPass1!"}
32
+
33
+
34
+ async def _tokens(app) -> list[UserAccessToken]:
35
+ async with app.state.sm.db.session_factory() as session:
36
+ return list((await session.execute(select(UserAccessToken))).scalars())
37
+
38
+
39
+ async def _only_token(app) -> UserAccessToken:
40
+ rows = await _tokens(app)
41
+ assert len(rows) == 1, f"expected exactly one access token, got {len(rows)}"
42
+ return rows[0]
43
+
44
+
45
+ def _seconds_out(row: UserAccessToken) -> float:
46
+ """How far ``expires_at`` sits from ``created_at``, in seconds."""
47
+ created, expires = row.created_at, row.expires_at
48
+ created = created.replace(tzinfo=UTC) if created.tzinfo is None else created
49
+ expires = expires.replace(tzinfo=UTC) if expires.tzinfo is None else expires
50
+ return (expires - created).total_seconds()
51
+
52
+
53
+ def _bearer(app, token: str) -> httpx.AsyncClient:
54
+ """A client presenting the row as an ``Authorization: Bearer`` header.
55
+
56
+ Read by ``UsersAuthProvider._resolve_bearer`` through ``AuthMiddleware``.
57
+ """
58
+ return httpx.AsyncClient(
59
+ transport=httpx.ASGITransport(app=app),
60
+ base_url="http://testserver",
61
+ headers={"Authorization": f"Bearer {token}"},
62
+ )
63
+
64
+
65
+ async def _expire_all(app) -> None:
66
+ """Push every row's deadline into the past, leaving ``created_at`` alone."""
67
+ async with app.state.sm.db.session_factory() as session:
68
+ for row in (await session.execute(select(UserAccessToken))).scalars():
69
+ row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
70
+ await session.commit()
71
+
72
+
73
+ async def _expire(app, token: str) -> None:
74
+ """Push a row's deadline into the past, leaving ``created_at`` alone.
75
+
76
+ The point of the test: the row is well inside the thirty-day read ceiling
77
+ and must still be refused, because the window it was *issued* for has run
78
+ out.
79
+ """
80
+ async with app.state.sm.db.session_factory() as session:
81
+ row = await session.get(UserAccessToken, token)
82
+ row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
83
+ await session.commit()
84
+
85
+
86
+ class TestTheRowLastsWhatWasPromised:
87
+ @pytest.mark.anyio
88
+ async def test_an_ordinary_sign_in_gets_the_cookie_window(self, users_app, anon_client):
89
+ """Not the thirty-day ceiling. This is the whole bug: the `Max-Age` said
90
+ fourteen days and the row behind it was honoured for thirty."""
91
+ assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
92
+
93
+ settings = users_app.state.users.settings
94
+ assert _seconds_out(await _only_token(users_app)) == pytest.approx(
95
+ settings.cookie_max_age_seconds, abs=5
96
+ )
97
+
98
+ @pytest.mark.anyio
99
+ async def test_keep_me_signed_in_gets_the_longer_window(self, users_app, anon_client):
100
+ """The checkbox still has to mean what it says."""
101
+ assert (
102
+ await anon_client.post(_LOGIN, data={**_CREDS, "remember": "true"})
103
+ ).status_code == 204
104
+
105
+ settings = users_app.state.users.settings
106
+ assert _seconds_out(await _only_token(users_app)) == pytest.approx(
107
+ settings.remember_me_max_age_seconds, abs=5
108
+ )
109
+
110
+ @pytest.mark.anyio
111
+ async def test_the_token_endpoint_honours_its_own_expires_in(self, users_app, anon_client):
112
+ """It advertised 15 minutes and minted a row good for a month, so a
113
+ client that honoured `expires_in` re-authenticated every 15 minutes
114
+ while the token it discarded stayed valid."""
115
+ resp = await anon_client.post(
116
+ _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
117
+ )
118
+ assert resp.status_code == 200, resp.text
119
+ advertised = resp.json()["expires_in"]
120
+
121
+ settings = users_app.state.users.settings
122
+ assert advertised == settings.bearer_token_lifetime_seconds
123
+ assert _seconds_out(await _only_token(users_app)) == pytest.approx(advertised, abs=5)
124
+
125
+
126
+ class TestAnExpiredRowStopsAuthenticating:
127
+ @pytest.mark.anyio
128
+ async def test_the_cookie_strategy_path_refuses_it(self, users_app, anon_client):
129
+ """`fastapi_users.current_user`, via ExpiringDatabaseStrategy.
130
+
131
+ ``/api/users/me`` is the probe because it depends on ``current_user``,
132
+ which is what reads the ``sm_auth`` row. The row stays well inside the
133
+ thirty-day read ceiling — only the deadline it was issued for has passed.
134
+ """
135
+ assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
136
+ assert (await anon_client.get(_ME)).status_code == 200
137
+
138
+ await _expire_all(users_app)
139
+ assert (await anon_client.get(_ME)).status_code == 401
140
+
141
+ @pytest.mark.anyio
142
+ async def test_the_provider_path_refuses_it(self, users_app, anon_client):
143
+ """`UsersAuthProvider._resolve_bearer`, via AuthMiddleware."""
144
+ from users.provider import UsersAuthProvider
145
+
146
+ resp = await anon_client.post(
147
+ _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
148
+ )
149
+ token = resp.json()["access_token"]
150
+ scope = {"app": users_app}
151
+
152
+ provider = UsersAuthProvider()
153
+ assert await provider._resolve_bearer(scope, token) is not None
154
+ await _expire(users_app, token)
155
+ assert await provider._resolve_bearer(scope, token) is None
156
+
157
+
158
+ class TestAPasswordChangeStrandsBearerTokens:
159
+ """`change_my_password` bumped `session_version` and stranded every session,
160
+ while every bearer token minted before it kept working — including any an
161
+ attacker who knew the old password had already collected."""
162
+
163
+ @pytest.mark.anyio
164
+ async def test_a_version_bump_strands_a_token_on_both_paths(self, users_app, anon_client):
165
+ from users.provider import UsersAuthProvider, forget_session_version
166
+
167
+ # One row per path: the sign-in writes the `sm_auth` row `current_user`
168
+ # reads, and `/auth/token` mints the one the Bearer header carries.
169
+ assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
170
+ minted = await anon_client.post(
171
+ _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
172
+ )
173
+ token = minted.json()["access_token"]
174
+ provider = UsersAuthProvider()
175
+ scope = {"app": users_app}
176
+
177
+ assert (await anon_client.get(_ME)).status_code == 200
178
+ assert await provider._resolve_bearer(scope, token) is not None
179
+
180
+ async with users_app.state.sm.db.session_factory() as session:
181
+ user = (
182
+ await session.execute(select(User).where(User.email == "admin@example.com"))
183
+ ).scalar_one()
184
+ user.session_version = int(user.session_version or 0) + 1
185
+ await session.commit()
186
+ forget_session_version(user.id)
187
+
188
+ assert (await anon_client.get(_ME)).status_code == 401
189
+ assert await provider._resolve_bearer(scope, token) is None
190
+
191
+ @pytest.mark.anyio
192
+ async def test_changing_the_password_deletes_the_rows(self, users_app, anon_client):
193
+ """The stamped counter already strands them; deleting is what stops a
194
+ stolen token resolving a row at all, and stops the refresh tokens
195
+ minting fresh ones behind it."""
196
+ assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
197
+ assert await _tokens(users_app)
198
+
199
+ resp = await anon_client.post(
200
+ "/api/users/me/password",
201
+ json={"current_password": "AdminPass1!", "new_password": "NewAdminPass1!"},
202
+ )
203
+ assert resp.status_code == 204, resp.text
204
+ assert await _tokens(users_app) == []
205
+
206
+
207
+ class TestTheMintedCounterIsCurrent:
208
+ @pytest.mark.anyio
209
+ async def test_a_refresh_carries_the_accounts_counter(self, users_app, anon_client):
210
+ """A refresh token outlives the access tokens it mints, so the account
211
+ is re-read rather than trusted from the row."""
212
+ resp = await anon_client.post(
213
+ _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
214
+ )
215
+ refresh = resp.json()["refresh_token"]
216
+
217
+ async with users_app.state.sm.db.session_factory() as session:
218
+ user = (
219
+ await session.execute(select(User).where(User.email == "admin@example.com"))
220
+ ).scalar_one()
221
+ user.session_version = 42
222
+ await session.commit()
223
+
224
+ rotated = await anon_client.post(
225
+ "/api/users/auth/token/refresh", json={"refresh_token": refresh}
226
+ )
227
+ assert rotated.status_code == 200, rotated.text
228
+
229
+ async with users_app.state.sm.db.session_factory() as session:
230
+ row = (
231
+ await session.execute(
232
+ select(UserAccessToken).where(
233
+ UserAccessToken.token == rotated.json()["access_token"]
234
+ )
235
+ )
236
+ ).scalar_one()
237
+ assert row.session_version == 42
238
+
239
+ @pytest.mark.anyio
240
+ async def test_a_refresh_for_a_deactivated_account_is_refused(self, users_app, anon_client):
241
+ resp = await anon_client.post(
242
+ _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
243
+ )
244
+ refresh = resp.json()["refresh_token"]
245
+
246
+ async with users_app.state.sm.db.session_factory() as session:
247
+ user = (
248
+ await session.execute(select(User).where(User.email == "admin@example.com"))
249
+ ).scalar_one()
250
+ user.is_active = False
251
+ await session.commit()
252
+
253
+ rotated = await anon_client.post(
254
+ "/api/users/auth/token/refresh", json={"refresh_token": refresh}
255
+ )
256
+ assert rotated.status_code == 401
257
+
258
+
259
+ class TestTheModelDefault:
260
+ def test_a_row_built_directly_still_gets_a_deadline(self):
261
+ """The mint paths always stamp one; the default keeps a caller that
262
+ builds the model by hand from minting a credential that never expires."""
263
+ row = UserAccessToken(token="t", user_id=uuid.uuid4())
264
+ assert row.expires_at is not None
265
+ assert _seconds_out(row) == pytest.approx(30 * 24 * 60 * 60, abs=5)