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.
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/PKG-INFO +6 -6
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/pyproject.toml +6 -6
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/_middleware_support.py +14 -12
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_api_admin.py +8 -9
- simple_module_users-0.0.16/tests/test_users_middleware_resolvers.py +190 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_views.py +34 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/middleware.py +29 -4
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/module.py +15 -4
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/AcceptInvite.tsx +2 -1
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/ForgotPassword.tsx +2 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/Login.tsx +2 -2
- simple_module_users-0.0.16/users/pages/Profile.tsx +154 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/Register.tsx +2 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/ResetPassword.tsx +2 -1
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/VerifyEmail.tsx +2 -1
- simple_module_users-0.0.14/users/pages/Profile.tsx +0 -148
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/.gitignore +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/LICENSE +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/README.md +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/package.json +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/conftest.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/tsconfig.json +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/__init__.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/api.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/service.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/views.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/auth_local/api.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/auth_local/views.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/backend.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/cli.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/constants.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/deps.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/exceptions.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/manager.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/_base.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/role.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/user.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/oauth/api.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/Users/Invite.tsx +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/py.typed +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/settings.py +0 -0
- {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.
|
|
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.
|
|
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.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.
|
|
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.
|
|
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.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
|
|
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":
|
|
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
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
assert resp.
|
|
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 ==
|
|
132
|
-
assert resp.
|
|
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 ==
|
|
209
|
-
assert resp.
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
191
|
-
# Dashboard module isn't installed
|
|
192
|
-
#
|
|
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
|
-
|
|
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;
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_users-0.0.14 → simple_module_users-0.0.16}/tests/test_bootstrap_resolution.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/components/IndexFilters.tsx
RENAMED
|
File without changes
|
{simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/components/RolesTab.tsx
RENAMED
|
File without changes
|
{simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/admin/components/UserRow.tsx
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/templates/reset_password.txt
RENAMED
|
File without changes
|
{simple_module_users-0.0.14 → simple_module_users-0.0.16}/users/mailer/templates/verify_email.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|