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

1"""Lumen-native staff login (mounted at "") — login/logout/me/forgot/act-as/profile/password. 

2 

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""" 

6 

7from __future__ import annotations 

8 

9from django.conf import settings 

10from ninja import Router 

11 

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 

29 

30router = Router(tags=["auth"]) 

31 

32 

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" 

36 

37 

38def _session_user(user: User) -> dict: 

39 return {"id": user.id, "name": f"{user.first_name} {user.last_name}", "role": _role_name(user)} 

40 

41 

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)} 

63 

64 

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 

69 

70 

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 } 

83 

84 

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()] 

88 

89 

90def _is_borrower(request) -> bool: 

91 return getattr(request.principal, "kind", "") == "borrower" 

92 

93 

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)) 

99 

100 

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) 

107 

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 

113 

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 

121 

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"] 

128 

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) 

133 

134 

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 

150 

151 

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 

158 

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} 

165 

166 

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 ] 

177 

178 

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)}