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