simple-module-users 0.0.1__py3-none-any.whl
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.1.dist-info/METADATA +88 -0
- simple_module_users-0.0.1.dist-info/RECORD +56 -0
- simple_module_users-0.0.1.dist-info/WHEEL +4 -0
- simple_module_users-0.0.1.dist-info/entry_points.txt +5 -0
- simple_module_users-0.0.1.dist-info/licenses/LICENSE +21 -0
- users/__init__.py +0 -0
- users/backend.py +85 -0
- users/bootstrap.py +246 -0
- users/cli.py +75 -0
- users/components/IndexFilters.tsx +72 -0
- users/components/RolesTab.tsx +72 -0
- users/constants.py +42 -0
- users/contracts/__init__.py +0 -0
- users/contracts/events.py +32 -0
- users/contracts/schemas.py +85 -0
- users/db_adapter.py +48 -0
- users/deps.py +83 -0
- users/endpoints/__init__.py +1 -0
- users/endpoints/api.py +227 -0
- users/endpoints/api_admin.py +167 -0
- users/endpoints/views.py +220 -0
- users/exceptions.py +18 -0
- users/mailer/__init__.py +33 -0
- users/mailer/console.py +27 -0
- users/mailer/smtp.py +77 -0
- users/mailer/templates/.gitkeep +0 -0
- users/mailer/templates/invite.txt +1 -0
- users/mailer/templates/reset_password.txt +1 -0
- users/mailer/templates/verify_email.txt +1 -0
- users/manager.py +146 -0
- users/middleware.py +143 -0
- users/models/__init__.py +24 -0
- users/models/_base.py +9 -0
- users/models/access_token.py +33 -0
- users/models/role.py +34 -0
- users/models/user.py +67 -0
- users/models/user_role.py +39 -0
- users/module.py +155 -0
- users/package.json +16 -0
- users/pages/.gitkeep +0 -0
- users/pages/AcceptInvite.tsx +106 -0
- users/pages/ForgotPassword.tsx +90 -0
- users/pages/Login.tsx +181 -0
- users/pages/Profile.tsx +112 -0
- users/pages/Register.tsx +152 -0
- users/pages/ResetPassword.tsx +112 -0
- users/pages/Users/Edit.tsx +293 -0
- users/pages/Users/Index.tsx +296 -0
- users/pages/Users/Invite.tsx +135 -0
- users/pages/VerifyEmail.tsx +110 -0
- users/py.typed +0 -0
- users/rate_limit.py +59 -0
- users/roles_cache.py +58 -0
- users/service.py +257 -0
- users/settings.py +99 -0
- users/state.py +33 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Admin REST endpoints for the users module.
|
|
2
|
+
|
|
3
|
+
Split out of :mod:`.api` to keep per-file complexity manageable. Mounted
|
|
4
|
+
into the main ``router`` via ``include_router`` at the bottom of ``api.py``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
12
|
+
from fastapi import status as http_status
|
|
13
|
+
from simple_module_core.events import EventBus
|
|
14
|
+
from simple_module_hosting.permissions import RequiresPermission
|
|
15
|
+
|
|
16
|
+
from users.constants import PERM_USERS_MANAGE, sanitize_list_filters
|
|
17
|
+
from users.contracts.events import RoleAssigned, UserDisabled, UserInvited
|
|
18
|
+
from users.contracts.schemas import (
|
|
19
|
+
PasswordResetLink,
|
|
20
|
+
RoleAssignment,
|
|
21
|
+
UserInvite,
|
|
22
|
+
UserListItem,
|
|
23
|
+
)
|
|
24
|
+
from users.deps import get_event_bus, get_mailer, get_user_service
|
|
25
|
+
from users.exceptions import UserNotFoundError
|
|
26
|
+
from users.service import UserService
|
|
27
|
+
|
|
28
|
+
admin_router = APIRouter(
|
|
29
|
+
prefix="/admin",
|
|
30
|
+
dependencies=[Depends(RequiresPermission(PERM_USERS_MANAGE))],
|
|
31
|
+
tags=["users-admin"],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@admin_router.get("", response_model=list[UserListItem])
|
|
36
|
+
async def admin_list_users(
|
|
37
|
+
page: int = 1,
|
|
38
|
+
per_page: int = 20,
|
|
39
|
+
q: str | None = None,
|
|
40
|
+
status: str | None = None,
|
|
41
|
+
role: str | None = None,
|
|
42
|
+
verified: str | None = None,
|
|
43
|
+
sort: str = "email",
|
|
44
|
+
order: str = "asc",
|
|
45
|
+
service: UserService = Depends(get_user_service),
|
|
46
|
+
):
|
|
47
|
+
"""List all users (paginated, optional search and filters)."""
|
|
48
|
+
_status, _verified, _sort, _order = sanitize_list_filters(status, verified, sort, order)
|
|
49
|
+
items, _ = await service.list_users(
|
|
50
|
+
page=page,
|
|
51
|
+
per_page=per_page,
|
|
52
|
+
search=q,
|
|
53
|
+
status=_status,
|
|
54
|
+
role_name=role or None,
|
|
55
|
+
verified=_verified,
|
|
56
|
+
sort=_sort,
|
|
57
|
+
order=_order,
|
|
58
|
+
)
|
|
59
|
+
return items
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@admin_router.post(
|
|
63
|
+
"/invite",
|
|
64
|
+
response_model=UserListItem,
|
|
65
|
+
status_code=http_status.HTTP_201_CREATED,
|
|
66
|
+
)
|
|
67
|
+
async def admin_invite_user(
|
|
68
|
+
data: UserInvite,
|
|
69
|
+
request: Request,
|
|
70
|
+
bus: EventBus = Depends(get_event_bus),
|
|
71
|
+
service: UserService = Depends(get_user_service),
|
|
72
|
+
mailer=Depends(get_mailer),
|
|
73
|
+
):
|
|
74
|
+
"""Invite a new user by email, optionally assigning roles."""
|
|
75
|
+
invited_by = getattr(request.state, "user", None)
|
|
76
|
+
invited_by_name = invited_by.name if invited_by else "Administrator"
|
|
77
|
+
user, token = await service.invite(
|
|
78
|
+
data.email, data.full_name, data.role_names, invited_by=invited_by
|
|
79
|
+
)
|
|
80
|
+
await mailer.send_invite(user.email, token, invited_by_name)
|
|
81
|
+
await bus.publish(
|
|
82
|
+
UserInvited(
|
|
83
|
+
user_id=user.id,
|
|
84
|
+
email=user.email,
|
|
85
|
+
invited_by=(str(invited_by.id) if invited_by else None),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return service.to_list_item(user)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@admin_router.patch("/{user_id}/disable", response_model=UserListItem)
|
|
92
|
+
async def admin_disable_user(
|
|
93
|
+
user_id: uuid.UUID,
|
|
94
|
+
bus: EventBus = Depends(get_event_bus),
|
|
95
|
+
service: UserService = Depends(get_user_service),
|
|
96
|
+
):
|
|
97
|
+
"""Disable a user account (sets is_active=False and disabled_at)."""
|
|
98
|
+
try:
|
|
99
|
+
user = await service.disable(user_id)
|
|
100
|
+
except UserNotFoundError:
|
|
101
|
+
raise HTTPException(status_code=404, detail="User not found") from None
|
|
102
|
+
await bus.publish(UserDisabled(user_id=user.id))
|
|
103
|
+
return service.to_list_item(user)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@admin_router.patch("/{user_id}/enable", response_model=UserListItem)
|
|
107
|
+
async def admin_enable_user(
|
|
108
|
+
user_id: uuid.UUID,
|
|
109
|
+
service: UserService = Depends(get_user_service),
|
|
110
|
+
):
|
|
111
|
+
"""Re-enable a previously disabled user account."""
|
|
112
|
+
try:
|
|
113
|
+
user = await service.enable(user_id)
|
|
114
|
+
except UserNotFoundError:
|
|
115
|
+
raise HTTPException(status_code=404, detail="User not found") from None
|
|
116
|
+
return service.to_list_item(user)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@admin_router.put("/{user_id}/roles", response_model=UserListItem)
|
|
120
|
+
async def admin_set_roles(
|
|
121
|
+
user_id: uuid.UUID,
|
|
122
|
+
data: RoleAssignment,
|
|
123
|
+
request: Request,
|
|
124
|
+
bus: EventBus = Depends(get_event_bus),
|
|
125
|
+
service: UserService = Depends(get_user_service),
|
|
126
|
+
):
|
|
127
|
+
"""Replace a user's role assignments."""
|
|
128
|
+
assigned_by = getattr(request.state, "user", None)
|
|
129
|
+
try:
|
|
130
|
+
user = await service.set_roles(
|
|
131
|
+
user_id,
|
|
132
|
+
data.role_names,
|
|
133
|
+
assigned_by=str(assigned_by.id) if assigned_by else None,
|
|
134
|
+
)
|
|
135
|
+
except UserNotFoundError:
|
|
136
|
+
raise HTTPException(status_code=404, detail="User not found") from None
|
|
137
|
+
for role in data.role_names:
|
|
138
|
+
await bus.publish(RoleAssigned(user_id=user.id, role_name=role))
|
|
139
|
+
return service.to_list_item(user)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@admin_router.patch("/{user_id}/verify", response_model=UserListItem)
|
|
143
|
+
async def admin_mark_verified(
|
|
144
|
+
user_id: uuid.UUID,
|
|
145
|
+
service: UserService = Depends(get_user_service),
|
|
146
|
+
):
|
|
147
|
+
"""Mark a user verified. Idempotent."""
|
|
148
|
+
try:
|
|
149
|
+
user = await service.mark_verified(user_id)
|
|
150
|
+
except UserNotFoundError:
|
|
151
|
+
raise HTTPException(status_code=404, detail="User not found") from None
|
|
152
|
+
return service.to_list_item(user)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@admin_router.post("/{user_id}/reset-password-link", response_model=PasswordResetLink)
|
|
156
|
+
async def admin_reset_password_link(
|
|
157
|
+
user_id: uuid.UUID,
|
|
158
|
+
request: Request,
|
|
159
|
+
service: UserService = Depends(get_user_service),
|
|
160
|
+
):
|
|
161
|
+
"""Generate a password-reset link for the given user (admin copy)."""
|
|
162
|
+
base_url = request.app.state.users.settings.base_url
|
|
163
|
+
try:
|
|
164
|
+
link = await service.generate_reset_link(user_id, base_url)
|
|
165
|
+
except UserNotFoundError:
|
|
166
|
+
raise HTTPException(status_code=404, detail="User not found") from None
|
|
167
|
+
return PasswordResetLink(link=link)
|
users/endpoints/views.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Inertia view routes for the users module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
9
|
+
from inertia import InertiaResponse
|
|
10
|
+
from simple_module_hosting.inertia_deps import InertiaDep
|
|
11
|
+
from simple_module_hosting.permissions import RequiresPermission
|
|
12
|
+
from starlette.responses import RedirectResponse
|
|
13
|
+
|
|
14
|
+
from users.constants import PERM_USERS_MANAGE, sanitize_list_filters
|
|
15
|
+
from users.deps import get_user_service
|
|
16
|
+
from users.exceptions import UserNotFoundError
|
|
17
|
+
from users.roles_cache import get_roles_cache
|
|
18
|
+
from users.service import UserService
|
|
19
|
+
|
|
20
|
+
router = APIRouter()
|
|
21
|
+
|
|
22
|
+
# Inertia page identifiers
|
|
23
|
+
_PAGE_LOGIN = "Users/Login"
|
|
24
|
+
_PAGE_REGISTER = "Users/Register"
|
|
25
|
+
_PAGE_FORGOT_PASSWORD = "Users/ForgotPassword"
|
|
26
|
+
_PAGE_RESET_PASSWORD = "Users/ResetPassword"
|
|
27
|
+
_PAGE_VERIFY_EMAIL = "Users/VerifyEmail"
|
|
28
|
+
_PAGE_ACCEPT_INVITE = "Users/AcceptInvite"
|
|
29
|
+
_PAGE_PROFILE = "Users/Profile"
|
|
30
|
+
_PAGE_ADMIN_INDEX = "Users/Users/Index"
|
|
31
|
+
_PAGE_ADMIN_INVITE = "Users/Users/Invite"
|
|
32
|
+
_PAGE_ADMIN_EDIT = "Users/Users/Edit"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _roles_payload(app) -> list[dict[str, str]]:
|
|
36
|
+
"""Shape roles-cache entries for Inertia props."""
|
|
37
|
+
return [{"id": r.id, "name": r.name} for r in await get_roles_cache(app)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── Public auth pages ───────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/login", response_model=None)
|
|
44
|
+
async def login_page(request: Request, inertia: InertiaDep) -> InertiaResponse:
|
|
45
|
+
users_settings = request.app.state.users.settings
|
|
46
|
+
# In development only, surface the bootstrap credentials as click-to-fill
|
|
47
|
+
# buttons so manual QA doesn't need to retype them. Never exposed in
|
|
48
|
+
# production, regardless of whether the vars are set.
|
|
49
|
+
dev_accounts: list[dict[str, str]] = []
|
|
50
|
+
if request.app.state.sm.settings.is_development:
|
|
51
|
+
admin_email = users_settings.bootstrap_email or os.environ.get(
|
|
52
|
+
"SM_USERS_BOOTSTRAP_EMAIL", ""
|
|
53
|
+
)
|
|
54
|
+
admin_password = users_settings.bootstrap_password or os.environ.get(
|
|
55
|
+
"SM_USERS_BOOTSTRAP_PASSWORD", ""
|
|
56
|
+
)
|
|
57
|
+
if admin_email and admin_password:
|
|
58
|
+
dev_accounts.append(
|
|
59
|
+
{"label": "Admin", "email": admin_email, "password": admin_password}
|
|
60
|
+
)
|
|
61
|
+
user_email = users_settings.bootstrap_user_email or os.environ.get(
|
|
62
|
+
"SM_USERS_BOOTSTRAP_USER_EMAIL", ""
|
|
63
|
+
)
|
|
64
|
+
user_password = users_settings.bootstrap_user_password or os.environ.get(
|
|
65
|
+
"SM_USERS_BOOTSTRAP_USER_PASSWORD", ""
|
|
66
|
+
)
|
|
67
|
+
if user_email and user_password:
|
|
68
|
+
dev_accounts.append({"label": "User", "email": user_email, "password": user_password})
|
|
69
|
+
return await inertia.render(
|
|
70
|
+
_PAGE_LOGIN,
|
|
71
|
+
{"allow_signup": users_settings.allow_signup, "dev_accounts": dev_accounts},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@router.post("/logout", response_model=None)
|
|
76
|
+
async def logout(request: Request) -> RedirectResponse:
|
|
77
|
+
"""Clear the session + auth cookie. POST-only to resist cross-site `<img>`
|
|
78
|
+
logout attacks — the menu's logout link submits this as an Inertia form."""
|
|
79
|
+
request.session.clear()
|
|
80
|
+
cookie_name = request.app.state.users.settings.cookie_name
|
|
81
|
+
# 303 forces the follow-up to GET — Inertia treats the redirect as a full
|
|
82
|
+
# navigation rather than replaying the POST.
|
|
83
|
+
response = RedirectResponse("/", status_code=303)
|
|
84
|
+
response.delete_cookie(cookie_name, path="/")
|
|
85
|
+
return response
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.get("/register", response_model=None)
|
|
89
|
+
async def register_page(request: Request, inertia: InertiaDep) -> InertiaResponse:
|
|
90
|
+
if not request.app.state.users.settings.allow_signup:
|
|
91
|
+
raise HTTPException(status_code=404)
|
|
92
|
+
return await inertia.render(_PAGE_REGISTER, {})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/forgot-password", response_model=None)
|
|
96
|
+
async def forgot_password_page(inertia: InertiaDep) -> InertiaResponse:
|
|
97
|
+
return await inertia.render(_PAGE_FORGOT_PASSWORD, {})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@router.get("/reset-password", response_model=None)
|
|
101
|
+
async def reset_password_page(inertia: InertiaDep, token: str = "") -> InertiaResponse:
|
|
102
|
+
return await inertia.render(_PAGE_RESET_PASSWORD, {"token": token})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@router.get("/verify", response_model=None)
|
|
106
|
+
async def verify_page(inertia: InertiaDep, token: str = "") -> InertiaResponse:
|
|
107
|
+
return await inertia.render(_PAGE_VERIFY_EMAIL, {"token": token})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.get("/invite/accept", response_model=None)
|
|
111
|
+
async def accept_invite_page(inertia: InertiaDep, token: str = "") -> InertiaResponse:
|
|
112
|
+
return await inertia.render(_PAGE_ACCEPT_INVITE, {"token": token})
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── Authenticated pages ─────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.get("/me", response_model=None)
|
|
119
|
+
async def profile_page(inertia: InertiaDep) -> InertiaResponse:
|
|
120
|
+
return await inertia.render(_PAGE_PROFILE, {})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── Admin pages ─────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@router.get(
|
|
127
|
+
"/admin",
|
|
128
|
+
response_model=None,
|
|
129
|
+
dependencies=[Depends(RequiresPermission(PERM_USERS_MANAGE))],
|
|
130
|
+
)
|
|
131
|
+
async def admin_index(
|
|
132
|
+
request: Request,
|
|
133
|
+
inertia: InertiaDep,
|
|
134
|
+
service: UserService = Depends(get_user_service),
|
|
135
|
+
page: int = 1,
|
|
136
|
+
per_page: int = 20,
|
|
137
|
+
q: str | None = None,
|
|
138
|
+
status: str | None = None,
|
|
139
|
+
role: str | None = None,
|
|
140
|
+
verified: str | None = None,
|
|
141
|
+
sort: str = "email",
|
|
142
|
+
order: str = "asc",
|
|
143
|
+
) -> InertiaResponse:
|
|
144
|
+
clean_status, clean_verified, clean_sort, clean_order = sanitize_list_filters(
|
|
145
|
+
status, verified, sort, order
|
|
146
|
+
)
|
|
147
|
+
users, total = await service.list_users(
|
|
148
|
+
page=page,
|
|
149
|
+
per_page=per_page,
|
|
150
|
+
search=q,
|
|
151
|
+
status=clean_status,
|
|
152
|
+
role_name=role or None,
|
|
153
|
+
verified=clean_verified,
|
|
154
|
+
sort=clean_sort,
|
|
155
|
+
order=clean_order,
|
|
156
|
+
)
|
|
157
|
+
roles = await service.list_roles()
|
|
158
|
+
return await inertia.render(
|
|
159
|
+
_PAGE_ADMIN_INDEX,
|
|
160
|
+
{
|
|
161
|
+
"users": [u.model_dump(mode="json") for u in users],
|
|
162
|
+
"pagination": {"page": page, "per_page": per_page, "total": total},
|
|
163
|
+
"query": q or "",
|
|
164
|
+
"roles": [r.model_dump(mode="json") for r in roles],
|
|
165
|
+
"filters": {
|
|
166
|
+
"status": clean_status or "all",
|
|
167
|
+
"role": role or "",
|
|
168
|
+
"verified": clean_verified or "all",
|
|
169
|
+
"sort": clean_sort,
|
|
170
|
+
"order": clean_order,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@router.get(
|
|
177
|
+
"/admin/invite",
|
|
178
|
+
response_model=None,
|
|
179
|
+
dependencies=[Depends(RequiresPermission(PERM_USERS_MANAGE))],
|
|
180
|
+
)
|
|
181
|
+
async def admin_invite_page(
|
|
182
|
+
request: Request,
|
|
183
|
+
inertia: InertiaDep,
|
|
184
|
+
) -> InertiaResponse:
|
|
185
|
+
return await inertia.render(
|
|
186
|
+
_PAGE_ADMIN_INVITE,
|
|
187
|
+
{
|
|
188
|
+
"roles": await _roles_payload(request.app),
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.get(
|
|
194
|
+
"/admin/{user_id}",
|
|
195
|
+
response_model=None,
|
|
196
|
+
dependencies=[Depends(RequiresPermission(PERM_USERS_MANAGE))],
|
|
197
|
+
)
|
|
198
|
+
async def admin_edit_page(
|
|
199
|
+
user_id: str,
|
|
200
|
+
request: Request,
|
|
201
|
+
inertia: InertiaDep,
|
|
202
|
+
service: UserService = Depends(get_user_service),
|
|
203
|
+
) -> InertiaResponse:
|
|
204
|
+
try:
|
|
205
|
+
uid = uuid.UUID(user_id)
|
|
206
|
+
except ValueError as exc:
|
|
207
|
+
raise HTTPException(status_code=404) from exc
|
|
208
|
+
try:
|
|
209
|
+
user_item = await service.get_list_item(uid)
|
|
210
|
+
except UserNotFoundError:
|
|
211
|
+
raise HTTPException(status_code=404) from None
|
|
212
|
+
has_permissions = any(m.meta.name == "Permissions" for m in request.app.state.sm.modules)
|
|
213
|
+
return await inertia.render(
|
|
214
|
+
_PAGE_ADMIN_EDIT,
|
|
215
|
+
{
|
|
216
|
+
"user": user_item.model_dump(mode="json"),
|
|
217
|
+
"roles": await _roles_payload(request.app),
|
|
218
|
+
"has_permissions_module": has_permissions,
|
|
219
|
+
},
|
|
220
|
+
)
|
users/exceptions.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Domain-level exceptions raised by the users module.
|
|
2
|
+
|
|
3
|
+
Kept internal to the module — callers in the endpoints layer translate these
|
|
4
|
+
into HTTP responses. Not re-exported via ``contracts/`` because no other
|
|
5
|
+
module catches them today. Promote to contracts if that changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserNotFoundError(Exception):
|
|
14
|
+
"""Raised when a user lookup by id/email returns nothing."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, user_id: uuid.UUID) -> None:
|
|
17
|
+
super().__init__(f"User {user_id} not found")
|
|
18
|
+
self.user_id = user_id
|
users/mailer/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Mailer interface and factory — pick console/smtp from settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from users.settings import UsersSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class Mailer(Protocol):
|
|
12
|
+
async def send_verification(self, email: str, token: str) -> None: ...
|
|
13
|
+
async def send_password_reset(self, email: str, token: str) -> None: ...
|
|
14
|
+
async def send_invite(self, email: str, token: str, invited_by_name: str) -> None: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_mailer(settings: UsersSettings) -> Mailer:
|
|
18
|
+
if settings.mailer == "smtp":
|
|
19
|
+
from users.mailer.smtp import SmtpMailer
|
|
20
|
+
|
|
21
|
+
return SmtpMailer(
|
|
22
|
+
host=settings.smtp_host,
|
|
23
|
+
port=settings.smtp_port,
|
|
24
|
+
username=settings.smtp_username,
|
|
25
|
+
password=settings.smtp_password,
|
|
26
|
+
from_address=settings.smtp_from,
|
|
27
|
+
use_tls=settings.smtp_tls,
|
|
28
|
+
base_url=settings.base_url,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from users.mailer.console import ConsoleMailer
|
|
32
|
+
|
|
33
|
+
return ConsoleMailer(base_url=settings.base_url)
|
users/mailer/console.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Console mailer — logs tokenized links for local development."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("users.mailer")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConsoleMailer:
|
|
11
|
+
def __init__(self, base_url: str) -> None:
|
|
12
|
+
self._base = base_url.rstrip("/")
|
|
13
|
+
|
|
14
|
+
async def send_verification(self, email: str, token: str) -> None:
|
|
15
|
+
link = f"{self._base}/users/verify?token={token}"
|
|
16
|
+
logger.info("users.verify.email", extra={"to": email, "link": link})
|
|
17
|
+
|
|
18
|
+
async def send_password_reset(self, email: str, token: str) -> None:
|
|
19
|
+
link = f"{self._base}/users/reset-password?token={token}"
|
|
20
|
+
logger.info("users.reset.email", extra={"to": email, "link": link})
|
|
21
|
+
|
|
22
|
+
async def send_invite(self, email: str, token: str, invited_by_name: str) -> None:
|
|
23
|
+
link = f"{self._base}/users/invite/accept?token={token}"
|
|
24
|
+
logger.info(
|
|
25
|
+
"users.invite.email",
|
|
26
|
+
extra={"to": email, "link": link, "invited_by": invited_by_name},
|
|
27
|
+
)
|
users/mailer/smtp.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""SMTP mailer — sends emails via aiosmtplib with Jinja2 templates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
from email.message import EmailMessage
|
|
7
|
+
|
|
8
|
+
import aiosmtplib
|
|
9
|
+
import jinja2
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_template_env() -> jinja2.Environment:
|
|
13
|
+
"""Build a Jinja2 Environment pointed at the bundled templates directory."""
|
|
14
|
+
templates_path = importlib.resources.files(__package__) / "templates"
|
|
15
|
+
return jinja2.Environment(
|
|
16
|
+
loader=jinja2.FileSystemLoader(str(templates_path)),
|
|
17
|
+
autoescape=False,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Resolve the template directory at import time — deterministic, async-safe,
|
|
22
|
+
# and the filesystem path is known by then anyway.
|
|
23
|
+
_template_env: jinja2.Environment = _load_template_env()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SmtpMailer:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
host: str,
|
|
30
|
+
port: int,
|
|
31
|
+
username: str,
|
|
32
|
+
password: str,
|
|
33
|
+
from_address: str,
|
|
34
|
+
use_tls: bool,
|
|
35
|
+
base_url: str,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._host = host
|
|
38
|
+
self._port = port
|
|
39
|
+
self._username = username
|
|
40
|
+
self._password = password
|
|
41
|
+
self._from = from_address
|
|
42
|
+
self._use_tls = use_tls
|
|
43
|
+
self._base = base_url.rstrip("/")
|
|
44
|
+
|
|
45
|
+
async def send_verification(self, email: str, token: str) -> None:
|
|
46
|
+
link = f"{self._base}/users/verify?token={token}"
|
|
47
|
+
template = _template_env.get_template("verify_email.txt")
|
|
48
|
+
body = template.render(link=link)
|
|
49
|
+
await self._send(email, "Verify your email address", body)
|
|
50
|
+
|
|
51
|
+
async def send_password_reset(self, email: str, token: str) -> None:
|
|
52
|
+
link = f"{self._base}/users/reset-password?token={token}"
|
|
53
|
+
template = _template_env.get_template("reset_password.txt")
|
|
54
|
+
body = template.render(link=link)
|
|
55
|
+
await self._send(email, "Reset your password", body)
|
|
56
|
+
|
|
57
|
+
async def send_invite(self, email: str, token: str, invited_by_name: str) -> None:
|
|
58
|
+
link = f"{self._base}/users/invite/accept?token={token}"
|
|
59
|
+
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"You've been invited by {invited_by_name}", body)
|
|
62
|
+
|
|
63
|
+
async def _send(self, to: str, subject: str, body: str) -> None:
|
|
64
|
+
message = EmailMessage()
|
|
65
|
+
message["From"] = self._from
|
|
66
|
+
message["To"] = to
|
|
67
|
+
message["Subject"] = subject
|
|
68
|
+
message.set_content(body)
|
|
69
|
+
|
|
70
|
+
await aiosmtplib.send(
|
|
71
|
+
message,
|
|
72
|
+
hostname=self._host,
|
|
73
|
+
port=self._port,
|
|
74
|
+
username=self._username or None,
|
|
75
|
+
password=self._password or None,
|
|
76
|
+
use_tls=self._use_tls,
|
|
77
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{{ invited_by_name }} invited you. Accept: {{ link }}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Reset your password: {{ link }}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Verify your email: {{ link }}
|