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