simple-module-users 0.0.31__tar.gz → 0.0.32__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/PKG-INFO +6 -6
  2. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/pyproject.toml +6 -6
  3. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_api_admin.py +11 -0
  4. simple_module_users-0.0.32/tests/test_users_shared_props.py +52 -0
  5. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_views_admin.py +49 -0
  6. simple_module_users-0.0.32/users/admin/components/UsersEmpty.tsx +83 -0
  7. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/queries.py +8 -3
  8. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/views.py +25 -10
  9. simple_module_users-0.0.32/users/locales/en.json +11 -0
  10. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/module.py +15 -0
  11. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/ForgotPassword.tsx +3 -2
  12. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Login.tsx +2 -4
  13. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Register.tsx +3 -2
  14. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/ResetPassword.tsx +2 -1
  15. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/Index.tsx +30 -9
  16. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/CreateUserFields.tsx +3 -1
  17. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/DangerZone.tsx +28 -5
  18. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/VerifyEmail.tsx +4 -3
  19. simple_module_users-0.0.32/users/shared_props.py +29 -0
  20. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/.gitignore +0 -0
  21. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/LICENSE +0 -0
  22. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/README.md +0 -0
  23. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/package.json +0 -0
  24. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/.gitkeep +0 -0
  25. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/_middleware_support.py +0 -0
  26. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/conftest.py +0 -0
  27. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_access_token_model.py +0 -0
  28. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_api_admin_crud.py +0 -0
  29. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_api_admin_filters.py +0 -0
  30. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_api_auth.py +0 -0
  31. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_backend.py +0 -0
  32. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_bootstrap.py +0 -0
  33. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_bootstrap_resolution.py +0 -0
  34. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_cli.py +0 -0
  35. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_constants.py +0 -0
  36. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_db_adapter.py +0 -0
  37. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_external_users.py +0 -0
  38. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_invite_flow.py +0 -0
  39. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_invite_reuse.py +0 -0
  40. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_mailer.py +0 -0
  41. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_negative_authz.py +0 -0
  42. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_oauth.py +0 -0
  43. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_oauth_routes.py +0 -0
  44. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_rate_limit.py +0 -0
  45. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_role_model.py +0 -0
  46. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_service_admin.py +0 -0
  47. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_service_admin_crud.py +0 -0
  48. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_settings.py +0 -0
  49. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_token_api.py +0 -0
  50. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_user_manager.py +0 -0
  51. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_user_model.py +0 -0
  52. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_user_role_model.py +0 -0
  53. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_user_service.py +0 -0
  54. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_users_bulk_invite.py +0 -0
  55. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_users_deps.py +0 -0
  56. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_users_middleware.py +0 -0
  57. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_users_middleware_public_paths.py +0 -0
  58. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_users_middleware_resolvers.py +0 -0
  59. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_users_provider.py +0 -0
  60. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_views.py +0 -0
  61. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tsconfig.json +0 -0
  62. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/__init__.py +0 -0
  63. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/__init__.py +0 -0
  64. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/api.py +0 -0
  65. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/bulk_invite.py +0 -0
  66. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/components/IndexFilters.tsx +0 -0
  67. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/components/RolesTab.tsx +0 -0
  68. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/components/UserRow.tsx +0 -0
  69. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/service.py +0 -0
  70. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/__init__.py +0 -0
  71. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/api.py +0 -0
  72. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/invite_preview.py +0 -0
  73. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/rate_limit.py +0 -0
  74. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/token_api.py +0 -0
  75. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/views.py +0 -0
  76. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/backend.py +0 -0
  77. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/bootstrap.py +0 -0
  78. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/cli.py +0 -0
  79. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/constants.py +0 -0
  80. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/contracts/__init__.py +0 -0
  81. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/contracts/events.py +0 -0
  82. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/contracts/schemas.py +0 -0
  83. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/db_adapter.py +0 -0
  84. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/deps.py +0 -0
  85. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/exceptions.py +0 -0
  86. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/health.py +0 -0
  87. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/__init__.py +0 -0
  88. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/console.py +0 -0
  89. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/smtp.py +0 -0
  90. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/templates/.gitkeep +0 -0
  91. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/templates/invite.txt +0 -0
  92. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/templates/reset_password.txt +0 -0
  93. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/templates/verify_email.txt +0 -0
  94. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/manager.py +0 -0
  95. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/middleware.py +0 -0
  96. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/__init__.py +0 -0
  97. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/_base.py +0 -0
  98. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/access_token.py +0 -0
  99. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/oauth_account.py +0 -0
  100. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/refresh_token.py +0 -0
  101. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/role.py +0 -0
  102. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/user.py +0 -0
  103. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/user_role.py +0 -0
  104. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/oauth/__init__.py +0 -0
  105. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/oauth/api.py +0 -0
  106. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/oauth/providers.py +0 -0
  107. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/.gitkeep +0 -0
  108. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/AcceptInvite.tsx +0 -0
  109. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Profile.tsx +0 -0
  110. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/AddPeople.tsx +0 -0
  111. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/Edit.tsx +0 -0
  112. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
  113. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/DetailsCard.tsx +0 -0
  114. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/InviteFields.tsx +0 -0
  115. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/InviteResults.tsx +0 -0
  116. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/MetadataCard.tsx +0 -0
  117. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/RolePicker.tsx +0 -0
  118. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/RolesCard.tsx +0 -0
  119. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/provider.py +0 -0
  120. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/py.typed +0 -0
  121. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/roles_cache.py +0 -0
  122. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/settings.py +0 -0
  123. {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_users
3
- Version: 0.0.31
3
+ Version: 0.0.32
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.31
28
- Requires-Dist: simple-module-core==0.0.31
29
- Requires-Dist: simple-module-db==0.0.31
30
- Requires-Dist: simple-module-hosting==0.0.31
31
- Requires-Dist: simple-module-settings==0.0.31
27
+ Requires-Dist: simple-module-auth==0.0.32
28
+ Requires-Dist: simple-module-core==0.0.32
29
+ Requires-Dist: simple-module-db==0.0.32
30
+ Requires-Dist: simple-module-hosting==0.0.32
31
+ Requires-Dist: simple-module-settings==0.0.32
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.31"
3
+ version = "0.0.32"
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.31",
25
- "simple_module_db==0.0.31",
26
- "simple_module_hosting==0.0.31",
27
- "simple_module_settings==0.0.31",
28
- "simple_module_auth==0.0.31",
24
+ "simple_module_core==0.0.32",
25
+ "simple_module_db==0.0.32",
26
+ "simple_module_hosting==0.0.32",
27
+ "simple_module_settings==0.0.32",
28
+ "simple_module_auth==0.0.32",
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.
@@ -71,6 +71,17 @@ class TestAdminList:
71
71
  assert "alpha@example.com" in emails
72
72
  assert "beta@example.com" not in emails
73
73
 
74
+ @pytest.mark.anyio
75
+ async def test_list_search_matches_like_metacharacters_literally(self, admin_client, users_db):
76
+ """A literal "_" or "%" in the search must match as text, not wildcard."""
77
+ await _make_user(users_db, email="dana_x@example.com")
78
+ await _make_user(users_db, email="danaYx@example.com")
79
+
80
+ resp = await admin_client.get("/api/users/admin?q=dana_x")
81
+ assert resp.status_code == 200
82
+ emails = [u["email"] for u in resp.json()]
83
+ assert emails == ["dana_x@example.com"]
84
+
74
85
  @pytest.mark.anyio
75
86
  async def test_list_pagination(self, admin_client, users_db):
76
87
  for i in range(5):
@@ -0,0 +1,52 @@
1
+ """The ``signup`` shared prop drives whether a "Sign up" link is rendered.
2
+
3
+ ``/users/register`` raises 404 when ``allow_signup`` is off, so the public shell
4
+ has to know the answer before it draws the link. Getting the default wrong in
5
+ either direction is a visible bug: too eager and every visitor hits a 404, too
6
+ shy and a genuinely open instance hides its own signup.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from types import SimpleNamespace
12
+
13
+ import pytest
14
+ from users.shared_props import users_shared_props
15
+
16
+
17
+ def _request(users_state: object) -> SimpleNamespace:
18
+ """A stand-in carrying only what the provider is allowed to touch."""
19
+ return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(users=users_state)))
20
+
21
+
22
+ class TestSignupSharedProp:
23
+ @pytest.mark.parametrize("allow", [True, False])
24
+ def test_reflects_the_setting(self, allow: bool) -> None:
25
+ request = _request(SimpleNamespace(settings=SimpleNamespace(allow_signup=allow)))
26
+ assert users_shared_props(request) == {"signup": {"allowed": allow}}
27
+
28
+ def test_coerces_to_a_real_bool(self) -> None:
29
+ """The value is serialised straight to JSON, so it must not leak a
30
+ truthy non-bool into the page props."""
31
+ request = _request(SimpleNamespace(settings=SimpleNamespace(allow_signup=1)))
32
+ assert users_shared_props(request)["signup"]["allowed"] is True
33
+
34
+ def test_defaults_closed_when_settings_are_missing(self) -> None:
35
+ request = _request(SimpleNamespace())
36
+ assert users_shared_props(request) == {"signup": {"allowed": False}}
37
+
38
+ def test_defaults_closed_when_module_state_is_absent(self) -> None:
39
+ """Never raises: the provider runs on every request and a failure here
40
+ would cost the whole page, not just the link."""
41
+ request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
42
+ assert users_shared_props(request) == {"signup": {"allowed": False}}
43
+
44
+
45
+ class TestProviderIsRegistered:
46
+ def test_the_built_app_carries_it(self, app) -> None:
47
+ """Registered during ``register_settings``, so a fully booted app has it
48
+ in place before the first request is served. Asserted against the real
49
+ app rather than a bare FastAPI: the registration sits downstream of the
50
+ settings module, and a stub would pass while the wiring was broken."""
51
+ providers = getattr(app.state, "inertia_shared_providers", [])
52
+ assert users_shared_props in providers
@@ -59,6 +59,45 @@ class TestAdminIndexFilters:
59
59
  assert props["filters"]["sort"] == "email"
60
60
  assert props["filters"]["order"] == "asc"
61
61
 
62
+ @pytest.mark.anyio
63
+ async def test_page_past_the_end_clamps_to_last_page(self, admin_client, users_db):
64
+ """A stale/hand-edited ?page= beyond the last page must clamp and
65
+ return the last page's rows, not an empty list with a nonzero total —
66
+ the same bug class background_tasks and file_storage fix for their
67
+ own admin listings."""
68
+ from test_api_admin import _make_user
69
+
70
+ await _make_user(users_db, email="clamp-a@x.com")
71
+ await _make_user(users_db, email="clamp-b@x.com")
72
+
73
+ resp = await admin_client.get(
74
+ "/users/admin?page=999&per_page=1",
75
+ headers={"X-Inertia": "true", "Accept": "application/json"},
76
+ )
77
+ assert resp.status_code == 200
78
+ props = resp.json()["props"]
79
+ assert props["users"], "expected the clamped last page to carry rows"
80
+ assert props["pagination"]["page"] < 999
81
+ assert props["pagination"]["total"] >= 2
82
+
83
+ @pytest.mark.anyio
84
+ async def test_pagination_prop_echoes_clamped_values(self, admin_client, users_db):
85
+ """The pagination prop must carry the values the query actually ran
86
+ with — ?page=0 renders page-1 rows and must not be labelled page 0,
87
+ and an oversized per_page is bounded to what the service fetched."""
88
+ from test_api_admin import _make_user
89
+
90
+ await _make_user(users_db, email="clamp-c@x.com")
91
+
92
+ resp = await admin_client.get(
93
+ "/users/admin?page=0&per_page=1000",
94
+ headers={"X-Inertia": "true", "Accept": "application/json"},
95
+ )
96
+ assert resp.status_code == 200
97
+ pagination = resp.json()["props"]["pagination"]
98
+ assert pagination["page"] == 1
99
+ assert pagination["per_page"] == 200
100
+
62
101
 
63
102
  # ---------------------------------------------------------------------------
64
103
  # has_permissions_module flag on admin edit page
@@ -184,3 +223,13 @@ class TestAdminAddPeoplePage:
184
223
  resp = await admin_client.get(old_path, follow_redirects=False)
185
224
  assert resp.status_code == 307
186
225
  assert resp.headers["location"] == f"/users/admin/add?mode={mode}"
226
+
227
+ @pytest.mark.anyio
228
+ @pytest.mark.parametrize("old_path", ["/users/admin/create", "/users/admin/invite"])
229
+ async def test_old_urls_require_auth(self, anon_client, old_path):
230
+ """The legacy aliases must stay gated behind the same permission as
231
+ the page they redirect to — an anonymous caller must not reach the
232
+ redirect (and learn the target route exists) before being sent to
233
+ log in."""
234
+ resp = await anon_client.get(old_path, follow_redirects=False)
235
+ assert resp.status_code == 302
@@ -0,0 +1,83 @@
1
+ import { Link } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+ import { EmptyState } from '@simple-module-py/ui/components/EmptyState';
4
+ import { TableEmptyRow } from '@simple-module-py/ui/components/TableEmptyRow';
5
+ import { Button } from '@simple-module-py/ui/components/ui/button';
6
+ import { Plus, UserPlus, Users } from 'lucide-react';
7
+
8
+ const ADD_PEOPLE_URL = '/users/admin/add';
9
+
10
+ function AddPeopleAction() {
11
+ const { t } = useT();
12
+ return (
13
+ <Button asChild className="gap-1.5">
14
+ <Link href={ADD_PEOPLE_URL}>
15
+ <Plus className="h-4 w-4" />
16
+ {t(keys.users.empty.add_people)}
17
+ </Link>
18
+ </Button>
19
+ );
20
+ }
21
+
22
+ /**
23
+ * The "you are alone in here" prompt.
24
+ *
25
+ * A workspace with one member renders a perfectly valid table containing the
26
+ * admin's own row, which answers "who has access" without ever suggesting the
27
+ * obvious next step. This sits above that table rather than replacing it: the
28
+ * single row is still the truthful answer to the question the screen asks, and
29
+ * hiding it would cost the admin the only route to their own record.
30
+ */
31
+ export function SoloAccountPrompt() {
32
+ const { t } = useT();
33
+ return (
34
+ <EmptyState
35
+ className="mb-4 rounded-xl border border-dashed border-border bg-card"
36
+ icon={UserPlus}
37
+ title={t(keys.users.empty.solo_title)}
38
+ description={t(keys.users.empty.invite_description)}
39
+ action={<AddPeopleAction />}
40
+ />
41
+ );
42
+ }
43
+
44
+ interface UsersEmptyRowProps {
45
+ /** A search term or filter is narrowing the list, so rows may exist outside it. */
46
+ filtered: boolean;
47
+ columnCount: number;
48
+ onClear: () => void;
49
+ }
50
+
51
+ /**
52
+ * The table's own empty row.
53
+ *
54
+ * "No users yet" is both wrong and alarming when the workspace is full and the
55
+ * filter is simply too narrow, so the two cases get opposite copy and opposite
56
+ * actions — clear the filter, or add the first person.
57
+ */
58
+ export function UsersEmptyRow({ filtered, columnCount, onClear }: UsersEmptyRowProps) {
59
+ const { t } = useT();
60
+ return (
61
+ <TableEmptyRow columnCount={columnCount}>
62
+ {filtered ? (
63
+ <EmptyState
64
+ icon={Users}
65
+ title={t(keys.users.empty.filtered_title)}
66
+ description={t(keys.users.empty.filtered_description)}
67
+ action={
68
+ <Button variant="outline" onClick={onClear}>
69
+ {t(keys.users.empty.clear_filters)}
70
+ </Button>
71
+ }
72
+ />
73
+ ) : (
74
+ <EmptyState
75
+ icon={UserPlus}
76
+ title={t(keys.users.empty.empty_title)}
77
+ description={t(keys.users.empty.invite_description)}
78
+ action={<AddPeopleAction />}
79
+ />
80
+ )}
81
+ </TableEmptyRow>
82
+ );
83
+ }
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import uuid
6
6
 
7
+ from simple_module_db import LIKE_ESCAPE_CHAR, like_contains_pattern
7
8
  from sqlalchemy import func, or_, select
8
9
  from sqlalchemy.ext.asyncio import AsyncSession
9
10
  from sqlalchemy.orm import selectinload
@@ -98,6 +99,10 @@ class _UserServiceBase:
98
99
  entities) + one batched roles query — avoids hydrating full User+Role
99
100
  ORM graphs, which dominated this endpoint's CPU under load.
100
101
  """
102
+ # Clamp here, not in the endpoints: the admin view takes these params
103
+ # raw, and a page<=0 would reach SQL as a negative OFFSET.
104
+ page = max(page, 1)
105
+ per_page = max(1, min(per_page, 200))
101
106
  stmt = select(
102
107
  User.id,
103
108
  User.email,
@@ -114,11 +119,11 @@ class _UserServiceBase:
114
119
  conditions = []
115
120
 
116
121
  if search:
117
- pattern = f"%{search}%"
122
+ pattern = like_contains_pattern(search)
118
123
  conditions.append(
119
124
  or_(
120
- User.email.ilike(pattern),
121
- User.full_name.ilike(pattern),
125
+ User.email.ilike(pattern, escape=LIKE_ESCAPE_CHAR),
126
+ User.full_name.ilike(pattern, escape=LIKE_ESCAPE_CHAR),
122
127
  )
123
128
  )
124
129
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import math
5
6
  import uuid
6
7
 
7
8
  from fastapi import APIRouter, Depends, HTTPException, Request
@@ -49,16 +50,30 @@ async def admin_index(
49
50
  clean_status, clean_verified, clean_sort, clean_order = sanitize_list_filters(
50
51
  status, verified, sort, order
51
52
  )
52
- users, total = await service.list_users(
53
- page=page,
54
- per_page=per_page,
55
- search=q,
56
- status=clean_status,
57
- role_name=role or None,
58
- verified=clean_verified,
59
- sort=clean_sort,
60
- order=clean_order,
61
- )
53
+ # Clamp before building filters, with the same bounds the service applies
54
+ # internally — the pagination prop must echo the values the query actually
55
+ # ran with, or ?page=0 renders page-1 rows labelled "Page 0".
56
+ page = max(page, 1)
57
+ per_page = max(1, min(per_page, 200))
58
+ filters = {
59
+ "per_page": per_page,
60
+ "search": q,
61
+ "status": clean_status,
62
+ "role_name": role or None,
63
+ "verified": clean_verified,
64
+ "sort": clean_sort,
65
+ "order": clean_order,
66
+ }
67
+ users, total = await service.list_users(page=page, **filters)
68
+ # A page requested past the end (a stale ?page= link, or the last row on
69
+ # that page just got deleted) must be clamped and re-queried — otherwise
70
+ # the client gets an empty `users` list with a nonzero `total`, which
71
+ # renders a blank table instead of the last real page.
72
+ # per_page is already clamped to >= 1 above, so this can't divide by zero.
73
+ total_pages = max(1, math.ceil(total / per_page))
74
+ if page > total_pages:
75
+ page = total_pages
76
+ users, total = await service.list_users(page=page, **filters)
62
77
  roles = await service.list_roles()
63
78
  aggregates = await service.count_user_states()
64
79
  return await inertia.render(
@@ -0,0 +1,11 @@
1
+ {
2
+ "empty": {
3
+ "solo_title": "You're the only account",
4
+ "invite_description": "Invite teammates by email, or create accounts with passwords you set.",
5
+ "add_people": "Add people",
6
+ "filtered_title": "No users match these filters",
7
+ "filtered_description": "Nobody in this workspace fits the current search and filters.",
8
+ "clear_filters": "Clear filters",
9
+ "empty_title": "No users yet"
10
+ }
11
+ }
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import importlib.resources
6
+ from pathlib import Path
5
7
  from typing import TYPE_CHECKING
6
8
 
7
9
  from fastapi import APIRouter, Depends
@@ -66,6 +68,15 @@ class UsersModule(ModuleBase):
66
68
 
67
69
  app.state.auth.auth_provider = UsersAuthProvider()
68
70
 
71
+ # The public shell renders a "Sign up" link only when signup is open —
72
+ # /users/register 404s otherwise. Registered here (not in the view
73
+ # layer) so it is set before the first request is served.
74
+ from simple_module_hosting.shared_props import register_inertia_shared_provider
75
+
76
+ from users.shared_props import users_shared_props
77
+
78
+ register_inertia_shared_provider(app, users_shared_props)
79
+
69
80
  def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
70
81
  """Rebuild the OAuth client cache when the users settings reload.
71
82
 
@@ -144,6 +155,10 @@ class UsersModule(ModuleBase):
144
155
  )
145
156
  )
146
157
 
158
+ def locale_dirs(self) -> dict[str, Path]:
159
+ base = Path(str(importlib.resources.files(__package__) / "locales"))
160
+ return {"users": base}
161
+
147
162
  def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
148
163
  from users.admin.api import admin_router
149
164
  from users.admin.views import router as admin_views
@@ -3,6 +3,7 @@ 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';
5
5
  import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
6
+ import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes';
6
7
  import { CheckCircle2 } from 'lucide-react';
7
8
  import { useState } from 'react';
8
9
 
@@ -40,7 +41,7 @@ function ForgotPassword() {
40
41
  </div>
41
42
  </div>
42
43
  <a
43
- href="/users/login"
44
+ href={LOGIN_PATH}
44
45
  className="mt-4 block text-center text-sm font-semibold text-primary-700 hover:text-primary-800"
45
46
  >
46
47
  Back to log in
@@ -77,7 +78,7 @@ function ForgotPassword() {
77
78
  </form>
78
79
  <p className="mt-5 text-center text-xs text-muted-foreground">
79
80
  Remembered?{' '}
80
- <a href="/users/login" className="font-semibold text-primary-700 hover:text-primary-800">
81
+ <a href={LOGIN_PATH} className="font-semibold text-primary-700 hover:text-primary-800">
81
82
  Log in
82
83
  </a>
83
84
  </p>
@@ -3,6 +3,7 @@ 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';
5
5
  import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
6
+ import { REGISTER_PATH } from '@simple-module-py/ui/lib/auth-routes';
6
7
  import { AlertTriangle } from 'lucide-react';
7
8
  import { useState } from 'react';
8
9
 
@@ -155,10 +156,7 @@ function Login() {
155
156
  {allow_signup && (
156
157
  <p className="mt-5 text-center text-xs text-muted-foreground">
157
158
  Don't have an account?{' '}
158
- <a
159
- href="/users/register"
160
- className="font-semibold text-primary-700 hover:text-primary-800"
161
- >
159
+ <a href={REGISTER_PATH} className="font-semibold text-primary-700 hover:text-primary-800">
162
160
  Sign up
163
161
  </a>
164
162
  </p>
@@ -3,6 +3,7 @@ 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';
5
5
  import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
6
+ import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes';
6
7
  import { CheckCircle2 } from 'lucide-react';
7
8
  import { useState } from 'react';
8
9
 
@@ -62,7 +63,7 @@ function Register() {
62
63
  </div>
63
64
  </div>
64
65
  <a
65
- href="/users/login"
66
+ href={LOGIN_PATH}
66
67
  className="mt-4 block text-center text-sm font-semibold text-primary-700 hover:text-primary-800"
67
68
  >
68
69
  Back to log in
@@ -149,7 +150,7 @@ function Register() {
149
150
 
150
151
  <p className="mt-5 text-center text-xs text-muted-foreground">
151
152
  Already have an account?{' '}
152
- <a href="/users/login" className="font-semibold text-primary-700 hover:text-primary-800">
153
+ <a href={LOGIN_PATH} className="font-semibold text-primary-700 hover:text-primary-800">
153
154
  Log in
154
155
  </a>
155
156
  </p>
@@ -3,6 +3,7 @@ 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';
5
5
  import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell';
6
+ import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes';
6
7
  import { useState } from 'react';
7
8
 
8
9
  interface Props {
@@ -38,7 +39,7 @@ function ResetPassword() {
38
39
  })
39
40
  .then(async (res) => {
40
41
  if (res.status === 200 || res.status === 204) {
41
- router.visit('/users/login');
42
+ router.visit(LOGIN_PATH);
42
43
  } else {
43
44
  const data = await res.json().catch(() => ({}));
44
45
  const detail =
@@ -8,7 +8,6 @@ import { Input } from '@simple-module-py/ui/components/ui/input';
8
8
  import {
9
9
  Table,
10
10
  TableBody,
11
- TableCell,
12
11
  TableHead,
13
12
  TableHeader,
14
13
  TableRow,
@@ -29,6 +28,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
29
28
  import { type Filters, IndexFilters } from '../../admin/components/IndexFilters';
30
29
  import { type RoleItem, RolesTab } from '../../admin/components/RolesTab';
31
30
  import { type UserListItem, UserRow } from '../../admin/components/UserRow';
31
+ import { SoloAccountPrompt, UsersEmptyRow } from '../../admin/components/UsersEmpty';
32
32
 
33
33
  interface Pagination {
34
34
  page: number;
@@ -60,6 +60,9 @@ const DEFAULT_FILTERS: Filters = {
60
60
 
61
61
  const HEAD_CLASS = 'text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground';
62
62
 
63
+ /** Member, Role, Status, Last seen, row actions — the empty row spans all of them. */
64
+ const COLUMN_COUNT = 5;
65
+
63
66
  function SortIcon({ col, filters }: { col: Filters['sort']; filters: Filters }) {
64
67
  if (filters.sort !== col) return null;
65
68
  return filters.order === 'asc' ? (
@@ -123,6 +126,25 @@ function Index() {
123
126
 
124
127
  const totalPages = Math.ceil(pagination.total / pagination.per_page);
125
128
 
129
+ // Sort column and direction are deliberately excluded: reordering an empty
130
+ // list leaves it empty for the same reason it already was, so counting them
131
+ // here would turn "no users yet" into a misleading "no matches".
132
+ const isFiltered =
133
+ !!search ||
134
+ filters.status !== DEFAULT_FILTERS.status ||
135
+ filters.role !== DEFAULT_FILTERS.role ||
136
+ filters.verified !== DEFAULT_FILTERS.verified;
137
+
138
+ const clearFilters = useCallback(() => {
139
+ setSearch('');
140
+ navigate({
141
+ q: '',
142
+ status: DEFAULT_FILTERS.status,
143
+ role: DEFAULT_FILTERS.role,
144
+ verified: DEFAULT_FILTERS.verified,
145
+ });
146
+ }, [navigate]);
147
+
126
148
  return (
127
149
  <PageShell
128
150
  title="Users"
@@ -187,6 +209,8 @@ function Index() {
187
209
  />
188
210
  </div>
189
211
 
212
+ {pagination.total === 1 && !isFiltered && <SoloAccountPrompt />}
213
+
190
214
  <Card className="border-border overflow-hidden p-0">
191
215
  <Table>
192
216
  <TableHeader className="bg-secondary/40">
@@ -221,14 +245,11 @@ function Index() {
221
245
  <UserRow key={user.id} user={user} />
222
246
  ))}
223
247
  {users.length === 0 && (
224
- <TableRow>
225
- <TableCell colSpan={5} className="h-32 text-center">
226
- <div className="flex flex-col items-center gap-2 text-muted-foreground">
227
- <Users className="size-8" />
228
- <p>{search ? `No users match "${search}"` : 'No users yet'}</p>
229
- </div>
230
- </TableCell>
231
- </TableRow>
248
+ <UsersEmptyRow
249
+ filtered={isFiltered}
250
+ columnCount={COLUMN_COUNT}
251
+ onClear={clearFilters}
252
+ />
232
253
  )}
233
254
  </TableBody>
234
255
  </Table>
@@ -64,13 +64,15 @@ export function CreateUserFields({
64
64
  type="password"
65
65
  value={password}
66
66
  onChange={(e) => onPasswordChange(e.target.value)}
67
+ placeholder="At least 8 characters"
67
68
  required
68
69
  autoComplete="new-password"
69
70
  className="pl-9"
70
71
  />
71
72
  </div>
72
73
  <p className="text-xs text-muted-foreground">
73
- The account is active and verified immediately — share the password securely.
74
+ Must be at least 8 characters and not all numbers. The account is active and verified
75
+ immediately — share the password securely.
74
76
  </p>
75
77
  </div>
76
78
  </>
@@ -13,8 +13,10 @@ import {
13
13
  } from '@simple-module-py/ui/components/ui/alert-dialog';
14
14
  import { Button } from '@simple-module-py/ui/components/ui/button';
15
15
  import { Card, CardContent } from '@simple-module-py/ui/components/ui/card';
16
+ import { Input } from '@simple-module-py/ui/components/ui/input';
17
+ import { Label } from '@simple-module-py/ui/components/ui/label';
16
18
  import { Trash2 } from 'lucide-react';
17
- import { useState } from 'react';
19
+ import { useId, useState } from 'react';
18
20
  import { toast } from 'sonner';
19
21
 
20
22
  interface Props {
@@ -25,6 +27,13 @@ interface Props {
25
27
 
26
28
  export function DangerZone({ userId, email, isSelf }: Props) {
27
29
  const [deleting, setDeleting] = useState(false);
30
+ // Deleting a user is unrecoverable and the row it starts from looks like
31
+ // every other row, so the confirm asks for the address to be typed out.
32
+ // Reading it back is the part that catches "wrong person" — a second click
33
+ // never would.
34
+ const [typed, setTyped] = useState('');
35
+ const confirmId = useId();
36
+ const confirmed = typed.trim().toLowerCase() === email.toLowerCase();
28
37
 
29
38
  const handleDelete = () => {
30
39
  setDeleting(true);
@@ -56,7 +65,7 @@ export function DangerZone({ userId, email, isSelf }: Props) {
56
65
  <p className="text-sm text-muted-foreground">
57
66
  Permanently delete this user. This cannot be undone.
58
67
  </p>
59
- <AlertDialog>
68
+ <AlertDialog onOpenChange={(open) => !open && setTyped('')}>
60
69
  <AlertDialogTrigger asChild>
61
70
  <Button variant="destructive" size="sm" className="gap-1.5" disabled={deleting}>
62
71
  <Trash2 className="h-3.5 w-3.5" />
@@ -67,13 +76,27 @@ export function DangerZone({ userId, email, isSelf }: Props) {
67
76
  <AlertDialogHeader>
68
77
  <AlertDialogTitle>Delete {email}?</AlertDialogTitle>
69
78
  <AlertDialogDescription>
70
- This permanently removes the account and all of its access. This action cannot
71
- be undone.
79
+ Removes the account, its roles and its sessions. Audit entries stay, attributed
80
+ to a deleted user. This cannot be undone.
72
81
  </AlertDialogDescription>
73
82
  </AlertDialogHeader>
83
+ <div className="grid gap-2">
84
+ <Label htmlFor={confirmId} className="text-sm font-normal">
85
+ Type <span className="font-medium text-foreground">{email}</span> to confirm
86
+ </Label>
87
+ <Input
88
+ id={confirmId}
89
+ value={typed}
90
+ onChange={(e) => setTyped(e.target.value)}
91
+ autoComplete="off"
92
+ placeholder={email}
93
+ />
94
+ </div>
74
95
  <AlertDialogFooter>
75
96
  <AlertDialogCancel>Cancel</AlertDialogCancel>
76
- <AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
97
+ <AlertDialogAction disabled={!confirmed} onClick={handleDelete}>
98
+ Delete user
99
+ </AlertDialogAction>
77
100
  </AlertDialogFooter>
78
101
  </AlertDialogContent>
79
102
  </AlertDialog>
@@ -1,6 +1,7 @@
1
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
+ import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes';
4
5
  import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
5
6
  import { useEffect, useState } from 'react';
6
7
 
@@ -67,7 +68,7 @@ function VerifyEmail() {
67
68
  title: 'Email verified!',
68
69
  description: 'Your account is now active.',
69
70
  body: (
70
- <a href="/users/login">
71
+ <a href={LOGIN_PATH}>
71
72
  <Button className="w-full">Log in</Button>
72
73
  </a>
73
74
  ),
@@ -78,7 +79,7 @@ function VerifyEmail() {
78
79
  title: 'Already verified',
79
80
  description: 'This account is already verified — you can log in.',
80
81
  body: (
81
- <a href="/users/login">
82
+ <a href={LOGIN_PATH}>
82
83
  <Button className="w-full">Log in</Button>
83
84
  </a>
84
85
  ),
@@ -90,7 +91,7 @@ function VerifyEmail() {
90
91
  description: errorMsg || 'Verification link expired or invalid.',
91
92
  body: (
92
93
  <a
93
- href="/users/login"
94
+ href={LOGIN_PATH}
94
95
  className="text-center text-sm font-semibold text-primary-700 hover:text-primary-800"
95
96
  >
96
97
  Back to log in
@@ -0,0 +1,29 @@
1
+ """Inertia shared props contributed by the users module.
2
+
3
+ The public shell (landing page, ``PublicLayout``) has to know whether local
4
+ signup is open *before* it renders a "Sign up" link: ``/users/register`` raises
5
+ 404 when ``allow_signup`` is off, so a link rendered unconditionally walks every
6
+ visitor into a dead end.
7
+
8
+ Exposed as its own top-level key rather than folded into ``auth`` — the
9
+ framework owns that block and a provider may not overwrite it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from starlette.requests import Request
18
+
19
+
20
+ def users_shared_props(request: Request) -> dict:
21
+ """Whether local self-signup is currently accepted.
22
+
23
+ Runs on every request, so it only reads already-hydrated state. Defaults to
24
+ closed: if settings are missing, the safe answer is "no signup link" rather
25
+ than a link that 404s.
26
+ """
27
+ state = getattr(request.app.state, "users", None)
28
+ settings = getattr(state, "settings", None)
29
+ return {"signup": {"allowed": bool(getattr(settings, "allow_signup", False))}}