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.
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/.gitignore +1 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/PKG-INFO +7 -7
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/pyproject.toml +6 -6
- simple_module_users-0.0.31/tests/test_users_bulk_invite.py +229 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_views_admin.py +42 -7
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/api.py +5 -0
- simple_module_users-0.0.31/users/admin/bulk_invite.py +219 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/views.py +31 -15
- simple_module_users-0.0.31/users/auth_local/invite_preview.py +64 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/auth_local/views.py +17 -3
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/contracts/schemas.py +47 -1
- simple_module_users-0.0.31/users/health.py +47 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/console.py +9 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/smtp.py +22 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/module.py +33 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/AcceptInvite.tsx +37 -3
- simple_module_users-0.0.31/users/pages/Users/AddPeople.tsx +212 -0
- simple_module_users-0.0.31/users/pages/Users/Edit.tsx +295 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/Users/Index.tsx +8 -14
- simple_module_users-0.0.31/users/pages/Users/components/CreateUserFields.tsx +78 -0
- simple_module_users-0.0.31/users/pages/Users/components/DetailsCard.tsx +53 -0
- simple_module_users-0.0.31/users/pages/Users/components/InviteFields.tsx +46 -0
- simple_module_users-0.0.31/users/pages/Users/components/InviteResults.tsx +107 -0
- simple_module_users-0.0.31/users/pages/Users/components/MetadataCard.tsx +85 -0
- simple_module_users-0.0.31/users/pages/Users/components/RolePicker.tsx +42 -0
- simple_module_users-0.0.31/users/pages/Users/components/RolesCard.tsx +37 -0
- simple_module_users-0.0.30/users/pages/Users/Create.tsx +0 -175
- simple_module_users-0.0.30/users/pages/Users/Edit.tsx +0 -266
- simple_module_users-0.0.30/users/pages/Users/Invite.tsx +0 -147
- simple_module_users-0.0.30/users/pages/Users/components/DetailsCard.tsx +0 -81
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/LICENSE +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/README.md +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/package.json +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/conftest.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_external_users.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tests/test_views.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/tsconfig.json +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/queries.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/admin/service.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/auth_local/api.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/auth_local/token_api.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/backend.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/cli.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/constants.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/deps.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/exceptions.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/manager.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/middleware.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/_base.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/role.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/user.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/oauth/api.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/Login.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/ResetPassword.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/Users/components/DangerZone.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/provider.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/py.typed +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/settings.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.31}/users/state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_users
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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.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.
|
|
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.
|
|
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.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
|
|
132
|
+
# Admin add-people page (create + invite merged behind a mode switch)
|
|
133
133
|
# ---------------------------------------------------------------------------
|
|
134
134
|
|
|
135
135
|
|
|
136
|
-
class
|
|
136
|
+
class TestAdminAddPeoplePage:
|
|
137
137
|
@pytest.mark.anyio
|
|
138
|
-
async def
|
|
138
|
+
async def test_add_page_renders_with_roles(self, admin_client):
|
|
139
139
|
resp = await admin_client.get(
|
|
140
|
-
"/users/admin/
|
|
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/
|
|
145
|
+
assert data["component"] == "Users/Users/AddPeople"
|
|
146
146
|
assert "roles" in data["props"]
|
|
147
147
|
|
|
148
148
|
@pytest.mark.anyio
|
|
149
|
-
async def
|
|
150
|
-
|
|
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)
|