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.
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/PKG-INFO +10 -10
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/README.md +3 -3
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/pyproject.toml +7 -7
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_cli.py +2 -2
- simple_module_users-0.0.12/tests/test_oauth.py +189 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/cli.py +4 -3
- simple_module_users-0.0.12/users/components/RolesTab.tsx +77 -0
- simple_module_users-0.0.12/users/components/UserRow.tsx +80 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/db_adapter.py +5 -2
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/endpoints/api.py +7 -1
- simple_module_users-0.0.12/users/endpoints/api_oauth.py +131 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/endpoints/views.py +4 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/manager.py +8 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/models/__init__.py +2 -0
- simple_module_users-0.0.12/users/models/oauth_account.py +41 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/models/user.py +12 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/module.py +5 -4
- simple_module_users-0.0.12/users/oauth.py +120 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/AcceptInvite.tsx +49 -43
- simple_module_users-0.0.12/users/pages/ForgotPassword.tsx +86 -0
- simple_module_users-0.0.12/users/pages/Login.tsx +210 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/Profile.tsx +51 -15
- simple_module_users-0.0.12/users/pages/Register.tsx +158 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/ResetPassword.tsx +44 -49
- simple_module_users-0.0.12/users/pages/Users/Edit.tsx +235 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/Users/Index.tsx +60 -89
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/Users/Invite.tsx +45 -33
- simple_module_users-0.0.12/users/pages/Users/components/AccountStatusCard.tsx +102 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/VerifyEmail.tsx +29 -19
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/service.py +14 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/settings.py +16 -1
- simple_module_users-0.0.10/users/components/RolesTab.tsx +0 -72
- simple_module_users-0.0.10/users/pages/ForgotPassword.tsx +0 -90
- simple_module_users-0.0.10/users/pages/Login.tsx +0 -183
- simple_module_users-0.0.10/users/pages/Register.tsx +0 -152
- simple_module_users-0.0.10/users/pages/Users/Edit.tsx +0 -293
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/.gitignore +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/LICENSE +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/package.json +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/conftest.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_views.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/tsconfig.json +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/__init__.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/backend.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/constants.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/deps.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/endpoints/__init__.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/endpoints/api_admin.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/exceptions.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/middleware.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/models/_base.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/models/role.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/py.typed +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/rate_limit.py +0 -0
- {simple_module_users-0.0.10 → simple_module_users-0.0.12}/users/roles_cache.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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 `
|
|
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
|
-
- `
|
|
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
|
|
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 `
|
|
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
|
-
- `
|
|
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
|
|
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.
|
|
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.
|
|
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.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
|
|
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
|
-
"""``
|
|
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 ``
|
|
3
|
+
Exposed via ``smpy users`` (see pyproject.toml
|
|
4
|
+
[project.entry-points.simple_module_cli.cli_plugins]).
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
+
)
|