Add change password functionality with modal in customer portal (#536)
taylor_socfortress committed
Nov 30, 2025 at 14:39 UTC
5799dbfa346a236a696eed7adfa3b11c7e4f3e7b
3 files changed
+248
-1
backend/app/auth/routes/auth.py
+1
-1
@@ -332,7 +332,7 @@ async def reset_password_via_username(
332
"/reset-password/me",
333
status_code=200,
334
description="Reset user's password",
335
- dependencies=[Security(AuthHandler().require_any_scope("analyst", "admin"))],
335
+ dependencies=[Security(AuthHandler().require_any_scope("analyst", "admin", "customer_user"))],
336
)
337
async def reset_password_me(
338
request: PasswordReset,
customer_portal/src/components/ChangePasswordModal.vue
new
+225
@@ -0,0 +1,225 @@
1
+<template>
2
+ <div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
3
+ <div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
4
+ <div class="mb-4 flex items-center justify-between">
5
+ <h3 class="text-xl font-bold text-gray-900">Change Password</h3>
6
+ <button @click="closeModal" class="text-gray-400 hover:text-gray-600">
7
+ <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
8
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
9
+ </svg>
10
+ </button>
11
+ </div>
12
+
13
+ <form @submit.prevent="handleSubmit" class="space-y-4">
14
+ <!-- Error/Success Messages -->
15
+ <div v-if="error" class="rounded-md border border-red-200 bg-red-50 p-3">
16
+ <div class="text-sm text-red-700">{{ error }}</div>
17
+ </div>
18
+
19
+ <div v-if="success" class="rounded-md border border-green-200 bg-green-50 p-3">
20
+ <div class="text-sm text-green-700">{{ success }}</div>
21
+ </div>
22
+
23
+ <!-- Current Password -->
24
+ <div>
25
+ <label for="currentPassword" class="mb-2 block text-sm font-medium text-gray-700">
26
+ Current Password
27
+ </label>
28
+ <input
29
+ id="currentPassword"
30
+ v-model="currentPassword"
31
+ type="password"
32
+ required
33
+ autocomplete="current-password"
34
+ class="block w-full rounded-md border border-gray-300 px-3 py-2 text-base placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
35
+ placeholder="Enter current password"
36
+ />
37
+ </div>
38
+
39
+ <!-- New Password -->
40
+ <div>
41
+ <label for="newPassword" class="mb-2 block text-sm font-medium text-gray-700">
42
+ New Password
43
+ </label>
44
+ <input
45
+ id="newPassword"
46
+ v-model="newPassword"
47
+ type="password"
48
+ required
49
+ autocomplete="new-password"
50
+ class="block w-full rounded-md border border-gray-300 px-3 py-2 text-base placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
51
+ placeholder="Enter new password"
52
+ />
53
+ </div>
54
+
55
+ <!-- Confirm New Password -->
56
+ <div>
57
+ <label for="confirmPassword" class="mb-2 block text-sm font-medium text-gray-700">
58
+ Confirm New Password
59
+ </label>
60
+ <input
61
+ id="confirmPassword"
62
+ v-model="confirmPassword"
63
+ type="password"
64
+ required
65
+ autocomplete="new-password"
66
+ class="block w-full rounded-md border border-gray-300 px-3 py-2 text-base placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
67
+ placeholder="Confirm new password"
68
+ />
69
+ </div>
70
+
71
+ <!-- Buttons -->
72
+ <div class="flex justify-end gap-3 pt-4">
73
+ <button
74
+ type="button"
75
+ @click="closeModal"
76
+ :disabled="loading"
77
+ class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
78
+ >
79
+ Cancel
80
+ </button>
81
+ <button
82
+ type="submit"
83
+ :disabled="loading || !isFormValid"
84
+ class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
85
+ >
86
+ <span v-if="loading" class="flex items-center">
87
+ <svg
88
+ class="mr-2 -ml-1 h-4 w-4 animate-spin text-white"
89
+ xmlns="http://www.w3.org/2000/svg"
90
+ fill="none"
91
+ viewBox="0 0 24 24"
92
+ >
93
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
94
+ <path
95
+ class="opacity-75"
96
+ fill="currentColor"
97
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
98
+ ></path>
99
+ </svg>
100
+ Changing...
101
+ </span>
102
+ <span v-else>Change Password</span>
103
+ </button>
104
+ </div>
105
+ </form>
106
+ </div>
107
+ </div>
108
+</template>
109
+
110
+<script setup lang="ts">
111
+import { ref, computed } from "vue"
112
+import axios from "axios"
113
+
114
+const props = defineProps<{
115
+ isOpen: boolean
116
+}>()
117
+
118
+const emit = defineEmits<{
119
+ (e: "close"): void
120
+ (e: "success"): void
121
+}>()
122
+
123
+const currentPassword = ref("")
124
+const newPassword = ref("")
125
+const confirmPassword = ref("")
126
+const loading = ref(false)
127
+const error = ref("")
128
+const success = ref("")
129
+
130
+const isFormValid = computed(() => {
131
+ return (
132
+ currentPassword.value.length > 0 &&
133
+ newPassword.value.length >= 8 &&
134
+ newPassword.value === confirmPassword.value
135
+ )
136
+})
137
+
138
+const closeModal = () => {
139
+ if (!loading.value) {
140
+ resetForm()
141
+ emit("close")
142
+ }
143
+}
144
+
145
+const resetForm = () => {
146
+ currentPassword.value = ""
147
+ newPassword.value = ""
148
+ confirmPassword.value = ""
149
+ error.value = ""
150
+ success.value = ""
151
+}
152
+
153
+const handleSubmit = async () => {
154
+ error.value = ""
155
+ success.value = ""
156
+
157
+ // Validate passwords match
158
+ if (newPassword.value !== confirmPassword.value) {
159
+ error.value = "New passwords do not match"
160
+ return
161
+ }
162
+
163
+ // Validate password length
164
+ if (newPassword.value.length < 8) {
165
+ error.value = "Password must be at least 8 characters long"
166
+ return
167
+ }
168
+
169
+ loading.value = true
170
+
171
+ try {
172
+ // Get token and username from localStorage
173
+ const token = localStorage.getItem("customer-portal-auth-token")
174
+ const userStr = localStorage.getItem("customer-portal-user")
175
+
176
+ if (!token || !userStr) {
177
+ error.value = "Authentication required. Please log in again."
178
+ return
179
+ }
180
+
181
+ const user = JSON.parse(userStr)
182
+ const username = user.username
183
+
184
+ // Call the reset-password/me endpoint
185
+ const response = await axios.post(
186
+ "/api/auth/reset-password/me",
187
+ {
188
+ username: username,
189
+ new_password: newPassword.value
190
+ },
191
+ {
192
+ headers: {
193
+ Authorization: `Bearer ${token}`
194
+ }
195
+ }
196
+ )
197
+
198
+ if (response.data.success) {
199
+ success.value = "Password changed successfully!"
200
+
201
+ // Close modal after 1.5 seconds
202
+ setTimeout(() => {
203
+ emit("success")
204
+ closeModal()
205
+ }, 1500)
206
+ } else {
207
+ error.value = response.data.message || "Failed to change password"
208
+ }
209
+ } catch (err: any) {
210
+ console.error("Change password error:", err)
211
+
212
+ if (err.response?.data?.detail) {
213
+ error.value = err.response.data.detail
214
+ } else if (err.response?.data?.message) {
215
+ error.value = err.response.data.message
216
+ } else if (err.message) {
217
+ error.value = err.message
218
+ } else {
219
+ error.value = "Failed to change password. Please try again."
220
+ }
221
+ } finally {
222
+ loading.value = false
223
+ }
224
+}
225
+</script>
customer_portal/src/views/OverviewPage.vue
+22
@@ -47,6 +47,12 @@
47
Welcome,
48
<span class="font-medium">{{ username }}</span>
49
</div>
50
+ <button
51
+ @click="openChangePasswordModal"
52
+ class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
53
+ >
54
+ Change Password
55
+ </button>
56
<button
57
@click="logout"
58
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700"
@@ -511,6 +517,7 @@
517
</div>
518
</div>
519
</main>
520
+ <ChangePasswordModal :is-open="showChangePasswordModal" @close="closeChangePasswordModal" @success="onPasswordChanged" />
521
</div>
522
</template>
523
@@ -521,6 +528,7 @@ import { usePortalSettingsStore } from "@/stores/portalSettings"
528
import AlertsAPI, { type Alert } from "@/api/alerts"
529
import CasesAPI, { type Case } from "@/api/cases"
530
import AgentsAPI from "@/api/agents"
531
+import ChangePasswordModal from "@/components/ChangePasswordModal.vue"
532
533
interface Stats {
534
totalAlerts: number
@@ -555,6 +563,7 @@ const portalSettingsStore = usePortalSettingsStore()
563
const loading = ref(true)
564
const error = ref("")
565
const showLogo = ref(true)
566
+const showChangePasswordModal = ref(false)
567
const stats = ref<Stats>({
568
totalAlerts: 0,
569
criticalAlerts: 0,
@@ -721,6 +730,19 @@ const goToAgents = () => {
730
router.push("/agents")
731
}
732
733
+const openChangePasswordModal = () => {
734
+ showChangePasswordModal.value = true
735
+}
736
+
737
+const closeChangePasswordModal = () => {
738
+ showChangePasswordModal.value = false
739
+}
740
+
741
+const onPasswordChanged = () => {
742
+ // Optionally show a success message or perform other actions
743
+ console.log("Password changed successfully")
744
+}
745
+
746
const logout = () => {
747
localStorage.removeItem("customer-portal-auth-token")
748
localStorage.removeItem("customer-portal-user")