simple-module-users 0.0.30__tar.gz → 0.0.31__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 (123) hide show
  1. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/.gitignore +1 -0
  2. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/PKG-INFO +7 -7
  3. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/pyproject.toml +6 -6
  4. simple_module_users-0.0.31/tests/test_users_bulk_invite.py +229 -0
  5. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_views_admin.py +42 -7
  6. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/api.py +5 -0
  7. simple_module_users-0.0.31/users/admin/bulk_invite.py +219 -0
  8. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/views.py +31 -15
  9. simple_module_users-0.0.31/users/auth_local/invite_preview.py +64 -0
  10. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/auth_local/views.py +17 -3
  11. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/contracts/schemas.py +47 -1
  12. simple_module_users-0.0.31/users/health.py +47 -0
  13. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/console.py +9 -0
  14. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/smtp.py +22 -0
  15. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/module.py +33 -0
  16. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/AcceptInvite.tsx +37 -3
  17. simple_module_users-0.0.31/users/pages/Users/AddPeople.tsx +212 -0
  18. simple_module_users-0.0.31/users/pages/Users/Edit.tsx +295 -0
  19. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/Users/Index.tsx +8 -14
  20. simple_module_users-0.0.31/users/pages/Users/components/CreateUserFields.tsx +78 -0
  21. simple_module_users-0.0.31/users/pages/Users/components/DetailsCard.tsx +53 -0
  22. simple_module_users-0.0.31/users/pages/Users/components/InviteFields.tsx +46 -0
  23. simple_module_users-0.0.31/users/pages/Users/components/InviteResults.tsx +107 -0
  24. simple_module_users-0.0.31/users/pages/Users/components/MetadataCard.tsx +85 -0
  25. simple_module_users-0.0.31/users/pages/Users/components/RolePicker.tsx +42 -0
  26. simple_module_users-0.0.31/users/pages/Users/components/RolesCard.tsx +37 -0
  27. simple_module_users-0.0.30/users/pages/Users/Create.tsx +0 -175
  28. simple_module_users-0.0.30/users/pages/Users/Edit.tsx +0 -266
  29. simple_module_users-0.0.30/users/pages/Users/Invite.tsx +0 -147
  30. simple_module_users-0.0.30/users/pages/Users/components/DetailsCard.tsx +0 -81
  31. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/LICENSE +0 -0
  32. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/README.md +0 -0
  33. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/package.json +0 -0
  34. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/.gitkeep +0 -0
  35. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/_middleware_support.py +0 -0
  36. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/conftest.py +0 -0
  37. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_access_token_model.py +0 -0
  38. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_api_admin.py +0 -0
  39. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_api_admin_crud.py +0 -0
  40. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_api_admin_filters.py +0 -0
  41. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_api_auth.py +0 -0
  42. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_backend.py +0 -0
  43. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_bootstrap.py +0 -0
  44. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_bootstrap_resolution.py +0 -0
  45. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_cli.py +0 -0
  46. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_constants.py +0 -0
  47. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_db_adapter.py +0 -0
  48. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_external_users.py +0 -0
  49. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_invite_flow.py +0 -0
  50. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_invite_reuse.py +0 -0
  51. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_mailer.py +0 -0
  52. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_negative_authz.py +0 -0
  53. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_oauth.py +0 -0
  54. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_oauth_routes.py +0 -0
  55. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_rate_limit.py +0 -0
  56. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_role_model.py +0 -0
  57. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_service_admin.py +0 -0
  58. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_service_admin_crud.py +0 -0
  59. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_settings.py +0 -0
  60. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_token_api.py +0 -0
  61. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_user_manager.py +0 -0
  62. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_user_model.py +0 -0
  63. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_user_role_model.py +0 -0
  64. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_user_service.py +0 -0
  65. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_users_deps.py +0 -0
  66. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_users_middleware.py +0 -0
  67. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_users_middleware_public_paths.py +0 -0
  68. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_users_middleware_resolvers.py +0 -0
  69. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_users_provider.py +0 -0
  70. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_views.py +0 -0
  71. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tsconfig.json +0 -0
  72. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/__init__.py +0 -0
  73. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/__init__.py +0 -0
  74. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/components/IndexFilters.tsx +0 -0
  75. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/components/RolesTab.tsx +0 -0
  76. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/components/UserRow.tsx +0 -0
  77. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/queries.py +0 -0
  78. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/service.py +0 -0
  79. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/auth_local/__init__.py +0 -0
  80. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/auth_local/api.py +0 -0
  81. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/auth_local/rate_limit.py +0 -0
  82. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/auth_local/token_api.py +0 -0
  83. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/backend.py +0 -0
  84. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/bootstrap.py +0 -0
  85. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/cli.py +0 -0
  86. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/constants.py +0 -0
  87. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/contracts/__init__.py +0 -0
  88. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/contracts/events.py +0 -0
  89. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/db_adapter.py +0 -0
  90. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/deps.py +0 -0
  91. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/exceptions.py +0 -0
  92. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/__init__.py +0 -0
  93. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/templates/.gitkeep +0 -0
  94. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/templates/invite.txt +0 -0
  95. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/templates/reset_password.txt +0 -0
  96. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/templates/verify_email.txt +0 -0
  97. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/manager.py +0 -0
  98. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/middleware.py +0 -0
  99. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/__init__.py +0 -0
  100. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/_base.py +0 -0
  101. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/access_token.py +0 -0
  102. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/oauth_account.py +0 -0
  103. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/refresh_token.py +0 -0
  104. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/role.py +0 -0
  105. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/user.py +0 -0
  106. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/user_role.py +0 -0
  107. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/oauth/__init__.py +0 -0
  108. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/oauth/api.py +0 -0
  109. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/oauth/providers.py +0 -0
  110. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/.gitkeep +0 -0
  111. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/ForgotPassword.tsx +0 -0
  112. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/Login.tsx +0 -0
  113. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/Profile.tsx +0 -0
  114. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/Register.tsx +0 -0
  115. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/ResetPassword.tsx +0 -0
  116. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
  117. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/Users/components/DangerZone.tsx +0 -0
  118. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/VerifyEmail.tsx +0 -0
  119. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/provider.py +0 -0
  120. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/py.typed +0 -0
  121. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/roles_cache.py +0 -0
  122. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/settings.py +0 -0
  123. {simple_module_users-0.0.30 → simple_module_users-0.0.31}/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.31
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.31
28
+ Requires-Dist: simple-module-core==0.0.31
29
+ Requires-Dist: simple-module-db==0.0.31
30
+ Requires-Dist: simple-module-hosting==0.0.31
31
+ Requires-Dist: simple-module-settings==0.0.31
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.31"
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.31",
25
+ "simple_module_db==0.0.31",
26
+ "simple_module_hosting==0.0.31",
27
+ "simple_module_settings==0.0.31",
28
+ "simple_module_auth==0.0.31",
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,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
@@ -129,23 +129,58 @@ class TestHasPermissionsModuleFlag:
129
129
 
130
130
 
131
131
  # ---------------------------------------------------------------------------
132
- # Admin create page
132
+ # Admin add-people page (create + invite merged behind a mode switch)
133
133
  # ---------------------------------------------------------------------------
134
134
 
135
135
 
136
- class TestAdminCreatePage:
136
+ class TestAdminAddPeoplePage:
137
137
  @pytest.mark.anyio
138
- async def test_create_page_renders_with_roles(self, admin_client):
138
+ async def test_add_page_renders_with_roles(self, admin_client):
139
139
  resp = await admin_client.get(
140
- "/users/admin/create",
140
+ "/users/admin/add",
141
141
  headers={"X-Inertia": "true", "Accept": "application/json"},
142
142
  )
143
143
  assert resp.status_code == 200
144
144
  data = resp.json()
145
- assert data["component"] == "Users/Users/Create"
145
+ assert data["component"] == "Users/Users/AddPeople"
146
146
  assert "roles" in data["props"]
147
147
 
148
148
  @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)
149
+ async def test_add_page_reports_whether_mail_can_be_delivered(self, admin_client):
150
+ """Drives the copy-link panel — the page has to know before submitting."""
151
+ resp = await admin_client.get(
152
+ "/users/admin/add",
153
+ headers={"X-Inertia": "true", "Accept": "application/json"},
154
+ )
155
+ assert "mailer_delivers" in resp.json()["props"]
156
+
157
+ @pytest.mark.anyio
158
+ async def test_no_mailer_does_not_promise_delivery(self, admin_client, app):
159
+ """With nothing able to send, the page must offer the copy-link panel —
160
+ claiming delivery is the one answer that is certainly wrong."""
161
+ original = app.state.users.mailer
162
+ app.state.users.mailer = None
163
+ try:
164
+ resp = await admin_client.get(
165
+ "/users/admin/add",
166
+ headers={"X-Inertia": "true", "Accept": "application/json"},
167
+ )
168
+ assert resp.json()["props"]["mailer_delivers"] is False
169
+ finally:
170
+ app.state.users.mailer = original
171
+
172
+ @pytest.mark.anyio
173
+ async def test_add_page_requires_auth(self, anon_client):
174
+ resp = await anon_client.get("/users/admin/add", follow_redirects=False)
151
175
  assert resp.status_code == 302
176
+
177
+ @pytest.mark.anyio
178
+ @pytest.mark.parametrize(
179
+ ("old_path", "mode"),
180
+ [("/users/admin/create", "create"), ("/users/admin/invite", "invite")],
181
+ )
182
+ async def test_old_urls_redirect_into_the_right_mode(self, admin_client, old_path, mode):
183
+ """Existing links must land on the merged form with their mode preselected."""
184
+ resp = await admin_client.get(old_path, follow_redirects=False)
185
+ assert resp.status_code == 307
186
+ assert resp.headers["location"] == f"/users/admin/add?mode={mode}"
@@ -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(
@@ -0,0 +1,219 @@
1
+ """Bulk invite — one submit, many addresses.
2
+
3
+ The invite form took a single address, so onboarding a team meant repeating
4
+ the same form once per person. This accepts a pasted list and reports each
5
+ address separately: one already-registered address in a list of twenty must
6
+ not discard the other nineteen.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import logging
13
+
14
+ from fastapi import APIRouter, Depends, Request
15
+ from fastapi_users.exceptions import UserAlreadyExists
16
+ from pydantic import EmailStr, TypeAdapter, ValidationError
17
+ from simple_module_core.events import EventBus
18
+ from simple_module_db.deps import get_db
19
+ from sqlalchemy.ext.asyncio import AsyncSession
20
+
21
+ from users.admin.service import UserService
22
+ from users.contracts.events import UserInvited
23
+ from users.contracts.schemas import BulkInviteResponse, BulkInviteResult, UserBulkInvite
24
+ from users.deps import get_event_bus, get_mailer, get_user_service
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ bulk_router = APIRouter()
29
+
30
+ STATUS_SENT = "sent"
31
+ STATUS_LINK = "link"
32
+ STATUS_FAILED = "failed"
33
+
34
+ MAX_ADDRESSES = 100
35
+ """Enough for a team, small enough that one submit cannot mint an unbounded
36
+ number of live invite tokens."""
37
+
38
+ _EMAIL = TypeAdapter(EmailStr)
39
+ """Per-address validation. Deliberately not a ``list[EmailStr]`` on the request
40
+ model: pydantic would reject the whole body over one typo, and the caller would
41
+ get a 422 naming an index rather than the per-address outcomes this endpoint
42
+ exists to produce."""
43
+
44
+
45
+ def _failure_detail(exc: Exception) -> str:
46
+ """Human-readable reason for one address failing.
47
+
48
+ ``str(UserAlreadyExists())`` is the empty string — fastapi-users carries the
49
+ meaning in the exception *type*, not its message. Passing that straight
50
+ through renders the single most common failure as a bare red address with no
51
+ reason beside it, which is exactly the question the per-address results exist
52
+ to answer. The final fallback names the exception class rather than leaving
53
+ the cell blank: a class name is a poor message, but it is still a lead.
54
+ """
55
+ if isinstance(exc, UserAlreadyExists):
56
+ return "Already registered"
57
+ return str(exc) or type(exc).__name__
58
+
59
+
60
+ def _invite_link(request: Request, token: str) -> str:
61
+ """Build the accept URL the admin will hand to the invitee.
62
+
63
+ Uses the module's configured ``base_url`` — the same value both mailers
64
+ build their links from — rather than ``request.base_url``. Behind a reverse
65
+ proxy without ``SM_TRUSTED_PROXY``, the request's own base URL is the
66
+ internal origin (``http://10.0.0.5:8000``), and this link is *only* ever
67
+ surfaced when mail could not be delivered, i.e. exactly when the admin has
68
+ to pass it on by hand. Falls back to the request when the module's settings
69
+ are unavailable, so the link is never simply missing.
70
+ """
71
+ services = getattr(request.app.state, "users", None)
72
+ configured = getattr(getattr(services, "settings", None), "base_url", "")
73
+ base = str(configured or request.base_url).rstrip("/")
74
+ return f"{base}/users/invite/accept?token={token}"
75
+
76
+
77
+ @bulk_router.post("/invite/bulk", response_model=BulkInviteResponse)
78
+ async def admin_bulk_invite(
79
+ data: UserBulkInvite,
80
+ request: Request,
81
+ bus: EventBus = Depends(get_event_bus),
82
+ service: UserService = Depends(get_user_service),
83
+ # The same request-scoped session the service was built from, taken through
84
+ # the dependency rather than off the service's private attribute.
85
+ db: AsyncSession = Depends(get_db),
86
+ mailer=Depends(get_mailer),
87
+ ) -> BulkInviteResponse:
88
+ """Invite every address in *data*, all sharing the same roles."""
89
+ invited_by = getattr(request.state, "user", None)
90
+ invited_by_name = invited_by.name if invited_by else "Administrator"
91
+
92
+ # Absent attribute means "assume it delivers" — a third-party mailer must
93
+ # never leak invite tokens into the response just by not declaring itself.
94
+ # No mailer at all delivers nothing, though: defaulting that to True sends
95
+ # every address down the send path, where the AttributeError surfaces to
96
+ # the admin as a raw "'NoneType' object has no attribute 'send_invite'".
97
+ # This matches what the page's ``mailer_delivers`` prop already reports.
98
+ delivers = mailer is not None and getattr(mailer, "delivers_email", True)
99
+
100
+ # Preserve submit order but drop repeats: pasting a list with the same
101
+ # address twice should not create two invites for it. A malformed address
102
+ # is reported like any other per-address failure rather than rejecting the
103
+ # submit — a typo in one line of a pasted column must not lose the column.
104
+ seen: set[str] = set()
105
+ ordered: list[str] = []
106
+ malformed: list[str] = []
107
+ for raw in data.emails:
108
+ email = str(raw).strip().lower()
109
+ if not email or email in seen:
110
+ continue
111
+ seen.add(email)
112
+ try:
113
+ _EMAIL.validate_python(email)
114
+ except ValidationError:
115
+ malformed.append(email)
116
+ continue
117
+ ordered.append(email)
118
+
119
+ # Anything past the cap is reported, never silently dropped: truncating in
120
+ # silence tells the admin "100 invites sent" while 50 people are never
121
+ # contacted, and nothing on screen says otherwise.
122
+ accepted, overflow = ordered[:MAX_ADDRESSES], ordered[MAX_ADDRESSES:]
123
+
124
+ results: list[BulkInviteResult] = []
125
+ for email in accepted:
126
+ try:
127
+ user, token = await service.invite(email, None, data.role_names, invited_by=invited_by)
128
+ # Make this invite durable before touching the session again. The
129
+ # failure path below rolls back, and the role rows the service
130
+ # flushed but did not commit would go with it — leaving the person
131
+ # invited with none of the roles the admin picked.
132
+ await db.commit()
133
+ except Exception as exc:
134
+ # Already-registered is the common case and reads fine as-is;
135
+ # anything else is logged so the admin's summary stays short.
136
+ logger.info("bulk invite failed for %s: %s", email, exc)
137
+ results.append(
138
+ BulkInviteResult(email=email, status=STATUS_FAILED, detail=_failure_detail(exc))
139
+ )
140
+ # Clear any failed transaction state before touching the session
141
+ # again. A real DB error (an IntegrityError from a concurrent
142
+ # signup, say) otherwise leaves every remaining address dying with
143
+ # PendingRollbackError — one bad row turning into total failure,
144
+ # the opposite of the partial success this endpoint promises.
145
+ # Safe to discard: every successful invite above is committed
146
+ # before the next one starts, so nothing durable is pending here.
147
+ with contextlib.suppress(Exception):
148
+ await db.rollback()
149
+ continue
150
+
151
+ if delivers:
152
+ try:
153
+ await mailer.send_invite(user.email, token, invited_by_name)
154
+ except Exception as exc:
155
+ # The account exists and the token is valid — the delivery
156
+ # failed. Handing back the link turns a dead end into a
157
+ # copy-paste, rather than stranding a half-finished invite.
158
+ logger.warning("invite mail failed for %s: %s", email, exc)
159
+ results.append(
160
+ BulkInviteResult(
161
+ email=email,
162
+ status=STATUS_LINK,
163
+ detail=str(exc),
164
+ link=_invite_link(request, token),
165
+ )
166
+ )
167
+ else:
168
+ results.append(BulkInviteResult(email=email, status=STATUS_SENT))
169
+ else:
170
+ results.append(
171
+ BulkInviteResult(
172
+ email=email,
173
+ status=STATUS_LINK,
174
+ link=_invite_link(request, token),
175
+ )
176
+ )
177
+
178
+ await bus.publish(
179
+ UserInvited(
180
+ user_id=user.id,
181
+ email=user.email,
182
+ invited_by=(str(invited_by.id) if invited_by else None),
183
+ )
184
+ )
185
+ # Handlers are awaited inline and may write through this same
186
+ # request-scoped session. Commit again so a *later* address failing —
187
+ # and rolling back to clear the transaction — cannot take their work
188
+ # with it, which is what makes the "nothing durable is pending"
189
+ # assumption in the failure branch above actually hold.
190
+ #
191
+ # Guarded: the invite itself is already committed, so a handler writing
192
+ # something the DB rejects must not 500 the request and discard every
193
+ # per-address result collected so far — that is exactly the total
194
+ # failure this endpoint exists to avoid.
195
+ try:
196
+ await db.commit()
197
+ except Exception as exc:
198
+ logger.warning("post-invite handler commit failed for %s: %s", email, exc)
199
+ with contextlib.suppress(Exception):
200
+ await db.rollback()
201
+
202
+ results.extend(
203
+ BulkInviteResult(
204
+ email=email,
205
+ status=STATUS_FAILED,
206
+ detail="Not a valid email address",
207
+ )
208
+ for email in malformed
209
+ )
210
+ results.extend(
211
+ BulkInviteResult(
212
+ email=email,
213
+ status=STATUS_FAILED,
214
+ detail=f"Not attempted — over the {MAX_ADDRESSES}-address limit for one submit",
215
+ )
216
+ for email in overflow
217
+ )
218
+
219
+ return BulkInviteResponse(results=results)