simple-module-users 0.0.10__tar.gz → 0.0.12__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 (95) hide show
  1. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/PKG-INFO +10 -10
  2. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/README.md +3 -3
  3. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/pyproject.toml +7 -7
  4. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_cli.py +2 -2
  5. simple_module_users-0.0.12/tests/test_oauth.py +189 -0
  6. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/cli.py +4 -3
  7. simple_module_users-0.0.12/users/components/RolesTab.tsx +77 -0
  8. simple_module_users-0.0.12/users/components/UserRow.tsx +80 -0
  9. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/db_adapter.py +5 -2
  10. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/endpoints/api.py +7 -1
  11. simple_module_users-0.0.12/users/endpoints/api_oauth.py +131 -0
  12. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/endpoints/views.py +4 -0
  13. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/manager.py +8 -0
  14. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/models/__init__.py +2 -0
  15. simple_module_users-0.0.12/users/models/oauth_account.py +41 -0
  16. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/models/user.py +12 -0
  17. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/module.py +5 -4
  18. simple_module_users-0.0.12/users/oauth.py +120 -0
  19. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/AcceptInvite.tsx +49 -43
  20. simple_module_users-0.0.12/users/pages/ForgotPassword.tsx +86 -0
  21. simple_module_users-0.0.12/users/pages/Login.tsx +210 -0
  22. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/Profile.tsx +51 -15
  23. simple_module_users-0.0.12/users/pages/Register.tsx +158 -0
  24. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/ResetPassword.tsx +44 -49
  25. simple_module_users-0.0.12/users/pages/Users/Edit.tsx +235 -0
  26. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/Users/Index.tsx +60 -89
  27. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/Users/Invite.tsx +45 -33
  28. simple_module_users-0.0.12/users/pages/Users/components/AccountStatusCard.tsx +102 -0
  29. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/VerifyEmail.tsx +29 -19
  30. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/service.py +14 -0
  31. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/settings.py +16 -1
  32. simple_module_users-0.0.10/users/components/RolesTab.tsx +0 -72
  33. simple_module_users-0.0.10/users/pages/ForgotPassword.tsx +0 -90
  34. simple_module_users-0.0.10/users/pages/Login.tsx +0 -183
  35. simple_module_users-0.0.10/users/pages/Register.tsx +0 -152
  36. simple_module_users-0.0.10/users/pages/Users/Edit.tsx +0 -293
  37. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/.gitignore +0 -0
  38. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/LICENSE +0 -0
  39. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/package.json +0 -0
  40. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/.gitkeep +0 -0
  41. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/_middleware_support.py +0 -0
  42. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/conftest.py +0 -0
  43. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_access_token_model.py +0 -0
  44. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_api_admin.py +0 -0
  45. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_api_admin_filters.py +0 -0
  46. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_api_auth.py +0 -0
  47. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_backend.py +0 -0
  48. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_bootstrap.py +0 -0
  49. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_constants.py +0 -0
  50. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_db_adapter.py +0 -0
  51. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_invite_flow.py +0 -0
  52. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_mailer.py +0 -0
  53. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_rate_limit.py +0 -0
  54. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_role_model.py +0 -0
  55. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_service_admin.py +0 -0
  56. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_settings.py +0 -0
  57. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_user_manager.py +0 -0
  58. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_user_model.py +0 -0
  59. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_user_role_model.py +0 -0
  60. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_user_service.py +0 -0
  61. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_users_deps.py +0 -0
  62. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_users_middleware.py +0 -0
  63. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_users_middleware_public_paths.py +0 -0
  64. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_views.py +0 -0
  65. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_views_admin.py +0 -0
  66. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tsconfig.json +0 -0
  67. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/__init__.py +0 -0
  68. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/backend.py +0 -0
  69. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/bootstrap.py +0 -0
  70. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/components/IndexFilters.tsx +0 -0
  71. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/constants.py +0 -0
  72. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/contracts/__init__.py +0 -0
  73. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/contracts/events.py +0 -0
  74. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/contracts/schemas.py +0 -0
  75. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/deps.py +0 -0
  76. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/endpoints/__init__.py +0 -0
  77. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/endpoints/api_admin.py +0 -0
  78. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/exceptions.py +0 -0
  79. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/__init__.py +0 -0
  80. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/console.py +0 -0
  81. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/smtp.py +0 -0
  82. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/templates/.gitkeep +0 -0
  83. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/templates/invite.txt +0 -0
  84. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/templates/reset_password.txt +0 -0
  85. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/templates/verify_email.txt +0 -0
  86. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/middleware.py +0 -0
  87. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/models/_base.py +0 -0
  88. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/models/access_token.py +0 -0
  89. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/models/role.py +0 -0
  90. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/models/user_role.py +0 -0
  91. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/.gitkeep +0 -0
  92. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/py.typed +0 -0
  93. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/rate_limit.py +0 -0
  94. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/roles_cache.py +0 -0
  95. {simple_module_users-0.0.10 → simple_module_users-0.0.12}/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.10
3
+ Version: 0.0.12
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
@@ -23,12 +23,12 @@ Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: aiosmtplib>=3.0
25
25
  Requires-Dist: cachetools>=5.3
26
- Requires-Dist: fastapi-users[sqlalchemy]<16,>=15
27
- Requires-Dist: simple-module-auth==0.0.10
28
- Requires-Dist: simple-module-core==0.0.10
29
- Requires-Dist: simple-module-db==0.0.10
30
- Requires-Dist: simple-module-hosting==0.0.10
31
- Requires-Dist: simple-module-settings==0.0.10
26
+ Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
27
+ Requires-Dist: simple-module-auth==0.0.12
28
+ Requires-Dist: simple-module-core==0.0.12
29
+ Requires-Dist: simple-module-db==0.0.12
30
+ Requires-Dist: simple-module-hosting==0.0.12
31
+ Requires-Dist: simple-module-settings==0.0.12
32
32
  Requires-Dist: typer>=0.12
33
33
  Description-Content-Type: text/markdown
34
34
 
@@ -42,7 +42,7 @@ Email+password user management for [simple_module](https://github.com/antosubash
42
42
  pip install simple_module_users
43
43
  ```
44
44
 
45
- Pre-wired into any app scaffolded with `simple-module new`.
45
+ Pre-wired into any app scaffolded with `smpy new`.
46
46
 
47
47
  ## What it provides
48
48
 
@@ -50,7 +50,7 @@ Pre-wired into any app scaffolded with `simple-module new`.
50
50
  - Admin invite flow — admin enters an email, recipient clicks a link, sets a password, is logged in.
51
51
  - Public signup toggle (`SM_USERS_ALLOW_SIGNUP`, default `false`).
52
52
  - Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
53
- - `sm-users create-admin` CLI for ad-hoc admin creation.
53
+ - `smpy users create-admin` CLI for ad-hoc admin creation.
54
54
  - Inertia pages for login/register/invite-accept/admin-invite.
55
55
  - Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
56
56
 
@@ -59,7 +59,7 @@ Pre-wired into any app scaffolded with `simple-module new`.
59
59
  CLI:
60
60
 
61
61
  ```bash
62
- uv run sm-users create-admin --email admin@example.com --password 'change-me'
62
+ uv run smpy users create-admin --email admin@example.com --password 'change-me'
63
63
  ```
64
64
 
65
65
  Bootstrap-on-boot (`.env`):
@@ -8,7 +8,7 @@ Email+password user management for [simple_module](https://github.com/antosubash
8
8
  pip install simple_module_users
9
9
  ```
10
10
 
11
- Pre-wired into any app scaffolded with `simple-module new`.
11
+ Pre-wired into any app scaffolded with `smpy new`.
12
12
 
13
13
  ## What it provides
14
14
 
@@ -16,7 +16,7 @@ Pre-wired into any app scaffolded with `simple-module new`.
16
16
  - Admin invite flow — admin enters an email, recipient clicks a link, sets a password, is logged in.
17
17
  - Public signup toggle (`SM_USERS_ALLOW_SIGNUP`, default `false`).
18
18
  - Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
19
- - `sm-users create-admin` CLI for ad-hoc admin creation.
19
+ - `smpy users create-admin` CLI for ad-hoc admin creation.
20
20
  - Inertia pages for login/register/invite-accept/admin-invite.
21
21
  - Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
22
22
 
@@ -25,7 +25,7 @@ Pre-wired into any app scaffolded with `simple-module new`.
25
25
  CLI:
26
26
 
27
27
  ```bash
28
- uv run sm-users create-admin --email admin@example.com --password 'change-me'
28
+ uv run smpy users create-admin --email admin@example.com --password 'change-me'
29
29
  ```
30
30
 
31
31
  Bootstrap-on-boot (`.env`):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_users"
3
- version = "0.0.10"
3
+ version = "0.0.12"
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,15 +21,15 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.10",
25
- "simple_module_db==0.0.10",
26
- "simple_module_hosting==0.0.10",
27
- "simple_module_settings==0.0.10",
28
- "simple_module_auth==0.0.10",
24
+ "simple_module_core==0.0.12",
25
+ "simple_module_db==0.0.12",
26
+ "simple_module_hosting==0.0.12",
27
+ "simple_module_settings==0.0.12",
28
+ "simple_module_auth==0.0.12",
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.
32
- "fastapi-users[sqlalchemy]>=15,<16",
32
+ "fastapi-users[sqlalchemy,oauth]>=15,<16",
33
33
  "aiosmtplib>=3.0",
34
34
  "cachetools>=5.3",
35
35
  "typer>=0.12",
@@ -1,4 +1,4 @@
1
- """Tests for the sm-users CLI (users.cli).
1
+ """Tests for the smpy users CLI (users.cli).
2
2
 
3
3
  Strategy: monkeypatch ``users.bootstrap.create_admin`` so tests do not need
4
4
  a real database. This avoids the complexity of standing up a schema-stamped
@@ -161,7 +161,7 @@ def test_create_admin_missing_email() -> None:
161
161
 
162
162
 
163
163
  def test_app_help() -> None:
164
- """``sm-users --help`` shows the top-level help text and lists create-admin."""
164
+ """``smpy users --help`` shows the top-level help text and lists create-admin."""
165
165
  result = runner.invoke(app, ["--help"])
166
166
  assert result.exit_code == 0
167
167
  assert "create-admin" in result.output
@@ -0,0 +1,189 @@
1
+ """Unit + integration tests for the OAuth/OIDC plumbing.
2
+
3
+ Provider client construction and the /authorize+/callback ASGI flow are not
4
+ covered here because both depend on real httpx-oauth clients that hit the
5
+ network (token exchange, profile fetch). Those are best validated in a manual
6
+ QA pass against a dev IdP. What this file *does* cover:
7
+
8
+ - ``enabled_provider_names`` correctly reflects settings.
9
+ - ``build_clients`` instantiates the Google + GitHub clients when configured.
10
+ - ``OAuthAccount`` persists and FK-cascades on user delete.
11
+ - ``UserManager.oauth_callback`` (the find-or-create core fastapi-users helper
12
+ the route delegates to) creates a fresh user + linked OAuthAccount, and
13
+ associates by email when the user already exists.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import uuid
19
+
20
+ import pytest
21
+ from fastapi_users.password import PasswordHelper
22
+ from sqlalchemy import select
23
+ from users.models import OAuthAccount, User
24
+ from users.oauth import build_clients, enabled_provider_names
25
+ from users.settings import UsersSettings
26
+
27
+ _pw = PasswordHelper()
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Settings → provider list
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def test_enabled_provider_names_empty_by_default():
36
+ assert enabled_provider_names(UsersSettings()) == []
37
+
38
+
39
+ def test_enabled_provider_names_lists_configured_providers():
40
+ s = UsersSettings(
41
+ oauth_google_client_id="g-id",
42
+ oauth_google_client_secret="g-secret",
43
+ oauth_github_client_id="gh-id",
44
+ oauth_github_client_secret="gh-secret",
45
+ )
46
+ names = [p["name"] for p in enabled_provider_names(s)]
47
+ assert names == ["google", "github"]
48
+
49
+
50
+ def test_enabled_provider_names_skips_provider_missing_secret():
51
+ s = UsersSettings(oauth_google_client_id="g-id") # no secret
52
+ assert enabled_provider_names(s) == []
53
+
54
+
55
+ def test_enabled_provider_names_oidc_requires_discovery_url():
56
+ s = UsersSettings(
57
+ oauth_oidc_client_id="x",
58
+ oauth_oidc_client_secret="y",
59
+ # discovery_url unset → not registered
60
+ )
61
+ assert enabled_provider_names(s) == []
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # build_clients (no-network providers only)
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ def test_build_clients_google_and_github():
70
+ s = UsersSettings(
71
+ oauth_google_client_id="g-id",
72
+ oauth_google_client_secret="g-secret",
73
+ oauth_github_client_id="gh-id",
74
+ oauth_github_client_secret="gh-secret",
75
+ )
76
+ providers = build_clients(s)
77
+ assert [p.name for p in providers] == ["google", "github"]
78
+ # Sanity-check that the underlying httpx-oauth client carries our id.
79
+ assert providers[0].client.client_id == "g-id"
80
+ assert providers[1].client.client_id == "gh-id"
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # OAuthAccount persistence + cascade
85
+ # ---------------------------------------------------------------------------
86
+
87
+
88
+ @pytest.mark.anyio
89
+ async def test_oauth_account_round_trip_and_cascade(users_db):
90
+ user = User(
91
+ id=uuid.uuid4(),
92
+ email="oauth-rt@example.com",
93
+ hashed_password=_pw.hash("SecurePass1!"),
94
+ is_active=True,
95
+ is_verified=True,
96
+ )
97
+ users_db.add(user)
98
+ await users_db.commit()
99
+
100
+ account = OAuthAccount(
101
+ user_id=user.id,
102
+ oauth_name="google",
103
+ access_token="tok",
104
+ account_id="google-123",
105
+ account_email=user.email,
106
+ )
107
+ users_db.add(account)
108
+ await users_db.commit()
109
+
110
+ found = (
111
+ await users_db.execute(select(OAuthAccount).where(OAuthAccount.account_id == "google-123"))
112
+ ).scalar_one()
113
+ assert found.user_id == user.id
114
+
115
+ # FK cascade: deleting the user removes the linked account.
116
+ await users_db.delete(user)
117
+ await users_db.commit()
118
+ remaining = (
119
+ await users_db.execute(select(OAuthAccount).where(OAuthAccount.account_id == "google-123"))
120
+ ).scalar_one_or_none()
121
+ assert remaining is None
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # UserManager.oauth_callback — the find-or-create flow the route delegates to
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ async def _build_user_manager(app):
130
+ """Construct a UserManager bound to the test app's DB session."""
131
+ from users.db_adapter import UserDatabaseWithRoles
132
+ from users.manager import UserManager
133
+ from users.models import OAuthAccount, User
134
+
135
+ session = app.state.sm.db.session_factory()
136
+ s = await session.__aenter__()
137
+ user_db = UserDatabaseWithRoles(s, User, OAuthAccount)
138
+ manager = UserManager(user_db, app.state.users.mailer, app.state.users.settings)
139
+ return manager, session, s
140
+
141
+
142
+ @pytest.mark.anyio
143
+ async def test_oauth_callback_creates_new_user_and_account(users_app):
144
+ manager, session, _ = await _build_user_manager(users_app)
145
+ try:
146
+ user = await manager.oauth_callback(
147
+ "google",
148
+ access_token="tok",
149
+ account_id="google-new-1",
150
+ account_email="newuser@example.com",
151
+ associate_by_email=True,
152
+ is_verified_by_default=True,
153
+ )
154
+ assert user.email == "newuser@example.com"
155
+ assert user.is_verified is True
156
+ assert len(user.oauth_accounts) == 1
157
+ assert user.oauth_accounts[0].oauth_name == "google"
158
+ assert user.oauth_accounts[0].account_id == "google-new-1"
159
+ finally:
160
+ await session.__aexit__(None, None, None)
161
+
162
+
163
+ @pytest.mark.anyio
164
+ async def test_oauth_callback_links_to_existing_email(users_app, users_db):
165
+ existing = User(
166
+ id=uuid.uuid4(),
167
+ email="existing@example.com",
168
+ hashed_password=_pw.hash("SecurePass1!"),
169
+ is_active=True,
170
+ is_verified=True,
171
+ )
172
+ users_db.add(existing)
173
+ await users_db.commit()
174
+
175
+ manager, session, _ = await _build_user_manager(users_app)
176
+ try:
177
+ linked = await manager.oauth_callback(
178
+ "github",
179
+ access_token="tok",
180
+ account_id="gh-42",
181
+ account_email="existing@example.com",
182
+ associate_by_email=True,
183
+ is_verified_by_default=True,
184
+ )
185
+ assert linked.id == existing.id
186
+ names = [a.oauth_name for a in linked.oauth_accounts]
187
+ assert names == ["github"]
188
+ finally:
189
+ await session.__aexit__(None, None, None)
@@ -1,9 +1,10 @@
1
1
  """Command-line entry points for the users module.
2
2
 
3
- Exposed via ``sm-users`` (see pyproject.toml [project.scripts]).
3
+ Exposed via ``smpy users`` (see pyproject.toml
4
+ [project.entry-points.simple_module_cli.cli_plugins]).
4
5
 
5
- sm-users create-admin --email a@b.test --password sekret [--full-name Me]
6
- sm-users create-admin --email a@b.test --password new --force
6
+ smpy users create-admin --email a@b.test --password sekret [--full-name Me]
7
+ smpy users create-admin --email a@b.test --password new --force
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -0,0 +1,77 @@
1
+ import { Link } from '@inertiajs/react';
2
+ import { Badge } from '@simple-module-py/ui/components/ui/badge';
3
+ import { Button } from '@simple-module-py/ui/components/ui/button';
4
+ import { Card, CardContent } from '@simple-module-py/ui/components/ui/card';
5
+ import { TabsContent } from '@simple-module-py/ui/components/ui/tabs';
6
+ import { Pencil, ShieldCheck, Users } from 'lucide-react';
7
+
8
+ export interface RoleItem {
9
+ id: string;
10
+ name: string;
11
+ description?: string | null;
12
+ user_count: number;
13
+ }
14
+
15
+ const SYSTEM_ROLES = new Set(['Owner', 'Admin', 'Viewer']);
16
+
17
+ export function RolesTab({ roles }: { roles: RoleItem[] }) {
18
+ return (
19
+ <TabsContent value="roles">
20
+ {roles.length === 0 ? (
21
+ <Card className="border-border">
22
+ <CardContent className="flex flex-col items-center gap-2 py-16 text-muted-foreground">
23
+ <ShieldCheck className="size-8" />
24
+ <p>No roles defined</p>
25
+ </CardContent>
26
+ </Card>
27
+ ) : (
28
+ <div className="grid gap-3 sm:grid-cols-2">
29
+ {roles.map((role) => {
30
+ const isSystem = SYSTEM_ROLES.has(role.name);
31
+ return (
32
+ <Card key={role.id} className="border-border">
33
+ <CardContent className="pt-5">
34
+ <div className="flex items-start gap-3">
35
+ <span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary-600/10 text-primary-700">
36
+ {isSystem ? (
37
+ <ShieldCheck className="h-[18px] w-[18px]" aria-hidden="true" />
38
+ ) : (
39
+ <Users className="h-[18px] w-[18px]" aria-hidden="true" />
40
+ )}
41
+ </span>
42
+ <div className="flex-1 min-w-0">
43
+ <div className="flex items-center gap-2">
44
+ <h3 className="text-[15px] font-bold tracking-tight font-[var(--font-display)] text-foreground">
45
+ {role.name}
46
+ </h3>
47
+ {isSystem && (
48
+ <Badge
49
+ variant="outline"
50
+ className="border-border bg-secondary text-[10px] text-muted-foreground"
51
+ >
52
+ system
53
+ </Badge>
54
+ )}
55
+ </div>
56
+ <p className="mt-1 text-xs text-muted-foreground line-clamp-2">
57
+ {role.description || 'No description.'}
58
+ </p>
59
+ <div className="mt-2 font-mono text-[11px] text-muted-foreground">
60
+ {role.user_count} {role.user_count === 1 ? 'member' : 'members'}
61
+ </div>
62
+ </div>
63
+ <Button asChild variant="ghost" size="icon-sm">
64
+ <Link href={`/permissions/roles/${role.id}/edit`} aria-label="Edit role">
65
+ <Pencil />
66
+ </Link>
67
+ </Button>
68
+ </div>
69
+ </CardContent>
70
+ </Card>
71
+ );
72
+ })}
73
+ </div>
74
+ )}
75
+ </TabsContent>
76
+ );
77
+ }
@@ -0,0 +1,80 @@
1
+ import { Link } from '@inertiajs/react';
2
+ import { Badge } from '@simple-module-py/ui/components/ui/badge';
3
+ import { Button } from '@simple-module-py/ui/components/ui/button';
4
+ import { TableCell, TableRow } from '@simple-module-py/ui/components/ui/table';
5
+ import { Pencil } from 'lucide-react';
6
+
7
+ export interface UserListItem {
8
+ id: string;
9
+ email: string;
10
+ full_name: string | null;
11
+ is_active: boolean;
12
+ is_verified: boolean;
13
+ last_login_at: string | null;
14
+ created_at: string | null;
15
+ roles: string[];
16
+ }
17
+
18
+ function Avatar({ initial }: { initial: string }) {
19
+ return (
20
+ <span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary-600 to-primary-800 text-[13px] font-bold text-white font-[var(--font-display)]">
21
+ {initial}
22
+ </span>
23
+ );
24
+ }
25
+
26
+ function StatusBadge({ user }: { user: UserListItem }) {
27
+ if (!user.is_active) {
28
+ return (
29
+ <Badge variant="outline" className="border-border bg-secondary text-muted-foreground">
30
+ disabled
31
+ </Badge>
32
+ );
33
+ }
34
+ if (!user.is_verified) {
35
+ return (
36
+ <Badge variant="outline" className="border-blue-200 bg-blue-50 text-blue-700">
37
+ invited
38
+ </Badge>
39
+ );
40
+ }
41
+ return (
42
+ <Badge variant="outline" className="border-primary-200 bg-primary-50 text-primary-700">
43
+ active
44
+ </Badge>
45
+ );
46
+ }
47
+
48
+ export function UserRow({ user }: { user: UserListItem }) {
49
+ return (
50
+ <TableRow className="hover:bg-secondary/40">
51
+ <TableCell className="py-3">
52
+ <div className="flex items-center gap-3">
53
+ <Avatar initial={(user.full_name || user.email).charAt(0).toUpperCase()} />
54
+ <div className="min-w-0">
55
+ <div className="truncate text-sm font-semibold text-foreground">
56
+ {user.full_name || user.email.split('@')[0]}
57
+ </div>
58
+ <div className="truncate text-[12px] text-muted-foreground">{user.email}</div>
59
+ </div>
60
+ </div>
61
+ </TableCell>
62
+ <TableCell className="hidden sm:table-cell text-sm text-muted-foreground">
63
+ {user.roles.length > 0 ? user.roles.join(', ') : '—'}
64
+ </TableCell>
65
+ <TableCell className="hidden sm:table-cell">
66
+ <StatusBadge user={user} />
67
+ </TableCell>
68
+ <TableCell className="hidden lg:table-cell text-sm text-muted-foreground">
69
+ {user.last_login_at ? new Date(user.last_login_at).toLocaleDateString() : '—'}
70
+ </TableCell>
71
+ <TableCell className="text-right">
72
+ <Button asChild variant="ghost" size="icon-sm">
73
+ <Link href={`/users/admin/${user.id}`}>
74
+ <Pencil />
75
+ </Link>
76
+ </Button>
77
+ </TableCell>
78
+ </TableRow>
79
+ );
80
+ }
@@ -12,7 +12,7 @@ from sqlalchemy import func, select
12
12
  from sqlalchemy.ext.asyncio import AsyncSession
13
13
  from sqlalchemy.orm import selectinload
14
14
 
15
- from users.models import User, UserAccessToken
15
+ from users.models import OAuthAccount, User, UserAccessToken
16
16
 
17
17
 
18
18
  class UserDatabaseWithRoles(SQLAlchemyUserDatabase):
@@ -39,7 +39,10 @@ class UserDatabaseWithRoles(SQLAlchemyUserDatabase):
39
39
  async def get_user_db(
40
40
  session: AsyncSession = Depends(get_db),
41
41
  ) -> AsyncGenerator[UserDatabaseWithRoles, None]:
42
- yield UserDatabaseWithRoles(session, User)
42
+ # OAuthAccount enables fastapi-users' OAuth router (get_by_oauth_account /
43
+ # add_oauth_account / update_oauth_account). Password-only flows are
44
+ # unaffected — those code paths never touch oauth_account_table.
45
+ yield UserDatabaseWithRoles(session, User, OAuthAccount)
43
46
 
44
47
 
45
48
  async def get_access_token_db(
@@ -30,8 +30,10 @@ from users.deps import (
30
30
  get_user_manager,
31
31
  )
32
32
  from users.endpoints.api_admin import admin_router
33
+ from users.endpoints.api_oauth import register_oauth_routes
33
34
  from users.manager import UserManager
34
35
  from users.rate_limit import LoginRateLimiter, ThroughputLimiter
36
+ from users.settings import UsersSettings
35
37
 
36
38
  logger = logging.getLogger(__name__)
37
39
  router = APIRouter()
@@ -125,7 +127,7 @@ auth_inner = fastapi_users.get_auth_router(auth_backend, requires_verification=T
125
127
  router.include_router(auth_inner, prefix="/auth-inner")
126
128
 
127
129
 
128
- def register_auth_routes(api_router: APIRouter) -> None:
130
+ def register_auth_routes(api_router: APIRouter, settings: UsersSettings) -> None:
129
131
  """Mount all auth routes.
130
132
 
131
133
  The stock fastapi-users routers (reset/verify/register) ship POST endpoints
@@ -137,6 +139,9 @@ def register_auth_routes(api_router: APIRouter) -> None:
137
139
 
138
140
  The register router is always mounted; ``require_signup_enabled`` gates
139
141
  it at request time so ``allow_signup`` is hot-reloadable.
142
+
143
+ OAuth providers configured in ``settings`` are mounted under
144
+ ``/auth/<provider>/{login,callback}`` — see :mod:`users.endpoints.api_oauth`.
140
145
  """
141
146
  api_router.include_router(router)
142
147
  api_router.include_router(
@@ -160,6 +165,7 @@ def register_auth_routes(api_router: APIRouter) -> None:
160
165
  Depends(enforce_auth_throughput_limit),
161
166
  ],
162
167
  )
168
+ register_oauth_routes(api_router, settings)
163
169
 
164
170
 
165
171
  # ── Accept-invite (verify + set password + login, one shot) ─────────────────
@@ -0,0 +1,131 @@
1
+ """OAuth/OIDC login routes — one pair (``/login``, ``/callback``) per provider.
2
+
3
+ Why a custom handler rather than ``fastapi_users.get_oauth_router``: the stock
4
+ router's ``/callback`` returns a 204 No Content with the auth cookie set. That
5
+ works for SPA flows that redirect on a successful AJAX response, but Inertia
6
+ expects the user's browser to land on a real page. Here ``/callback`` returns
7
+ a 303 redirect to ``settings.login_redirect_url`` instead, with the same
8
+ cookie attached.
9
+
10
+ Find-or-create + email-association logic still goes through
11
+ ``UserManager.oauth_callback`` — we don't reimplement it, only the transport
12
+ around it. State CSRF uses Starlette's signed session cookie (already mounted
13
+ by the framework) instead of fastapi-users' separate JWT-state cookie.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import secrets
20
+ from typing import TYPE_CHECKING
21
+
22
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
23
+ from fastapi_users import exceptions as fu_exceptions
24
+ from starlette.responses import RedirectResponse
25
+
26
+ from users.deps import auth_backend, get_user_manager
27
+ from users.oauth import OAuthProvider, build_clients
28
+
29
+ if TYPE_CHECKING:
30
+ from users.manager import UserManager
31
+ from users.settings import UsersSettings
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _SESSION_STATE_KEY_FMT = "oauth_state:{provider}"
36
+
37
+
38
+ def _build_provider_router(provider: OAuthProvider, login_redirect_url: str) -> APIRouter:
39
+ """Mount /login + /callback for one provider."""
40
+ router = APIRouter()
41
+ state_key = _SESSION_STATE_KEY_FMT.format(provider=provider.name)
42
+
43
+ @router.get("/login")
44
+ async def begin(request: Request) -> RedirectResponse:
45
+ """Generate a state nonce, stash it in the session, redirect to the IdP."""
46
+ state = secrets.token_urlsafe(32)
47
+ request.session[state_key] = state
48
+ callback_url = str(request.url_for(f"oauth_{provider.name}_callback"))
49
+ authorization_url = await provider.client.get_authorization_url(callback_url, state)
50
+ return RedirectResponse(authorization_url, status_code=302)
51
+
52
+ @router.get("/callback", name=f"oauth_{provider.name}_callback")
53
+ async def callback(
54
+ request: Request,
55
+ code: str | None = None,
56
+ state: str | None = None,
57
+ user_manager: UserManager = Depends(get_user_manager),
58
+ strategy=Depends(auth_backend.get_strategy),
59
+ ) -> RedirectResponse:
60
+ """Verify state, exchange code, find-or-create user, set cookie, redirect."""
61
+ expected_state = request.session.pop(state_key, None)
62
+ if not state or not expected_state or not secrets.compare_digest(state, expected_state):
63
+ raise HTTPException(
64
+ status_code=status.HTTP_400_BAD_REQUEST, detail="OAUTH_INVALID_STATE"
65
+ )
66
+ if not code:
67
+ raise HTTPException(
68
+ status_code=status.HTTP_400_BAD_REQUEST, detail="OAUTH_MISSING_CODE"
69
+ )
70
+
71
+ callback_url = str(request.url_for(f"oauth_{provider.name}_callback"))
72
+ token = await provider.client.get_access_token(code, callback_url)
73
+ account_id, account_email = await provider.client.get_id_email(token["access_token"])
74
+ if account_email is None:
75
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OAUTH_NO_EMAIL")
76
+
77
+ try:
78
+ user = await user_manager.oauth_callback(
79
+ provider.name,
80
+ token["access_token"],
81
+ account_id,
82
+ account_email,
83
+ token.get("expires_at"),
84
+ token.get("refresh_token"),
85
+ request,
86
+ associate_by_email=True,
87
+ is_verified_by_default=True,
88
+ )
89
+ except fu_exceptions.UserAlreadyExists:
90
+ # Email exists but associate_by_email=False would forbid linking.
91
+ # We always pass True above, so this branch only fires if the
92
+ # provider returns ambiguous data.
93
+ raise HTTPException(
94
+ status_code=status.HTTP_400_BAD_REQUEST,
95
+ detail="OAUTH_USER_ALREADY_EXISTS",
96
+ ) from None
97
+
98
+ if not user.is_active:
99
+ raise HTTPException(
100
+ status_code=status.HTTP_400_BAD_REQUEST, detail="LOGIN_BAD_CREDENTIALS"
101
+ )
102
+
103
+ # Set the auth cookie via the existing backend, then bridge the
104
+ # session in on_after_login (sets session["user_id"] for AuthMiddleware).
105
+ login_response = await auth_backend.login(strategy, user)
106
+ await user_manager.on_after_login(user, request, login_response)
107
+
108
+ redirect = RedirectResponse(login_redirect_url, status_code=303)
109
+ for key, value in login_response.headers.items():
110
+ if key.lower() == "set-cookie":
111
+ redirect.raw_headers.append((b"set-cookie", value.encode("latin-1")))
112
+ return redirect
113
+
114
+ return router
115
+
116
+
117
+ def register_oauth_routes(api_router: APIRouter, settings: UsersSettings) -> None:
118
+ """Mount /auth/<provider>/{login,callback} for every configured provider."""
119
+ providers = build_clients(settings)
120
+ for provider in providers:
121
+ api_router.include_router(
122
+ _build_provider_router(provider, settings.login_redirect_url),
123
+ prefix=f"/auth/{provider.name}",
124
+ tags=["users-auth"],
125
+ )
126
+ if providers:
127
+ logger.info(
128
+ "Registered %d OAuth provider(s): %s",
129
+ len(providers),
130
+ ", ".join(p.name for p in providers),
131
+ )