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.
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/PKG-INFO +6 -6
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/pyproject.toml +6 -6
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_api_admin.py +11 -0
- simple_module_users-0.0.32/tests/test_users_shared_props.py +52 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_views_admin.py +49 -0
- simple_module_users-0.0.32/users/admin/components/UsersEmpty.tsx +83 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/queries.py +8 -3
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/views.py +25 -10
- simple_module_users-0.0.32/users/locales/en.json +11 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/module.py +15 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/ForgotPassword.tsx +3 -2
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Login.tsx +2 -4
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Register.tsx +3 -2
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/ResetPassword.tsx +2 -1
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/Index.tsx +30 -9
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/CreateUserFields.tsx +3 -1
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/DangerZone.tsx +28 -5
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/VerifyEmail.tsx +4 -3
- simple_module_users-0.0.32/users/shared_props.py +29 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/.gitignore +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/LICENSE +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/README.md +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/package.json +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/conftest.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_external_users.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_users_bulk_invite.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tests/test_views.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/tsconfig.json +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/__init__.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/api.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/bulk_invite.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/service.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/api.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/invite_preview.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/token_api.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/views.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/backend.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/cli.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/constants.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/deps.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/exceptions.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/health.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/manager.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/middleware.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/_base.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/role.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/user.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/oauth/api.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/AddPeople.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/DetailsCard.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/InviteFields.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/InviteResults.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/MetadataCard.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/RolePicker.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/RolesCard.tsx +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/provider.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/py.typed +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/settings.py +0 -0
- {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.
|
|
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.
|
|
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.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.
|
|
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.
|
|
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.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 =
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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(
|
|
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
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}>
|
|
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=
|
|
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=
|
|
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=
|
|
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))}}
|
|
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.31 → simple_module_users-0.0.32}/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.31 → simple_module_users-0.0.32}/tests/test_users_middleware_resolvers.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
|
{simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/components/IndexFilters.tsx
RENAMED
|
File without changes
|
{simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/components/RolesTab.tsx
RENAMED
|
File without changes
|
{simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/admin/components/UserRow.tsx
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/auth_local/invite_preview.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
|
{simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/mailer/templates/reset_password.txt
RENAMED
|
File without changes
|
{simple_module_users-0.0.31 → simple_module_users-0.0.32}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_users-0.0.31 → simple_module_users-0.0.32}/users/pages/Users/components/RolesCard.tsx
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|