simple-module-users 0.0.35__tar.gz → 0.0.36__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/PKG-INFO +6 -6
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/pyproject.toml +6 -6
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/_middleware_support.py +0 -2
- simple_module_users-0.0.36/tests/test_legacy_cached_tenant.py +54 -0
- simple_module_users-0.0.36/tests/test_login_redirect_root_mount.py +39 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_user_model.py +0 -1
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_middleware.py +0 -1
- simple_module_users-0.0.36/tests/test_users_setup_lock.py +63 -0
- simple_module_users-0.0.36/tests/test_users_setup_wizard.py +187 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_shared_props.py +15 -0
- simple_module_users-0.0.36/tests-js/LoginForm.test.tsx +31 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/queries.py +9 -9
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/api.py +1 -1
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/LoginForm.tsx +5 -1
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/bootstrap.py +22 -12
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/contracts/schemas.py +0 -1
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/deps.py +2 -2
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/locales/en.json +5 -1
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/role.py +16 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/user.py +0 -1
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/AcceptInvite.tsx +3 -3
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/ResetPassword.tsx +3 -2
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/AddPeople.tsx +103 -96
- simple_module_users-0.0.36/users/pages/Users/Edit.tsx +129 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/Index.tsx +69 -66
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/provider.py +3 -0
- simple_module_users-0.0.36/users/setup.py +101 -0
- simple_module_users-0.0.36/users/setup_action.py +129 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/shared_props.py +8 -2
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/startup.py +2 -6
- simple_module_users-0.0.35/users/pages/Users/Edit.tsx +0 -126
- simple_module_users-0.0.35/users/setup.py +0 -51
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/.gitignore +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/LICENSE +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/README.md +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/package.json +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/_demo_support.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/_users_app_builders.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/conftest.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_activity_summary.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_admin_resend_invite.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_auth_screens_props.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_bearer_token_age.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_bearer_token_expiry.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_bulk_invite_message.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_demo_account.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_demo_guard.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_demo_login.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_edit_recent_activity.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_external_users.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_legacy_session_expiry.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_profile_password.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_remember_me.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_revocation_broadcast.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_session_expiry.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_session_revocation.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_session_version_cache.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_session_version_failopen.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_session_version_hardening.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_sessions_revoke.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_user_state.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_bulk_invite.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_list_invites.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_login_deep_link.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_views.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests-js/EmailChipInput.test.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests-js/deriveState.test.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tests-js/invite-emails.test.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/tsconfig.json +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/api.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/bulk_invite.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/StatusPill.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/UserCards.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/UserRowMenu.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/UsersEmpty.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/UsersTable.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/useUserRowActions.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/components/user-list-item.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/recent_activity.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/resend_invite.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/service.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/user_state.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/admin/views.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/audit.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/AuthField.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/AuthIntro.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/AuthStateCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/DemoSignIn.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/InviteSummary.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/PasswordCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/PasswordFields.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/PreferencesCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/ResendVerification.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/ResetLinkSent.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/SessionsCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/WaitingOnYou.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/profile-user.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/demo_api.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/invite_preview.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/self_account.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/token_api.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/token_preview.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/views.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/backend.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/cli.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/constants.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/demo.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/demo_guard.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/exceptions.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/health.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/manager.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/middleware.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/_base.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/module.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/oauth/api.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Login.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/AccountCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/CreateUserFields.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/DangerZone.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/DetailsCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/EmailChipInput.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/InviteFields.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/InviteResults.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/RolePicker.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/UserStats.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/useEditUserForm.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/components/useUserActions.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/Users/invite-emails.ts +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/py.typed +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/session_revocation.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/session_version_cache.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/settings.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/state.py +0 -0
- {simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/token_strategy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_users
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.36
|
|
4
4
|
Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -24,11 +24,11 @@ Requires-Python: >=3.12
|
|
|
24
24
|
Requires-Dist: aiosmtplib>=3.0
|
|
25
25
|
Requires-Dist: cachetools>=5.3
|
|
26
26
|
Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
|
|
27
|
-
Requires-Dist: simple-module-auth==0.0.
|
|
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-settings==0.0.
|
|
27
|
+
Requires-Dist: simple-module-auth==0.0.36
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.36
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.36
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.36
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.36
|
|
32
32
|
Requires-Dist: typer>=0.12
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_users"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.36"
|
|
4
4
|
description = "Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,11 +21,11 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
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.36",
|
|
25
|
+
"simple_module_db==0.0.36",
|
|
26
|
+
"simple_module_hosting==0.0.36",
|
|
27
|
+
"simple_module_settings==0.0.36",
|
|
28
|
+
"simple_module_auth==0.0.36",
|
|
29
29
|
# Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
|
|
30
30
|
# fields after construction (see reconfigure_cookie_transport in backend.py).
|
|
31
31
|
# Bumping the major version requires re-checking those field names.
|
|
@@ -49,7 +49,6 @@ async def _build_app(db_state, inner_handler=None, *, principal_resolvers=None):
|
|
|
49
49
|
"email": user.email,
|
|
50
50
|
"name": user.name,
|
|
51
51
|
"roles": user.roles,
|
|
52
|
-
"tenant_id": user.tenant_id,
|
|
53
52
|
}
|
|
54
53
|
if user is not None
|
|
55
54
|
else None
|
|
@@ -106,7 +105,6 @@ async def mw_active_user(db_session, _mw_seed_roles):
|
|
|
106
105
|
is_superuser=False,
|
|
107
106
|
is_verified=True,
|
|
108
107
|
full_name="Middleware Tester",
|
|
109
|
-
tenant_id="acme",
|
|
110
108
|
)
|
|
111
109
|
link = UserRole(user_id=user_id, role_id=ADMIN_ROLE_ID)
|
|
112
110
|
db_session.add_all([user, link])
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""A pre-#381 session cookie's cached ``tenant_id`` is ignored.
|
|
2
|
+
|
|
3
|
+
``users_user.tenant_id`` is gone and the active tenant is resolved per request
|
|
4
|
+
from memberships, but cookies signed before the upgrade still carry the old
|
|
5
|
+
value inside ``user_ctx``. Honouring it would keep acting for a tenant the
|
|
6
|
+
user may have left.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from auth.contracts.schemas import UserContext
|
|
15
|
+
from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY
|
|
16
|
+
from sqlalchemy import select
|
|
17
|
+
from starlette.requests import Request
|
|
18
|
+
from users.models import User
|
|
19
|
+
from users.provider import UsersAuthProvider
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _admin_id(users_app) -> str:
|
|
23
|
+
async with users_app.state.sm.db.session_factory() as session:
|
|
24
|
+
stmt = select(User.id).where(User.email == "admin@example.com")
|
|
25
|
+
return str((await session.execute(stmt)).scalar_one())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.anyio
|
|
29
|
+
async def test_old_cookie_tenant_id_is_dropped_on_the_cached_path(users_app):
|
|
30
|
+
user_id = await _admin_id(users_app)
|
|
31
|
+
legacy_ctx = UserContext(
|
|
32
|
+
id=user_id, email="admin@example.com", name="Admin", roles=["admin"], tenant_id="acme"
|
|
33
|
+
).to_session_dict()
|
|
34
|
+
session = {
|
|
35
|
+
"user_id": user_id,
|
|
36
|
+
"user_ctx": legacy_ctx,
|
|
37
|
+
"session_version": 0,
|
|
38
|
+
SESSION_EXPIRES_AT_KEY: int(time.time()) + 3600,
|
|
39
|
+
}
|
|
40
|
+
scope = {
|
|
41
|
+
"type": "http",
|
|
42
|
+
"method": "GET",
|
|
43
|
+
"path": "/",
|
|
44
|
+
"headers": [],
|
|
45
|
+
"app": users_app,
|
|
46
|
+
"session": session,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ctx = await UsersAuthProvider().resolve_user(Request(scope))
|
|
50
|
+
|
|
51
|
+
assert ctx is not None
|
|
52
|
+
assert ctx.id == user_id
|
|
53
|
+
assert ctx.name == "Admin" # the cached context was used, not a DB reload
|
|
54
|
+
assert ctx.tenant_id is None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Login fallback with a module mounted at the root (#435)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _fallback_url(app, prefixes: dict[str, str]) -> str:
|
|
9
|
+
"""Run the login fallback against a Dashboard-less module set (#435)."""
|
|
10
|
+
from users.startup import apply_login_redirect_fallback
|
|
11
|
+
|
|
12
|
+
sm = app.state.sm
|
|
13
|
+
saved_mods, saved_prefixes = sm.modules, dict(sm.view_prefixes)
|
|
14
|
+
settings = app.state.users.settings
|
|
15
|
+
saved_url = settings.login_redirect_url
|
|
16
|
+
object.__setattr__(sm, "modules", tuple(m for m in saved_mods if m.meta.name != "Dashboard"))
|
|
17
|
+
sm.view_prefixes.clear()
|
|
18
|
+
sm.view_prefixes.update(prefixes)
|
|
19
|
+
settings.login_redirect_url = "/dashboard/"
|
|
20
|
+
try:
|
|
21
|
+
apply_login_redirect_fallback(app, settings, "Users", "/dashboard/")
|
|
22
|
+
return settings.login_redirect_url
|
|
23
|
+
finally:
|
|
24
|
+
object.__setattr__(sm, "modules", saved_mods)
|
|
25
|
+
sm.view_prefixes.clear()
|
|
26
|
+
sm.view_prefixes.update(saved_prefixes)
|
|
27
|
+
settings.login_redirect_url = saved_url
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.anyio
|
|
31
|
+
async def test_login_redirect_fallback_prefers_root_mounted_module(users_app):
|
|
32
|
+
"""#435: a module that serves views at ``/`` (view_prefix == "") wins."""
|
|
33
|
+
assert _fallback_url(users_app, {"Users": "/users", "Home": "", "Other": "/o"}) == "/"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.anyio
|
|
37
|
+
async def test_login_redirect_fallback_skips_api_only_module(users_app):
|
|
38
|
+
"""An API-only module also has view_prefix == "" but is not in the map."""
|
|
39
|
+
assert _fallback_url(users_app, {"Users": "/users", "Other": "/o"}) == "/o/"
|
|
@@ -81,7 +81,6 @@ async def test_authenticated_request_sets_user_context(db_state, mw_active_user)
|
|
|
81
81
|
assert data["user"]["email"] == "middleware-test@example.com"
|
|
82
82
|
assert data["user"]["name"] == "Middleware Tester"
|
|
83
83
|
assert data["user"]["roles"] == ["admin"]
|
|
84
|
-
assert data["user"]["tenant_id"] == "acme"
|
|
85
84
|
|
|
86
85
|
|
|
87
86
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The database lock behind the first-admin wizard action, across connections.
|
|
2
|
+
|
|
3
|
+
``test_users_setup_wizard`` drives concurrency through one app, where the
|
|
4
|
+
in-process lock already serializes requests (and an in-memory SQLite engine
|
|
5
|
+
shares one connection anyway). Two workers share neither, so the database lock
|
|
6
|
+
is the real guarantee — exercised here on two real connections.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
import sqlalchemy as sa
|
|
15
|
+
from simple_module_db import init_db
|
|
16
|
+
from sqlalchemy.exc import OperationalError
|
|
17
|
+
from users.models import User
|
|
18
|
+
from users.setup_action import _lock_admin_creation
|
|
19
|
+
|
|
20
|
+
pytestmark = pytest.mark.anyio
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def test_sqlite_lock_holds_off_a_second_writer(tmp_path) -> None:
|
|
24
|
+
state = init_db(f"sqlite+aiosqlite:///{tmp_path / 'lock.db'}", sqlite_busy_timeout_ms=100)
|
|
25
|
+
try:
|
|
26
|
+
async with state.engine.begin() as conn:
|
|
27
|
+
await conn.run_sync(lambda sync: User.__table__.create(sync))
|
|
28
|
+
|
|
29
|
+
async with state.session_factory() as first, state.session_factory() as second:
|
|
30
|
+
await _lock_admin_creation(first)
|
|
31
|
+
|
|
32
|
+
with pytest.raises(OperationalError, match="locked"):
|
|
33
|
+
await _lock_admin_creation(second)
|
|
34
|
+
await second.rollback()
|
|
35
|
+
|
|
36
|
+
# Released at commit: the next worker gets through and re-checks.
|
|
37
|
+
await first.commit()
|
|
38
|
+
await _lock_admin_creation(second)
|
|
39
|
+
await second.rollback()
|
|
40
|
+
finally:
|
|
41
|
+
await state.engine.dispose()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.skipif(
|
|
45
|
+
not os.environ.get("SM_TEST_DATABASE_URL", "").startswith("postgresql"),
|
|
46
|
+
reason="needs SM_TEST_DATABASE_URL pointing at Postgres",
|
|
47
|
+
)
|
|
48
|
+
async def test_postgres_advisory_lock_is_held_for_the_transaction() -> None:
|
|
49
|
+
state = init_db(os.environ["SM_TEST_DATABASE_URL"])
|
|
50
|
+
try:
|
|
51
|
+
async with state.session_factory() as first, state.session_factory() as second:
|
|
52
|
+
await _lock_admin_creation(first)
|
|
53
|
+
probe = sa.text("SELECT pg_try_advisory_xact_lock(:key)")
|
|
54
|
+
from users.setup_action import _PG_LOCK_KEY
|
|
55
|
+
|
|
56
|
+
assert await second.scalar(probe, {"key": _PG_LOCK_KEY}) is False
|
|
57
|
+
await second.rollback()
|
|
58
|
+
|
|
59
|
+
await first.commit()
|
|
60
|
+
assert await second.scalar(probe, {"key": _PG_LOCK_KEY}) is True
|
|
61
|
+
await second.rollback()
|
|
62
|
+
finally:
|
|
63
|
+
await state.engine.dispose()
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""The ``users.administrator`` wizard action: anonymous creation of the first admin.
|
|
2
|
+
|
|
3
|
+
What is pinned here, in the order the action's docstring argues it:
|
|
4
|
+
|
|
5
|
+
* it completes the step and releases the gate;
|
|
6
|
+
* it is gated on *its own step*, so an install that re-enters setup mode with
|
|
7
|
+
administrators intact (schema behind head) refuses it;
|
|
8
|
+
* it re-checks under a lock inside the inserting transaction, so concurrent
|
|
9
|
+
requests mint exactly one superuser;
|
|
10
|
+
* the password goes through the module's real policy.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from types import SimpleNamespace
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
from fastapi import HTTPException
|
|
20
|
+
from simple_module_test.setup_wizard import post_step, wizard_client, wizard_headers
|
|
21
|
+
from sqlalchemy import func, select
|
|
22
|
+
from users.models import User
|
|
23
|
+
from users.setup import STEP_ADMINISTRATOR
|
|
24
|
+
from users.setup_action import create_first_administrator
|
|
25
|
+
|
|
26
|
+
pytestmark = pytest.mark.anyio
|
|
27
|
+
|
|
28
|
+
_BEHIND = {
|
|
29
|
+
"current_revision": "abc123",
|
|
30
|
+
"head_revision": "def456",
|
|
31
|
+
"is_current": False,
|
|
32
|
+
"pending_count": 1,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _superusers(app) -> list[str]:
|
|
37
|
+
async with app.state.sm.db.session_factory() as session:
|
|
38
|
+
rows = await session.scalars(
|
|
39
|
+
select(User.email).where(User.is_superuser.is_(True), User.is_active.is_(True))
|
|
40
|
+
)
|
|
41
|
+
return list(rows)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _schema_behind(app, monkeypatch) -> None:
|
|
45
|
+
"""Put the install back into setup mode the way a deploy-before-migrate does."""
|
|
46
|
+
app.state.migration = dict(_BEHIND)
|
|
47
|
+
|
|
48
|
+
# The gate re-reads a behind-head verdict from the database rather than
|
|
49
|
+
# trusting the boot snapshot, so the stub has to keep saying "behind".
|
|
50
|
+
async def _still_behind(*_args, **_kwargs):
|
|
51
|
+
return dict(_BEHIND)
|
|
52
|
+
|
|
53
|
+
monkeypatch.setattr(
|
|
54
|
+
"simple_module_hosting.migrations.migration_status", _still_behind, raising=True
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_creating_an_admin_completes_setup(setup_pending_app) -> None:
|
|
59
|
+
async with wizard_client(setup_pending_app) as client:
|
|
60
|
+
resp = await post_step(
|
|
61
|
+
client,
|
|
62
|
+
STEP_ADMINISTRATOR,
|
|
63
|
+
{"email": "root@example.com", "password": "SetupPass1!", "full_name": None},
|
|
64
|
+
)
|
|
65
|
+
assert resp.status_code == 200, resp.text
|
|
66
|
+
assert resp.json()["result"] == {"created": True, "email": "root@example.com"}
|
|
67
|
+
|
|
68
|
+
# The gate must now release for ordinary routes, and the wizard close.
|
|
69
|
+
after = await client.get("/", follow_redirects=False)
|
|
70
|
+
wizard = await client.get("/setup")
|
|
71
|
+
|
|
72
|
+
assert after.status_code != 302
|
|
73
|
+
assert wizard.status_code == 404
|
|
74
|
+
assert await _superusers(setup_pending_app) == ["root@example.com"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def test_refused_when_setup_mode_reopens_with_admins_intact(app, monkeypatch) -> None:
|
|
78
|
+
"""A behind-head schema reopens the wizard, not admin creation.
|
|
79
|
+
|
|
80
|
+
``create_app`` registers ``host.migrations`` for every install, so a live
|
|
81
|
+
deployment that ships code ahead of its migration job re-enters setup mode
|
|
82
|
+
with its administrators intact. Gated on "setup mode" this action would
|
|
83
|
+
hand an anonymous request a fresh superuser there.
|
|
84
|
+
"""
|
|
85
|
+
_schema_behind(app, monkeypatch)
|
|
86
|
+
|
|
87
|
+
async with wizard_client(app) as client:
|
|
88
|
+
resp = await post_step(
|
|
89
|
+
client, STEP_ADMINISTRATOR, {"email": "intruder@example.com", "password": "Whatever1!"}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
assert resp.status_code == 409
|
|
93
|
+
assert "intruder@example.com" not in await _superusers(app)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def test_handler_rechecks_inside_its_transaction(app) -> None:
|
|
97
|
+
"""The race loser: it passed the wizard's step check, then someone else
|
|
98
|
+
committed an admin. The in-transaction re-check must refuse it."""
|
|
99
|
+
request = SimpleNamespace(app=app)
|
|
100
|
+
|
|
101
|
+
with pytest.raises(HTTPException) as exc:
|
|
102
|
+
await create_first_administrator(
|
|
103
|
+
request, {"email": "late@example.com", "password": "LatePass1!"}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
assert exc.value.status_code == 409
|
|
107
|
+
assert "late@example.com" not in await _superusers(app)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def test_concurrent_submissions_create_one_admin(setup_pending_app) -> None:
|
|
111
|
+
clients = [wizard_client(setup_pending_app) for _ in range(4)]
|
|
112
|
+
try:
|
|
113
|
+
# Every client loads the wizard first, as concurrent operators would —
|
|
114
|
+
# once the winner commits, the wizard is gone for anyone arriving later.
|
|
115
|
+
headers = [await wizard_headers(c) for c in clients]
|
|
116
|
+
responses = await asyncio.gather(
|
|
117
|
+
*(
|
|
118
|
+
c.post(
|
|
119
|
+
f"/setup/steps/{STEP_ADMINISTRATOR}",
|
|
120
|
+
json={"email": f"root{n}@example.com", "password": "RacePass1!"},
|
|
121
|
+
headers=h,
|
|
122
|
+
)
|
|
123
|
+
for n, (c, h) in enumerate(zip(clients, headers, strict=True))
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
finally:
|
|
127
|
+
for c in clients:
|
|
128
|
+
await c.aclose()
|
|
129
|
+
|
|
130
|
+
codes = sorted(r.status_code for r in responses)
|
|
131
|
+
assert codes.count(200) == 1, codes
|
|
132
|
+
# Losers are refused by the step gate or the in-transaction re-check (409),
|
|
133
|
+
# or — once the winner has closed the wizard entirely — by its absence (404).
|
|
134
|
+
assert all(code in (200, 404, 409) for code in codes), codes
|
|
135
|
+
async with setup_pending_app.state.sm.db.session_factory() as session:
|
|
136
|
+
count = await session.scalar(
|
|
137
|
+
select(func.count()).select_from(User).where(User.is_superuser.is_(True))
|
|
138
|
+
)
|
|
139
|
+
assert count == 1
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def test_existing_non_admin_address_is_refused(setup_pending_app) -> None:
|
|
143
|
+
"""create_admin leaves an existing account untouched; reporting success for
|
|
144
|
+
a step that is still pending would leave the operator stuck."""
|
|
145
|
+
from users.bootstrap import create_standard_user
|
|
146
|
+
|
|
147
|
+
async with setup_pending_app.state.sm.db.session_factory() as session:
|
|
148
|
+
await create_standard_user(session, email="user@example.com", password="UserPass1!")
|
|
149
|
+
|
|
150
|
+
async with wizard_client(setup_pending_app) as client:
|
|
151
|
+
resp = await post_step(
|
|
152
|
+
client, STEP_ADMINISTRATOR, {"email": "user@example.com", "password": "Another1!"}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
assert resp.status_code == 409
|
|
156
|
+
assert await _superusers(setup_pending_app) == []
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@pytest.mark.parametrize(
|
|
160
|
+
"password,why",
|
|
161
|
+
[
|
|
162
|
+
(" ", "whitespace-only, exactly eight characters"),
|
|
163
|
+
(" a ", "one real character padded to eight"),
|
|
164
|
+
("short", "under the minimum"),
|
|
165
|
+
("", "empty"),
|
|
166
|
+
("12345678", "all digits — the policy rejects these"),
|
|
167
|
+
],
|
|
168
|
+
)
|
|
169
|
+
async def test_weak_passwords_are_refused(setup_pending_app, password: str, why: str) -> None:
|
|
170
|
+
"""``create_admin`` writes the hash directly, bypassing ``UserManager``."""
|
|
171
|
+
async with wizard_client(setup_pending_app) as client:
|
|
172
|
+
resp = await post_step(
|
|
173
|
+
client, STEP_ADMINISTRATOR, {"email": "root@example.com", "password": password}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
assert resp.status_code == 422, f"accepted a password that is {why}: {resp.text[:120]}"
|
|
177
|
+
# The wizard renders this straight into its error line.
|
|
178
|
+
detail = resp.json().get("detail")
|
|
179
|
+
assert isinstance(detail, str) and detail, f"unusable error body: {resp.text[:200]}"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def test_malformed_payload_is_422(setup_pending_app) -> None:
|
|
183
|
+
async with wizard_client(setup_pending_app) as client:
|
|
184
|
+
resp = await post_step(client, STEP_ADMINISTRATOR, {"email": "not-an-address"})
|
|
185
|
+
|
|
186
|
+
assert resp.status_code == 422
|
|
187
|
+
assert isinstance(resp.json()["detail"], str)
|
|
@@ -109,3 +109,18 @@ class TestProviderIsRegistered:
|
|
|
109
109
|
settings module, and a stub would pass while the wiring was broken."""
|
|
110
110
|
providers = getattr(app.state, "inertia_shared_providers", [])
|
|
111
111
|
assert users_shared_props in providers
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestHomeUrlSharedProp:
|
|
115
|
+
def test_reflects_login_redirect_url(self) -> None:
|
|
116
|
+
request = _request(SimpleNamespace(settings=SimpleNamespace(login_redirect_url="/home/")))
|
|
117
|
+
assert users_shared_props(request)["homeUrl"] == "/home/"
|
|
118
|
+
|
|
119
|
+
def test_tracks_runtime_edits(self) -> None:
|
|
120
|
+
settings = SimpleNamespace(login_redirect_url="/a/")
|
|
121
|
+
request = _request(SimpleNamespace(settings=settings))
|
|
122
|
+
settings.login_redirect_url = "/b/"
|
|
123
|
+
assert users_shared_props(request)["homeUrl"] == "/b/"
|
|
124
|
+
|
|
125
|
+
def test_omitted_when_settings_are_missing(self) -> None:
|
|
126
|
+
assert "homeUrl" not in users_shared_props(_request(SimpleNamespace()))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { LoginForm } from '../users/auth_local/components/LoginForm';
|
|
6
|
+
|
|
7
|
+
const baseProps = {
|
|
8
|
+
email: '',
|
|
9
|
+
onEmailChange: vi.fn(),
|
|
10
|
+
password: '',
|
|
11
|
+
onPasswordChange: vi.fn(),
|
|
12
|
+
remember: false,
|
|
13
|
+
onRememberChange: vi.fn(),
|
|
14
|
+
rememberDays: 30,
|
|
15
|
+
loading: false,
|
|
16
|
+
onSubmit: vi.fn(),
|
|
17
|
+
allowSignup: false,
|
|
18
|
+
oauthProviders: [],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('LoginForm', () => {
|
|
22
|
+
test('announces a sign-in error as an alert', () => {
|
|
23
|
+
render(<LoginForm {...baseProps} error="Invalid credentials" />);
|
|
24
|
+
expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('renders no alert without an error', () => {
|
|
28
|
+
render(<LoginForm {...baseProps} error={null} />);
|
|
29
|
+
expect(screen.queryByRole('alert')).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -226,16 +226,16 @@ class _UserServiceBase:
|
|
|
226
226
|
deck labels "Pending invites" means outstanding *invitations*: a
|
|
227
227
|
self-registered account that never clicked its verification mail is
|
|
228
228
|
nobody's to chase.
|
|
229
|
+
|
|
230
|
+
One scan with ``COUNT(*) FILTER (WHERE …)`` per state rather than a
|
|
231
|
+
round-trip each (Postgres and SQLite >= 3.30 both support it).
|
|
229
232
|
"""
|
|
230
|
-
|
|
231
|
-
for
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
)
|
|
236
|
-
stmt = select(func.count()).select_from(User).where(condition())
|
|
237
|
-
counts[key] = int((await self._db.execute(stmt)).scalar_one())
|
|
238
|
-
return counts
|
|
233
|
+
keys = ("active", "unverified", "invited")
|
|
234
|
+
stmt = select(*(func.count().filter(STATUS_CONDITIONS[k]()) for k in keys)).select_from(
|
|
235
|
+
User
|
|
236
|
+
)
|
|
237
|
+
row = (await self._db.execute(stmt)).one()
|
|
238
|
+
return {key: int(value) for key, value in zip(keys, row, strict=True)}
|
|
239
239
|
|
|
240
240
|
async def get_with_roles(self, user_id: uuid.UUID) -> User | None:
|
|
241
241
|
return await self._get_user_with_roles(user_id)
|
|
@@ -53,7 +53,7 @@ __all__ = ["enforce_auth_throughput_limit", "router"]
|
|
|
53
53
|
# ── Rate limit ───────────────────────────────────────────────────────────────
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def get_rate_limiter(request: Request) -> LoginRateLimiter:
|
|
56
|
+
async def get_rate_limiter(request: Request) -> LoginRateLimiter:
|
|
57
57
|
"""Return the per-app LoginRateLimiter built in UsersModule.on_startup."""
|
|
58
58
|
return request.app.state.users.rate_limiter
|
|
59
59
|
|
{simple_module_users-0.0.35 → simple_module_users-0.0.36}/users/auth_local/components/LoginForm.tsx
RENAMED
|
@@ -105,7 +105,11 @@ export function LoginForm({
|
|
|
105
105
|
</Label>
|
|
106
106
|
</div>
|
|
107
107
|
|
|
108
|
-
{error &&
|
|
108
|
+
{error && (
|
|
109
|
+
<p role="alert" className="text-sm text-destructive">
|
|
110
|
+
{error}
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
109
113
|
|
|
110
114
|
<Button type="submit" size="lg" className="w-full" disabled={loading}>
|
|
111
115
|
{loading ? t(keys.users.login.submitting) : t(keys.users.login.submit)}
|
|
@@ -134,6 +134,27 @@ async def create_admin(
|
|
|
134
134
|
return CreateAdminResult(user=existing, created=False)
|
|
135
135
|
|
|
136
136
|
|
|
137
|
+
async def ensure_user_role(db: AsyncSession) -> Role:
|
|
138
|
+
"""The standard ``user`` Role row, created (and flushed) if it is missing.
|
|
139
|
+
|
|
140
|
+
The seed migration normally inserts it; tests build the schema with
|
|
141
|
+
``create_all`` and get no seed rows, so callers that attach the role must
|
|
142
|
+
not assume it exists.
|
|
143
|
+
"""
|
|
144
|
+
user_role = (
|
|
145
|
+
await db.execute(select(Role).where(Role.name == USER_ROLE_NAME))
|
|
146
|
+
).scalar_one_or_none()
|
|
147
|
+
if user_role is None:
|
|
148
|
+
user_role = (
|
|
149
|
+
await db.execute(select(Role).where(Role.id == USER_ROLE_ID))
|
|
150
|
+
).scalar_one_or_none()
|
|
151
|
+
if user_role is None:
|
|
152
|
+
user_role = Role(id=USER_ROLE_ID, name=USER_ROLE_NAME, description=USER_ROLE_DESCRIPTION)
|
|
153
|
+
db.add(user_role)
|
|
154
|
+
await db.flush()
|
|
155
|
+
return user_role
|
|
156
|
+
|
|
157
|
+
|
|
137
158
|
async def create_standard_user(
|
|
138
159
|
db: AsyncSession,
|
|
139
160
|
*,
|
|
@@ -153,18 +174,7 @@ async def create_standard_user(
|
|
|
153
174
|
logger.info(_EVT_USER_NOOP, extra={"email": email, "id": str(existing.id)})
|
|
154
175
|
return CreateAdminResult(user=existing, created=False)
|
|
155
176
|
|
|
156
|
-
user_role = (
|
|
157
|
-
await db.execute(select(Role).where(Role.name == USER_ROLE_NAME))
|
|
158
|
-
).scalar_one_or_none()
|
|
159
|
-
if user_role is None:
|
|
160
|
-
user_role = (
|
|
161
|
-
await db.execute(select(Role).where(Role.id == USER_ROLE_ID))
|
|
162
|
-
).scalar_one_or_none()
|
|
163
|
-
if user_role is None:
|
|
164
|
-
# Safety net — the seed migration normally inserts this row.
|
|
165
|
-
user_role = Role(id=USER_ROLE_ID, name=USER_ROLE_NAME, description=USER_ROLE_DESCRIPTION)
|
|
166
|
-
db.add(user_role)
|
|
167
|
-
await db.flush()
|
|
177
|
+
user_role = await ensure_user_role(db)
|
|
168
178
|
|
|
169
179
|
user = User(
|
|
170
180
|
email=email,
|
|
@@ -48,12 +48,12 @@ current_active_user = fastapi_users.current_user(active=True)
|
|
|
48
48
|
current_superuser = fastapi_users.current_user(active=True, superuser=True)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
def get_mailer(request: Request):
|
|
51
|
+
async def get_mailer(request: Request):
|
|
52
52
|
"""Return the mailer from app.state.users (built in UsersModule.on_startup)."""
|
|
53
53
|
return request.app.state.users.mailer
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def get_event_bus(request: Request) -> EventBus:
|
|
56
|
+
async def get_event_bus(request: Request) -> EventBus:
|
|
57
57
|
"""Return the event bus from app.state.sm."""
|
|
58
58
|
return request.app.state.sm.event_bus
|
|
59
59
|
|
|
@@ -356,7 +356,11 @@
|
|
|
356
356
|
"setup": {
|
|
357
357
|
"administrator": {
|
|
358
358
|
"title": "Create an administrator",
|
|
359
|
-
"description": "An account that can sign in and manage this install."
|
|
359
|
+
"description": "An account that can sign in and manage this install.",
|
|
360
|
+
"email": "Email",
|
|
361
|
+
"password": "Password",
|
|
362
|
+
"full_name": "Full name",
|
|
363
|
+
"submit": "Create administrator"
|
|
360
364
|
}
|
|
361
365
|
},
|
|
362
366
|
"user_row": {
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
import uuid
|
|
7
7
|
|
|
8
8
|
from fastapi_users_db_sqlalchemy.generics import GUID
|
|
9
|
+
from simple_module_core.tenancy import TENANT_ROLE_PREFIX
|
|
9
10
|
from simple_module_db.mixins import AuditMixin
|
|
11
|
+
from sqlalchemy.orm import validates
|
|
10
12
|
from sqlmodel import Field, Relationship
|
|
11
13
|
|
|
12
14
|
from users.models._base import Base
|
|
@@ -32,3 +34,17 @@ class Role(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
|
32
34
|
back_populates="roles",
|
|
33
35
|
sa_relationship_kwargs={"lazy": "noload"},
|
|
34
36
|
)
|
|
37
|
+
|
|
38
|
+
@validates("name")
|
|
39
|
+
def _reject_tenant_namespace(self, _key: str, value: str) -> str:
|
|
40
|
+
"""``tenant:*`` is the synthetic per-tenant principal namespace (#377).
|
|
41
|
+
|
|
42
|
+
A platform ``Role`` row with that prefix would be indistinguishable from
|
|
43
|
+
the active tenant's membership role on a principal, so the name is
|
|
44
|
+
refused at the model, whichever path (service, CLI, seed) sets it.
|
|
45
|
+
"""
|
|
46
|
+
if isinstance(value, str) and value.casefold().startswith(TENANT_ROLE_PREFIX):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"Role names starting with {TENANT_ROLE_PREFIX!r} are reserved for tenant roles"
|
|
49
|
+
)
|
|
50
|
+
return value
|
|
@@ -56,7 +56,6 @@ class User(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
|
|
|
56
56
|
)
|
|
57
57
|
|
|
58
58
|
full_name: str | None = Field(default=None, max_length=255)
|
|
59
|
-
tenant_id: str | None = Field(default=None, max_length=50, index=True)
|
|
60
59
|
disabled_at: datetime | None = Field(
|
|
61
60
|
default=None,
|
|
62
61
|
sa_type=DateTime(timezone=True),
|