Coverage for apps/accounts/local_auth_api.py: 92%
102 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
1"""Lumen-native staff login (mounted at "") — login/logout/me/forgot/act-as/profile/password.
3The first-run Setup installer is its own later change (add-staff-auth-login proposal.md).
4Profile editing is add-staff-my-account-api.
5"""
7from __future__ import annotations
9from django.conf import settings
10from ninja import Router
12from apps.accounts.auth import BorrowerAuth, StaffAuth, mint_local_token
13from apps.accounts.local_auth_schemas import (
14 ActAsCandidateOut,
15 ActAsInput,
16 ForgotPasswordInput,
17 ForgotPasswordOut,
18 LoginInput,
19 LoginResultOut,
20 PasswordChangeInput,
21 ProfileOut,
22 ProfilePatch,
23)
24from apps.accounts.models import User, UserRole
25from apps.borrower_portal import account_flows as borrower_flows
26from apps.borrower_portal.schemas import BorrowerUserOut
27from apps.borrower_portal.services import borrower_session, find_borrower_for_login
28from domain.result import AppError, DomainError
30router = Router(tags=["auth"])
33def _role_name(user: User) -> str:
34 binding = UserRole.objects.filter(user=user).select_related("role").first()
35 return binding.role.name if binding else "Staff"
38def _session_user(user: User) -> dict:
39 return {"id": user.id, "name": f"{user.first_name} {user.last_name}", "role": _role_name(user)}
42@router.post("/auth/login", response=LoginResultOut)
43def login(request, payload: LoginInput):
44 user = User.objects.filter(email__iexact=payload.email.strip()).first()
45 if user is None or not user.check_password(payload.password):
46 # Not a staff sign-in: try the borrower portal's accounts (add-borrower-portal-api
47 # design.md D4). Both failing gives the one UNAUTHORIZED answer, so the response never
48 # reveals which kind of account an email has.
49 account = find_borrower_for_login(payload.email, payload.password)
50 if account is not None:
51 return borrower_session(account)
52 raise DomainError([AppError("UNAUTHORIZED", "Invalid email or password.")])
53 if not user.is_active:
54 raise DomainError(
55 [
56 AppError(
57 "ACCOUNT_DISABLED",
58 "This account has been disabled. Contact your administrator.",
59 )
60 ]
61 )
62 return {"token": mint_local_token(user), "user": _session_user(user)}
65@router.post("/auth/logout", response={204: None}, auth=[StaffAuth(), BorrowerAuth()])
66def logout(request):
67 # Stateless token, nothing to revoke server-side (add-staff-auth-login design.md).
68 return 204, None
71def _profile(user: User) -> dict:
72 return {
73 "id": user.id,
74 "name": f"{user.first_name} {user.last_name}",
75 "email": user.email,
76 "phone": user.phone or None,
77 "title": user.title or None,
78 "nmls_id": user.nmls_id or None,
79 "signature": user.signature or None,
80 "notify": {"email": user.notify_email, "sms": user.notify_sms},
81 "role": _role_name(user),
82 }
85# Shared by staff and borrowers (add-borrower-portal-api design.md D3): a borrower token gets the
86# borrower portal's BorrowerUser; the staff branch below is unchanged.
87_STAFF_OR_BORROWER = [StaffAuth(), BorrowerAuth()]
90def _is_borrower(request) -> bool:
91 return getattr(request.principal, "kind", "") == "borrower"
94@router.get("/auth/me", response=BorrowerUserOut | ProfileOut, auth=_STAFF_OR_BORROWER)
95def me(request):
96 if _is_borrower(request):
97 return borrower_flows.me(request)
98 return _profile(User.objects.get(pk=request.principal.user_id))
101@router.patch("/me/profile", response=BorrowerUserOut | ProfileOut, auth=_STAFF_OR_BORROWER)
102def update_profile(request, payload: ProfilePatch):
103 data = payload.model_dump(exclude_unset=True)
104 if _is_borrower(request):
105 return borrower_flows.update_profile(request, data)
106 user = User.objects.get(pk=request.principal.user_id)
108 name = data.pop("name", None)
109 if name is not None:
110 first_name, _, last_name = name.partition(" ")
111 user.first_name = first_name
112 user.last_name = last_name
114 email = data.pop("email", None)
115 if email is not None:
116 if User.objects.exclude(pk=user.pk).filter(email__iexact=email).exists(): 116 ↛ 120line 116 didn't jump to line 120 because the condition on line 116 was always true
117 raise DomainError(
118 [AppError("VALIDATION", "That email is already in use.", "email")]
119 )
120 user.email = email
122 notify = data.pop("notify", None)
123 if notify is not None: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 if "email" in notify:
125 user.notify_email = notify["email"]
126 if "sms" in notify:
127 user.notify_sms = notify["sms"]
129 for field, value in data.items():
130 setattr(user, field, value if value is not None else "")
131 user.save()
132 return _profile(user)
135@router.post("/me/password", response={204: None}, auth=_STAFF_OR_BORROWER)
136def change_password(request, payload: PasswordChangeInput):
137 if _is_borrower(request):
138 borrower_flows.change_password(request, payload.current, payload.next)
139 return 204, None
140 user = User.objects.get(pk=request.principal.user_id)
141 if not user.check_password(payload.current):
142 raise DomainError([AppError("AUTH_BAD_PASSWORD", "Current password is incorrect.")])
143 if len(payload.next) < 4:
144 raise DomainError(
145 [AppError("VALIDATION", "New password must be at least 4 characters.", "next")]
146 )
147 user.set_password(payload.next)
148 user.save(update_fields=["password"])
149 return 204, None
152@router.post("/auth/forgot", response=ForgotPasswordOut, exclude_none=True)
153def forgot_password(request, payload: ForgotPasswordInput):
154 # Same answer for every email (no account-existence leak). A borrower account gets a reset
155 # link to the borrower portal, a staff user one to the Staff LOS (add-sendgrid-email). An
156 # email that is both only gets the link for the app the request came from (``app``).
157 from apps.borrower_portal.api import send_reset_link, send_staff_reset_link
159 borrower_link = send_reset_link(payload.email) if payload.app != "staff" else None
160 staff_link = send_staff_reset_link(payload.email) if payload.app != "borrower" else None
161 link = borrower_link or staff_link
162 if link and settings.DEBUG:
163 return {"sent": True, "devResetLink": link}
164 return {"sent": True}
167@router.get("/act-as", response=list[ActAsCandidateOut], auth=StaffAuth())
168def act_as_candidates(request):
169 # Mirrors the frontend's own mock exactly: no permission -> empty list,
170 # never a 403 (only POST /act-as throws FORBIDDEN).
171 if "MANAGE_USER_ACCOUNT" not in request.principal.permissions:
172 return []
173 return [
174 {"id": u.id, "name": f"{u.first_name} {u.last_name}", "role": _role_name(u)}
175 for u in User.objects.filter(is_active=True).exclude(pk=request.principal.user_id)
176 ]
179@router.post("/act-as", response=LoginResultOut, auth=StaffAuth())
180def act_as(request, payload: ActAsInput):
181 if "MANAGE_USER_ACCOUNT" not in request.principal.permissions:
182 raise DomainError(
183 [AppError("FORBIDDEN", "You don't have permission to act as another user.")]
184 )
185 target = User.objects.filter(pk=payload.target_user_id).first()
186 if target is None:
187 raise DomainError([AppError("NOT_FOUND", "Target user not found.")])
188 token = mint_local_token(target, acting_admin_id=request.principal.user_id)
189 return {"token": token, "user": _session_user(target)}