@cryptotaxi247 / CoPilot / commits / fab82fce

fix(customer-portal): repair self-service password change (#896) (#897)

The Change Password modal read JWT/username from non-existent localStorage keys ("customer-portal-auth-token" / "customer-portal-user"), so it always bailed with "Authentication required. Please log in again." The portal auth store persists encrypted via secure-ls under __persisted-session__auth. - Modal now sources username from useAuthStore(); Bearer token is already injected by httpClient. Sends the collected current_password and validates the new password against the backend complexity rules client-side. - auth.ts resetPassword() forwards current_password. - Backend PasswordReset gains optional current_password; /auth/reset-password/me verifies it against the stored hash when supplied (400 on mismatch). Optional keeps the analyst UI flow (no current password) working. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

taylor_socfortress committed Jun 1, 2026 at 10:49 UTC fab82fce36a0e3d8c4177e0e26b6a9bb584bf939
4 files changed +24 -18
backend/app/auth/models/users.py
+4
@@ -200,6 +200,10 @@ class PasswordResetToken(BaseModel):
200
201 class PasswordReset(BaseModel):
202 username: str
203 + # Optional: when supplied (e.g. customer-portal self-service flow) the route verifies it
204 + # against the stored hash before changing the password. Admin/analyst reset flows that
205 + # already prove identity via the JWT may omit it, preserving backwards compatibility.
206 + current_password: Optional[str] = None
207 # 8-72 chars — bcrypt's 72-byte input limit (matches UserInput).
208 new_password: str = Field(
209 max_length=72,
backend/app/auth/routes/auth.py
+2
@@ -315,6 +315,8 @@ async def reset_password_me(
315 if not user:
316 raise HTTPException(status_code=404, detail="User not found")
317 await auth_handler.verify_reset_token_me(token, user)
318 + if request.current_password is not None and not auth_handler.verify_password(request.current_password, user.password):
319 + raise HTTPException(status_code=400, detail="Current password is incorrect")
320 hashed_pwd = auth_handler.get_password_hash(request.new_password)
321 user.password = hashed_pwd
322 session.add(user)
customer-portal/src/api/endpoints/auth.ts
+2 -1
@@ -18,9 +18,10 @@ export default {
18 return HttpClient.post<CommonResponse<AuthResponse>>("/auth/refresh", { refresh_token: refreshToken })
19 },
20
21 - resetPassword(username: string, newPassword: string) {
21 + resetPassword(username: string, newPassword: string, currentPassword: string) {
22 return HttpClient.post<CommonResponse>("/auth/reset-password/me", {
23 username,
24 + current_password: currentPassword,
25 new_password: newPassword
26 })
27 }
customer-portal/src/components/auth/ChangePasswordModal.vue
+16 -17
@@ -62,6 +62,7 @@ import { NButton, NForm, NFormItem, NInput, NModal, useMessage } from "naive-ui"
62 import { computed, ref } from "vue"
63 import Api from "@/api"
64 import Icon from "@/components/common/Icon.vue"
65 +import { useAuthStore } from "@/stores/auth"
66 import { getApiErrorMessage } from "@/utils"
67
68 const emit = defineEmits<{
@@ -73,6 +74,7 @@ const isOpen = defineModel<boolean>("open", { default: false })
74 const loading = defineModel<boolean>("loading", { default: false })
75
76 const message = useMessage()
77 +const authStore = useAuthStore()
78 const currentPassword = ref("")
79 const newPassword = ref("")
80 const confirmPassword = ref("")
@@ -104,29 +106,26 @@ async function handleSubmit() {
106 return
107 }
108
107 - // Validate password length
108 - if (newPassword.value.length < 8) {
109 - message.error("Password must be at least 8 characters long")
109 + // Mirror the backend complexity requirements so the user gets a clear message
110 + // instead of an opaque 422 from the API.
111 + if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,72}$/.test(newPassword.value)) {
112 + message.error(
113 + "Password must be 8-72 characters and include an uppercase letter, a lowercase letter, a number, and a special character (@$!%*?&#)"
114 + )
115 + return
116 + }
117 +
118 + const username = authStore.userName
119 + if (!username) {
120 + message.error("Authentication required. Please log in again.")
121 return
122 }
123
124 loading.value = true
125
126 try {
116 - // Get token and username from localStorage
117 - const token = localStorage.getItem("customer-portal-auth-token")
118 - const userStr = localStorage.getItem("customer-portal-user")
119 -
120 - if (!token || !userStr) {
121 - message.error("Authentication required. Please log in again.")
122 - return
123 - }
124 -
125 - const user = JSON.parse(userStr)
126 - const username = user.username
127 -
128 - // Call the reset-password/me endpoint
129 - const response = await Api.auth.resetPassword(username, newPassword.value)
127 + // Token is injected by the HTTP client from the auth store.
128 + const response = await Api.auth.resetPassword(username, newPassword.value, currentPassword.value)
129
130 if (response.data.success) {
131 message.success("Password changed successfully!")