simple-module-users 0.0.14__tar.gz → 0.0.16__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 (98) hide show
  1. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/PKG-INFO +6 -6
  2. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/pyproject.toml +6 -6
  3. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/_middleware_support.py +14 -12
  4. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_api_admin.py +8 -9
  5. simple_module_users-0.0.16/tests/test_users_middleware_resolvers.py +190 -0
  6. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_views.py +34 -0
  7. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/middleware.py +29 -4
  8. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/module.py +15 -4
  9. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/AcceptInvite.tsx +2 -1
  10. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/ForgotPassword.tsx +2 -0
  11. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/Login.tsx +2 -2
  12. simple_module_users-0.0.16/users/pages/Profile.tsx +154 -0
  13. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/Register.tsx +2 -0
  14. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/ResetPassword.tsx +2 -1
  15. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/VerifyEmail.tsx +2 -1
  16. simple_module_users-0.0.14/users/pages/Profile.tsx +0 -148
  17. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/.gitignore +0 -0
  18. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/LICENSE +0 -0
  19. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/README.md +0 -0
  20. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/package.json +0 -0
  21. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/.gitkeep +0 -0
  22. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/conftest.py +0 -0
  23. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_access_token_model.py +0 -0
  24. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_api_admin_filters.py +0 -0
  25. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_api_auth.py +0 -0
  26. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_backend.py +0 -0
  27. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_bootstrap.py +0 -0
  28. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_bootstrap_resolution.py +0 -0
  29. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_cli.py +0 -0
  30. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_constants.py +0 -0
  31. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_db_adapter.py +0 -0
  32. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_invite_flow.py +0 -0
  33. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_invite_reuse.py +0 -0
  34. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_mailer.py +0 -0
  35. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_negative_authz.py +0 -0
  36. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_oauth.py +0 -0
  37. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_rate_limit.py +0 -0
  38. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_role_model.py +0 -0
  39. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_service_admin.py +0 -0
  40. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_settings.py +0 -0
  41. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_user_manager.py +0 -0
  42. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_user_model.py +0 -0
  43. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_user_role_model.py +0 -0
  44. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_user_service.py +0 -0
  45. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_users_deps.py +0 -0
  46. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_users_middleware.py +0 -0
  47. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_users_middleware_public_paths.py +0 -0
  48. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_views_admin.py +0 -0
  49. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tsconfig.json +0 -0
  50. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/__init__.py +0 -0
  51. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/__init__.py +0 -0
  52. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/api.py +0 -0
  53. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/components/IndexFilters.tsx +0 -0
  54. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/components/RolesTab.tsx +0 -0
  55. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/components/UserRow.tsx +0 -0
  56. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/service.py +0 -0
  57. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/views.py +0 -0
  58. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/auth_local/__init__.py +0 -0
  59. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/auth_local/api.py +0 -0
  60. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/auth_local/rate_limit.py +0 -0
  61. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/auth_local/views.py +0 -0
  62. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/backend.py +0 -0
  63. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/bootstrap.py +0 -0
  64. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/cli.py +0 -0
  65. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/constants.py +0 -0
  66. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/contracts/__init__.py +0 -0
  67. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/contracts/events.py +0 -0
  68. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/contracts/schemas.py +0 -0
  69. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/db_adapter.py +0 -0
  70. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/deps.py +0 -0
  71. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/exceptions.py +0 -0
  72. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/__init__.py +0 -0
  73. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/console.py +0 -0
  74. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/smtp.py +0 -0
  75. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/templates/.gitkeep +0 -0
  76. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/templates/invite.txt +0 -0
  77. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/templates/reset_password.txt +0 -0
  78. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/templates/verify_email.txt +0 -0
  79. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/manager.py +0 -0
  80. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/__init__.py +0 -0
  81. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/_base.py +0 -0
  82. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/access_token.py +0 -0
  83. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/oauth_account.py +0 -0
  84. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/role.py +0 -0
  85. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/user.py +0 -0
  86. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/user_role.py +0 -0
  87. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/oauth/__init__.py +0 -0
  88. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/oauth/api.py +0 -0
  89. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/oauth/providers.py +0 -0
  90. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/.gitkeep +0 -0
  91. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/Users/Edit.tsx +0 -0
  92. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/Users/Index.tsx +0 -0
  93. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/Users/Invite.tsx +0 -0
  94. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
  95. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/py.typed +0 -0
  96. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/roles_cache.py +0 -0
  97. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/settings.py +0 -0
  98. {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_users
3
- Version: 0.0.14
3
+ Version: 0.0.16
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.14
28
- Requires-Dist: simple-module-core==0.0.14
29
- Requires-Dist: simple-module-db==0.0.14
30
- Requires-Dist: simple-module-hosting==0.0.14
31
- Requires-Dist: simple-module-settings==0.0.14
27
+ Requires-Dist: simple-module-auth==0.0.16
28
+ Requires-Dist: simple-module-core==0.0.16
29
+ Requires-Dist: simple-module-db==0.0.16
30
+ Requires-Dist: simple-module-hosting==0.0.16
31
+ Requires-Dist: simple-module-settings==0.0.16
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.14"
3
+ version = "0.0.16"
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.14",
25
- "simple_module_db==0.0.14",
26
- "simple_module_hosting==0.0.14",
27
- "simple_module_settings==0.0.14",
28
- "simple_module_auth==0.0.14",
24
+ "simple_module_core==0.0.16",
25
+ "simple_module_db==0.0.16",
26
+ "simple_module_hosting==0.0.16",
27
+ "simple_module_settings==0.0.16",
28
+ "simple_module_auth==0.0.16",
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.
@@ -9,15 +9,13 @@ parameters.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- import json
13
12
  import uuid
14
- from base64 import b64encode
15
13
  from types import SimpleNamespace
16
14
  from typing import Any
17
15
 
18
16
  import pytest
19
17
  from fastapi import FastAPI, Request
20
- from itsdangerous import TimestampSigner
18
+ from simple_module_test import forge_session_cookie
21
19
  from starlette.middleware.sessions import SessionMiddleware
22
20
  from starlette.responses import JSONResponse
23
21
  from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID
@@ -26,18 +24,19 @@ from users.middleware import AuthMiddleware
26
24
  SECRET_KEY = "test-secret-key-for-session-middleware"
27
25
 
28
26
 
29
- def _sign_session(data: dict[str, Any], secret: str = SECRET_KEY) -> str:
30
- """Encode and sign a session dict exactly as Starlette's SessionMiddleware does."""
31
- raw = b64encode(json.dumps(data).encode()).decode()
32
- return TimestampSigner(secret).sign(raw).decode("utf-8")
33
-
34
-
35
27
  def _session_cookie(data: dict[str, Any]) -> dict[str, str]:
36
- return {"session": _sign_session(data)}
28
+ return {"session": forge_session_cookie(SECRET_KEY, data)}
37
29
 
38
30
 
39
- async def _build_app(db_state, inner_handler=None):
40
- """Build a minimal ASGI app with AuthMiddleware + SessionMiddleware."""
31
+ async def _build_app(db_state, inner_handler=None, *, principal_resolvers=None):
32
+ """Build a minimal ASGI app with AuthMiddleware + SessionMiddleware.
33
+
34
+ ``principal_resolvers`` (optional) is a list of resolvers seeded onto
35
+ ``app.state.auth.principal_resolvers`` before the middleware runs.
36
+ Defaults to an empty registry — matches a production app where no
37
+ downstream module has registered anything.
38
+ """
39
+ from auth.state import AuthState
41
40
 
42
41
  async def _default_handler(request: Request):
43
42
  user = getattr(request.state, "user", None)
@@ -62,6 +61,9 @@ async def _build_app(db_state, inner_handler=None):
62
61
 
63
62
  app = FastAPI()
64
63
  app.state.sm = SimpleNamespace(db=db_state)
64
+ app.state.auth = AuthState(
65
+ principal_resolvers=list(principal_resolvers or []),
66
+ )
65
67
 
66
68
  @app.get("/{path:path}")
67
69
  async def _catch_all(request: Request, path: str = ""):
@@ -46,11 +46,10 @@ class TestAdminList:
46
46
  @pytest.mark.anyio
47
47
  async def test_list_without_auth_is_rejected(self, anon_client):
48
48
  resp = await anon_client.get("/api/users/admin", follow_redirects=False)
49
- # AuthMiddleware redirects unauthenticated non-public API paths to
50
- # /users/login. Preserving the 302 here so a regression to 401 or
51
- # pass-through is caught.
52
- assert resp.status_code == 302
53
- assert resp.headers["location"].endswith("/users/login")
49
+ # AuthMiddleware returns 401 JSON for unauthenticated /api/* paths
50
+ # (view routes still get a 302 redirect to /users/login).
51
+ assert resp.status_code == 401
52
+ assert resp.json() == {"detail": "Not authenticated"}
54
53
 
55
54
  @pytest.mark.anyio
56
55
  async def test_list_as_admin_returns_200(self, admin_client, users_db):
@@ -128,8 +127,8 @@ class TestAdminInvite:
128
127
  json={"email": "hacker@example.com"},
129
128
  follow_redirects=False,
130
129
  )
131
- assert resp.status_code == 302
132
- assert resp.headers["location"].endswith("/users/login")
130
+ assert resp.status_code == 401
131
+ assert resp.json() == {"detail": "Not authenticated"}
133
132
 
134
133
 
135
134
  # ---------------------------------------------------------------------------
@@ -205,8 +204,8 @@ class TestAdminSetRoles:
205
204
  json={"role_names": ["admin"]},
206
205
  follow_redirects=False,
207
206
  )
208
- assert resp.status_code == 302
209
- assert resp.headers["location"].endswith("/users/login")
207
+ assert resp.status_code == 401
208
+ assert resp.json() == {"detail": "Not authenticated"}
210
209
 
211
210
  @pytest.mark.anyio
212
211
  async def test_set_roles_nonexistent_returns_404(self, admin_client):
@@ -0,0 +1,190 @@
1
+ """AuthMiddleware tests for the principal-resolver chain.
2
+
3
+ Covers ``app.state.auth.principal_resolvers`` consultation order, error
4
+ isolation, the session-short-circuit precedence, and the /api/* vs view-path
5
+ unauthenticated split (401 JSON vs 302 redirect).
6
+
7
+ Helpers + fixtures live in ``_middleware_support`` alongside the broader
8
+ middleware tests in ``test_users_middleware`` and the PUBLIC_PATHS suite in
9
+ ``test_users_middleware_public_paths``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import httpx
15
+ import pytest
16
+ from _middleware_support import _build_app, _session_cookie
17
+ from fastapi import Request
18
+ from starlette.responses import JSONResponse
19
+
20
+
21
+ def _ctx(uid: str = "11111111-1111-1111-1111-111111111111", **overrides):
22
+ """Build a UserContext for resolver tests."""
23
+ from auth.contracts.schemas import UserContext
24
+
25
+ fields = {
26
+ "id": uid,
27
+ "email": "pat@example.com",
28
+ "name": "PAT User",
29
+ "roles": ["user"],
30
+ "tenant_id": None,
31
+ }
32
+ fields.update(overrides)
33
+ return UserContext(**fields)
34
+
35
+
36
+ @pytest.mark.anyio
37
+ async def test_resolver_returning_context_authenticates_request(db_state):
38
+ """A registered resolver that returns a UserContext authenticates the request."""
39
+
40
+ async def stub_resolver(request):
41
+ return _ctx()
42
+
43
+ app = await _build_app(db_state, principal_resolvers=[stub_resolver])
44
+ transport = httpx.ASGITransport(app=app)
45
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
46
+ resp = await client.get("/dashboard")
47
+
48
+ assert resp.status_code == 200
49
+ data = resp.json()
50
+ assert data["user"]["email"] == "pat@example.com"
51
+
52
+
53
+ @pytest.mark.anyio
54
+ async def test_resolver_first_non_none_wins(db_state):
55
+ """The first resolver returning a context wins; later resolvers are not consulted."""
56
+ second_called = False
57
+
58
+ async def first_none(request):
59
+ return None
60
+
61
+ async def second_returns(request):
62
+ nonlocal second_called
63
+ second_called = True
64
+ return _ctx(email="second@example.com")
65
+
66
+ async def third_should_not_run(request):
67
+ raise AssertionError("third resolver should not run after a match")
68
+
69
+ app = await _build_app(
70
+ db_state,
71
+ principal_resolvers=[first_none, second_returns, third_should_not_run],
72
+ )
73
+ transport = httpx.ASGITransport(app=app)
74
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
75
+ resp = await client.get("/dashboard")
76
+
77
+ assert resp.status_code == 200
78
+ assert resp.json()["user"]["email"] == "second@example.com"
79
+ assert second_called
80
+
81
+
82
+ @pytest.mark.anyio
83
+ async def test_all_resolvers_return_none_falls_through_to_redirect(db_state):
84
+ """When every resolver returns None for a view route → 302 to /users/login."""
85
+
86
+ async def none_resolver(request):
87
+ return None
88
+
89
+ app = await _build_app(db_state, principal_resolvers=[none_resolver])
90
+ transport = httpx.ASGITransport(app=app)
91
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
92
+ resp = await client.get("/dashboard", follow_redirects=False)
93
+
94
+ assert resp.status_code == 302
95
+ assert resp.headers["location"] == "/users/login"
96
+
97
+
98
+ @pytest.mark.anyio
99
+ async def test_resolver_raising_does_not_crash_middleware(db_state, caplog):
100
+ """A resolver that raises is logged and the chain continues to the next."""
101
+ import logging
102
+
103
+ async def boom(request):
104
+ raise RuntimeError("resolver kaboom")
105
+
106
+ async def fallback(request):
107
+ return _ctx(email="fallback@example.com")
108
+
109
+ app = await _build_app(db_state, principal_resolvers=[boom, fallback])
110
+ transport = httpx.ASGITransport(app=app)
111
+ with caplog.at_level(logging.ERROR, logger="users.middleware"):
112
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
113
+ resp = await client.get("/dashboard")
114
+
115
+ assert resp.status_code == 200
116
+ assert resp.json()["user"]["email"] == "fallback@example.com"
117
+ assert any("resolver" in rec.message.lower() for rec in caplog.records)
118
+
119
+
120
+ @pytest.mark.anyio
121
+ async def test_api_path_unauthenticated_returns_401_json(db_state):
122
+ """Unauthenticated /api/private should return 401 JSON, not a 302 redirect."""
123
+ app = await _build_app(db_state)
124
+ transport = httpx.ASGITransport(app=app)
125
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
126
+ resp = await client.get("/api/private-thing", follow_redirects=False)
127
+
128
+ assert resp.status_code == 401
129
+ assert resp.headers["content-type"].startswith("application/json")
130
+ assert resp.json() == {"detail": "Not authenticated"}
131
+
132
+
133
+ @pytest.mark.anyio
134
+ async def test_view_path_unauthenticated_still_redirects(db_state):
135
+ """View routes (non-/api/*) keep the existing 302-to-login behavior."""
136
+ app = await _build_app(db_state)
137
+ transport = httpx.ASGITransport(app=app)
138
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
139
+ resp = await client.get("/dashboard", follow_redirects=False)
140
+
141
+ assert resp.status_code == 302
142
+ assert resp.headers["location"] == "/users/login"
143
+
144
+
145
+ @pytest.mark.anyio
146
+ async def test_session_wins_over_resolver(db_state, mw_active_user):
147
+ """A valid session cookie short-circuits — the resolver chain is not consulted."""
148
+ resolver_called = False
149
+
150
+ async def should_not_run(request):
151
+ nonlocal resolver_called
152
+ resolver_called = True
153
+ return _ctx(email="should-not-win@example.com")
154
+
155
+ app = await _build_app(db_state, principal_resolvers=[should_not_run])
156
+ transport = httpx.ASGITransport(app=app)
157
+ cookies = _session_cookie({"user_id": str(mw_active_user.id)})
158
+ async with httpx.AsyncClient(
159
+ transport=transport, base_url="http://testserver", cookies=cookies
160
+ ) as client:
161
+ resp = await client.get("/dashboard")
162
+
163
+ assert resp.status_code == 200
164
+ assert resp.json()["user"]["email"] == "middleware-test@example.com"
165
+ assert resolver_called is False
166
+
167
+
168
+ @pytest.mark.anyio
169
+ async def test_resolver_does_not_write_session(db_state):
170
+ """Resolver-authenticated requests must not persist anything to the session."""
171
+ captured = {}
172
+
173
+ async def capture(request: Request):
174
+ captured["session"] = dict(request.session)
175
+ user = getattr(request.state, "user", None)
176
+ return JSONResponse({"authenticated": user is not None})
177
+
178
+ async def stub_resolver(request):
179
+ return _ctx()
180
+
181
+ app = await _build_app(db_state, capture, principal_resolvers=[stub_resolver])
182
+ transport = httpx.ASGITransport(app=app)
183
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
184
+ resp = await client.get("/dashboard")
185
+
186
+ assert resp.status_code == 200
187
+ assert resp.json() == {"authenticated": True}
188
+ # Session must not contain a user_id, user_ctx, or anything resolver-added.
189
+ assert "user_id" not in captured["session"]
190
+ assert "user_ctx" not in captured["session"]
@@ -112,6 +112,15 @@ class TestLoginPage:
112
112
  {"label": "User", "email": "user@example.com", "password": "UserPass1!"},
113
113
  ]
114
114
 
115
+ @pytest.mark.anyio
116
+ async def test_login_redirect_url_is_dashboard_when_installed(self, anon_client):
117
+ """Regression for #173: with Dashboard installed, prop stays /dashboard/."""
118
+ resp = await anon_client.get(
119
+ "/users/login",
120
+ headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"},
121
+ )
122
+ assert resp.json()["props"]["login_redirect_url"] == "/dashboard/"
123
+
115
124
 
116
125
  class TestRegisterPage:
117
126
  @pytest.mark.anyio
@@ -241,3 +250,28 @@ async def test_roles_payload_returns_id_name_dicts(users_app):
241
250
  names = [item["name"] for item in payload]
242
251
  assert "admin" in names
243
252
  assert "user" in names
253
+
254
+
255
+ @pytest.mark.anyio
256
+ async def test_login_redirect_fallback_without_dashboard(users_app):
257
+ """Regression for #173: without Dashboard, redirect falls back to
258
+ the first sibling module view_prefix, not ``/`` (which may 404)."""
259
+ sm = users_app.state.sm
260
+ original = sm.modules
261
+ no_dash = tuple(m for m in original if m.meta.name != "Dashboard")
262
+ assert len(no_dash) < len(original), "test setup: Dashboard should exist"
263
+
264
+ settings = users_app.state.users.settings
265
+ settings.login_redirect_url = "/dashboard/"
266
+ object.__setattr__(sm, "modules", no_dash)
267
+ try:
268
+ from users.module import UsersModule
269
+
270
+ mod = UsersModule()
271
+ await mod.on_startup(users_app)
272
+ url = settings.login_redirect_url
273
+ assert url != "/", "must not fall back to / (may 404)"
274
+ assert url.startswith("/"), "must be an absolute path"
275
+ assert url.endswith("/"), "must have trailing slash"
276
+ finally:
277
+ object.__setattr__(sm, "modules", original)
@@ -24,7 +24,7 @@ from simple_module_db.listeners import current_user_id
24
24
  from sqlalchemy import select
25
25
  from sqlalchemy.orm import selectinload
26
26
  from starlette.requests import Request
27
- from starlette.responses import RedirectResponse
27
+ from starlette.responses import JSONResponse, RedirectResponse
28
28
  from starlette.types import ASGIApp, Receive, Scope, Send
29
29
 
30
30
  from users.constants import SESSION_USER_ID_KEY
@@ -37,6 +37,8 @@ _SESSION_USER_ID_KEY = SESSION_USER_ID_KEY
37
37
  _SESSION_NEXT_KEY = "next"
38
38
  _SCOPE_HTTP = "http"
39
39
  _LOGIN_REDIRECT = "/users/login"
40
+ _API_PATH_PREFIX = "/api/"
41
+ _UNAUTH_DETAIL = "Not authenticated"
40
42
 
41
43
  # Paths that don't require authentication.
42
44
  PUBLIC_PATHS = (
@@ -102,10 +104,33 @@ class AuthMiddleware:
102
104
  else:
103
105
  session[SESSION_USER_CTX_KEY] = user_ctx.to_session_dict()
104
106
 
107
+ # Fall-through: registered principal resolvers (PAT, API key, ...).
108
+ # The session-cookie path above is authoritative; resolvers only run
109
+ # when no session-authenticated user was resolved.
110
+ if user_ctx is None:
111
+ auth_state = getattr(scope["app"].state, "auth", None)
112
+ resolvers = getattr(auth_state, "principal_resolvers", ()) if auth_state else ()
113
+ if resolvers:
114
+ request = Request(scope)
115
+ for resolver in resolvers:
116
+ try:
117
+ user_ctx = await resolver(request)
118
+ except Exception:
119
+ logger.exception(
120
+ "Principal resolver %r raised; treating as no-match",
121
+ resolver,
122
+ )
123
+ continue
124
+ if user_ctx is not None:
125
+ break
126
+
105
127
  if user_ctx is None and not is_public:
106
- request = Request(scope)
107
- session[_SESSION_NEXT_KEY] = str(request.url)
108
- response = RedirectResponse(_LOGIN_REDIRECT, status_code=302)
128
+ if path.startswith(_API_PATH_PREFIX):
129
+ response = JSONResponse({"detail": _UNAUTH_DETAIL}, status_code=401)
130
+ else:
131
+ request = Request(scope)
132
+ session[_SESSION_NEXT_KEY] = str(request.url)
133
+ response = RedirectResponse(_LOGIN_REDIRECT, status_code=302)
109
134
  await response(scope, receive, send)
110
135
  return
111
136
 
@@ -187,13 +187,24 @@ class UsersModule(ModuleBase):
187
187
  )
188
188
  state.oauth_providers = enabled_provider_names(s)
189
189
 
190
- # Auto-fall-back from the default ``/dashboard/`` to ``/`` when the
191
- # Dashboard module isn't installed, so ``--preset minimal`` doesn't
192
- # 404 on login. Operator-set overrides are preserved.
190
+ # Auto-fall-back when the default ``/dashboard/`` target is
191
+ # unreachable because the Dashboard module isn't installed (e.g.
192
+ # ``--preset minimal`` or apps like smpy_gis that omit it).
193
+ # Pick the first sibling module that exposes view routes instead
194
+ # of hard-coding ``/`` which may itself 404 (#173). Operator-set
195
+ # overrides are always preserved.
193
196
  if s.login_redirect_url == "/dashboard/" and not any(
194
197
  m.meta.name == "Dashboard" for m in app.state.sm.modules
195
198
  ):
196
- s.login_redirect_url = "/"
199
+ first_view = next(
200
+ (
201
+ m.meta.view_prefix
202
+ for m in app.state.sm.modules
203
+ if m.meta.view_prefix and m.meta.name != self.meta.name
204
+ ),
205
+ None,
206
+ )
207
+ s.login_redirect_url = f"{first_view}/" if first_view else "/"
197
208
 
198
209
  reconfigure_cookie_transport(auth_backend, s)
199
210
 
@@ -1,4 +1,4 @@
1
- import { router, usePage } from '@inertiajs/react';
1
+ import { Head, router, usePage } from '@inertiajs/react';
2
2
  import { Button } from '@simple-module-py/ui/components/ui/button';
3
3
  import { Input } from '@simple-module-py/ui/components/ui/input';
4
4
  import { Label } from '@simple-module-py/ui/components/ui/label';
@@ -55,6 +55,7 @@ function AcceptInvite() {
55
55
 
56
56
  return (
57
57
  <AuthCardShell>
58
+ <Head title="Accept Invite" />
58
59
  <div className="flex items-start gap-3 rounded-xl border border-primary-200 bg-primary-50 p-3 text-sm text-primary-800">
59
60
  <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
60
61
  <div>
@@ -1,3 +1,4 @@
1
+ import { Head } from '@inertiajs/react';
1
2
  import { Button } from '@simple-module-py/ui/components/ui/button';
2
3
  import { Input } from '@simple-module-py/ui/components/ui/input';
3
4
  import { Label } from '@simple-module-py/ui/components/ui/label';
@@ -50,6 +51,7 @@ function ForgotPassword() {
50
51
 
51
52
  return (
52
53
  <AuthCardShell>
54
+ <Head title="Forgot Password" />
53
55
  <h1 className="mb-1.5 text-[22px] font-bold tracking-tight font-[var(--font-display)] text-foreground">
54
56
  Forgot password
55
57
  </h1>
@@ -1,4 +1,4 @@
1
- import { router, usePage } from '@inertiajs/react';
1
+ import { Head, router, usePage } from '@inertiajs/react';
2
2
  import { Button } from '@simple-module-py/ui/components/ui/button';
3
3
  import { Input } from '@simple-module-py/ui/components/ui/input';
4
4
  import { Label } from '@simple-module-py/ui/components/ui/label';
@@ -87,6 +87,7 @@ function Login() {
87
87
 
88
88
  return (
89
89
  <AuthCardShell>
90
+ <Head title="Login" />
90
91
  <h1 className="mb-1.5 text-[22px] font-bold tracking-tight font-[var(--font-display)] text-foreground">
91
92
  Welcome back
92
93
  </h1>
@@ -194,7 +195,6 @@ function Login() {
194
195
  onClick={() => {
195
196
  setEmail(acct.email);
196
197
  setPassword(acct.password);
197
- submitLogin(acct.email, acct.password);
198
198
  }}
199
199
  >
200
200
  {acct.label}
@@ -0,0 +1,154 @@
1
+ import { Head, usePage } from '@inertiajs/react';
2
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
3
+ import { SectionTitle } from '@simple-module-py/ui/components/SectionTitle';
4
+ import { Badge } from '@simple-module-py/ui/components/ui/badge';
5
+ import { Button } from '@simple-module-py/ui/components/ui/button';
6
+ import { Card, CardContent } from '@simple-module-py/ui/components/ui/card';
7
+ import { Input } from '@simple-module-py/ui/components/ui/input';
8
+ import { Label } from '@simple-module-py/ui/components/ui/label';
9
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
10
+ import { useState } from 'react';
11
+ import { toast } from 'sonner';
12
+
13
+ interface AuthUser {
14
+ id: string;
15
+ email: string;
16
+ full_name: string | null;
17
+ is_verified: boolean;
18
+ roles: string[];
19
+ }
20
+
21
+ interface SharedProps {
22
+ auth: {
23
+ user: AuthUser | null;
24
+ };
25
+ }
26
+
27
+ function Profile() {
28
+ const { auth } = usePage<{ props: SharedProps }>().props as unknown as SharedProps;
29
+ const user = auth?.user;
30
+
31
+ const [fullName, setFullName] = useState(user?.full_name ?? '');
32
+ const [saving, setSaving] = useState(false);
33
+
34
+ const handleSubmit = (e: React.FormEvent) => {
35
+ e.preventDefault();
36
+ setSaving(true);
37
+ fetch('/api/users/me', {
38
+ method: 'PATCH',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ full_name: fullName }),
41
+ })
42
+ .then(async (res) => {
43
+ if (res.ok) {
44
+ toast.success('Profile updated');
45
+ } else {
46
+ const data = await res.json().catch(() => ({}));
47
+ toast.error(typeof data?.detail === 'string' ? data.detail : 'Failed to update profile');
48
+ }
49
+ })
50
+ .catch(() => toast.error('An error occurred'))
51
+ .finally(() => setSaving(false));
52
+ };
53
+
54
+ if (!user) {
55
+ return null;
56
+ }
57
+
58
+ const initial = (user.full_name || user.email).charAt(0).toUpperCase();
59
+
60
+ return (
61
+ <>
62
+ <Head title="Profile" />
63
+ <PageShell title="Your profile" description="Shown to teammates in audit logs and dropdowns.">
64
+ <Card className="max-w-2xl border-border">
65
+ <CardContent className="pt-6">
66
+ <SectionTitle>Account</SectionTitle>
67
+ <div className="mb-5 flex items-center gap-4">
68
+ <span className="inline-flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-primary-600 to-primary-800 text-2xl font-bold text-white shadow-md font-[var(--font-display)]">
69
+ {initial}
70
+ </span>
71
+ <div>
72
+ <Button type="button" size="sm" variant="outline">
73
+ Upload avatar
74
+ </Button>
75
+ <div className="mt-1.5 text-xs text-muted-foreground">PNG or JPG, up to 2MB.</div>
76
+ </div>
77
+ </div>
78
+ <form onSubmit={handleSubmit} className="grid gap-4 sm:grid-cols-2">
79
+ <div className="space-y-1.5 sm:col-span-2">
80
+ <Label htmlFor="email" className="text-sm font-medium text-muted-foreground">
81
+ Email
82
+ </Label>
83
+ <div className="flex items-center gap-2">
84
+ <Input
85
+ id="email"
86
+ type="email"
87
+ value={user.email}
88
+ readOnly
89
+ className="bg-muted flex-1"
90
+ />
91
+ {user.is_verified ? (
92
+ <Badge
93
+ variant="outline"
94
+ className="border-primary-200 bg-primary-50 text-primary-700"
95
+ >
96
+ verified
97
+ </Badge>
98
+ ) : (
99
+ <Badge
100
+ variant="outline"
101
+ className="border-amber-200 bg-amber-50 text-amber-700"
102
+ >
103
+ unverified
104
+ </Badge>
105
+ )}
106
+ </div>
107
+ </div>
108
+
109
+ <div className="space-y-1.5 sm:col-span-2">
110
+ <Label htmlFor="full_name" className="text-sm font-medium text-muted-foreground">
111
+ Display name
112
+ </Label>
113
+ <Input
114
+ id="full_name"
115
+ type="text"
116
+ value={fullName}
117
+ onChange={(e) => setFullName(e.target.value)}
118
+ placeholder="Your name"
119
+ maxLength={200}
120
+ />
121
+ </div>
122
+
123
+ {user.roles.length > 0 && (
124
+ <div className="space-y-1.5 sm:col-span-2">
125
+ <Label className="text-sm font-medium text-muted-foreground">Roles</Label>
126
+ <div className="flex flex-wrap gap-1.5">
127
+ {user.roles.map((role) => (
128
+ <Badge
129
+ key={role}
130
+ variant="outline"
131
+ className="border-primary-200 bg-primary-50 text-primary-700"
132
+ >
133
+ {role}
134
+ </Badge>
135
+ ))}
136
+ </div>
137
+ </div>
138
+ )}
139
+
140
+ <div className="sm:col-span-2 flex justify-end">
141
+ <Button type="submit" disabled={saving}>
142
+ {saving ? 'Saving…' : 'Save changes'}
143
+ </Button>
144
+ </div>
145
+ </form>
146
+ </CardContent>
147
+ </Card>
148
+ </PageShell>
149
+ </>
150
+ );
151
+ }
152
+
153
+ Profile.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
154
+ export default Profile;
@@ -1,3 +1,4 @@
1
+ import { Head } from '@inertiajs/react';
1
2
  import { Button } from '@simple-module-py/ui/components/ui/button';
2
3
  import { Input } from '@simple-module-py/ui/components/ui/input';
3
4
  import { Label } from '@simple-module-py/ui/components/ui/label';
@@ -72,6 +73,7 @@ function Register() {
72
73
 
73
74
  return (
74
75
  <AuthCardShell>
76
+ <Head title="Register" />
75
77
  <h1 className="mb-1.5 text-[22px] font-bold tracking-tight font-[var(--font-display)] text-foreground">
76
78
  Create your account
77
79
  </h1>
@@ -1,4 +1,4 @@
1
- import { router, usePage } from '@inertiajs/react';
1
+ import { Head, router, usePage } from '@inertiajs/react';
2
2
  import { Button } from '@simple-module-py/ui/components/ui/button';
3
3
  import { Input } from '@simple-module-py/ui/components/ui/input';
4
4
  import { Label } from '@simple-module-py/ui/components/ui/label';
@@ -54,6 +54,7 @@ function ResetPassword() {
54
54
 
55
55
  return (
56
56
  <AuthCardShell>
57
+ <Head title="Reset Password" />
57
58
  <h1 className="mb-1.5 text-[22px] font-bold tracking-tight font-[var(--font-display)] text-foreground">
58
59
  Reset password
59
60
  </h1>
@@ -1,4 +1,4 @@
1
- import { usePage } from '@inertiajs/react';
1
+ import { Head, usePage } from '@inertiajs/react';
2
2
  import { Button } from '@simple-module-py/ui/components/ui/button';
3
3
  import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
4
4
  import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
@@ -103,6 +103,7 @@ function VerifyEmail() {
103
103
 
104
104
  return (
105
105
  <AuthCardShell>
106
+ <Head title="Verify Email" />
106
107
  <div className="flex flex-col items-center gap-3 text-center">
107
108
  <span className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-secondary">
108
109
  <Icon className={`h-6 w-6 ${content.iconClass}`} aria-hidden="true" />
@@ -1,148 +0,0 @@
1
- import { usePage } from '@inertiajs/react';
2
- import { PageShell } from '@simple-module-py/ui/components/PageShell';
3
- import { SectionTitle } from '@simple-module-py/ui/components/SectionTitle';
4
- import { Badge } from '@simple-module-py/ui/components/ui/badge';
5
- import { Button } from '@simple-module-py/ui/components/ui/button';
6
- import { Card, CardContent } from '@simple-module-py/ui/components/ui/card';
7
- import { Input } from '@simple-module-py/ui/components/ui/input';
8
- import { Label } from '@simple-module-py/ui/components/ui/label';
9
- import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
10
- import { useState } from 'react';
11
- import { toast } from 'sonner';
12
-
13
- interface AuthUser {
14
- id: string;
15
- email: string;
16
- full_name: string | null;
17
- is_verified: boolean;
18
- roles: string[];
19
- }
20
-
21
- interface SharedProps {
22
- auth: {
23
- user: AuthUser | null;
24
- };
25
- }
26
-
27
- function Profile() {
28
- const { auth } = usePage<{ props: SharedProps }>().props as unknown as SharedProps;
29
- const user = auth?.user;
30
-
31
- const [fullName, setFullName] = useState(user?.full_name ?? '');
32
- const [saving, setSaving] = useState(false);
33
-
34
- const handleSubmit = (e: React.FormEvent) => {
35
- e.preventDefault();
36
- setSaving(true);
37
- fetch('/api/users/me', {
38
- method: 'PATCH',
39
- headers: { 'Content-Type': 'application/json' },
40
- body: JSON.stringify({ full_name: fullName }),
41
- })
42
- .then(async (res) => {
43
- if (res.ok) {
44
- toast.success('Profile updated');
45
- } else {
46
- const data = await res.json().catch(() => ({}));
47
- toast.error(typeof data?.detail === 'string' ? data.detail : 'Failed to update profile');
48
- }
49
- })
50
- .catch(() => toast.error('An error occurred'))
51
- .finally(() => setSaving(false));
52
- };
53
-
54
- if (!user) {
55
- return null;
56
- }
57
-
58
- const initial = (user.full_name || user.email).charAt(0).toUpperCase();
59
-
60
- return (
61
- <PageShell title="Your profile" description="Shown to teammates in audit logs and dropdowns.">
62
- <Card className="max-w-2xl border-border">
63
- <CardContent className="pt-6">
64
- <SectionTitle>Account</SectionTitle>
65
- <div className="mb-5 flex items-center gap-4">
66
- <span className="inline-flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-primary-600 to-primary-800 text-2xl font-bold text-white shadow-md font-[var(--font-display)]">
67
- {initial}
68
- </span>
69
- <div>
70
- <Button type="button" size="sm" variant="outline">
71
- Upload avatar
72
- </Button>
73
- <div className="mt-1.5 text-xs text-muted-foreground">PNG or JPG, up to 2MB.</div>
74
- </div>
75
- </div>
76
- <form onSubmit={handleSubmit} className="grid gap-4 sm:grid-cols-2">
77
- <div className="space-y-1.5 sm:col-span-2">
78
- <Label htmlFor="email" className="text-sm font-medium text-muted-foreground">
79
- Email
80
- </Label>
81
- <div className="flex items-center gap-2">
82
- <Input
83
- id="email"
84
- type="email"
85
- value={user.email}
86
- readOnly
87
- className="bg-muted flex-1"
88
- />
89
- {user.is_verified ? (
90
- <Badge
91
- variant="outline"
92
- className="border-primary-200 bg-primary-50 text-primary-700"
93
- >
94
- verified
95
- </Badge>
96
- ) : (
97
- <Badge variant="outline" className="border-amber-200 bg-amber-50 text-amber-700">
98
- unverified
99
- </Badge>
100
- )}
101
- </div>
102
- </div>
103
-
104
- <div className="space-y-1.5 sm:col-span-2">
105
- <Label htmlFor="full_name" className="text-sm font-medium text-muted-foreground">
106
- Display name
107
- </Label>
108
- <Input
109
- id="full_name"
110
- type="text"
111
- value={fullName}
112
- onChange={(e) => setFullName(e.target.value)}
113
- placeholder="Your name"
114
- maxLength={200}
115
- />
116
- </div>
117
-
118
- {user.roles.length > 0 && (
119
- <div className="space-y-1.5 sm:col-span-2">
120
- <Label className="text-sm font-medium text-muted-foreground">Roles</Label>
121
- <div className="flex flex-wrap gap-1.5">
122
- {user.roles.map((role) => (
123
- <Badge
124
- key={role}
125
- variant="outline"
126
- className="border-primary-200 bg-primary-50 text-primary-700"
127
- >
128
- {role}
129
- </Badge>
130
- ))}
131
- </div>
132
- </div>
133
- )}
134
-
135
- <div className="sm:col-span-2 flex justify-end">
136
- <Button type="submit" disabled={saving}>
137
- {saving ? 'Saving…' : 'Save changes'}
138
- </Button>
139
- </div>
140
- </form>
141
- </CardContent>
142
- </Card>
143
- </PageShell>
144
- );
145
- }
146
-
147
- Profile.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
148
- export default Profile;