simple-module-users 0.0.37__tar.gz → 0.0.39__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/.gitignore +1 -2
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/PKG-INFO +12 -12
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/README.md +3 -3
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/package.json +1 -1
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/pyproject.toml +12 -9
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/_middleware_support.py +6 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/_users_app_builders.py +58 -7
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/conftest.py +8 -1
- simple_module_users-0.0.37/tests/test_demo_account.py → simple_module_users-0.0.39/tests/demo/test_users_demo_account.py +11 -15
- simple_module_users-0.0.39/tests/demo/test_users_demo_credentials.py +139 -0
- simple_module_users-0.0.37/tests/test_demo_guard.py → simple_module_users-0.0.39/tests/demo/test_users_demo_guard.py +1 -1
- simple_module_users-0.0.37/tests/test_admin_resend_invite.py → simple_module_users-0.0.39/tests/invites/test_users_admin_resend_invite.py +9 -7
- {simple_module_users-0.0.37/tests → simple_module_users-0.0.39/tests/invites}/test_users_bulk_invite.py +2 -2
- simple_module_users-0.0.37/tests/test_bulk_invite_message.py → simple_module_users-0.0.39/tests/invites/test_users_bulk_invite_message.py +10 -8
- simple_module_users-0.0.37/tests/test_invite_flow.py → simple_module_users-0.0.39/tests/invites/test_users_invite_flow.py +27 -26
- simple_module_users-0.0.37/tests/test_invite_reuse.py → simple_module_users-0.0.39/tests/invites/test_users_invite_reuse.py +18 -18
- simple_module_users-0.0.39/tests/lockout/test_users_admin_unlock.py +100 -0
- simple_module_users-0.0.39/tests/lockout/test_users_lockout_checkpoint.py +114 -0
- simple_module_users-0.0.39/tests/lockout/test_users_lockout_columns.py +31 -0
- simple_module_users-0.0.39/tests/lockout/test_users_lockout_enumeration.py +125 -0
- simple_module_users-0.0.39/tests/lockout/test_users_lockout_service.py +202 -0
- simple_module_users-0.0.39/tests/lockout/test_users_login_lockout.py +149 -0
- simple_module_users-0.0.39/tests/test_api_admin_pagination_bounds.py +31 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_auth_screens_props.py +1 -1
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_bearer_token_expiry.py +7 -3
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_bootstrap.py +31 -3
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_edit_recent_activity.py +12 -10
- simple_module_users-0.0.39/tests/test_mailer.py +69 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_profile_password.py +2 -2
- simple_module_users-0.0.39/tests/test_rate_limit.py +28 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_session_revocation.py +1 -1
- simple_module_users-0.0.39/tests/test_session_version_atomic.py +35 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_settings.py +14 -43
- simple_module_users-0.0.39/tests/test_users_admin_session_revocation.py +106 -0
- simple_module_users-0.0.39/tests/test_users_default_role.py +75 -0
- simple_module_users-0.0.39/tests/test_users_directory.py +131 -0
- simple_module_users-0.0.39/tests/test_users_domain_errors.py +40 -0
- simple_module_users-0.0.39/tests/test_users_mailer_core_sender.py +100 -0
- simple_module_users-0.0.37/tests/test_user_manager.py → simple_module_users-0.0.39/tests/test_users_manager.py +0 -33
- simple_module_users-0.0.39/tests/test_users_password_change_keeps_api.py +98 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_provider.py +10 -5
- simple_module_users-0.0.39/tests/test_users_register_single_connection.py +55 -0
- simple_module_users-0.0.39/tests/test_users_reset_link_origin.py +48 -0
- simple_module_users-0.0.39/tests/test_users_startup_role.py +83 -0
- simple_module_users-0.0.39/tests-js/AccountCardLocked.test.tsx +50 -0
- simple_module_users-0.0.39/tests-js/users_Login.test.tsx +94 -0
- simple_module_users-0.0.39/tests-js/users_actions.test.tsx +88 -0
- simple_module_users-0.0.39/tests-js/users_fixtures.ts +23 -0
- simple_module_users-0.0.39/tests-js/users_profile_cards.test.tsx +56 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/api.py +22 -8
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/StatusPill.tsx +18 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/UserCards.tsx +1 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/UserRow.tsx +2 -1
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/useUserRowActions.ts +26 -14
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/user-list-item.ts +2 -0
- simple_module_users-0.0.39/users/admin/links.py +39 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/queries.py +52 -24
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/recent_activity.py +28 -59
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/service.py +28 -7
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/views.py +15 -14
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/audit.py +12 -32
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/api.py +34 -41
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/AuthIntro.tsx +2 -2
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/PasswordCard.tsx +19 -10
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/ProfileDetailsCard.tsx +11 -9
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/ResendVerification.tsx +3 -6
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/SessionsCard.tsx +26 -13
- simple_module_users-0.0.39/users/auth_local/lockout/__init__.py +209 -0
- simple_module_users-0.0.39/users/auth_local/lockout/attempt.py +87 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/rate_limit.py +6 -34
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/self_account.py +50 -22
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/token_api.py +49 -17
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/token_preview.py +1 -1
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/views.py +1 -1
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/backend.py +30 -5
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/bootstrap.py +38 -27
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/constants.py +13 -0
- simple_module_users-0.0.39/users/contracts/roles.py +11 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/contracts/schemas.py +3 -1
- simple_module_users-0.0.37/users/demo.py → simple_module_users-0.0.39/users/demo/__init__.py +7 -4
- simple_module_users-0.0.37/users/auth_local/demo_api.py → simple_module_users-0.0.39/users/demo/api.py +1 -1
- simple_module_users-0.0.37/users/demo_guard.py → simple_module_users-0.0.39/users/demo/guard.py +39 -13
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/deps.py +2 -1
- simple_module_users-0.0.39/users/directory.py +108 -0
- simple_module_users-0.0.39/users/errors.py +19 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/exceptions.py +19 -4
- simple_module_users-0.0.39/users/invites/__init__.py +1 -0
- simple_module_users-0.0.37/users/admin/bulk_invite.py → simple_module_users-0.0.39/users/invites/bulk.py +23 -49
- simple_module_users-0.0.37/users/admin/resend_invite.py → simple_module_users-0.0.39/users/invites/resend.py +5 -5
- simple_module_users-0.0.39/users/locales/de.json +419 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/locales/en.json +11 -5
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/mailer/__init__.py +4 -19
- simple_module_users-0.0.39/users/mailer/templated.py +77 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/manager.py +13 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/user.py +15 -1
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/module.py +31 -23
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/AcceptInvite.tsx +16 -18
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/ForgotPassword.tsx +7 -11
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Login.tsx +36 -49
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Profile.tsx +2 -4
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Register.tsx +9 -6
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/ResetPassword.tsx +19 -32
- simple_module_users-0.0.39/users/pages/Users/AddPeople.tsx +136 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/Edit.tsx +18 -13
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/Index.tsx +9 -10
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/AccountCard.tsx +24 -1
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/DangerZone.tsx +22 -19
- simple_module_users-0.0.39/users/pages/Users/components/MailerBanner.tsx +37 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/RecentActivityCard.tsx +3 -3
- simple_module_users-0.0.39/users/pages/Users/components/useAddPeople.ts +157 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/useEditUserForm.ts +24 -31
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/useUserActions.ts +49 -14
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/VerifyEmail.tsx +11 -22
- simple_module_users-0.0.39/users/pages/api.generated.ts +1923 -0
- simple_module_users-0.0.39/users/pages/api.ts +9 -0
- simple_module_users-0.0.39/users/pages/failureMessage.ts +16 -0
- simple_module_users-0.0.39/users/pages/signIn.ts +25 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/provider.py +3 -17
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/registration.py +17 -11
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/session_revocation.py +25 -2
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/settings.py +31 -16
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/setup_action.py +1 -1
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/shared_props.py +1 -1
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/startup.py +24 -43
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/state.py +1 -2
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/token_strategy.py +6 -5
- simple_module_users-0.0.37/tests/test_mailer.py +0 -130
- simple_module_users-0.0.37/tests/test_rate_limit.py +0 -132
- simple_module_users-0.0.37/tests/test_users_default_role.py +0 -82
- simple_module_users-0.0.37/users/health.py +0 -47
- simple_module_users-0.0.37/users/mailer/console.py +0 -61
- simple_module_users-0.0.37/users/mailer/smtp.py +0 -123
- simple_module_users-0.0.37/users/pages/Users/AddPeople.tsx +0 -298
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/LICENSE +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/_demo_support.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/_users_oauth_support.py +0 -0
- /simple_module_users-0.0.37/tests/test_demo_login.py → /simple_module_users-0.0.39/tests/demo/test_users_demo_login.py +0 -0
- {simple_module_users-0.0.37/tests → simple_module_users-0.0.39/tests/invites}/test_users_list_invites.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_activity_summary.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_bearer_token_age.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_external_users.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_legacy_cached_tenant.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_legacy_session_expiry.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_login_redirect_root_mount.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_remember_me.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_revocation_broadcast.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_session_expiry.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_session_version_cache.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_session_version_failopen.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_session_version_hardening.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_sessions_revoke.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_user_state.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_bearer_scheme.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_login_deep_link.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_oauth_signup_gate.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_oauth_unverified_link.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_register_role_durable.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_require_verification.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_seed_role_migration.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_setup_lock.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_setup_wizard.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_users_shared_props.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_views.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests-js/EmailChipInput.test.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests-js/LoginForm.test.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests-js/deriveState.test.ts +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests-js/invite-emails.test.ts +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tests-js/registerErrors.test.ts +0 -0
- /simple_module_users-0.0.37/tests-js/Register.test.tsx → /simple_module_users-0.0.39/tests-js/users_Register.test.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/tsconfig.json +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/__init__.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/UserRowMenu.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/UsersEmpty.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/components/UsersTable.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/admin/user_state.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/AuthField.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/AuthStateCard.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/DemoSignIn.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/InviteSummary.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/LoginForm.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/PasswordFields.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/PreferencesCard.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/ResetLinkSent.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/WaitingOnYou.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/components/profile-user.ts +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/auth_local/registerErrors.ts +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/cli.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/db_adapter.py +0 -0
- /simple_module_users-0.0.37/users/auth_local/invite_preview.py → /simple_module_users-0.0.39/users/invites/preview.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/middleware.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/_base.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/role.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/oauth/api.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/CreateUserFields.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/DetailsCard.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/EmailChipInput.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/InviteFields.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/InviteResults.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/RolePicker.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/components/UserStats.tsx +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/pages/Users/invite-emails.ts +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/py.typed +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/users/session_version_cache.py +0 -0
- {simple_module_users-0.0.37 → simple_module_users-0.0.39}/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.
|
|
3
|
+
Version: 0.0.39
|
|
4
4
|
Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -21,15 +21,15 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
|
-
Requires-Dist:
|
|
25
|
-
Requires-Dist: cachetools>=5.3
|
|
24
|
+
Requires-Dist: cachetools<8,>=5.3
|
|
26
25
|
Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
|
|
27
|
-
Requires-Dist: simple-module-auth==0.0.
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
29
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
31
|
-
Requires-Dist: simple-module-
|
|
32
|
-
Requires-Dist:
|
|
26
|
+
Requires-Dist: simple-module-auth==0.0.39
|
|
27
|
+
Requires-Dist: simple-module-core==0.0.39
|
|
28
|
+
Requires-Dist: simple-module-db==0.0.39
|
|
29
|
+
Requires-Dist: simple-module-hosting==0.0.39
|
|
30
|
+
Requires-Dist: simple-module-mail==0.0.39
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.39
|
|
32
|
+
Requires-Dist: typer<0.28,>=0.12
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
35
35
|
# simple_module_users
|
|
@@ -52,7 +52,7 @@ Pre-wired into any app scaffolded with `smpy new`.
|
|
|
52
52
|
- Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
|
|
53
53
|
- `smpy users create-admin` CLI for ad-hoc admin creation.
|
|
54
54
|
- Inertia pages for login/register/invite-accept/admin-invite.
|
|
55
|
-
-
|
|
55
|
+
- Sends verification, reset and invite emails through the framework mail sender (configure the transport in the `mail` module).
|
|
56
56
|
- Demo mode — one-click, read-only shared **admin** and **standard user** accounts on the sign-in card, for public showcase instances.
|
|
57
57
|
|
|
58
58
|
## Usage
|
|
@@ -73,7 +73,7 @@ SM_USERS_BOOTSTRAP_PASSWORD=change-me
|
|
|
73
73
|
Program:
|
|
74
74
|
|
|
75
75
|
```python
|
|
76
|
-
from auth.deps import CurrentUser
|
|
76
|
+
from auth.deps import CurrentUser
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
@router.get("/profile")
|
|
@@ -84,7 +84,7 @@ async def profile(user: CurrentUser):
|
|
|
84
84
|
## Depends on
|
|
85
85
|
|
|
86
86
|
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_settings`, `simple_module_auth`
|
|
87
|
-
- `fastapi-users[sqlalchemy,oauth]>=15,<16`, `
|
|
87
|
+
- `fastapi-users[sqlalchemy,oauth]>=15,<16`, `cachetools`, `typer`
|
|
88
88
|
|
|
89
89
|
## Demo mode
|
|
90
90
|
|
|
@@ -18,7 +18,7 @@ Pre-wired into any app scaffolded with `smpy new`.
|
|
|
18
18
|
- Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
|
|
19
19
|
- `smpy users create-admin` CLI for ad-hoc admin creation.
|
|
20
20
|
- Inertia pages for login/register/invite-accept/admin-invite.
|
|
21
|
-
-
|
|
21
|
+
- Sends verification, reset and invite emails through the framework mail sender (configure the transport in the `mail` module).
|
|
22
22
|
- Demo mode — one-click, read-only shared **admin** and **standard user** accounts on the sign-in card, for public showcase instances.
|
|
23
23
|
|
|
24
24
|
## Usage
|
|
@@ -39,7 +39,7 @@ SM_USERS_BOOTSTRAP_PASSWORD=change-me
|
|
|
39
39
|
Program:
|
|
40
40
|
|
|
41
41
|
```python
|
|
42
|
-
from auth.deps import CurrentUser
|
|
42
|
+
from auth.deps import CurrentUser
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
@router.get("/profile")
|
|
@@ -50,7 +50,7 @@ async def profile(user: CurrentUser):
|
|
|
50
50
|
## Depends on
|
|
51
51
|
|
|
52
52
|
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_settings`, `simple_module_auth`
|
|
53
|
-
- `fastapi-users[sqlalchemy,oauth]>=15,<16`, `
|
|
53
|
+
- `fastapi-users[sqlalchemy,oauth]>=15,<16`, `cachetools`, `typer`
|
|
54
54
|
|
|
55
55
|
## Demo mode
|
|
56
56
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_users"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.39"
|
|
4
4
|
description = "Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,18 +21,20 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
27
|
-
"simple_module_settings==0.0.
|
|
28
|
-
"simple_module_auth==0.0.
|
|
24
|
+
"simple_module_core==0.0.39",
|
|
25
|
+
"simple_module_db==0.0.39",
|
|
26
|
+
"simple_module_hosting==0.0.39",
|
|
27
|
+
"simple_module_settings==0.0.39",
|
|
28
|
+
"simple_module_auth==0.0.39",
|
|
29
|
+
# Sends through the framework mail sender; `mail` owns the SMTP transport
|
|
30
|
+
# (GH #496), so an upgrade keeps delivering mail.
|
|
31
|
+
"simple_module_mail==0.0.39",
|
|
29
32
|
# Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
|
|
30
33
|
# fields after construction (see reconfigure_cookie_transport in backend.py).
|
|
31
34
|
# Bumping the major version requires re-checking those field names.
|
|
32
35
|
"fastapi-users[sqlalchemy,oauth]>=15,<16",
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"typer>=0.12",
|
|
36
|
+
"cachetools>=5.3,<8",
|
|
37
|
+
"typer>=0.12,<0.28",
|
|
36
38
|
]
|
|
37
39
|
|
|
38
40
|
[project.entry-points.simple_module]
|
|
@@ -63,3 +65,4 @@ simple_module_db = { workspace = true }
|
|
|
63
65
|
simple_module_hosting = { workspace = true }
|
|
64
66
|
simple_module_settings = { workspace = true }
|
|
65
67
|
simple_module_auth = { workspace = true }
|
|
68
|
+
simple_module_mail = { workspace = true }
|
|
@@ -60,8 +60,14 @@ async def _build_app(db_state, inner_handler=None, *, principal_resolvers=None):
|
|
|
60
60
|
|
|
61
61
|
app = FastAPI()
|
|
62
62
|
app.state.sm = SimpleNamespace(db=db_state)
|
|
63
|
+
from simple_module_core.public_routes import PublicRouteRegistry
|
|
64
|
+
from users.module import UsersModule
|
|
63
65
|
from users.provider import UsersAuthProvider
|
|
64
66
|
|
|
67
|
+
# The host fills this from every module's register_public_routes at boot.
|
|
68
|
+
app.state.public_routes = PublicRouteRegistry()
|
|
69
|
+
UsersModule().register_public_routes(app.state.public_routes)
|
|
70
|
+
|
|
65
71
|
app.state.auth = AuthState(
|
|
66
72
|
auth_provider=UsersAuthProvider(),
|
|
67
73
|
principal_resolvers=list(principal_resolvers or []),
|
|
@@ -7,9 +7,12 @@ wiring. Not named ``test_*`` so pytest does not collect it.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import httpx
|
|
10
11
|
from simple_module_hosting.settings import Settings
|
|
11
12
|
from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID
|
|
12
13
|
|
|
14
|
+
ALICE = "alice@example.com"
|
|
15
|
+
|
|
13
16
|
# ---------------------------------------------------------------------------
|
|
14
17
|
|
|
15
18
|
|
|
@@ -17,7 +20,6 @@ def _users_overrides(allow_signup: bool = False) -> dict[str, tuple[str, str]]:
|
|
|
17
20
|
"""Values seeded into the ``SettingsStore`` before the app lifespan runs."""
|
|
18
21
|
return {
|
|
19
22
|
"allow_signup": (str(allow_signup).lower(), "bool"),
|
|
20
|
-
"mailer": ("console", "string"),
|
|
21
23
|
"base_url": ("http://testserver", "string"),
|
|
22
24
|
"cookie_secure": ("false", "bool"),
|
|
23
25
|
# 32+ bytes to clear pyjwt's InsecureKeyLengthWarning for HMAC-SHA256.
|
|
@@ -82,9 +84,16 @@ async def _seed_roles(application) -> None:
|
|
|
82
84
|
if "user" not in existing:
|
|
83
85
|
session.add(Role(id=USER_ROLE_ID, name="user", description="Standard user"))
|
|
84
86
|
await session.commit()
|
|
87
|
+
# Direct inserts bypass the role service that would refresh the cache; startup
|
|
88
|
+
# already warmed it (with the role it seeds), so refresh like a real write would.
|
|
89
|
+
from users.roles_cache import refresh_roles_cache
|
|
90
|
+
|
|
91
|
+
await refresh_roles_cache(application)
|
|
85
92
|
|
|
86
93
|
|
|
87
|
-
async def _seed_users_settings(
|
|
94
|
+
async def _seed_users_settings(
|
|
95
|
+
application, *, allow_signup: bool, overrides: dict[str, tuple[str, str]] | None = None
|
|
96
|
+
) -> None:
|
|
88
97
|
"""Write the test UsersSettings overrides to the DB before hydrate runs.
|
|
89
98
|
|
|
90
99
|
Hydrate fires inside the lifespan ``__aenter__``, so this needs to land
|
|
@@ -95,7 +104,8 @@ async def _seed_users_settings(application, *, allow_signup: bool) -> None:
|
|
|
95
104
|
|
|
96
105
|
async with application.state.sm.db.session_factory() as session:
|
|
97
106
|
store = SettingsStore(SettingService(session))
|
|
98
|
-
|
|
107
|
+
rows = _users_overrides(allow_signup=allow_signup) | (overrides or {})
|
|
108
|
+
for field, (raw, vtype) in rows.items():
|
|
99
109
|
await store.set_override("users", field, raw, vtype)
|
|
100
110
|
await session.commit()
|
|
101
111
|
|
|
@@ -136,19 +146,33 @@ async def _make_standard_user(app, email: str = "user@example.com"):
|
|
|
136
146
|
return user
|
|
137
147
|
|
|
138
148
|
|
|
139
|
-
async def _build_users_app(
|
|
140
|
-
|
|
149
|
+
async def _build_users_app(
|
|
150
|
+
monkeypatch,
|
|
151
|
+
*,
|
|
152
|
+
allow_signup: bool,
|
|
153
|
+
seed_admin: bool = True,
|
|
154
|
+
database_url: str = "sqlite+aiosqlite:///:memory:",
|
|
155
|
+
overrides: dict[str, tuple[str, str]] | None = None,
|
|
156
|
+
):
|
|
157
|
+
"""Build a test FastAPI app with DB created, settings seeded, lifespan started.
|
|
158
|
+
|
|
159
|
+
``database_url`` lets several apps share one SQLite file, standing in for
|
|
160
|
+
workers of one deployment; ``overrides`` adds ``UsersSettings`` rows.
|
|
161
|
+
"""
|
|
141
162
|
from simple_module_hosting.app_builder import create_app
|
|
142
163
|
|
|
143
164
|
settings = Settings(
|
|
144
|
-
database_url=
|
|
165
|
+
database_url=database_url,
|
|
145
166
|
environment="testing",
|
|
146
167
|
secret_key="test-secret-key",
|
|
147
168
|
multi_tenant=False,
|
|
169
|
+
# Explicit: a developer's .env pointing at the shared Redis would make
|
|
170
|
+
# login-lockout counters outlive the test that wrote them.
|
|
171
|
+
redis_url=None,
|
|
148
172
|
)
|
|
149
173
|
application = create_app(settings)
|
|
150
174
|
await _setup_app_db(application)
|
|
151
|
-
await _seed_users_settings(application, allow_signup=allow_signup)
|
|
175
|
+
await _seed_users_settings(application, allow_signup=allow_signup, overrides=overrides)
|
|
152
176
|
|
|
153
177
|
ctx = application.router.lifespan_context(application)
|
|
154
178
|
await ctx.__aenter__()
|
|
@@ -161,3 +185,30 @@ async def _build_users_app(monkeypatch, *, allow_signup: bool, seed_admin: bool
|
|
|
161
185
|
if seed_admin:
|
|
162
186
|
await _make_admin_user(application)
|
|
163
187
|
return application, ctx
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Request and row helpers for the login-lockout tests
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _client(app, ip: str) -> httpx.AsyncClient:
|
|
196
|
+
"""A client whose requests arrive from *ip*."""
|
|
197
|
+
transport = httpx.ASGITransport(app=app, client=(ip, 1))
|
|
198
|
+
return httpx.AsyncClient(transport=transport, base_url="http://testserver")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def _login(app, ip: str, username: str, password: str = "wrong") -> httpx.Response:
|
|
202
|
+
async with _client(app, ip) as c:
|
|
203
|
+
return await c.post(
|
|
204
|
+
"/api/users/auth/login", data={"username": username, "password": password}
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def user_row(app, email: str):
|
|
209
|
+
"""The ``User`` row for *email*, or ``None``, read in a fresh session."""
|
|
210
|
+
from sqlalchemy import select
|
|
211
|
+
from users.models import User
|
|
212
|
+
|
|
213
|
+
async with app.state.sm.db.session_factory() as session:
|
|
214
|
+
return (await session.execute(select(User).where(User.email == email))).scalar_one_or_none()
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The ``users_app`` fixture builds a full FastAPI app via ``create_app`` but
|
|
4
4
|
with an in-memory SQLite database, seeded roles, and test-friendly settings
|
|
5
|
-
(
|
|
5
|
+
(no mail sender installed, signup disabled by default, short secrets).
|
|
6
6
|
|
|
7
7
|
The ``anon_client`` gives a plain httpx client.
|
|
8
8
|
The ``admin_client`` gives a client with a signed local-user session cookie
|
|
@@ -21,6 +21,7 @@ import pytest
|
|
|
21
21
|
# top-level modules on pytest's sys.path — same mechanism as the
|
|
22
22
|
# _middleware_support plugin loaded at the bottom of this file.
|
|
23
23
|
from _users_app_builders import (
|
|
24
|
+
ALICE,
|
|
24
25
|
_build_users_app,
|
|
25
26
|
_make_admin_user,
|
|
26
27
|
_make_standard_user,
|
|
@@ -148,6 +149,12 @@ async def user_client(users_app) -> AsyncGenerator[httpx.AsyncClient, None]:
|
|
|
148
149
|
yield c
|
|
149
150
|
|
|
150
151
|
|
|
152
|
+
@pytest.fixture
|
|
153
|
+
async def alice(users_app):
|
|
154
|
+
"""A standard user, ``ALICE``, for the login-lockout tests."""
|
|
155
|
+
return await _make_standard_user(users_app, ALICE)
|
|
156
|
+
|
|
157
|
+
|
|
151
158
|
# ---------------------------------------------------------------------------
|
|
152
159
|
# DB session fixture scoped to users_app
|
|
153
160
|
# ---------------------------------------------------------------------------
|
|
@@ -9,6 +9,7 @@ feature is off. The third, a demo session that is allowed to write, is
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from _demo_support import DEMO_ADMIN_EMAIL, DEMO_USER_EMAIL
|
|
12
|
+
from _users_app_builders import user_row
|
|
12
13
|
from sqlalchemy import select
|
|
13
14
|
from users.constants import ADMIN_ROLE_NAME, USER_ROLE_NAME
|
|
14
15
|
from users.demo import (
|
|
@@ -75,11 +76,6 @@ def test_read_only_is_the_default_posture():
|
|
|
75
76
|
# ── seeding ─────────────────────────────────────────────────────────────────
|
|
76
77
|
|
|
77
78
|
|
|
78
|
-
async def _row(app, email: str) -> User | None:
|
|
79
|
-
async with app.state.sm.db.session_factory() as session:
|
|
80
|
-
return (await session.execute(select(User).where(User.email == email))).scalar_one_or_none()
|
|
81
|
-
|
|
82
|
-
|
|
83
79
|
async def _role_names(app, user_id) -> list[str]:
|
|
84
80
|
async with app.state.sm.db.session_factory() as session:
|
|
85
81
|
return list(
|
|
@@ -102,8 +98,8 @@ async def test_ensure_demo_users_seeds_both_accounts(users_app):
|
|
|
102
98
|
|
|
103
99
|
assert len(ids) == 2
|
|
104
100
|
assert users_app.state.users.demo_user_ids == ids
|
|
105
|
-
admin = await
|
|
106
|
-
user = await
|
|
101
|
+
admin = await user_row(users_app, DEMO_ADMIN_EMAIL)
|
|
102
|
+
user = await user_row(users_app, DEMO_USER_EMAIL)
|
|
107
103
|
assert admin is not None and admin.is_superuser
|
|
108
104
|
assert user is not None and not user.is_superuser
|
|
109
105
|
assert all(row.is_active and row.is_verified for row in (admin, user))
|
|
@@ -113,8 +109,8 @@ async def test_each_account_gets_its_own_role(users_app):
|
|
|
113
109
|
users_app.state.users.settings.demo_mode = True
|
|
114
110
|
await ensure_demo_users(users_app)
|
|
115
111
|
|
|
116
|
-
admin = await
|
|
117
|
-
user = await
|
|
112
|
+
admin = await user_row(users_app, DEMO_ADMIN_EMAIL)
|
|
113
|
+
user = await user_row(users_app, DEMO_USER_EMAIL)
|
|
118
114
|
assert await _role_names(users_app, admin.id) == [ADMIN_ROLE_NAME]
|
|
119
115
|
assert await _role_names(users_app, user.id) == [USER_ROLE_NAME]
|
|
120
116
|
|
|
@@ -124,8 +120,8 @@ async def test_a_blank_password_still_produces_usable_accounts(users_app):
|
|
|
124
120
|
users_app.state.users.settings.demo_mode = True
|
|
125
121
|
await ensure_demo_users(users_app)
|
|
126
122
|
|
|
127
|
-
assert (await
|
|
128
|
-
assert (await
|
|
123
|
+
assert (await user_row(users_app, DEMO_ADMIN_EMAIL)).hashed_password
|
|
124
|
+
assert (await user_row(users_app, DEMO_USER_EMAIL)).hashed_password
|
|
129
125
|
|
|
130
126
|
|
|
131
127
|
async def test_a_generated_password_is_not_re_rolled_on_every_boot(users_app):
|
|
@@ -133,23 +129,23 @@ async def test_a_generated_password_is_not_re_rolled_on_every_boot(users_app):
|
|
|
133
129
|
worker per restart, for no gain."""
|
|
134
130
|
users_app.state.users.settings.demo_mode = True
|
|
135
131
|
await ensure_demo_users(users_app)
|
|
136
|
-
first = (await
|
|
132
|
+
first = (await user_row(users_app, DEMO_ADMIN_EMAIL)).hashed_password
|
|
137
133
|
|
|
138
134
|
await ensure_demo_users(users_app)
|
|
139
135
|
|
|
140
|
-
assert (await
|
|
136
|
+
assert (await user_row(users_app, DEMO_ADMIN_EMAIL)).hashed_password == first
|
|
141
137
|
|
|
142
138
|
|
|
143
139
|
async def test_a_configured_password_is_reapplied(users_app):
|
|
144
140
|
"""Changing a demo password in the admin UI has to take effect."""
|
|
145
141
|
users_app.state.users.settings.demo_mode = True
|
|
146
142
|
await ensure_demo_users(users_app)
|
|
147
|
-
first = (await
|
|
143
|
+
first = (await user_row(users_app, DEMO_USER_EMAIL)).hashed_password
|
|
148
144
|
|
|
149
145
|
users_app.state.users.settings.demo_user_password = "a-published-demo-password"
|
|
150
146
|
await ensure_demo_users(users_app)
|
|
151
147
|
|
|
152
|
-
assert (await
|
|
148
|
+
assert (await user_row(users_app, DEMO_USER_EMAIL)).hashed_password != first
|
|
153
149
|
|
|
154
150
|
|
|
155
151
|
async def test_demo_off_clears_the_cached_ids(users_app):
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Demo read-only mode follows identities authenticated without demo cookies."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
from api_tokens.constants import TokenKind
|
|
6
|
+
from api_tokens.service import ApiTokenService
|
|
7
|
+
from simple_module_test import forge_session_cookie
|
|
8
|
+
from users.auth_local.token_api import _create_token_pair
|
|
9
|
+
from users.demo import ensure_demo_users
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
async def demo_credentials(demo_app):
|
|
14
|
+
settings = demo_app.state.users.settings
|
|
15
|
+
settings.demo_admin_password = "DemoPass1!"
|
|
16
|
+
await ensure_demo_users(demo_app)
|
|
17
|
+
user_id = demo_app.state.users.demo_user_ids[0]
|
|
18
|
+
async with demo_app.state.sm.db.session_factory() as db:
|
|
19
|
+
pair = await _create_token_pair(db, user_id, settings)
|
|
20
|
+
_, pat = await ApiTokenService(db).create(
|
|
21
|
+
kind=TokenKind.PERSONAL,
|
|
22
|
+
name="pre-existing demo PAT",
|
|
23
|
+
scopes=["users.manage", "api_tokens.use"],
|
|
24
|
+
expires_in_days=1,
|
|
25
|
+
max_lifetime_days=30,
|
|
26
|
+
owner_user_id=user_id,
|
|
27
|
+
)
|
|
28
|
+
await db.commit()
|
|
29
|
+
return {"bearer": pair.access_token, "pat": pat, "refresh": pair.refresh_token}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.parametrize("credential", ["bearer", "pat"])
|
|
33
|
+
@pytest.mark.parametrize("read_only", [True, False])
|
|
34
|
+
async def test_demo_token_writes_follow_read_only_setting(
|
|
35
|
+
demo_app, demo_client, demo_credentials, credential, read_only
|
|
36
|
+
):
|
|
37
|
+
demo_app.state.users.settings.demo_read_only = read_only
|
|
38
|
+
headers = {"Authorization": f"Bearer {demo_credentials[credential]}"}
|
|
39
|
+
# A successful read proves this credential actually resolves the demo identity.
|
|
40
|
+
target = demo_app.state.users.demo_user_ids[1]
|
|
41
|
+
assert (await demo_client.get("/api/users/admin", headers=headers)).status_code == 200
|
|
42
|
+
response = await demo_client.patch(
|
|
43
|
+
f"/api/users/admin/{target}",
|
|
44
|
+
json={"email": demo_app.state.users.settings.demo_user_email, "full_name": "Changed"},
|
|
45
|
+
headers=headers,
|
|
46
|
+
)
|
|
47
|
+
assert response.status_code == (403 if read_only else 200), response.text
|
|
48
|
+
if read_only:
|
|
49
|
+
assert response.json() == {"detail": "DEMO_READ_ONLY"}
|
|
50
|
+
else:
|
|
51
|
+
assert response.json()["full_name"] == "Changed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize("credential", ["bearer", "pat"])
|
|
55
|
+
async def test_demo_token_inertia_write_uses_existing_refusal(
|
|
56
|
+
demo_app, demo_client, demo_credentials, credential
|
|
57
|
+
):
|
|
58
|
+
response = await demo_client.patch(
|
|
59
|
+
f"/api/users/admin/{demo_app.state.users.demo_user_ids[1]}",
|
|
60
|
+
json={"email": demo_app.state.users.settings.demo_user_email, "full_name": "Changed"},
|
|
61
|
+
headers={
|
|
62
|
+
"Authorization": f"Bearer {demo_credentials[credential]}",
|
|
63
|
+
"X-Inertia": "true",
|
|
64
|
+
"Referer": "http://testserver/admin/users/",
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
assert response.status_code == 409
|
|
68
|
+
assert response.headers["X-Inertia-Location"] == "http://testserver/admin/users/"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.parametrize("operation", ["login", "refresh"])
|
|
72
|
+
async def test_demo_cannot_mint_token_pair_without_session(
|
|
73
|
+
demo_app, demo_client, demo_credentials, operation
|
|
74
|
+
):
|
|
75
|
+
if operation == "login":
|
|
76
|
+
path = "/api/users/auth/token"
|
|
77
|
+
body = {"email": demo_app.state.users.settings.demo_admin_email, "password": "DemoPass1!"}
|
|
78
|
+
else:
|
|
79
|
+
path = "/api/users/auth/token/refresh"
|
|
80
|
+
body = {"refresh_token": demo_credentials["refresh"]}
|
|
81
|
+
response = await demo_client.post(path, json=body)
|
|
82
|
+
assert response.status_code == 403, response.text
|
|
83
|
+
assert response.json() == {"detail": "DEMO_READ_ONLY"}
|
|
84
|
+
# Refusal must not consume the refresh token or leave a login lockout slot.
|
|
85
|
+
demo_app.state.users.settings.demo_read_only = False
|
|
86
|
+
assert (await demo_client.post(path, json=body)).status_code == 200
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def test_demo_session_cannot_create_pat(demo_app):
|
|
90
|
+
cookie = forge_session_cookie(
|
|
91
|
+
str(demo_app.state.sm.settings.secret_key),
|
|
92
|
+
{"user_id": str(demo_app.state.users.demo_user_ids[0]), "sm_csrf_token": "csrf"},
|
|
93
|
+
)
|
|
94
|
+
async with httpx.AsyncClient(
|
|
95
|
+
transport=httpx.ASGITransport(app=demo_app),
|
|
96
|
+
base_url="http://testserver",
|
|
97
|
+
cookies={"session": cookie},
|
|
98
|
+
) as client:
|
|
99
|
+
response = await client.post(
|
|
100
|
+
"/api/api_tokens/tokens/",
|
|
101
|
+
json={"name": "demo", "scopes": ["users.manage"], "expires_in_days": 1},
|
|
102
|
+
headers={"X-CSRF-Token": "csrf"},
|
|
103
|
+
)
|
|
104
|
+
assert response.status_code == 403
|
|
105
|
+
assert response.json() == {"detail": "DEMO_READ_ONLY"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def test_real_bearer_account_can_still_write_on_demo_instance(demo_app, demo_client):
|
|
109
|
+
response = await demo_client.post(
|
|
110
|
+
"/api/users/auth/token", json={"email": "admin@example.com", "password": "AdminPass1!"}
|
|
111
|
+
)
|
|
112
|
+
assert response.status_code == 200
|
|
113
|
+
response = await demo_client.patch(
|
|
114
|
+
f"/api/users/admin/{demo_app.state.users.demo_user_ids[1]}",
|
|
115
|
+
json={
|
|
116
|
+
"email": demo_app.state.users.settings.demo_user_email,
|
|
117
|
+
"full_name": "Real admin write",
|
|
118
|
+
},
|
|
119
|
+
headers={"Authorization": f"Bearer {response.json()['access_token']}"},
|
|
120
|
+
)
|
|
121
|
+
assert response.status_code == 200
|
|
122
|
+
assert response.json()["full_name"] == "Real admin write"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def test_demo_token_login_still_checks_password(demo_app, demo_client, demo_credentials):
|
|
126
|
+
response = await demo_client.post(
|
|
127
|
+
"/api/users/auth/token",
|
|
128
|
+
json={"email": demo_app.state.users.settings.demo_admin_email, "password": "wrong"},
|
|
129
|
+
)
|
|
130
|
+
assert response.status_code == 401
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.parametrize("credential", ["bearer", "pat"])
|
|
134
|
+
async def test_demo_token_can_still_logout(demo_client, demo_credentials, credential):
|
|
135
|
+
response = await demo_client.post(
|
|
136
|
+
"/api/users/auth/logout",
|
|
137
|
+
headers={"Authorization": f"Bearer {demo_credentials[credential]}"},
|
|
138
|
+
)
|
|
139
|
+
assert response.status_code == 204
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import httpx
|
|
10
10
|
from users.constants import ADMIN_ROLE_NAME, SESSION_USER_ID_KEY, USER_ROLE_NAME
|
|
11
11
|
from users.demo import SESSION_DEMO_KEY
|
|
12
|
-
from users.
|
|
12
|
+
from users.demo.guard import DEMO_READ_ONLY_DETAIL
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def _demo_route(role: str) -> str:
|
|
@@ -10,6 +10,7 @@ message nobody received.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import logging
|
|
13
|
+
import re
|
|
13
14
|
from datetime import UTC, datetime, timedelta
|
|
14
15
|
|
|
15
16
|
import pytest
|
|
@@ -174,14 +175,15 @@ class TestResendInvite:
|
|
|
174
175
|
|
|
175
176
|
class TestInviteTokenClaims:
|
|
176
177
|
@pytest.mark.anyio
|
|
177
|
-
async def test_invite_token_names_the_inviter(self, admin_client, users_app,
|
|
178
|
+
async def test_invite_token_names_the_inviter(self, admin_client, users_app, captured_mail):
|
|
178
179
|
"""The accept-invite card says who invited you; the claim carries it."""
|
|
179
180
|
import jwt
|
|
180
181
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
await _invite(admin_client, "whosent@example.com")
|
|
183
|
+
text = next(
|
|
184
|
+
m.text for m in captured_mail.to("whosent@example.com") if "invited you" in m.subject
|
|
185
|
+
)
|
|
186
|
+
token = re.search(r"token=(\S+)", text).group(1)
|
|
185
187
|
|
|
186
188
|
payload = jwt.decode(
|
|
187
189
|
token,
|
|
@@ -200,7 +202,7 @@ class TestMailerFailureIsNotEchoed:
|
|
|
200
202
|
async def test_the_admin_gets_the_link_and_a_generic_reason(
|
|
201
203
|
self, admin_client, users_app, caplog
|
|
202
204
|
):
|
|
203
|
-
from users.
|
|
205
|
+
from users.invites.bulk import MAILER_FAILURE_DETAIL
|
|
204
206
|
|
|
205
207
|
class _BrokenMailer(_SpyMailer):
|
|
206
208
|
async def send_invite(self, email, token, invited_by_name, message=None):
|
|
@@ -210,7 +212,7 @@ class TestMailerFailureIsNotEchoed:
|
|
|
210
212
|
original = users_app.state.users.mailer
|
|
211
213
|
users_app.state.users.mailer = _BrokenMailer()
|
|
212
214
|
try:
|
|
213
|
-
with caplog.at_level(logging.ERROR, logger="users.
|
|
215
|
+
with caplog.at_level(logging.ERROR, logger="users.invites.resend"):
|
|
214
216
|
resp = await admin_client.post(f"/api/users/admin/{user_id}/resend-invite")
|
|
215
217
|
finally:
|
|
216
218
|
users_app.state.users.mailer = original
|
|
@@ -139,7 +139,7 @@ class TestBulkInvite:
|
|
|
139
139
|
|
|
140
140
|
async def test_address_count_is_capped(self, authenticated_client: httpx.AsyncClient):
|
|
141
141
|
"""One submit must not be able to mint unbounded live invite tokens."""
|
|
142
|
-
from users.
|
|
142
|
+
from users.invites.bulk import MAX_ADDRESSES
|
|
143
143
|
|
|
144
144
|
emails = [f"bulk{i}@example.com" for i in range(MAX_ADDRESSES + 5)]
|
|
145
145
|
resp = await authenticated_client.post(_URL, json={"emails": emails, "role_names": []})
|
|
@@ -151,7 +151,7 @@ class TestBulkInvite:
|
|
|
151
151
|
):
|
|
152
152
|
"""Silent truncation reports "100 invites sent" while 5 people are
|
|
153
153
|
never contacted, with nothing on screen saying so."""
|
|
154
|
-
from users.
|
|
154
|
+
from users.invites.bulk import MAX_ADDRESSES
|
|
155
155
|
|
|
156
156
|
emails = [f"over{i}@example.com" for i in range(MAX_ADDRESSES + 5)]
|
|
157
157
|
resp = await authenticated_client.post(_URL, json={"emails": emails, "role_names": []})
|
|
@@ -8,8 +8,6 @@ rather than stopping at the API boundary.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
import logging
|
|
12
|
-
|
|
13
11
|
import pytest
|
|
14
12
|
|
|
15
13
|
_URL = "/api/users/admin/invite/bulk"
|
|
@@ -111,11 +109,15 @@ class TestBulkInviteMessage:
|
|
|
111
109
|
assert row["state"] == "invited"
|
|
112
110
|
|
|
113
111
|
|
|
114
|
-
class
|
|
112
|
+
class TestTemplatedMailerMessage:
|
|
113
|
+
@pytest.mark.anyio
|
|
114
|
+
async def test_mailer_quotes_the_message(self, users_app, captured_mail):
|
|
115
|
+
mailer = users_app.state.users.mailer
|
|
116
|
+
await mailer.send_invite("x@example.com", "tok", "Alex Doyle", "See you Monday.")
|
|
117
|
+
assert "See you Monday." in captured_mail.to("x@example.com")[0].text
|
|
118
|
+
|
|
115
119
|
@pytest.mark.anyio
|
|
116
|
-
async def
|
|
120
|
+
async def test_blank_message_leaves_no_quote(self, users_app, captured_mail):
|
|
117
121
|
mailer = users_app.state.users.mailer
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
record = next(r for r in caplog.records if r.getMessage() == "users.invite.email")
|
|
121
|
-
assert record.message_body == "See you Monday."
|
|
122
|
+
await mailer.send_invite("x@example.com", "tok", "Alex Doyle", " \n ")
|
|
123
|
+
assert "See you" not in captured_mail.to("x@example.com")[0].text
|