simple-module-users 0.0.20__tar.gz → 0.0.22__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.20 → simple_module_users-0.0.22}/PKG-INFO +6 -6
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/pyproject.toml +6 -6
- simple_module_users-0.0.22/tests/test_external_users.py +228 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_mailer.py +50 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/api.py +10 -1
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/components/UserRow.tsx +9 -1
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/queries.py +2 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/service.py +13 -2
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/auth_local/token_api.py +11 -1
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/backend.py +1 -1
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/constants.py +6 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/contracts/schemas.py +2 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/exceptions.py +12 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/mailer/__init__.py +19 -2
- simple_module_users-0.0.22/users/mailer/console.py +43 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/mailer/smtp.py +17 -6
- simple_module_users-0.0.22/users/mailer/templates/invite.txt +7 -0
- simple_module_users-0.0.22/users/mailer/templates/reset_password.txt +7 -0
- simple_module_users-0.0.22/users/mailer/templates/verify_email.txt +7 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/manager.py +53 -1
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/user.py +12 -2
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/module.py +10 -2
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/oauth/api.py +5 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/Edit.tsx +20 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/components/AccountStatusCard.tsx +36 -21
- simple_module_users-0.0.20/users/mailer/console.py +0 -27
- simple_module_users-0.0.20/users/mailer/templates/invite.txt +0 -1
- simple_module_users-0.0.20/users/mailer/templates/reset_password.txt +0 -1
- simple_module_users-0.0.20/users/mailer/templates/verify_email.txt +0 -1
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/.gitignore +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/LICENSE +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/README.md +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/package.json +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/conftest.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_views.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/tsconfig.json +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/__init__.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/views.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/auth_local/api.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/auth_local/views.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/cli.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/deps.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/middleware.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/_base.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/role.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Login.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/ResetPassword.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/Create.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/Invite.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/components/DangerZone.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/Users/components/DetailsCard.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/provider.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/py.typed +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/settings.py +0 -0
- {simple_module_users-0.0.20 → simple_module_users-0.0.22}/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.22
|
|
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.22
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.22
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.22
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.22
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.22
|
|
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.22"
|
|
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.22",
|
|
25
|
+
"simple_module_db==0.0.22",
|
|
26
|
+
"simple_module_hosting==0.0.22",
|
|
27
|
+
"simple_module_settings==0.0.22",
|
|
28
|
+
"simple_module_auth==0.0.22",
|
|
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.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""External (SSO) user behaviour: null password, marking, and guards.
|
|
2
|
+
|
|
3
|
+
Covers the feature added on top of the OAuth/OIDC login flow:
|
|
4
|
+
- a *new* OAuth login provisions an external user (null password, marked),
|
|
5
|
+
- an OAuth login that *links* to an existing password account leaves it local,
|
|
6
|
+
- external users cannot password-login or be sent a reset link,
|
|
7
|
+
- admins can still assign roles to external users like any other user,
|
|
8
|
+
- the admin list surfaces ``is_external``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
from urllib.parse import parse_qs, urlparse
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
from fastapi_users.password import PasswordHelper
|
|
18
|
+
from sqlalchemy import select
|
|
19
|
+
from users.models import Role, User
|
|
20
|
+
|
|
21
|
+
_pw = PasswordHelper()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Fake OAuth provider (no network) to drive the /login + /callback flow
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _FakeOAuthClient:
|
|
30
|
+
def __init__(self, account_id: str, account_email: str) -> None:
|
|
31
|
+
self._account_id = account_id
|
|
32
|
+
self._account_email = account_email
|
|
33
|
+
|
|
34
|
+
async def get_authorization_url(self, redirect_uri: str, state: str) -> str:
|
|
35
|
+
return f"https://idp.example/authorize?state={state}"
|
|
36
|
+
|
|
37
|
+
async def get_access_token(self, code: str, redirect_uri: str) -> dict:
|
|
38
|
+
return {"access_token": "fake-token", "expires_at": None, "refresh_token": None}
|
|
39
|
+
|
|
40
|
+
async def get_id_email(self, access_token: str) -> tuple[str, str]:
|
|
41
|
+
return self._account_id, self._account_email
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _install_fake_provider(app, account_id: str, account_email: str) -> None:
|
|
45
|
+
from users.oauth import OAuthProvider
|
|
46
|
+
|
|
47
|
+
app.state.users.oauth_clients["microsoft"] = OAuthProvider(
|
|
48
|
+
"microsoft", "Microsoft", _FakeOAuthClient(account_id, account_email)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _run_oauth_login(client) -> int:
|
|
53
|
+
"""Drive /login then /callback; return the callback status code."""
|
|
54
|
+
login = await client.get("/api/users/auth/microsoft/login", follow_redirects=False)
|
|
55
|
+
assert login.status_code == 302
|
|
56
|
+
state = parse_qs(urlparse(login.headers["location"]).query)["state"][0]
|
|
57
|
+
cb = await client.get(
|
|
58
|
+
f"/api/users/auth/microsoft/callback?code=abc&state={state}",
|
|
59
|
+
follow_redirects=False,
|
|
60
|
+
)
|
|
61
|
+
return cb.status_code
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _get_user_by_email(db, email: str) -> User:
|
|
65
|
+
return (await db.execute(select(User).where(User.email == email))).scalar_one()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Provisioning
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.anyio
|
|
74
|
+
async def test_new_oauth_login_creates_external_user(users_app, anon_client, users_db):
|
|
75
|
+
_install_fake_provider(users_app, "ms-oid-1", "sso-new@example.com")
|
|
76
|
+
|
|
77
|
+
assert await _run_oauth_login(anon_client) == 303
|
|
78
|
+
|
|
79
|
+
user = await _get_user_by_email(users_db, "sso-new@example.com")
|
|
80
|
+
assert user.is_external is True
|
|
81
|
+
assert user.hashed_password is None
|
|
82
|
+
assert user.is_verified is True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.anyio
|
|
86
|
+
async def test_oauth_login_links_existing_password_user_stays_local(
|
|
87
|
+
users_app, anon_client, users_db
|
|
88
|
+
):
|
|
89
|
+
existing = User(
|
|
90
|
+
id=uuid.uuid4(),
|
|
91
|
+
email="local@example.com",
|
|
92
|
+
hashed_password=_pw.hash("SecurePass1!"),
|
|
93
|
+
is_active=True,
|
|
94
|
+
is_verified=True,
|
|
95
|
+
)
|
|
96
|
+
users_db.add(existing)
|
|
97
|
+
await users_db.commit()
|
|
98
|
+
|
|
99
|
+
_install_fake_provider(users_app, "ms-oid-2", "local@example.com")
|
|
100
|
+
assert await _run_oauth_login(anon_client) == 303
|
|
101
|
+
|
|
102
|
+
user = await _get_user_by_email(users_db, "local@example.com")
|
|
103
|
+
assert user.is_external is False
|
|
104
|
+
assert user.hashed_password is not None # password retained
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Credential guards
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _seed_external_user(db, email: str = "ext@example.com") -> User:
|
|
113
|
+
user = User(
|
|
114
|
+
id=uuid.uuid4(),
|
|
115
|
+
email=email,
|
|
116
|
+
hashed_password=None,
|
|
117
|
+
is_active=True,
|
|
118
|
+
is_verified=True,
|
|
119
|
+
is_external=True,
|
|
120
|
+
)
|
|
121
|
+
db.add(user)
|
|
122
|
+
await db.commit()
|
|
123
|
+
return user
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.anyio
|
|
127
|
+
async def test_external_user_cannot_password_login(users_app, anon_client, users_db):
|
|
128
|
+
await _seed_external_user(users_db, "ext-login@example.com")
|
|
129
|
+
|
|
130
|
+
resp = await anon_client.post(
|
|
131
|
+
"/api/users/auth/login",
|
|
132
|
+
data={"username": "ext-login@example.com", "password": "anything-at-all"},
|
|
133
|
+
)
|
|
134
|
+
assert resp.status_code == 400
|
|
135
|
+
assert resp.json()["detail"] == "LOGIN_BAD_CREDENTIALS"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@pytest.mark.anyio
|
|
139
|
+
async def test_external_user_cannot_bearer_token_login(users_app, anon_client, users_db):
|
|
140
|
+
"""The bearer-token login path must reject external users cleanly (401),
|
|
141
|
+
not 500 on a None-hash verify, and without a timing/error enumeration leak."""
|
|
142
|
+
await _seed_external_user(users_db, "ext-token@example.com")
|
|
143
|
+
|
|
144
|
+
resp = await anon_client.post(
|
|
145
|
+
"/api/users/auth/token",
|
|
146
|
+
json={"email": "ext-token@example.com", "password": "anything-at-all"},
|
|
147
|
+
)
|
|
148
|
+
assert resp.status_code == 401
|
|
149
|
+
assert resp.json()["detail"] == "Invalid credentials"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.mark.anyio
|
|
153
|
+
async def test_admin_reset_link_rejected_for_external_user(admin_client, users_db):
|
|
154
|
+
user = await _seed_external_user(users_db, "ext-reset@example.com")
|
|
155
|
+
|
|
156
|
+
resp = await admin_client.post(f"/api/users/admin/{user.id}/reset-password-link")
|
|
157
|
+
assert resp.status_code == 409
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.anyio
|
|
161
|
+
async def test_forgot_password_is_noop_for_external_user(users_app):
|
|
162
|
+
from users.db_adapter import UserDatabaseWithRoles
|
|
163
|
+
from users.manager import UserManager
|
|
164
|
+
from users.models import OAuthAccount
|
|
165
|
+
|
|
166
|
+
async with users_app.state.sm.db.session_factory() as session:
|
|
167
|
+
user_db = UserDatabaseWithRoles(session, User, OAuthAccount)
|
|
168
|
+
manager = UserManager(user_db, users_app.state.users.mailer, users_app.state.users.settings)
|
|
169
|
+
|
|
170
|
+
sent: list[str] = []
|
|
171
|
+
|
|
172
|
+
async def _spy(user, token, request=None):
|
|
173
|
+
sent.append(user.email)
|
|
174
|
+
|
|
175
|
+
manager.on_after_forgot_password = _spy
|
|
176
|
+
|
|
177
|
+
external = User(
|
|
178
|
+
id=uuid.uuid4(),
|
|
179
|
+
email="ext-forgot@example.com",
|
|
180
|
+
hashed_password=None,
|
|
181
|
+
is_active=True,
|
|
182
|
+
is_verified=True,
|
|
183
|
+
is_external=True,
|
|
184
|
+
)
|
|
185
|
+
local = User(
|
|
186
|
+
id=uuid.uuid4(),
|
|
187
|
+
email="local-forgot@example.com",
|
|
188
|
+
hashed_password=_pw.hash("SecurePass1!"),
|
|
189
|
+
is_active=True,
|
|
190
|
+
is_verified=True,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
await manager.forgot_password(external)
|
|
194
|
+
assert sent == [] # SSO account: no reset email
|
|
195
|
+
|
|
196
|
+
await manager.forgot_password(local)
|
|
197
|
+
assert sent == ["local-forgot@example.com"] # control: local account still works
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Roles + admin visibility (external users are managed like normal users)
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@pytest.mark.anyio
|
|
206
|
+
async def test_admin_can_assign_role_to_external_user(admin_client, users_db):
|
|
207
|
+
user = await _seed_external_user(users_db, "ext-role@example.com")
|
|
208
|
+
users_db.add(Role(id=uuid.uuid4(), name="member", description="Member"))
|
|
209
|
+
await users_db.commit()
|
|
210
|
+
|
|
211
|
+
resp = await admin_client.put(
|
|
212
|
+
f"/api/users/admin/{user.id}/roles",
|
|
213
|
+
json={"role_names": ["member"]},
|
|
214
|
+
)
|
|
215
|
+
assert resp.status_code == 200
|
|
216
|
+
assert "member" in resp.json()["roles"]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@pytest.mark.anyio
|
|
220
|
+
async def test_admin_list_exposes_is_external(admin_client, users_db):
|
|
221
|
+
await _seed_external_user(users_db, "ext-list@example.com")
|
|
222
|
+
|
|
223
|
+
resp = await admin_client.get("/api/users/admin?per_page=100")
|
|
224
|
+
assert resp.status_code == 200
|
|
225
|
+
rows = {r["email"]: r for r in resp.json()}
|
|
226
|
+
assert rows["ext-list@example.com"]["is_external"] is True
|
|
227
|
+
# The seeded admin is a local account.
|
|
228
|
+
assert rows["admin@example.com"]["is_external"] is False
|
|
@@ -78,3 +78,53 @@ async def test_base_url_trailing_slash_stripped(caplog):
|
|
|
78
78
|
link = caplog.records[0].link # type: ignore[attr-defined]
|
|
79
79
|
assert not link.startswith("http://localhost:8000//")
|
|
80
80
|
assert link == "http://localhost:8000/users/verify?token=tok"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.anyio
|
|
84
|
+
async def test_console_logs_configured_app_name(caplog):
|
|
85
|
+
"""A supplied provider brands the console log; otherwise the default."""
|
|
86
|
+
from users.mailer.console import ConsoleMailer
|
|
87
|
+
|
|
88
|
+
branded = ConsoleMailer(base_url="http://localhost:8000", app_name_provider=lambda: "Acme")
|
|
89
|
+
default = ConsoleMailer(base_url="http://localhost:8000")
|
|
90
|
+
|
|
91
|
+
with caplog.at_level(logging.INFO, logger="users.mailer"):
|
|
92
|
+
await branded.send_invite("a@b.com", "tok", "Alice")
|
|
93
|
+
await default.send_verification("a@b.com", "tok")
|
|
94
|
+
|
|
95
|
+
assert caplog.records[0].app_name == "Acme" # type: ignore[attr-defined]
|
|
96
|
+
assert caplog.records[1].app_name == "SimpleModule" # type: ignore[attr-defined]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.anyio
|
|
100
|
+
async def test_smtp_emails_carry_the_app_name(monkeypatch):
|
|
101
|
+
"""SMTP subjects + bodies include the live app name (and keep the link)."""
|
|
102
|
+
from users.mailer.smtp import SmtpMailer
|
|
103
|
+
|
|
104
|
+
captured: list[dict[str, str]] = []
|
|
105
|
+
|
|
106
|
+
async def fake_send(self, to: str, subject: str, body: str) -> None:
|
|
107
|
+
captured.append({"to": to, "subject": subject, "body": body})
|
|
108
|
+
|
|
109
|
+
monkeypatch.setattr(SmtpMailer, "_send", fake_send)
|
|
110
|
+
mailer = SmtpMailer(
|
|
111
|
+
host="smtp.test",
|
|
112
|
+
port=25,
|
|
113
|
+
username="",
|
|
114
|
+
password="",
|
|
115
|
+
from_address="from@test",
|
|
116
|
+
use_tls=False,
|
|
117
|
+
base_url="http://localhost:8000",
|
|
118
|
+
app_name_provider=lambda: "Acme",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
await mailer.send_invite("new@x.com", "tok", "Alice")
|
|
122
|
+
await mailer.send_verification("v@x.com", "vtok")
|
|
123
|
+
await mailer.send_password_reset("r@x.com", "rtok")
|
|
124
|
+
|
|
125
|
+
invite, verify, reset = captured
|
|
126
|
+
assert "Acme" in invite["subject"] and "Alice" in invite["subject"]
|
|
127
|
+
assert "Acme" in invite["body"]
|
|
128
|
+
assert "http://localhost:8000/users/invite/accept?token=tok" in invite["body"]
|
|
129
|
+
assert "Acme" in verify["subject"] and "Acme" in verify["body"]
|
|
130
|
+
assert "Acme" in reset["subject"] and "Acme" in reset["body"]
|
|
@@ -28,7 +28,11 @@ from users.contracts.schemas import (
|
|
|
28
28
|
UserListItem,
|
|
29
29
|
)
|
|
30
30
|
from users.deps import get_event_bus, get_mailer, get_user_service
|
|
31
|
-
from users.exceptions import
|
|
31
|
+
from users.exceptions import (
|
|
32
|
+
EmailAlreadyExistsError,
|
|
33
|
+
ExternalUserNoPasswordError,
|
|
34
|
+
UserNotFoundError,
|
|
35
|
+
)
|
|
32
36
|
|
|
33
37
|
admin_router = APIRouter(
|
|
34
38
|
prefix="/admin",
|
|
@@ -243,4 +247,9 @@ async def admin_reset_password_link(
|
|
|
243
247
|
link = await service.generate_reset_link(user_id, base_url)
|
|
244
248
|
except UserNotFoundError:
|
|
245
249
|
raise HTTPException(status_code=404, detail="User not found") from None
|
|
250
|
+
except ExternalUserNoPasswordError:
|
|
251
|
+
raise HTTPException(
|
|
252
|
+
status_code=409,
|
|
253
|
+
detail="External (SSO) users have no password to reset",
|
|
254
|
+
) from None
|
|
246
255
|
return PasswordResetLink(link=link)
|
{simple_module_users-0.0.20 → simple_module_users-0.0.22}/users/admin/components/UserRow.tsx
RENAMED
|
@@ -10,6 +10,7 @@ export interface UserListItem {
|
|
|
10
10
|
full_name: string | null;
|
|
11
11
|
is_active: boolean;
|
|
12
12
|
is_verified: boolean;
|
|
13
|
+
is_external: boolean;
|
|
13
14
|
last_login_at: string | null;
|
|
14
15
|
created_at: string | null;
|
|
15
16
|
roles: string[];
|
|
@@ -63,7 +64,14 @@ export function UserRow({ user }: { user: UserListItem }) {
|
|
|
63
64
|
{user.roles.length > 0 ? user.roles.join(', ') : '—'}
|
|
64
65
|
</TableCell>
|
|
65
66
|
<TableCell className="hidden sm:table-cell">
|
|
66
|
-
<
|
|
67
|
+
<div className="flex items-center gap-1.5">
|
|
68
|
+
<StatusBadge user={user} />
|
|
69
|
+
{user.is_external && (
|
|
70
|
+
<Badge variant="outline" className="border-violet-200 bg-violet-50 text-violet-700">
|
|
71
|
+
SSO
|
|
72
|
+
</Badge>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
67
75
|
</TableCell>
|
|
68
76
|
<TableCell className="hidden lg:table-cell text-sm text-muted-foreground">
|
|
69
77
|
{user.last_login_at ? new Date(user.last_login_at).toLocaleDateString() : '—'}
|
|
@@ -40,6 +40,7 @@ class _UserServiceBase:
|
|
|
40
40
|
full_name=user.full_name,
|
|
41
41
|
is_active=user.is_active,
|
|
42
42
|
is_verified=user.is_verified,
|
|
43
|
+
is_external=user.is_external,
|
|
43
44
|
disabled_at=user.disabled_at,
|
|
44
45
|
last_login_at=user.last_login_at,
|
|
45
46
|
created_at=user.created_at,
|
|
@@ -103,6 +104,7 @@ class _UserServiceBase:
|
|
|
103
104
|
User.full_name,
|
|
104
105
|
User.is_active,
|
|
105
106
|
User.is_verified,
|
|
107
|
+
User.is_external,
|
|
106
108
|
User.disabled_at,
|
|
107
109
|
User.last_login_at,
|
|
108
110
|
User.created_at,
|
|
@@ -11,7 +11,11 @@ from sqlalchemy.orm import noload
|
|
|
11
11
|
|
|
12
12
|
from users.admin.queries import _UserServiceBase
|
|
13
13
|
from users.contracts.schemas import UserCreate
|
|
14
|
-
from users.exceptions import
|
|
14
|
+
from users.exceptions import (
|
|
15
|
+
EmailAlreadyExistsError,
|
|
16
|
+
ExternalUserNoPasswordError,
|
|
17
|
+
UserNotFoundError,
|
|
18
|
+
)
|
|
15
19
|
from users.models import OAuthAccount, RefreshToken, User, UserAccessToken, UserRole
|
|
16
20
|
|
|
17
21
|
|
|
@@ -205,8 +209,15 @@ class UserService(_UserServiceBase):
|
|
|
205
209
|
return user
|
|
206
210
|
|
|
207
211
|
async def generate_reset_link(self, user_id: uuid.UUID, base_url: str) -> str:
|
|
208
|
-
"""Build an admin-copyable password-reset URL. No email side-effect.
|
|
212
|
+
"""Build an admin-copyable password-reset URL. No email side-effect.
|
|
213
|
+
|
|
214
|
+
Raises ``ExternalUserNoPasswordError`` for SSO users — they have no
|
|
215
|
+
local password, so a reset link is meaningless (and would crash on the
|
|
216
|
+
null-hash fingerprint).
|
|
217
|
+
"""
|
|
209
218
|
user = await self._require_user(user_id)
|
|
219
|
+
if user.is_external:
|
|
220
|
+
raise ExternalUserNoPasswordError(user_id)
|
|
210
221
|
|
|
211
222
|
token = await self._manager.generate_reset_password_token(user)
|
|
212
223
|
return f"{base_url.rstrip('/')}/users/reset-password?token={token}"
|
|
@@ -62,7 +62,17 @@ async def token_login(
|
|
|
62
62
|
|
|
63
63
|
stmt = select(User).where(User.email == body.email)
|
|
64
64
|
user = (await db.execute(stmt)).scalar_one_or_none()
|
|
65
|
-
if
|
|
65
|
+
if (
|
|
66
|
+
user is None
|
|
67
|
+
or not user.is_active
|
|
68
|
+
or user.disabled_at is not None
|
|
69
|
+
# External (SSO) users have ``hashed_password is None`` — there's no
|
|
70
|
+
# local password to verify. Treat them like a missing user: verifying
|
|
71
|
+
# against a None hash would raise (500) and the instant failure would
|
|
72
|
+
# leak that the account is SSO-only. The session login is guarded the
|
|
73
|
+
# same way in ``UserManager.authenticate``.
|
|
74
|
+
or user.hashed_password is None
|
|
75
|
+
):
|
|
66
76
|
# Constant-time: run bcrypt on a dummy hash to prevent timing-based
|
|
67
77
|
# email enumeration (existing user + wrong password takes ~50ms for
|
|
68
78
|
# bcrypt; missing user would be instant without this).
|
|
@@ -82,4 +82,4 @@ def reconfigure_cookie_transport(
|
|
|
82
82
|
transport.cookie_name = settings.cookie_name
|
|
83
83
|
transport.cookie_max_age = settings.cookie_max_age_seconds
|
|
84
84
|
transport.cookie_secure = settings.cookie_secure
|
|
85
|
-
transport.cookie_samesite = settings.cookie_samesite # type: ignore[assignment]
|
|
85
|
+
transport.cookie_samesite = settings.cookie_samesite # type: ignore[assignment]
|
|
@@ -20,6 +20,12 @@ PERM_USERS_SELF_PROFILE = "users.self.profile"
|
|
|
20
20
|
# Session keys
|
|
21
21
|
SESSION_USER_ID_KEY = "user_id"
|
|
22
22
|
|
|
23
|
+
# request.state flag set by the OAuth callback before find-or-create so the
|
|
24
|
+
# manager's on_after_register hook can mark *newly provisioned* OAuth users as
|
|
25
|
+
# external (null password). on_after_register fires only for new users, so the
|
|
26
|
+
# flag is ignored when an OAuth login merely links to an existing account.
|
|
27
|
+
OAUTH_REGISTRATION_REQUEST_FLAG = "users_oauth_registration"
|
|
28
|
+
|
|
23
29
|
# Admin list-endpoint allowed filter/sort values
|
|
24
30
|
ALLOWED_STATUS = frozenset({"active", "disabled"})
|
|
25
31
|
ALLOWED_VERIFIED = frozenset({"yes", "no"})
|
|
@@ -25,6 +25,7 @@ class UserRead(CreateUpdateDictModel, SQLModel):
|
|
|
25
25
|
is_active: bool = True
|
|
26
26
|
is_superuser: bool = False
|
|
27
27
|
is_verified: bool = False
|
|
28
|
+
is_external: bool = False
|
|
28
29
|
full_name: str | None = None
|
|
29
30
|
tenant_id: str | None = None
|
|
30
31
|
disabled_at: datetime | None = None
|
|
@@ -74,6 +75,7 @@ class UserListItem(SQLModel):
|
|
|
74
75
|
full_name: str | None = None
|
|
75
76
|
is_active: bool
|
|
76
77
|
is_verified: bool
|
|
78
|
+
is_external: bool = False
|
|
77
79
|
disabled_at: datetime | None = None
|
|
78
80
|
last_login_at: datetime | None = None
|
|
79
81
|
created_at: datetime | None = None
|
|
@@ -24,3 +24,15 @@ class EmailAlreadyExistsError(Exception):
|
|
|
24
24
|
def __init__(self, email: str) -> None:
|
|
25
25
|
super().__init__(f"Email {email} already in use")
|
|
26
26
|
self.email = email
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExternalUserNoPasswordError(Exception):
|
|
30
|
+
"""Raised when a password-credential action targets an external (SSO) user.
|
|
31
|
+
|
|
32
|
+
External users have no local password, so password reset / set has no
|
|
33
|
+
meaning for them. Endpoints translate this into a 4xx.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, user_id: uuid.UUID) -> None:
|
|
37
|
+
super().__init__(f"User {user_id} is external (SSO) and has no password")
|
|
38
|
+
self.user_id = user_id
|
|
@@ -2,10 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Callable
|
|
5
6
|
from typing import Protocol, runtime_checkable
|
|
6
7
|
|
|
7
8
|
from users.settings import UsersSettings
|
|
8
9
|
|
|
10
|
+
#: Returns the live application name (e.g. from the branding module) so emails
|
|
11
|
+
#: are branded with the deployment's name rather than the framework default.
|
|
12
|
+
AppNameProvider = Callable[[], str]
|
|
13
|
+
|
|
14
|
+
#: Fallback app name when no provider is supplied (e.g. branding not installed).
|
|
15
|
+
DEFAULT_APP_NAME = "SimpleModule"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def default_app_name() -> str:
|
|
19
|
+
return DEFAULT_APP_NAME
|
|
20
|
+
|
|
9
21
|
|
|
10
22
|
@runtime_checkable
|
|
11
23
|
class Mailer(Protocol):
|
|
@@ -14,7 +26,11 @@ class Mailer(Protocol):
|
|
|
14
26
|
async def send_invite(self, email: str, token: str, invited_by_name: str) -> None: ...
|
|
15
27
|
|
|
16
28
|
|
|
17
|
-
def build_mailer(
|
|
29
|
+
def build_mailer(
|
|
30
|
+
settings: UsersSettings,
|
|
31
|
+
app_name_provider: AppNameProvider | None = None,
|
|
32
|
+
) -> Mailer:
|
|
33
|
+
provider = app_name_provider or default_app_name
|
|
18
34
|
if settings.mailer == "smtp":
|
|
19
35
|
from users.mailer.smtp import SmtpMailer
|
|
20
36
|
|
|
@@ -26,8 +42,9 @@ def build_mailer(settings: UsersSettings) -> Mailer:
|
|
|
26
42
|
from_address=settings.smtp_from,
|
|
27
43
|
use_tls=settings.smtp_tls,
|
|
28
44
|
base_url=settings.base_url,
|
|
45
|
+
app_name_provider=provider,
|
|
29
46
|
)
|
|
30
47
|
|
|
31
48
|
from users.mailer.console import ConsoleMailer
|
|
32
49
|
|
|
33
|
-
return ConsoleMailer(base_url=settings.base_url)
|
|
50
|
+
return ConsoleMailer(base_url=settings.base_url, app_name_provider=provider)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Console mailer — logs tokenized links for local development."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from users.mailer import AppNameProvider
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("users.mailer")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConsoleMailer:
|
|
15
|
+
def __init__(self, base_url: str, app_name_provider: AppNameProvider | None = None) -> None:
|
|
16
|
+
self._base = base_url.rstrip("/")
|
|
17
|
+
from users.mailer import default_app_name
|
|
18
|
+
|
|
19
|
+
self._app_name = app_name_provider or default_app_name
|
|
20
|
+
|
|
21
|
+
async def send_verification(self, email: str, token: str) -> None:
|
|
22
|
+
link = f"{self._base}/users/verify?token={token}"
|
|
23
|
+
logger.info(
|
|
24
|
+
"users.verify.email", extra={"to": email, "link": link, "app_name": self._app_name()}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
async def send_password_reset(self, email: str, token: str) -> None:
|
|
28
|
+
link = f"{self._base}/users/reset-password?token={token}"
|
|
29
|
+
logger.info(
|
|
30
|
+
"users.reset.email", extra={"to": email, "link": link, "app_name": self._app_name()}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
async def send_invite(self, email: str, token: str, invited_by_name: str) -> None:
|
|
34
|
+
link = f"{self._base}/users/invite/accept?token={token}"
|
|
35
|
+
logger.info(
|
|
36
|
+
"users.invite.email",
|
|
37
|
+
extra={
|
|
38
|
+
"to": email,
|
|
39
|
+
"link": link,
|
|
40
|
+
"invited_by": invited_by_name,
|
|
41
|
+
"app_name": self._app_name(),
|
|
42
|
+
},
|
|
43
|
+
)
|
|
@@ -4,10 +4,14 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import importlib.resources
|
|
6
6
|
from email.message import EmailMessage
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
7
8
|
|
|
8
9
|
import aiosmtplib
|
|
9
10
|
import jinja2
|
|
10
11
|
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from users.mailer import AppNameProvider
|
|
14
|
+
|
|
11
15
|
|
|
12
16
|
def _load_template_env() -> jinja2.Environment:
|
|
13
17
|
"""Build a Jinja2 Environment pointed at the bundled templates directory."""
|
|
@@ -33,6 +37,7 @@ class SmtpMailer:
|
|
|
33
37
|
from_address: str,
|
|
34
38
|
use_tls: bool,
|
|
35
39
|
base_url: str,
|
|
40
|
+
app_name_provider: AppNameProvider | None = None,
|
|
36
41
|
) -> None:
|
|
37
42
|
self._host = host
|
|
38
43
|
self._port = port
|
|
@@ -41,24 +46,30 @@ class SmtpMailer:
|
|
|
41
46
|
self._from = from_address
|
|
42
47
|
self._use_tls = use_tls
|
|
43
48
|
self._base = base_url.rstrip("/")
|
|
49
|
+
from users.mailer import default_app_name
|
|
50
|
+
|
|
51
|
+
self._app_name = app_name_provider or default_app_name
|
|
44
52
|
|
|
45
53
|
async def send_verification(self, email: str, token: str) -> None:
|
|
54
|
+
app = self._app_name()
|
|
46
55
|
link = f"{self._base}/users/verify?token={token}"
|
|
47
56
|
template = _template_env.get_template("verify_email.txt")
|
|
48
|
-
body = template.render(link=link)
|
|
49
|
-
await self._send(email, "Verify your email
|
|
57
|
+
body = template.render(link=link, app_name=app)
|
|
58
|
+
await self._send(email, f"Verify your email for {app}", body)
|
|
50
59
|
|
|
51
60
|
async def send_password_reset(self, email: str, token: str) -> None:
|
|
61
|
+
app = self._app_name()
|
|
52
62
|
link = f"{self._base}/users/reset-password?token={token}"
|
|
53
63
|
template = _template_env.get_template("reset_password.txt")
|
|
54
|
-
body = template.render(link=link)
|
|
55
|
-
await self._send(email, "Reset your password", body)
|
|
64
|
+
body = template.render(link=link, app_name=app)
|
|
65
|
+
await self._send(email, f"Reset your {app} password", body)
|
|
56
66
|
|
|
57
67
|
async def send_invite(self, email: str, token: str, invited_by_name: str) -> None:
|
|
68
|
+
app = self._app_name()
|
|
58
69
|
link = f"{self._base}/users/invite/accept?token={token}"
|
|
59
70
|
template = _template_env.get_template("invite.txt")
|
|
60
|
-
body = template.render(link=link, invited_by_name=invited_by_name)
|
|
61
|
-
await self._send(email, f"
|
|
71
|
+
body = template.render(link=link, invited_by_name=invited_by_name, app_name=app)
|
|
72
|
+
await self._send(email, f"{invited_by_name} invited you to {app}", body)
|
|
62
73
|
|
|
63
74
|
async def _send(self, to: str, subject: str, body: str) -> None:
|
|
64
75
|
message = EmailMessage()
|