Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
This commit is contained in:
503
frontend/src/components/profile/UserProfile.vue
Normal file
503
frontend/src/components/profile/UserProfile.vue
Normal file
@@ -0,0 +1,503 @@
|
||||
<template>
|
||||
<div class="view">
|
||||
<EntityEditForm
|
||||
entityLabel="User Profile"
|
||||
:fields="fields"
|
||||
:initialData="initialData"
|
||||
:isEdit="true"
|
||||
:loading="loading"
|
||||
:error="errorMsg"
|
||||
:title="'User Profile'"
|
||||
:fieldErrors="{ push_enabled: pushError }"
|
||||
@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, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import {
|
||||
isSubscribedToPush,
|
||||
subscribeToPushWithResult,
|
||||
unsubscribeFromPush,
|
||||
getPushPermissionState,
|
||||
setPushOptOut,
|
||||
} from '@/services/pushSubscription'
|
||||
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 pushError = ref('')
|
||||
const pushPermissionDenied = ref(false)
|
||||
|
||||
const initialData = ref<{
|
||||
image_id: string | null
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
email_digest_enabled: boolean
|
||||
push_enabled: boolean
|
||||
}>({
|
||||
image_id: null,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
email_digest_enabled: true,
|
||||
push_enabled: false,
|
||||
})
|
||||
|
||||
const fields = computed(() => [
|
||||
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 1 },
|
||||
{ name: 'first_name', label: 'First Name', type: 'text' as const, required: true, maxlength: 64 },
|
||||
{ name: 'last_name', label: 'Last Name', type: 'text' as const, required: true, maxlength: 64 },
|
||||
{ name: 'email', label: 'Email Address', type: 'custom' as const },
|
||||
{
|
||||
name: 'email_digest_enabled',
|
||||
label: 'Daily Digest',
|
||||
type: 'toggle' as const,
|
||||
description:
|
||||
'Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links.',
|
||||
},
|
||||
{
|
||||
name: 'push_enabled',
|
||||
label: 'Push Notifications',
|
||||
type: 'toggle' as const,
|
||||
description: 'Receive instant push notifications when a chore or reward needs your approval.',
|
||||
disabled: pushPermissionDenied.value,
|
||||
},
|
||||
])
|
||||
|
||||
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()
|
||||
pushPermissionDenied.value = getPushPermissionState() === 'denied'
|
||||
const pushSubscribed = await isSubscribedToPush()
|
||||
initialData.value = {
|
||||
image_id: data.image_id || null,
|
||||
first_name: data.first_name || '',
|
||||
last_name: data.last_name || '',
|
||||
email: data.email || '',
|
||||
email_digest_enabled: data.email_digest_enabled !== false,
|
||||
push_enabled: data.push_notifications_enabled !== false && pushSubscribed,
|
||||
}
|
||||
} 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
|
||||
email_digest_enabled?: boolean
|
||||
push_enabled?: boolean
|
||||
}) {
|
||||
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
|
||||
email_digest_enabled?: boolean
|
||||
push_enabled?: boolean
|
||||
}) {
|
||||
const prevDigest = initialData.value.email_digest_enabled
|
||||
const prevPush = initialData.value.push_enabled
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
first_name: form.first_name,
|
||||
last_name: form.last_name,
|
||||
image_id: form.image_id,
|
||||
}
|
||||
if (form.email_digest_enabled !== undefined && form.email_digest_enabled !== prevDigest) {
|
||||
body.email_digest_enabled = form.email_digest_enabled
|
||||
}
|
||||
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
|
||||
body.push_notifications_enabled = form.push_enabled
|
||||
}
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update profile')
|
||||
let actualPushEnabled = prevPush
|
||||
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
|
||||
const pushOk = await applyPushChange(form.push_enabled)
|
||||
actualPushEnabled = pushOk ? form.push_enabled : prevPush
|
||||
}
|
||||
initialData.value = {
|
||||
...initialData.value,
|
||||
...form,
|
||||
push_enabled: actualPushEnabled,
|
||||
}
|
||||
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 applyPushChange(newValue: boolean): Promise<boolean> {
|
||||
pushError.value = ''
|
||||
try {
|
||||
if (newValue) {
|
||||
const result = await subscribeToPushWithResult()
|
||||
if (result.ok) {
|
||||
setPushOptOut(false)
|
||||
return true
|
||||
}
|
||||
pushError.value =
|
||||
result.reason === 'permission_denied'
|
||||
? 'Notifications are blocked. Enable them in your browser settings.'
|
||||
: 'Failed to enable push notifications.'
|
||||
return false
|
||||
} else {
|
||||
await unsubscribeFromPush()
|
||||
setPushOptOut(true)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
pushError.value = 'Failed to update push notification settings.'
|
||||
return 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>
|
||||
Reference in New Issue
Block a user