Files
chore/frontend/vue-app/src/components/profile/UserProfile.vue
Ryan Kegel b2618361e4
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m29s
feat: implement force logout notifications for password reset and account deletion
2026-03-05 16:52:11 -05:00

433 lines
12 KiB
Vue

<template>
<div class="view">
<EntityEditForm
entityLabel="User Profile"
:fields="fields"
:initialData="initialData"
:isEdit="true"
:loading="loading"
:error="errorMsg"
:title="'User Profile'"
@submit="handleSubmit"
@cancel="router.back"
@add-image="onAddImage"
>
<template #custom-field-email="{ modelValue }">
<div class="email-actions">
<input id="email" type="email" :value="modelValue" disabled class="readonly-input" />
<button type="button" class="btn-link btn-link-space" @click="goToChangeParentPin">
Change Parent PIN
</button>
<button
type="button"
class="btn-link btn-link-space"
@click="resetPassword"
:disabled="resetting"
>
Change Password
</button>
<button type="button" class="btn-link btn-link-space" @click="openDeleteWarning">
Delete My Account
</button>
</div>
</template>
</EntityEditForm>
<div v-if="errorMsg" class="error-message" aria-live="polite">{{ errorMsg }}</div>
<ModalDialog
v-if="showModal"
:title="modalTitle"
:subtitle="modalSubtitle"
@close="handlePasswordModalClose"
>
<div class="modal-message">{{ modalMessage }}</div>
<div class="modal-actions" v-if="!resetting">
<button class="btn btn-primary" @click="handlePasswordModalClose">OK</button>
</div>
</ModalDialog>
<!-- Delete Account Warning Modal -->
<ModalDialog v-if="showDeleteWarning" title="Delete Your Account?" @close="closeDeleteWarning">
<div class="modal-message">
This will permanently delete your account and all associated data. This action cannot be
undone.
</div>
<div class="form-group" style="margin-top: 1rem">
<label for="confirmEmail">Type your email address to confirm:</label>
<input
id="confirmEmail"
v-model="confirmEmail"
type="email"
class="email-confirm-input"
placeholder="Enter your email"
:disabled="deletingAccount"
/>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="closeDeleteWarning" :disabled="deletingAccount">
Cancel
</button>
<button
class="btn btn-danger"
@click="confirmDeleteAccount"
:disabled="!isEmailValid(confirmEmail) || deletingAccount"
>
{{ deletingAccount ? 'Deleting...' : 'Delete' }}
</button>
</div>
</ModalDialog>
<!-- Delete Account Success Modal -->
<ModalDialog v-if="showDeleteSuccess" title="Account Deleted">
<div class="modal-message">
Your account has been marked for deletion. You will now be signed out.
</div>
<div class="modal-actions">
<button class="btn btn-primary" @click="handleDeleteSuccess">OK</button>
</div>
</ModalDialog>
<!-- Delete Account Error Modal -->
<ModalDialog v-if="showDeleteError" title="Error" @close="closeDeleteError">
<div class="modal-message">{{ deleteErrorMessage }}</div>
<div class="modal-actions">
<button class="btn btn-primary" @click="closeDeleteError">Close</button>
</div>
</ModalDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { parseErrorResponse, isEmailValid } from '@/common/api'
import { ALREADY_MARKED } from '@/common/errorCodes'
import { logoutUser, suppressForceLogout } from '@/stores/auth'
import '@/assets/styles.css'
const router = useRouter()
const loading = ref(false)
const errorMsg = ref('')
const resetting = ref(false)
const localImageFile = ref<File | null>(null)
const showModal = ref(false)
const modalTitle = ref('')
const modalSubtitle = ref('')
const modalMessage = ref('')
// Delete account modal state
const showDeleteWarning = ref(false)
const confirmEmail = ref('')
const deletingAccount = ref(false)
const showDeleteSuccess = ref(false)
const showDeleteError = ref(false)
const deleteErrorMessage = ref('')
const initialData = ref<{
image_id: string | null
first_name: string
last_name: string
email: string
}>({
image_id: null,
first_name: '',
last_name: '',
email: '',
})
const fields: Array<{
name: string
label: string
type: 'image' | 'text' | 'custom'
imageType?: number
required?: boolean
maxlength?: number
}> = [
{ name: 'image_id', label: 'Image', type: 'image', imageType: 1 },
{ name: 'first_name', label: 'First Name', type: 'text', required: true, maxlength: 64 },
{ name: 'last_name', label: 'Last Name', type: 'text', required: true, maxlength: 64 },
{ name: 'email', label: 'Email Address', type: 'custom' },
]
onMounted(async () => {
loading.value = true
try {
const res = await fetch('/api/user/profile')
if (!res.ok) throw new Error('Failed to load profile')
const data = await res.json()
initialData.value = {
image_id: data.image_id || null,
first_name: data.first_name || '',
last_name: data.last_name || '',
email: data.email || '',
}
} catch {
errorMsg.value = 'Could not load user profile.'
} finally {
loading.value = false
}
})
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
} else {
localImageFile.value = null
initialData.value.image_id = id
}
}
function handleSubmit(form: {
image_id: string | null
first_name: string
last_name: string
email: string
}) {
errorMsg.value = ''
loading.value = true
// Handle image upload if local file
let imageId = form.image_id
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '1')
formData.append('permanent', 'true')
fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
.then(async (resp) => {
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
// Now update profile
return updateProfile({
...form,
image_id: imageId,
})
})
.catch(() => {
errorMsg.value = 'Failed to upload image.'
loading.value = false
})
} else {
updateProfile(form)
}
}
async function updateProfile(form: {
image_id: string | null
first_name: string
last_name: string
email: string
}) {
fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: form.first_name,
last_name: form.last_name,
image_id: form.image_id,
}),
})
.then(async (res) => {
if (!res.ok) throw new Error('Failed to update profile')
// Update initialData to reflect the saved state
initialData.value = { ...form }
modalTitle.value = 'Profile Updated'
modalSubtitle.value = ''
modalMessage.value = 'Your profile was updated successfully.'
showModal.value = true
})
.catch(() => {
errorMsg.value = 'Failed to update profile.'
})
.finally(() => {
loading.value = false
})
}
async function handlePasswordModalClose() {
const wasProfileUpdate = modalTitle.value === 'Profile Updated'
showModal.value = false
if (wasProfileUpdate) {
router.back()
}
}
async function resetPassword() {
// Show modal immediately with loading message
modalTitle.value = 'Change Password'
modalMessage.value = 'Sending password change email...'
modalSubtitle.value = ''
showModal.value = true
resetting.value = true
errorMsg.value = ''
try {
const res = await fetch('/api/auth/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: initialData.value.email }),
})
if (!res.ok) throw new Error('Failed to send reset email')
modalTitle.value = 'Password Change Email Sent'
modalMessage.value =
'If this email is registered, you will receive a password change link shortly.'
} catch {
modalTitle.value = 'Password Change Failed'
modalMessage.value = 'Failed to send password change email.'
} finally {
resetting.value = false
}
}
function goToChangeParentPin() {
router.push({ name: 'ParentPinSetup' })
}
function openDeleteWarning() {
confirmEmail.value = ''
showDeleteWarning.value = true
}
function closeDeleteWarning() {
showDeleteWarning.value = false
confirmEmail.value = ''
}
async function confirmDeleteAccount() {
if (!isEmailValid(confirmEmail.value)) return
// Set flag before the request so it's guaranteed to be set
// before the force_logout SSE event can arrive on this tab
suppressForceLogout.value = true
deletingAccount.value = true
try {
const res = await fetch('/api/user/mark-for-deletion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: confirmEmail.value }),
})
if (!res.ok) {
suppressForceLogout.value = false
const { msg, code } = await parseErrorResponse(res)
let errorMessage = msg
if (code === ALREADY_MARKED) {
errorMessage = 'This account is already marked for deletion.'
}
deleteErrorMessage.value = errorMessage
showDeleteWarning.value = false
showDeleteError.value = true
return
}
// Success — suppressForceLogout is already set; show confirmation modal
showDeleteWarning.value = false
showDeleteSuccess.value = true
} catch {
suppressForceLogout.value = false
deleteErrorMessage.value = 'Network error. Please try again.'
showDeleteWarning.value = false
showDeleteError.value = true
} finally {
deletingAccount.value = false
}
}
function handleDeleteSuccess() {
showDeleteSuccess.value = false
// Call logout API to clear server cookies
fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
}).finally(() => {
// Clear client-side auth and redirect, regardless of logout response
logoutUser()
router.push('/')
})
}
function closeDeleteError() {
showDeleteError.value = false
deleteErrorMessage.value = ''
}
</script>
<style scoped>
.view {
max-width: 400px;
margin: 0 auto;
background: var(--form-bg);
border-radius: 12px;
box-shadow: 0 4px 24px var(--form-shadow);
padding: 2rem 2.2rem 1.5rem 2.2rem;
}
/* ...existing styles... */
.email-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.success-message {
color: var(--success, #16a34a);
font-size: 1rem;
}
.error-message {
color: var(--error, #e53e3e);
font-size: 0.98rem;
margin-top: 0.4rem;
}
.readonly-input {
width: 100%;
padding: 0.6rem;
border-radius: 7px;
border: 1px solid var(--form-input-border, #e6e6e6);
font-size: 1rem;
background: var(--form-input-bg, #f5f5f5);
color: var(--form-label, #888);
box-sizing: border-box;
}
.btn-danger-link {
background: none;
border: none;
color: var(--error, #e53e3e);
font-size: 0.95rem;
cursor: pointer;
padding: 0;
text-decoration: underline;
margin-top: 0.25rem;
align-self: flex-start;
}
.btn-danger-link:hover {
color: var(--error-hover, #c53030);
}
.btn-danger-link:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.email-confirm-input {
width: 100%;
padding: 0.6rem;
border-radius: 7px;
border: 1px solid var(--form-input-border, #e6e6e6);
font-size: 1rem;
background: var(--form-input-bg, #ffffff);
color: var(--text, #1a1a1a);
box-sizing: border-box;
margin-top: 0.5rem;
}
.email-confirm-input:focus {
outline: none;
border-color: var(--btn-primary, #4a90e2);
}
</style>