simple-module-users 0.0.30__tar.gz → 0.0.32__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/.gitignore +1 -0
  2. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/PKG-INFO +7 -7
  3. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/pyproject.toml +6 -6
  4. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_api_admin.py +11 -0
  5. simple_module_users-0.0.32/tests/test_users_bulk_invite.py +229 -0
  6. simple_module_users-0.0.32/tests/test_users_shared_props.py +52 -0
  7. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_views_admin.py +91 -7
  8. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/api.py +5 -0
  9. simple_module_users-0.0.32/users/admin/bulk_invite.py +219 -0
  10. simple_module_users-0.0.32/users/admin/components/UsersEmpty.tsx +83 -0
  11. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/queries.py +8 -3
  12. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/views.py +56 -25
  13. simple_module_users-0.0.32/users/auth_local/invite_preview.py +64 -0
  14. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/auth_local/views.py +17 -3
  15. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/contracts/schemas.py +47 -1
  16. simple_module_users-0.0.32/users/health.py +47 -0
  17. simple_module_users-0.0.32/users/locales/en.json +11 -0
  18. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/console.py +9 -0
  19. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/smtp.py +22 -0
  20. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/module.py +48 -0
  21. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/AcceptInvite.tsx +37 -3
  22. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/ForgotPassword.tsx +3 -2
  23. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/Login.tsx +2 -4
  24. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/Register.tsx +3 -2
  25. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/ResetPassword.tsx +2 -1
  26. simple_module_users-0.0.32/users/pages/Users/AddPeople.tsx +212 -0
  27. simple_module_users-0.0.32/users/pages/Users/Edit.tsx +295 -0
  28. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/Users/Index.tsx +38 -23
  29. simple_module_users-0.0.32/users/pages/Users/components/CreateUserFields.tsx +80 -0
  30. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/Users/components/DangerZone.tsx +28 -5
  31. simple_module_users-0.0.32/users/pages/Users/components/DetailsCard.tsx +53 -0
  32. simple_module_users-0.0.32/users/pages/Users/components/InviteFields.tsx +46 -0
  33. simple_module_users-0.0.32/users/pages/Users/components/InviteResults.tsx +107 -0
  34. simple_module_users-0.0.32/users/pages/Users/components/MetadataCard.tsx +85 -0
  35. simple_module_users-0.0.32/users/pages/Users/components/RolePicker.tsx +42 -0
  36. simple_module_users-0.0.32/users/pages/Users/components/RolesCard.tsx +37 -0
  37. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/VerifyEmail.tsx +4 -3
  38. simple_module_users-0.0.32/users/shared_props.py +29 -0
  39. simple_module_users-0.0.30/users/pages/Users/Create.tsx +0 -175
  40. simple_module_users-0.0.30/users/pages/Users/Edit.tsx +0 -266
  41. simple_module_users-0.0.30/users/pages/Users/Invite.tsx +0 -147
  42. simple_module_users-0.0.30/users/pages/Users/components/DetailsCard.tsx +0 -81
  43. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/LICENSE +0 -0
  44. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/README.md +0 -0
  45. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/package.json +0 -0
  46. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/.gitkeep +0 -0
  47. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/_middleware_support.py +0 -0
  48. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/conftest.py +0 -0
  49. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_access_token_model.py +0 -0
  50. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_api_admin_crud.py +0 -0
  51. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_api_admin_filters.py +0 -0
  52. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_api_auth.py +0 -0
  53. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_backend.py +0 -0
  54. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_bootstrap.py +0 -0
  55. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_bootstrap_resolution.py +0 -0
  56. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_cli.py +0 -0
  57. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_constants.py +0 -0
  58. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_db_adapter.py +0 -0
  59. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_external_users.py +0 -0
  60. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_invite_flow.py +0 -0
  61. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_invite_reuse.py +0 -0
  62. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_mailer.py +0 -0
  63. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_negative_authz.py +0 -0
  64. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_oauth.py +0 -0
  65. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_oauth_routes.py +0 -0
  66. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_rate_limit.py +0 -0
  67. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_role_model.py +0 -0
  68. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_service_admin.py +0 -0
  69. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_service_admin_crud.py +0 -0
  70. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_settings.py +0 -0
  71. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_token_api.py +0 -0
  72. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_user_manager.py +0 -0
  73. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_user_model.py +0 -0
  74. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_user_role_model.py +0 -0
  75. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_user_service.py +0 -0
  76. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_users_deps.py +0 -0
  77. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_users_middleware.py +0 -0
  78. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_users_middleware_public_paths.py +0 -0
  79. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_users_middleware_resolvers.py +0 -0
  80. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_users_provider.py +0 -0
  81. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_views.py +0 -0
  82. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tsconfig.json +0 -0
  83. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/__init__.py +0 -0
  84. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/__init__.py +0 -0
  85. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/components/IndexFilters.tsx +0 -0
  86. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/components/RolesTab.tsx +0 -0
  87. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/components/UserRow.tsx +0 -0
  88. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/service.py +0 -0
  89. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/auth_local/__init__.py +0 -0
  90. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/auth_local/api.py +0 -0
  91. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/auth_local/rate_limit.py +0 -0
  92. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/auth_local/token_api.py +0 -0
  93. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/backend.py +0 -0
  94. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/bootstrap.py +0 -0
  95. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/cli.py +0 -0
  96. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/constants.py +0 -0
  97. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/contracts/__init__.py +0 -0
  98. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/contracts/events.py +0 -0
  99. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/db_adapter.py +0 -0
  100. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/deps.py +0 -0
  101. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/exceptions.py +0 -0
  102. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/__init__.py +0 -0
  103. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/templates/.gitkeep +0 -0
  104. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/templates/invite.txt +0 -0
  105. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/templates/reset_password.txt +0 -0
  106. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/templates/verify_email.txt +0 -0
  107. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/manager.py +0 -0
  108. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/middleware.py +0 -0
  109. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/__init__.py +0 -0
  110. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/_base.py +0 -0
  111. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/access_token.py +0 -0
  112. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/oauth_account.py +0 -0
  113. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/refresh_token.py +0 -0
  114. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/role.py +0 -0
  115. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/user.py +0 -0
  116. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/user_role.py +0 -0
  117. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/oauth/__init__.py +0 -0
  118. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/oauth/api.py +0 -0
  119. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/oauth/providers.py +0 -0
  120. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/.gitkeep +0 -0
  121. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/Profile.tsx +0 -0
  122. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
  123. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/provider.py +0 -0
  124. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/py.typed +0 -0
  125. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/roles_cache.py +0 -0
  126. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/settings.py +0 -0
  127. {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/state.py +0 -0
@@ -63,3 +63,4 @@ Thumbs.db
63
63
  host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
+ .verify/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: simple_module_users
3
- Version: 0.0.30
3
+ Version: 0.0.32
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.30
28
- Requires-Dist: simple-module-core==0.0.30
29
- Requires-Dist: simple-module-db==0.0.30
30
- Requires-Dist: simple-module-hosting==0.0.30
31
- Requires-Dist: simple-module-settings==0.0.30
27
+ Requires-Dist: simple-module-auth==0.0.32
28
+ Requires-Dist: simple-module-core==0.0.32
29
+ Requires-Dist: simple-module-db==0.0.32
30
+ Requires-Dist: simple-module-hosting==0.0.32
31
+ Requires-Dist: simple-module-settings==0.0.32
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.30"
3
+ version = "0.0.32"
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.30",
25
- "simple_module_db==0.0.30",
26
- "simple_module_hosting==0.0.30",
27
- "simple_module_settings==0.0.30",
28
- "simple_module_auth==0.0.30",
24
+ "simple_module_core==0.0.32",
25
+ "simple_module_db==0.0.32",
26
+ "simple_module_hosting==0.0.32",
27
+ "simple_module_settings==0.0.32",
28
+ "simple_module_auth==0.0.32",
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.
@@ -71,6 +71,17 @@ class TestAdminList:
71
71
  assert "alpha@example.com" in emails
72
72
  assert "beta@example.com" not in emails
73
73
 
74
+ @pytest.mark.anyio
75
+ async def test_list_search_matches_like_metacharacters_literally(self, admin_client, users_db):
76
+ """A literal "_" or "%" in the search must match as text, not wildcard."""
77
+ await _make_user(users_db, email="dana_x@example.com")
78
+ await _make_user(users_db, email="danaYx@example.com")
79
+
80
+ resp = await admin_client.get("/api/users/admin?q=dana_x")
81
+ assert resp.status_code == 200
82
+ emails = [u["email"] for u in resp.json()]
83
+ assert emails == ["dana_x@example.com"]
84
+
74
85
  @pytest.mark.anyio
75
86
  async def test_list_pagination(self, admin_client, users_db):
76
87
  for i in range(5):
@@ -0,0 +1,229 @@
1
+ """Bulk invite — many addresses per submit, with per-address outcomes.
2
+
3
+ The invite form took one address at a time, so onboarding a team meant
4
+ repeating the form once per person. Partial success is the normal case here:
5
+ one already-registered address must not discard the rest.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ _URL = "/api/users/admin/invite/bulk"
14
+
15
+
16
+ class TestBulkInvite:
17
+ async def test_invites_every_address(self, authenticated_client: httpx.AsyncClient):
18
+ resp = await authenticated_client.post(
19
+ _URL,
20
+ json={"emails": ["a@example.com", "b@example.com"], "role_names": []},
21
+ )
22
+ assert resp.status_code == 200, resp.text
23
+ results = resp.json()["results"]
24
+ assert [r["email"] for r in results] == ["a@example.com", "b@example.com"]
25
+
26
+ async def test_duplicate_addresses_are_invited_once(
27
+ self, authenticated_client: httpx.AsyncClient
28
+ ):
29
+ """Pasting a list with a repeat should not mint two invites for it."""
30
+ resp = await authenticated_client.post(
31
+ _URL,
32
+ json={"emails": ["dup@example.com", "DUP@example.com"], "role_names": []},
33
+ )
34
+ assert len(resp.json()["results"]) == 1
35
+
36
+ async def test_addresses_are_normalised_to_lowercase(
37
+ self, authenticated_client: httpx.AsyncClient
38
+ ):
39
+ resp = await authenticated_client.post(
40
+ _URL, json={"emails": ["Mixed@Example.com"], "role_names": []}
41
+ )
42
+ assert resp.json()["results"][0]["email"] == "mixed@example.com"
43
+
44
+ async def test_one_failure_does_not_discard_the_others(
45
+ self, authenticated_client: httpx.AsyncClient
46
+ ):
47
+ """A duplicate in a pasted list of twenty must not lose the other 19."""
48
+ await authenticated_client.post(
49
+ _URL, json={"emails": ["taken@example.com"], "role_names": []}
50
+ )
51
+ resp = await authenticated_client.post(
52
+ _URL,
53
+ json={"emails": ["taken@example.com", "fresh@example.com"], "role_names": []},
54
+ )
55
+ assert resp.status_code == 200, resp.text
56
+ by_email = {r["email"]: r for r in resp.json()["results"]}
57
+ assert by_email["taken@example.com"]["status"] == "failed"
58
+ assert by_email["fresh@example.com"]["status"] in ("sent", "link")
59
+
60
+ async def test_a_failure_always_carries_a_reason(self, authenticated_client: httpx.AsyncClient):
61
+ """Every failed row must say why.
62
+
63
+ ``str(UserAlreadyExists())`` is empty — the reason lives in the type, not
64
+ the message — so passing it through rendered an address in red with a
65
+ blank reason beside it. Asserting the status alone is what let that ship.
66
+ """
67
+ await authenticated_client.post(
68
+ _URL, json={"emails": ["why@example.com"], "role_names": []}
69
+ )
70
+ resp = await authenticated_client.post(
71
+ _URL, json={"emails": ["why@example.com"], "role_names": []}
72
+ )
73
+ result = resp.json()["results"][0]
74
+ assert result["status"] == "failed"
75
+ assert result["detail"], "a failed address must explain itself"
76
+ assert "already" in result["detail"].lower()
77
+
78
+ async def test_console_mailer_hands_back_a_copyable_link(
79
+ self, authenticated_client: httpx.AsyncClient
80
+ ):
81
+ """The test app uses the console mailer, which delivers nothing — the
82
+ admin needs the link or the invite is undeliverable."""
83
+ resp = await authenticated_client.post(
84
+ _URL, json={"emails": ["linkme@example.com"], "role_names": []}
85
+ )
86
+ result = resp.json()["results"][0]
87
+ assert result["status"] == "link"
88
+ assert "/users/invite/accept?token=" in result["link"]
89
+
90
+ async def test_roles_apply_to_every_address(self, authenticated_client: httpx.AsyncClient):
91
+ resp = await authenticated_client.post(
92
+ _URL,
93
+ json={"emails": ["r1@example.com", "r2@example.com"], "role_names": ["user"]},
94
+ )
95
+ assert resp.status_code == 200, resp.text
96
+ assert all(r["status"] in ("sent", "link") for r in resp.json()["results"])
97
+
98
+ async def test_roles_survive_a_later_failure(self, authenticated_client: httpx.AsyncClient):
99
+ """A failure mid-list must not roll back an earlier invite's roles.
100
+
101
+ ``invite`` only flushes its role rows; the rollback that clears failed
102
+ transaction state used to take them with it, so the person invited just
103
+ before a duplicate address ended up with none of the chosen roles.
104
+ """
105
+ await authenticated_client.post(
106
+ _URL, json={"emails": ["dupe@example.com"], "role_names": []}
107
+ )
108
+ resp = await authenticated_client.post(
109
+ _URL,
110
+ json={"emails": ["kept@example.com", "dupe@example.com"], "role_names": ["admin"]},
111
+ )
112
+ assert resp.status_code == 200, resp.text
113
+
114
+ listing = await authenticated_client.get("/api/users/admin")
115
+ users = {u["email"]: u for u in listing.json()}
116
+ assert users["kept@example.com"]["roles"] == ["admin"]
117
+
118
+ async def test_a_malformed_address_does_not_reject_the_submit(
119
+ self, authenticated_client: httpx.AsyncClient
120
+ ):
121
+ """A typo in one line of a pasted column used to 422 the whole body,
122
+ leaving the admin with a generic error and no idea which line."""
123
+ resp = await authenticated_client.post(
124
+ _URL,
125
+ json={"emails": ["good@example.com", "not-an-address"], "role_names": []},
126
+ )
127
+ assert resp.status_code == 200, resp.text
128
+ by_email = {r["email"]: r for r in resp.json()["results"]}
129
+ assert by_email["good@example.com"]["status"] in ("sent", "link")
130
+ assert by_email["not-an-address"]["status"] == "failed"
131
+ assert "valid email" in by_email["not-an-address"]["detail"]
132
+
133
+ async def test_empty_list_is_accepted_and_does_nothing(
134
+ self, authenticated_client: httpx.AsyncClient
135
+ ):
136
+ resp = await authenticated_client.post(_URL, json={"emails": [], "role_names": []})
137
+ assert resp.status_code == 200
138
+ assert resp.json()["results"] == []
139
+
140
+ async def test_address_count_is_capped(self, authenticated_client: httpx.AsyncClient):
141
+ """One submit must not be able to mint unbounded live invite tokens."""
142
+ from users.admin.bulk_invite import MAX_ADDRESSES
143
+
144
+ emails = [f"bulk{i}@example.com" for i in range(MAX_ADDRESSES + 5)]
145
+ resp = await authenticated_client.post(_URL, json={"emails": emails, "role_names": []})
146
+ invited = [r for r in resp.json()["results"] if r["status"] != "failed"]
147
+ assert len(invited) == MAX_ADDRESSES
148
+
149
+ async def test_addresses_over_the_cap_are_reported_not_dropped(
150
+ self, authenticated_client: httpx.AsyncClient
151
+ ):
152
+ """Silent truncation reports "100 invites sent" while 5 people are
153
+ never contacted, with nothing on screen saying so."""
154
+ from users.admin.bulk_invite import MAX_ADDRESSES
155
+
156
+ emails = [f"over{i}@example.com" for i in range(MAX_ADDRESSES + 5)]
157
+ resp = await authenticated_client.post(_URL, json={"emails": emails, "role_names": []})
158
+ results = resp.json()["results"]
159
+
160
+ assert len(results) == MAX_ADDRESSES + 5, "every submitted address needs an outcome"
161
+ overflow = {r["email"]: r for r in results[-5:]}
162
+ assert set(overflow) == {
163
+ f"over{i}@example.com" for i in range(MAX_ADDRESSES, MAX_ADDRESSES + 5)
164
+ }
165
+ for result in overflow.values():
166
+ assert result["status"] == "failed"
167
+ assert "limit" in result["detail"]
168
+
169
+ async def test_requires_authentication(self, client: httpx.AsyncClient):
170
+ resp = await client.post(
171
+ _URL, json={"emails": ["x@example.com"], "role_names": []}, follow_redirects=False
172
+ )
173
+ assert resp.status_code in (302, 401, 403)
174
+
175
+
176
+ class TestInvitePreview:
177
+ async def test_accept_page_names_the_invitee(
178
+ self, authenticated_client: httpx.AsyncClient, client: httpx.AsyncClient
179
+ ):
180
+ """The card asked for a password while identifying nobody."""
181
+ created = await authenticated_client.post(
182
+ _URL, json={"emails": ["preview@example.com"], "role_names": []}
183
+ )
184
+ link = created.json()["results"][0]["link"]
185
+ token = link.split("token=")[1]
186
+
187
+ resp = await client.get(
188
+ f"/users/invite/accept?token={token}",
189
+ headers={"X-Inertia": "true", "Accept": "application/json"},
190
+ )
191
+ assert resp.status_code == 200, resp.text
192
+ invite = resp.json()["props"]["invite"]
193
+ assert invite["email"] == "preview@example.com"
194
+ assert invite["already_accepted"] is False
195
+
196
+ @pytest.mark.parametrize("token", ["", "not-a-jwt", "a.b.c"])
197
+ async def test_unreadable_tokens_yield_no_preview(self, client: httpx.AsyncClient, token: str):
198
+ """Expired, tampered and absent all look the same here on purpose —
199
+ the reason belongs to the accept attempt, which validates properly."""
200
+ resp = await client.get(
201
+ f"/users/invite/accept?token={token}",
202
+ headers={"X-Inertia": "true", "Accept": "application/json"},
203
+ )
204
+ assert resp.status_code == 200
205
+ assert resp.json()["props"]["invite"] is None
206
+
207
+ async def test_preview_does_not_consume_the_invite(
208
+ self, authenticated_client: httpx.AsyncClient, client: httpx.AsyncClient
209
+ ):
210
+ """Viewing the page must leave the token usable — UserManager.verify
211
+ marks the account verified as a side effect, so the preview cannot
212
+ route through it."""
213
+ created = await authenticated_client.post(
214
+ _URL, json={"emails": ["unspent@example.com"], "role_names": []}
215
+ )
216
+ token = created.json()["results"][0]["link"].split("token=")[1]
217
+
218
+ for _ in range(2):
219
+ resp = await client.get(
220
+ f"/users/invite/accept?token={token}",
221
+ headers={"X-Inertia": "true", "Accept": "application/json"},
222
+ )
223
+ assert resp.json()["props"]["invite"]["already_accepted"] is False
224
+
225
+ accepted = await client.post(
226
+ "/api/users/auth/accept-invite",
227
+ json={"token": token, "password": "a-good-password-123"},
228
+ )
229
+ assert accepted.status_code in (200, 204), accepted.text
@@ -0,0 +1,52 @@
1
+ """The ``signup`` shared prop drives whether a "Sign up" link is rendered.
2
+
3
+ ``/users/register`` raises 404 when ``allow_signup`` is off, so the public shell
4
+ has to know the answer before it draws the link. Getting the default wrong in
5
+ either direction is a visible bug: too eager and every visitor hits a 404, too
6
+ shy and a genuinely open instance hides its own signup.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from types import SimpleNamespace
12
+
13
+ import pytest
14
+ from users.shared_props import users_shared_props
15
+
16
+
17
+ def _request(users_state: object) -> SimpleNamespace:
18
+ """A stand-in carrying only what the provider is allowed to touch."""
19
+ return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(users=users_state)))
20
+
21
+
22
+ class TestSignupSharedProp:
23
+ @pytest.mark.parametrize("allow", [True, False])
24
+ def test_reflects_the_setting(self, allow: bool) -> None:
25
+ request = _request(SimpleNamespace(settings=SimpleNamespace(allow_signup=allow)))
26
+ assert users_shared_props(request) == {"signup": {"allowed": allow}}
27
+
28
+ def test_coerces_to_a_real_bool(self) -> None:
29
+ """The value is serialised straight to JSON, so it must not leak a
30
+ truthy non-bool into the page props."""
31
+ request = _request(SimpleNamespace(settings=SimpleNamespace(allow_signup=1)))
32
+ assert users_shared_props(request)["signup"]["allowed"] is True
33
+
34
+ def test_defaults_closed_when_settings_are_missing(self) -> None:
35
+ request = _request(SimpleNamespace())
36
+ assert users_shared_props(request) == {"signup": {"allowed": False}}
37
+
38
+ def test_defaults_closed_when_module_state_is_absent(self) -> None:
39
+ """Never raises: the provider runs on every request and a failure here
40
+ would cost the whole page, not just the link."""
41
+ request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
42
+ assert users_shared_props(request) == {"signup": {"allowed": False}}
43
+
44
+
45
+ class TestProviderIsRegistered:
46
+ def test_the_built_app_carries_it(self, app) -> None:
47
+ """Registered during ``register_settings``, so a fully booted app has it
48
+ in place before the first request is served. Asserted against the real
49
+ app rather than a bare FastAPI: the registration sits downstream of the
50
+ settings module, and a stub would pass while the wiring was broken."""
51
+ providers = getattr(app.state, "inertia_shared_providers", [])
52
+ assert users_shared_props in providers
@@ -59,6 +59,45 @@ class TestAdminIndexFilters:
59
59
  assert props["filters"]["sort"] == "email"
60
60
  assert props["filters"]["order"] == "asc"
61
61
 
62
+ @pytest.mark.anyio
63
+ async def test_page_past_the_end_clamps_to_last_page(self, admin_client, users_db):
64
+ """A stale/hand-edited ?page= beyond the last page must clamp and
65
+ return the last page's rows, not an empty list with a nonzero total —
66
+ the same bug class background_tasks and file_storage fix for their
67
+ own admin listings."""
68
+ from test_api_admin import _make_user
69
+
70
+ await _make_user(users_db, email="clamp-a@x.com")
71
+ await _make_user(users_db, email="clamp-b@x.com")
72
+
73
+ resp = await admin_client.get(
74
+ "/users/admin?page=999&per_page=1",
75
+ headers={"X-Inertia": "true", "Accept": "application/json"},
76
+ )
77
+ assert resp.status_code == 200
78
+ props = resp.json()["props"]
79
+ assert props["users"], "expected the clamped last page to carry rows"
80
+ assert props["pagination"]["page"] < 999
81
+ assert props["pagination"]["total"] >= 2
82
+
83
+ @pytest.mark.anyio
84
+ async def test_pagination_prop_echoes_clamped_values(self, admin_client, users_db):
85
+ """The pagination prop must carry the values the query actually ran
86
+ with — ?page=0 renders page-1 rows and must not be labelled page 0,
87
+ and an oversized per_page is bounded to what the service fetched."""
88
+ from test_api_admin import _make_user
89
+
90
+ await _make_user(users_db, email="clamp-c@x.com")
91
+
92
+ resp = await admin_client.get(
93
+ "/users/admin?page=0&per_page=1000",
94
+ headers={"X-Inertia": "true", "Accept": "application/json"},
95
+ )
96
+ assert resp.status_code == 200
97
+ pagination = resp.json()["props"]["pagination"]
98
+ assert pagination["page"] == 1
99
+ assert pagination["per_page"] == 200
100
+
62
101
 
63
102
  # ---------------------------------------------------------------------------
64
103
  # has_permissions_module flag on admin edit page
@@ -129,23 +168,68 @@ class TestHasPermissionsModuleFlag:
129
168
 
130
169
 
131
170
  # ---------------------------------------------------------------------------
132
- # Admin create page
171
+ # Admin add-people page (create + invite merged behind a mode switch)
133
172
  # ---------------------------------------------------------------------------
134
173
 
135
174
 
136
- class TestAdminCreatePage:
175
+ class TestAdminAddPeoplePage:
137
176
  @pytest.mark.anyio
138
- async def test_create_page_renders_with_roles(self, admin_client):
177
+ async def test_add_page_renders_with_roles(self, admin_client):
139
178
  resp = await admin_client.get(
140
- "/users/admin/create",
179
+ "/users/admin/add",
141
180
  headers={"X-Inertia": "true", "Accept": "application/json"},
142
181
  )
143
182
  assert resp.status_code == 200
144
183
  data = resp.json()
145
- assert data["component"] == "Users/Users/Create"
184
+ assert data["component"] == "Users/Users/AddPeople"
146
185
  assert "roles" in data["props"]
147
186
 
148
187
  @pytest.mark.anyio
149
- async def test_create_page_requires_auth(self, anon_client):
150
- resp = await anon_client.get("/users/admin/create", follow_redirects=False)
188
+ async def test_add_page_reports_whether_mail_can_be_delivered(self, admin_client):
189
+ """Drives the copy-link panel — the page has to know before submitting."""
190
+ resp = await admin_client.get(
191
+ "/users/admin/add",
192
+ headers={"X-Inertia": "true", "Accept": "application/json"},
193
+ )
194
+ assert "mailer_delivers" in resp.json()["props"]
195
+
196
+ @pytest.mark.anyio
197
+ async def test_no_mailer_does_not_promise_delivery(self, admin_client, app):
198
+ """With nothing able to send, the page must offer the copy-link panel —
199
+ claiming delivery is the one answer that is certainly wrong."""
200
+ original = app.state.users.mailer
201
+ app.state.users.mailer = None
202
+ try:
203
+ resp = await admin_client.get(
204
+ "/users/admin/add",
205
+ headers={"X-Inertia": "true", "Accept": "application/json"},
206
+ )
207
+ assert resp.json()["props"]["mailer_delivers"] is False
208
+ finally:
209
+ app.state.users.mailer = original
210
+
211
+ @pytest.mark.anyio
212
+ async def test_add_page_requires_auth(self, anon_client):
213
+ resp = await anon_client.get("/users/admin/add", follow_redirects=False)
214
+ assert resp.status_code == 302
215
+
216
+ @pytest.mark.anyio
217
+ @pytest.mark.parametrize(
218
+ ("old_path", "mode"),
219
+ [("/users/admin/create", "create"), ("/users/admin/invite", "invite")],
220
+ )
221
+ async def test_old_urls_redirect_into_the_right_mode(self, admin_client, old_path, mode):
222
+ """Existing links must land on the merged form with their mode preselected."""
223
+ resp = await admin_client.get(old_path, follow_redirects=False)
224
+ assert resp.status_code == 307
225
+ assert resp.headers["location"] == f"/users/admin/add?mode={mode}"
226
+
227
+ @pytest.mark.anyio
228
+ @pytest.mark.parametrize("old_path", ["/users/admin/create", "/users/admin/invite"])
229
+ async def test_old_urls_require_auth(self, anon_client, old_path):
230
+ """The legacy aliases must stay gated behind the same permission as
231
+ the page they redirect to — an anonymous caller must not reach the
232
+ redirect (and learn the target route exists) before being sent to
233
+ log in."""
234
+ resp = await anon_client.get(old_path, follow_redirects=False)
151
235
  assert resp.status_code == 302
@@ -10,6 +10,7 @@ from fastapi_users import exceptions as fa_exceptions
10
10
  from simple_module_core.events import EventBus
11
11
  from simple_module_hosting.permissions import RequiresPermission
12
12
 
13
+ from users.admin.bulk_invite import bulk_router
13
14
  from users.admin.service import UserService
14
15
  from users.constants import PERM_USERS_MANAGE, sanitize_list_filters
15
16
  from users.contracts.events import (
@@ -40,6 +41,10 @@ admin_router = APIRouter(
40
41
  tags=["users-admin"],
41
42
  )
42
43
 
44
+ # Bulk invite lives in its own module (this file is near the 300-line cap) but
45
+ # mounts here so it inherits the users.manage guard above.
46
+ admin_router.include_router(bulk_router)
47
+
43
48
 
44
49
  @admin_router.get("", response_model=list[UserListItem])
45
50
  async def admin_list_users(