simple-module-users 0.0.19__tar.gz → 0.0.20__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 (109) hide show
  1. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/.gitignore +1 -0
  2. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/PKG-INFO +6 -6
  3. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/pyproject.toml +6 -6
  4. simple_module_users-0.0.20/tests/test_api_admin_crud.py +163 -0
  5. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_negative_authz.py +7 -0
  6. simple_module_users-0.0.20/tests/test_service_admin_crud.py +172 -0
  7. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_views_admin.py +23 -0
  8. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/admin/api.py +86 -3
  9. simple_module_users-0.0.19/users/admin/service.py → simple_module_users-0.0.20/users/admin/queries.py +38 -104
  10. simple_module_users-0.0.20/users/admin/service.py +212 -0
  11. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/admin/views.py +18 -0
  12. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/contracts/events.py +12 -0
  13. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/contracts/schemas.py +21 -2
  14. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/db_adapter.py +18 -5
  15. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/exceptions.py +8 -0
  16. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/user.py +7 -2
  17. simple_module_users-0.0.20/users/pages/Users/Create.tsx +175 -0
  18. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Users/Edit.tsx +12 -1
  19. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Users/Index.tsx +14 -6
  20. simple_module_users-0.0.20/users/pages/Users/components/DangerZone.tsx +85 -0
  21. simple_module_users-0.0.20/users/pages/Users/components/DetailsCard.tsx +81 -0
  22. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/provider.py +14 -4
  23. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/LICENSE +0 -0
  24. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/README.md +0 -0
  25. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/package.json +0 -0
  26. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/.gitkeep +0 -0
  27. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/_middleware_support.py +0 -0
  28. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/conftest.py +0 -0
  29. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_access_token_model.py +0 -0
  30. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_api_admin.py +0 -0
  31. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_api_admin_filters.py +0 -0
  32. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_api_auth.py +0 -0
  33. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_backend.py +0 -0
  34. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_bootstrap.py +0 -0
  35. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_bootstrap_resolution.py +0 -0
  36. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_cli.py +0 -0
  37. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_constants.py +0 -0
  38. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_db_adapter.py +0 -0
  39. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_invite_flow.py +0 -0
  40. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_invite_reuse.py +0 -0
  41. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_mailer.py +0 -0
  42. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_oauth.py +0 -0
  43. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_oauth_routes.py +0 -0
  44. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_rate_limit.py +0 -0
  45. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_role_model.py +0 -0
  46. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_service_admin.py +0 -0
  47. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_settings.py +0 -0
  48. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_token_api.py +0 -0
  49. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_user_manager.py +0 -0
  50. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_user_model.py +0 -0
  51. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_user_role_model.py +0 -0
  52. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_user_service.py +0 -0
  53. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_users_deps.py +0 -0
  54. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_users_middleware.py +0 -0
  55. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_users_middleware_public_paths.py +0 -0
  56. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_users_middleware_resolvers.py +0 -0
  57. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_users_provider.py +0 -0
  58. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_views.py +0 -0
  59. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tsconfig.json +0 -0
  60. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/__init__.py +0 -0
  61. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/admin/__init__.py +0 -0
  62. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/admin/components/IndexFilters.tsx +0 -0
  63. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/admin/components/RolesTab.tsx +0 -0
  64. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/admin/components/UserRow.tsx +0 -0
  65. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/auth_local/__init__.py +0 -0
  66. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/auth_local/api.py +0 -0
  67. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/auth_local/rate_limit.py +0 -0
  68. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/auth_local/token_api.py +0 -0
  69. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/auth_local/views.py +0 -0
  70. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/backend.py +0 -0
  71. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/bootstrap.py +0 -0
  72. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/cli.py +0 -0
  73. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/constants.py +0 -0
  74. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/contracts/__init__.py +0 -0
  75. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/deps.py +0 -0
  76. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/__init__.py +0 -0
  77. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/console.py +0 -0
  78. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/smtp.py +0 -0
  79. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/templates/.gitkeep +0 -0
  80. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/templates/invite.txt +0 -0
  81. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/templates/reset_password.txt +0 -0
  82. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/templates/verify_email.txt +0 -0
  83. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/manager.py +0 -0
  84. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/middleware.py +0 -0
  85. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/__init__.py +0 -0
  86. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/_base.py +0 -0
  87. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/access_token.py +0 -0
  88. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/oauth_account.py +0 -0
  89. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/refresh_token.py +0 -0
  90. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/role.py +0 -0
  91. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/user_role.py +0 -0
  92. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/module.py +0 -0
  93. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/oauth/__init__.py +0 -0
  94. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/oauth/api.py +0 -0
  95. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/oauth/providers.py +0 -0
  96. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/.gitkeep +0 -0
  97. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/AcceptInvite.tsx +0 -0
  98. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/ForgotPassword.tsx +0 -0
  99. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Login.tsx +0 -0
  100. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Profile.tsx +0 -0
  101. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Register.tsx +0 -0
  102. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/ResetPassword.tsx +0 -0
  103. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Users/Invite.tsx +0 -0
  104. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
  105. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/VerifyEmail.tsx +0 -0
  106. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/py.typed +0 -0
  107. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/roles_cache.py +0 -0
  108. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/settings.py +0 -0
  109. {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/state.py +0 -0
@@ -61,3 +61,4 @@ Thumbs.db
61
61
  .playwright-mcp/*
62
62
  host/client_app/.playwright-cli/*
63
63
  .superpowers/
64
+ .qa/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_users
3
- Version: 0.0.19
3
+ Version: 0.0.20
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.19
28
- Requires-Dist: simple-module-core==0.0.19
29
- Requires-Dist: simple-module-db==0.0.19
30
- Requires-Dist: simple-module-hosting==0.0.19
31
- Requires-Dist: simple-module-settings==0.0.19
27
+ Requires-Dist: simple-module-auth==0.0.20
28
+ Requires-Dist: simple-module-core==0.0.20
29
+ Requires-Dist: simple-module-db==0.0.20
30
+ Requires-Dist: simple-module-hosting==0.0.20
31
+ Requires-Dist: simple-module-settings==0.0.20
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.19"
3
+ version = "0.0.20"
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.19",
25
- "simple_module_db==0.0.19",
26
- "simple_module_hosting==0.0.19",
27
- "simple_module_settings==0.0.19",
28
- "simple_module_auth==0.0.19",
24
+ "simple_module_core==0.0.20",
25
+ "simple_module_db==0.0.20",
26
+ "simple_module_hosting==0.0.20",
27
+ "simple_module_settings==0.0.20",
28
+ "simple_module_auth==0.0.20",
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,163 @@
1
+ """Admin REST CRUD tests: create / update / delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ import pytest
8
+ from sqlalchemy import select
9
+ from test_api_admin import _make_user
10
+ from users.models import User, UserRole
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Admin create
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ class TestAdminCreate:
18
+ @pytest.mark.anyio
19
+ async def test_create_returns_201(self, admin_client):
20
+ resp = await admin_client.post(
21
+ "/api/users/admin",
22
+ json={
23
+ "email": "newuser@example.com",
24
+ "password": "SecurePass1!",
25
+ "full_name": "New User",
26
+ "role_names": ["user"],
27
+ },
28
+ )
29
+ assert resp.status_code == 201
30
+ body = resp.json()
31
+ assert body["email"] == "newuser@example.com"
32
+ assert body["is_active"] is True
33
+ assert body["is_verified"] is True
34
+ assert body["roles"] == ["user"]
35
+
36
+ @pytest.mark.anyio
37
+ async def test_create_duplicate_returns_409(self, admin_client, users_db):
38
+ await _make_user(users_db, email="taken@example.com")
39
+ resp = await admin_client.post(
40
+ "/api/users/admin",
41
+ json={"email": "taken@example.com", "password": "SecurePass1!"},
42
+ )
43
+ assert resp.status_code == 409
44
+ assert "already exists" in resp.json()["detail"]
45
+
46
+ @pytest.mark.anyio
47
+ async def test_create_weak_password_returns_400(self, admin_client):
48
+ resp = await admin_client.post(
49
+ "/api/users/admin",
50
+ json={"email": "weakpw@example.com", "password": "short"},
51
+ )
52
+ assert resp.status_code == 400
53
+ assert "8 characters" in resp.json()["detail"]
54
+
55
+ @pytest.mark.anyio
56
+ async def test_create_without_auth_is_rejected(self, anon_client):
57
+ resp = await anon_client.post(
58
+ "/api/users/admin",
59
+ json={"email": "hacker@example.com", "password": "SecurePass1!"},
60
+ follow_redirects=False,
61
+ )
62
+ assert resp.status_code == 401
63
+ assert resp.json() == {"detail": "Not authenticated"}
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Admin update details
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ class TestAdminUpdate:
72
+ @pytest.mark.anyio
73
+ async def test_update_changes_email_and_name(self, admin_client, users_db):
74
+ user = await _make_user(users_db, email="before@example.com")
75
+ resp = await admin_client.patch(
76
+ f"/api/users/admin/{user.id}",
77
+ json={"email": "after@example.com", "full_name": "After Name"},
78
+ )
79
+ assert resp.status_code == 200
80
+ body = resp.json()
81
+ assert body["email"] == "after@example.com"
82
+ assert body["full_name"] == "After Name"
83
+
84
+ @pytest.mark.anyio
85
+ async def test_update_duplicate_email_returns_409(self, admin_client, users_db):
86
+ await _make_user(users_db, email="exists@example.com")
87
+ target = await _make_user(users_db, email="target@example.com")
88
+ resp = await admin_client.patch(
89
+ f"/api/users/admin/{target.id}",
90
+ json={"email": "exists@example.com"},
91
+ )
92
+ assert resp.status_code == 409
93
+
94
+ @pytest.mark.anyio
95
+ async def test_update_nonexistent_returns_404(self, admin_client):
96
+ resp = await admin_client.patch(
97
+ f"/api/users/admin/{uuid.uuid4()}",
98
+ json={"email": "ghost@example.com"},
99
+ )
100
+ assert resp.status_code == 404
101
+
102
+ @pytest.mark.anyio
103
+ async def test_update_without_auth_is_rejected(self, anon_client):
104
+ resp = await anon_client.patch(
105
+ f"/api/users/admin/{uuid.uuid4()}",
106
+ json={"email": "x@example.com"},
107
+ follow_redirects=False,
108
+ )
109
+ assert resp.status_code == 401
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Admin delete
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ class TestAdminDelete:
118
+ @pytest.mark.anyio
119
+ async def test_delete_returns_204(self, admin_client, users_db):
120
+ user = await _make_user(users_db, email="deleteme@example.com")
121
+ resp = await admin_client.delete(f"/api/users/admin/{user.id}")
122
+ assert resp.status_code == 204
123
+
124
+ @pytest.mark.anyio
125
+ async def test_delete_user_with_role_returns_204(self, admin_client, users_db):
126
+ """Regression: deleting a user that HAS a role must not 500.
127
+
128
+ The delete runs in the request session, which eager-loads the user's
129
+ ``roles`` relationship. Bulk-deleting the ``users_user_role`` rows the
130
+ ORM is tracking raised ``StaleDataError`` on flush. The roleless
131
+ ``test_delete_returns_204`` never exercised this path."""
132
+ user = await _make_user(users_db, email="hasrole@example.com", role_names=["admin"])
133
+ resp = await admin_client.delete(f"/api/users/admin/{user.id}")
134
+ assert resp.status_code == 204
135
+ # The role association row is gone too (no orphan).
136
+ rows = (
137
+ (await users_db.execute(select(UserRole).where(UserRole.user_id == user.id)))
138
+ .scalars()
139
+ .all()
140
+ )
141
+ assert rows == []
142
+
143
+ @pytest.mark.anyio
144
+ async def test_delete_nonexistent_returns_404(self, admin_client):
145
+ resp = await admin_client.delete(f"/api/users/admin/{uuid.uuid4()}")
146
+ assert resp.status_code == 404
147
+
148
+ @pytest.mark.anyio
149
+ async def test_delete_self_returns_400(self, admin_client, users_app):
150
+ async with users_app.state.sm.db.session_factory() as session:
151
+ admin = (
152
+ await session.execute(select(User).where(User.email == "admin@example.com"))
153
+ ).scalar_one()
154
+ resp = await admin_client.delete(f"/api/users/admin/{admin.id}")
155
+ assert resp.status_code == 400
156
+
157
+ @pytest.mark.anyio
158
+ async def test_delete_without_auth_is_rejected(self, anon_client):
159
+ resp = await anon_client.delete(
160
+ f"/api/users/admin/{uuid.uuid4()}",
161
+ follow_redirects=False,
162
+ )
163
+ assert resp.status_code == 401
@@ -27,6 +27,13 @@ _PROTECTED_ENDPOINTS: tuple[tuple[str, str, dict | None], ...] = (
27
27
  ("PUT", f"/api/users/admin/{_FAKE_ID}/roles", {"role_names": []}),
28
28
  ("PATCH", f"/api/users/admin/{_FAKE_ID}/verify", None),
29
29
  ("POST", f"/api/users/admin/{_FAKE_ID}/reset-password-link", None),
30
+ (
31
+ "POST",
32
+ "/api/users/admin",
33
+ {"email": "x@y.test", "password": "SecurePass1!", "role_names": []},
34
+ ),
35
+ ("PATCH", f"/api/users/admin/{_FAKE_ID}", {"email": "x@y.test"}),
36
+ ("DELETE", f"/api/users/admin/{_FAKE_ID}", None),
30
37
  # permissions — root GET lists registered groups (PERM_VIEW)
31
38
  ("GET", "/api/permissions/", None),
32
39
  ("GET", f"/api/permissions/roles/{_FAKE_ID}", None),
@@ -0,0 +1,172 @@
1
+ """UserService write-op tests: create / update / delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ import pytest
8
+ from test_service_admin import _build_service
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # create_user
12
+ # ---------------------------------------------------------------------------
13
+
14
+
15
+ @pytest.mark.anyio
16
+ async def test_create_user_active_verified_with_roles(users_app):
17
+ """create_user makes an active+verified user and assigns roles."""
18
+ async with users_app.state.sm.db.session_factory() as session:
19
+ svc = _build_service(session, users_app)
20
+ user = await svc.create_user(
21
+ email="created@example.com",
22
+ password="SecurePass1!",
23
+ full_name="Created User",
24
+ role_names=["user"],
25
+ created_by="admin-id",
26
+ )
27
+ assert user.is_active is True
28
+ assert user.is_verified is True
29
+ assert user.email == "created@example.com"
30
+ assert user.full_name == "Created User"
31
+ assert [r.name for r in user.roles] == ["user"]
32
+
33
+
34
+ @pytest.mark.anyio
35
+ async def test_create_user_weak_password_rejected(users_app):
36
+ from fastapi_users import exceptions as fa_exc
37
+
38
+ async with users_app.state.sm.db.session_factory() as session:
39
+ svc = _build_service(session, users_app)
40
+ with pytest.raises(fa_exc.InvalidPasswordException):
41
+ await svc.create_user(
42
+ email="weak@example.com",
43
+ password="short",
44
+ full_name=None,
45
+ role_names=[],
46
+ created_by=None,
47
+ )
48
+
49
+
50
+ @pytest.mark.anyio
51
+ async def test_create_user_duplicate_email_rejected(users_app):
52
+ from fastapi_users import exceptions as fa_exc
53
+
54
+ async with users_app.state.sm.db.session_factory() as session:
55
+ svc = _build_service(session, users_app)
56
+ await svc.create_user(
57
+ email="dup@example.com",
58
+ password="SecurePass1!",
59
+ full_name=None,
60
+ role_names=[],
61
+ created_by=None,
62
+ )
63
+ await session.flush()
64
+ with pytest.raises(fa_exc.UserAlreadyExists):
65
+ await svc.create_user(
66
+ email="dup@example.com",
67
+ password="SecurePass1!",
68
+ full_name=None,
69
+ role_names=[],
70
+ created_by=None,
71
+ )
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # update_details
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ @pytest.mark.anyio
80
+ async def test_update_details_changes_email_and_name(users_app):
81
+ from test_api_admin import _make_user
82
+
83
+ async with users_app.state.sm.db.session_factory() as session:
84
+ user = await _make_user(session, email="old@example.com")
85
+ svc = _build_service(session, users_app)
86
+ updated = await svc.update_details(user.id, email="new@example.com", full_name="New Name")
87
+ assert updated.email == "new@example.com"
88
+ assert updated.full_name == "New Name"
89
+
90
+
91
+ @pytest.mark.anyio
92
+ async def test_update_details_duplicate_email_rejected(users_app):
93
+ from test_api_admin import _make_user
94
+ from users.exceptions import EmailAlreadyExistsError
95
+
96
+ async with users_app.state.sm.db.session_factory() as session:
97
+ await _make_user(session, email="a@example.com")
98
+ target = await _make_user(session, email="b@example.com")
99
+ svc = _build_service(session, users_app)
100
+ with pytest.raises(EmailAlreadyExistsError):
101
+ await svc.update_details(target.id, email="a@example.com", full_name=None)
102
+
103
+
104
+ @pytest.mark.anyio
105
+ async def test_update_details_same_email_is_allowed(users_app):
106
+ from test_api_admin import _make_user
107
+
108
+ async with users_app.state.sm.db.session_factory() as session:
109
+ user = await _make_user(session, email="keep@example.com")
110
+ svc = _build_service(session, users_app)
111
+ updated = await svc.update_details(user.id, email="keep@example.com", full_name="Renamed")
112
+ assert updated.email == "keep@example.com"
113
+ assert updated.full_name == "Renamed"
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # delete_user
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ @pytest.mark.anyio
122
+ async def test_delete_user_removes_user_and_all_child_rows(users_app):
123
+ from datetime import UTC, datetime, timedelta
124
+
125
+ from sqlalchemy import select
126
+ from test_api_admin import _make_user
127
+ from users.models import (
128
+ OAuthAccount,
129
+ RefreshToken,
130
+ User,
131
+ UserAccessToken,
132
+ UserRole,
133
+ )
134
+
135
+ async with users_app.state.sm.db.session_factory() as session:
136
+ user = await _make_user(session, email="todelete@example.com", role_names=["admin"])
137
+ session.add(UserAccessToken(token=f"tok-{user.id.hex}", user_id=user.id))
138
+ session.add(
139
+ OAuthAccount(
140
+ user_id=user.id,
141
+ oauth_name="google",
142
+ access_token="x",
143
+ account_id="acct-1",
144
+ account_email="todelete@example.com",
145
+ )
146
+ )
147
+ session.add(RefreshToken(user_id=user.id, expires_at=datetime.now(UTC) + timedelta(days=1)))
148
+ await session.flush()
149
+
150
+ svc = _build_service(session, users_app)
151
+ await svc.delete_user(user.id)
152
+
153
+ assert (
154
+ await session.execute(select(User).where(User.id == user.id))
155
+ ).scalar_one_or_none() is None
156
+ for model in (UserRole, UserAccessToken, OAuthAccount, RefreshToken):
157
+ rows = (
158
+ (await session.execute(select(model).where(model.user_id == user.id)))
159
+ .scalars()
160
+ .all()
161
+ )
162
+ assert rows == [], f"{model.__name__} rows not cleaned up"
163
+
164
+
165
+ @pytest.mark.anyio
166
+ async def test_delete_user_nonexistent_raises(users_app):
167
+ from users.exceptions import UserNotFoundError
168
+
169
+ async with users_app.state.sm.db.session_factory() as session:
170
+ svc = _build_service(session, users_app)
171
+ with pytest.raises(UserNotFoundError):
172
+ await svc.delete_user(uuid.uuid4())
@@ -126,3 +126,26 @@ class TestHasPermissionsModuleFlag:
126
126
  assert resp.status_code == 200
127
127
  props = resp.json()["props"]
128
128
  assert props["has_permissions_module"] is False
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Admin create page
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ class TestAdminCreatePage:
137
+ @pytest.mark.anyio
138
+ async def test_create_page_renders_with_roles(self, admin_client):
139
+ resp = await admin_client.get(
140
+ "/users/admin/create",
141
+ headers={"X-Inertia": "true", "Accept": "application/json"},
142
+ )
143
+ assert resp.status_code == 200
144
+ data = resp.json()
145
+ assert data["component"] == "Users/Users/Create"
146
+ assert "roles" in data["props"]
147
+
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)
151
+ assert resp.status_code == 302
@@ -4,22 +4,31 @@ from __future__ import annotations
4
4
 
5
5
  import uuid
6
6
 
7
- from fastapi import APIRouter, Depends, HTTPException, Request
7
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
8
8
  from fastapi import status as http_status
9
+ from fastapi_users import exceptions as fa_exceptions
9
10
  from simple_module_core.events import EventBus
10
11
  from simple_module_hosting.permissions import RequiresPermission
11
12
 
12
13
  from users.admin.service import UserService
13
14
  from users.constants import PERM_USERS_MANAGE, sanitize_list_filters
14
- from users.contracts.events import RoleAssigned, UserDisabled, UserInvited
15
+ from users.contracts.events import (
16
+ RoleAssigned,
17
+ UserCreated,
18
+ UserDeleted,
19
+ UserDisabled,
20
+ UserInvited,
21
+ )
15
22
  from users.contracts.schemas import (
16
23
  PasswordResetLink,
17
24
  RoleAssignment,
25
+ UserAdminCreate,
26
+ UserDetailsUpdate,
18
27
  UserInvite,
19
28
  UserListItem,
20
29
  )
21
30
  from users.deps import get_event_bus, get_mailer, get_user_service
22
- from users.exceptions import UserNotFoundError
31
+ from users.exceptions import EmailAlreadyExistsError, UserNotFoundError
23
32
 
24
33
  admin_router = APIRouter(
25
34
  prefix="/admin",
@@ -84,6 +93,80 @@ async def admin_invite_user(
84
93
  return service.to_list_item(user)
85
94
 
86
95
 
96
+ @admin_router.post(
97
+ "",
98
+ response_model=UserListItem,
99
+ status_code=http_status.HTTP_201_CREATED,
100
+ )
101
+ async def admin_create_user(
102
+ data: UserAdminCreate,
103
+ request: Request,
104
+ bus: EventBus = Depends(get_event_bus),
105
+ service: UserService = Depends(get_user_service),
106
+ ):
107
+ """Create an active+verified user with an admin-set password."""
108
+ creator = getattr(request.state, "user", None)
109
+ created_by = str(creator.id) if creator else None
110
+ try:
111
+ user = await service.create_user(
112
+ data.email,
113
+ data.password,
114
+ data.full_name,
115
+ data.role_names,
116
+ created_by=created_by,
117
+ )
118
+ except fa_exceptions.UserAlreadyExists:
119
+ raise HTTPException(
120
+ status_code=409,
121
+ detail="A user with this email already exists.",
122
+ ) from None
123
+ except fa_exceptions.InvalidPasswordException as exc:
124
+ raise HTTPException(status_code=400, detail=exc.reason) from None
125
+ await bus.publish(UserCreated(user_id=user.id, email=user.email, created_by=created_by))
126
+ return service.to_list_item(user)
127
+
128
+
129
+ @admin_router.patch("/{user_id}", response_model=UserListItem)
130
+ async def admin_update_user(
131
+ user_id: uuid.UUID,
132
+ data: UserDetailsUpdate,
133
+ service: UserService = Depends(get_user_service),
134
+ ):
135
+ """Update a user's email and full name."""
136
+ try:
137
+ user = await service.update_details(user_id, data.email, data.full_name)
138
+ except UserNotFoundError:
139
+ raise HTTPException(status_code=404, detail="User not found") from None
140
+ except EmailAlreadyExistsError:
141
+ raise HTTPException(
142
+ status_code=409,
143
+ detail="A user with this email already exists.",
144
+ ) from None
145
+ return service.to_list_item(user)
146
+
147
+
148
+ @admin_router.delete("/{user_id}", status_code=http_status.HTTP_204_NO_CONTENT)
149
+ async def admin_delete_user(
150
+ user_id: uuid.UUID,
151
+ request: Request,
152
+ bus: EventBus = Depends(get_event_bus),
153
+ service: UserService = Depends(get_user_service),
154
+ ):
155
+ """Hard-delete a user. An admin cannot delete their own account."""
156
+ actor = getattr(request.state, "user", None)
157
+ if actor is not None and str(user_id) == actor.id:
158
+ raise HTTPException(
159
+ status_code=400,
160
+ detail="You cannot delete your own account.",
161
+ )
162
+ try:
163
+ await service.delete_user(user_id)
164
+ except UserNotFoundError:
165
+ raise HTTPException(status_code=404, detail="User not found") from None
166
+ await bus.publish(UserDeleted(user_id=user_id))
167
+ return Response(status_code=http_status.HTTP_204_NO_CONTENT)
168
+
169
+
87
170
  @admin_router.patch("/{user_id}/disable", response_model=UserListItem)
88
171
  async def admin_disable_user(
89
172
  user_id: uuid.UUID,