Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/testing/fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ The admin has `*` permission (via `DEFAULT_ROLE_PERMISSIONS["admin"]`), so it by
```python
@pytest.mark.asyncio
async def test_non_admin_denied(client, db_session):
from users.service import UserService
from users.admin.service import UserService
svc = UserService(db_session)
await svc.create(email="u@e.com", password="x", roles=["viewer"])
await db_session.commit()
Expand Down
2 changes: 1 addition & 1 deletion modules/users/tests/test_api_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ async def test_forgot_password_rate_limited_after_threshold(
self, anon_client, users_app, users_db
):
"""After the configured attempt budget, /forgot-password returns 429."""
from users.rate_limit import ThroughputLimiter
from users.auth_local.rate_limit import ThroughputLimiter

# Tighten the limit for the test so we don't need to hit 10 real endpoints
users_app.state.users.auth_throughput_limiter = ThroughputLimiter(
Expand Down
2 changes: 1 addition & 1 deletion modules/users/tests/test_rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import pytest
from users.rate_limit import LoginRateLimiter, ThroughputLimiter
from users.auth_local.rate_limit import LoginRateLimiter, ThroughputLimiter


@pytest.fixture
Expand Down
2 changes: 1 addition & 1 deletion modules/users/tests/test_service_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@

def _build_service(session, users_app):
"""Build a UserService directly (bypass FastAPI Depends)."""
from users.admin.service import UserService
from users.db_adapter import UserDatabaseWithRoles
from users.manager import UserManager
from users.models import User
from users.service import UserService

user_db = UserDatabaseWithRoles(session, User)
manager = UserManager(
Expand Down
4 changes: 2 additions & 2 deletions modules/users/tests/test_user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@

def _build_service(session, users_app):
"""Build a UserService directly (bypass FastAPI Depends)."""
from users.admin.service import UserService
from users.db_adapter import UserDatabaseWithRoles
from users.manager import UserManager
from users.models import User
from users.service import UserService

user_db = UserDatabaseWithRoles(session, User)
manager = UserManager(
Expand Down Expand Up @@ -77,10 +77,10 @@ async def test_get_list_item_unknown_user_raises_user_not_found(users_app):
async def test_to_list_item_includes_created_at(users_app):
"""`UserListItem` carries `created_at` sourced from AuditMixin."""
from fastapi_users.password import PasswordHelper
from users.admin.service import UserService
from users.db_adapter import UserDatabaseWithRoles
from users.manager import UserManager
from users.models import User
from users.service import UserService

async with users_app.state.sm.db.session_factory() as session:
user = User(
Expand Down
2 changes: 1 addition & 1 deletion modules/users/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ async def test_admin_edit_page_unknown_user_returns_404(admin_client):
@pytest.mark.anyio
async def test_roles_payload_returns_id_name_dicts(users_app):
"""Helper reads the roles cache and returns id/name dicts in cache order."""
from users.endpoints.views import _roles_payload
from users.admin.views import _roles_payload

payload = await _roles_payload(users_app)

Expand Down
1 change: 1 addition & 0 deletions modules/users/users/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Admin user management. Intentionally empty — import via fully-qualified paths."""
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
"""Admin REST endpoints for the users module.

Split out of :mod:`.api` to keep per-file complexity manageable. Mounted
into the main ``router`` via ``include_router`` at the bottom of ``api.py``.
"""
"""Admin REST endpoints for the users module."""

from __future__ import annotations

Expand All @@ -13,6 +9,7 @@
from simple_module_core.events import EventBus
from simple_module_hosting.permissions import RequiresPermission

from users.admin.service import UserService
from users.constants import PERM_USERS_MANAGE, sanitize_list_filters
from users.contracts.events import RoleAssigned, UserDisabled, UserInvited
from users.contracts.schemas import (
Expand All @@ -23,7 +20,6 @@
)
from users.deps import get_event_bus, get_mailer, get_user_service
from users.exceptions import UserNotFoundError
from users.service import UserService

admin_router = APIRouter(
prefix="/admin",
Expand Down
File renamed without changes.
126 changes: 126 additions & 0 deletions modules/users/users/admin/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Inertia view routes for admin user management."""

from __future__ import annotations

import uuid

from fastapi import APIRouter, Depends, HTTPException, Request
from inertia import InertiaResponse
from simple_module_hosting.inertia_deps import InertiaDep
from simple_module_hosting.permissions import RequiresPermission

from users.admin.service import UserService
from users.constants import PERM_USERS_MANAGE, sanitize_list_filters
from users.deps import get_user_service
from users.exceptions import UserNotFoundError
from users.roles_cache import get_roles_cache

router = APIRouter()

_PAGE_ADMIN_INDEX = "Users/Users/Index"
_PAGE_ADMIN_INVITE = "Users/Users/Invite"
_PAGE_ADMIN_EDIT = "Users/Users/Edit"


async def _roles_payload(app) -> list[dict[str, str]]:
"""Shape roles-cache entries for Inertia props."""
return [{"id": r.id, "name": r.name} for r in await get_roles_cache(app)]


@router.get(
"/admin",
response_model=None,
dependencies=[Depends(RequiresPermission(PERM_USERS_MANAGE))],
)
async def admin_index(
request: Request,
inertia: InertiaDep,
service: UserService = Depends(get_user_service),
page: int = 1,
per_page: int = 20,
q: str | None = None,
status: str | None = None,
role: str | None = None,
verified: str | None = None,
sort: str = "email",
order: str = "asc",
) -> InertiaResponse:
clean_status, clean_verified, clean_sort, clean_order = sanitize_list_filters(
status, verified, sort, order
)
users, total = await service.list_users(
page=page,
per_page=per_page,
search=q,
status=clean_status,
role_name=role or None,
verified=clean_verified,
sort=clean_sort,
order=clean_order,
)
roles = await service.list_roles()
aggregates = await service.count_user_states()
return await inertia.render(
_PAGE_ADMIN_INDEX,
{
"users": [u.model_dump(mode="json") for u in users],
"pagination": {"page": page, "per_page": per_page, "total": total},
"aggregates": aggregates,
"query": q or "",
"roles": [r.model_dump(mode="json") for r in roles],
"filters": {
"status": clean_status or "all",
"role": role or "",
"verified": clean_verified or "all",
"sort": clean_sort,
"order": clean_order,
},
},
)


@router.get(
"/admin/invite",
response_model=None,
dependencies=[Depends(RequiresPermission(PERM_USERS_MANAGE))],
)
async def admin_invite_page(
request: Request,
inertia: InertiaDep,
) -> InertiaResponse:
return await inertia.render(
_PAGE_ADMIN_INVITE,
{
"roles": await _roles_payload(request.app),
},
)


@router.get(
"/admin/{user_id}",
response_model=None,
dependencies=[Depends(RequiresPermission(PERM_USERS_MANAGE))],
)
async def admin_edit_page(
user_id: str,
request: Request,
inertia: InertiaDep,
service: UserService = Depends(get_user_service),
) -> InertiaResponse:
try:
uid = uuid.UUID(user_id)
except ValueError as exc:
raise HTTPException(status_code=404) from exc
try:
user_item = await service.get_list_item(uid)
except UserNotFoundError:
raise HTTPException(status_code=404) from None
has_permissions = any(m.meta.name == "Permissions" for m in request.app.state.sm.modules)
return await inertia.render(
_PAGE_ADMIN_EDIT,
{
"user": user_item.model_dump(mode="json"),
"roles": await _roles_payload(request.app),
"has_permissions_module": has_permissions,
},
)
4 changes: 4 additions & 0 deletions modules/users/users/auth_local/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Local-credential auth: login/register/reset/verify/profile.

Intentionally empty — import via fully-qualified paths.
"""
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""REST API endpoints for the users module.

Structure:
/api/users/auth/login — wrapper with rate limit
/api/users/auth/* — fastapi-users routers (register/reset/verify/logout)
/api/users/auth/accept-invite — custom (verify + set password + login)
/api/users/me — self profile
/api/users/admin/* — admin REST (RequiresPermission('users.manage'))
"""REST endpoints for local-credential auth + self profile.

Routes owned here (all mounted by :meth:`UsersModule.register_routes`):
POST /api/users/auth/login rate-limited wrapper around fastapi-users
POST /api/users/auth/accept-invite — verify invite + set password + login
GET /api/users/me — current user
PATCH /api/users/me — update current user
/api/users/auth-inner/* — fastapi-users stock auth router
"""

from __future__ import annotations
Expand All @@ -16,11 +16,11 @@
from fastapi.security import OAuth2PasswordRequestForm
from fastapi_users import exceptions as fu_exceptions

from users.auth_local.rate_limit import LoginRateLimiter, ThroughputLimiter
from users.constants import SESSION_USER_ID_KEY
from users.contracts.schemas import (
AcceptInviteRequest,
SelfProfileUpdate,
UserCreate,
UserRead,
UserUpdate,
)
Expand All @@ -29,11 +29,7 @@
fastapi_users,
get_user_manager,
)
from users.endpoints.api_admin import admin_router
from users.endpoints.api_oauth import register_oauth_routes
from users.manager import UserManager
from users.rate_limit import LoginRateLimiter, ThroughputLimiter
from users.settings import UsersSettings

logger = logging.getLogger(__name__)
router = APIRouter()
Expand Down Expand Up @@ -127,47 +123,6 @@ async def login(
router.include_router(auth_inner, prefix="/auth-inner")


def register_auth_routes(api_router: APIRouter, settings: UsersSettings) -> None:
"""Mount all auth routes.

The stock fastapi-users routers (reset/verify/register) ship POST endpoints
that trigger email side-effects or account creation. We wrap them with the
throughput limiter so an attacker can't spam password-reset emails or mint
accounts indefinitely. ``router`` itself is left unwrapped because its
rate-limited endpoints apply the dep themselves (login via LoginRateLimiter,
accept-invite via ``enforce_auth_throughput_limit``).

The register router is always mounted; ``require_signup_enabled`` gates
it at request time so ``allow_signup`` is hot-reloadable.

OAuth providers configured in ``settings`` are mounted under
``/auth/<provider>/{login,callback}`` — see :mod:`users.endpoints.api_oauth`.
"""
api_router.include_router(router)
api_router.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["users-auth"],
dependencies=[Depends(enforce_auth_throughput_limit)],
)
api_router.include_router(
fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["users-auth"],
dependencies=[Depends(enforce_auth_throughput_limit)],
)
api_router.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["users-auth"],
dependencies=[
Depends(require_signup_enabled),
Depends(enforce_auth_throughput_limit),
],
)
register_oauth_routes(api_router, settings)


# ── Accept-invite (verify + set password + login, one shot) ─────────────────


Expand Down Expand Up @@ -226,8 +181,3 @@ async def update_me(
user,
request=request,
)


# ── Admin REST — defined in api_admin.py, mounted here ──────────────────────

router.include_router(admin_router)
Loading
Loading