Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
688 lines
18 KiB
Vue
688 lines
18 KiB
Vue
<template>
|
|
<div class="view">
|
|
<h2>Profile</h2>
|
|
|
|
<div v-if="loading" class="loading-message">Loading profile...</div>
|
|
<div v-else class="profile-card">
|
|
<div v-if="errorMsg" class="error-banner" aria-live="polite">{{ errorMsg }}</div>
|
|
|
|
<ProfileSection title="User" :defaultOpen="true">
|
|
<div class="name-fields" @focusout="handleNameFocusOut">
|
|
<div class="field-group">
|
|
<label for="first-name">First Name</label>
|
|
<input
|
|
id="first-name"
|
|
v-model="firstName"
|
|
type="text"
|
|
maxlength="64"
|
|
:disabled="saving"
|
|
/>
|
|
</div>
|
|
<div class="field-group">
|
|
<label for="last-name">Last Name</label>
|
|
<input
|
|
id="last-name"
|
|
v-model="lastName"
|
|
type="text"
|
|
maxlength="64"
|
|
:disabled="saving"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="field-group">
|
|
<label>Image</label>
|
|
<ImagePicker
|
|
:modelValue="imageId"
|
|
@update:modelValue="onImageChange"
|
|
@add-image="onAddImage"
|
|
:image-type="1"
|
|
/>
|
|
</div>
|
|
</ProfileSection>
|
|
|
|
<ProfileSection title="Account">
|
|
<div class="field-group">
|
|
<label for="email">Email Address</label>
|
|
<input id="email" type="email" :value="email" disabled class="readonly-input" />
|
|
</div>
|
|
<div class="action-links">
|
|
<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>
|
|
</ProfileSection>
|
|
|
|
<ProfileSection title="Notifications">
|
|
<div class="toggle-stack">
|
|
<ToggleField
|
|
label="Daily Digest"
|
|
:modelValue="emailDigestEnabled"
|
|
@update:modelValue="onToggleDigest"
|
|
:disabled="saving"
|
|
description="Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links."
|
|
/>
|
|
<ToggleField
|
|
label="Push Notifications"
|
|
:modelValue="pushEnabled"
|
|
@update:modelValue="onTogglePush"
|
|
:disabled="saving || pushPermissionDenied"
|
|
description="Receive instant push notifications when a chore or reward needs your approval."
|
|
:error="pushError"
|
|
/>
|
|
</div>
|
|
</ProfileSection>
|
|
|
|
<ProfileSection title="Help">
|
|
<ToggleField
|
|
label="Show tutorial tips"
|
|
:modelValue="tutorialEnabled"
|
|
@update:modelValue="onToggleTutorial"
|
|
description="Show helpful tips as I use the app."
|
|
/>
|
|
<button type="button" class="btn-link btn-link-space" @click="openRestartConfirm">
|
|
Restart tutorial
|
|
</button>
|
|
</ProfileSection>
|
|
</div>
|
|
|
|
<!-- Password reset modal -->
|
|
<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>
|
|
|
|
<!-- Restart confirmation -->
|
|
<ModalDialog
|
|
v-if="showRestartConfirm"
|
|
title="Tutorial Restart"
|
|
@close="showRestartConfirm = false"
|
|
>
|
|
<div class="modal-message">
|
|
Start the tour again from the beginning? You'll see the tips again as you use the app.
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" @click="showRestartConfirm = false">Cancel</button>
|
|
<button class="btn btn-primary" @click="confirmRestartTutorial">Restart</button>
|
|
</div>
|
|
</ModalDialog>
|
|
|
|
<!-- Restart success -->
|
|
<ModalDialog
|
|
v-if="showRestartSuccess"
|
|
title="Tutorial Restart"
|
|
@close="showRestartSuccess = false"
|
|
>
|
|
<div class="modal-message">Tutorial mode has been restarted.</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-primary" @click="showRestartSuccess = false">OK</button>
|
|
</div>
|
|
</ModalDialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import ProfileSection from './ProfileSection.vue'
|
|
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
|
import ToggleField from '@/components/shared/ToggleField.vue'
|
|
import ModalDialog from '@/components/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 { tutorialEnabled, setTutorialEnabled, resetAllProgress } from '@/tutorial/controller'
|
|
import '@/assets/styles.css'
|
|
|
|
const router = useRouter()
|
|
|
|
// Profile data
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const errorMsg = ref('')
|
|
|
|
const firstName = ref('')
|
|
const lastName = ref('')
|
|
const imageId = ref<string | null>(null)
|
|
const email = ref('')
|
|
const emailDigestEnabled = ref(true)
|
|
const pushEnabled = ref(false)
|
|
const pushPermissionDenied = ref(false)
|
|
const pushError = ref('')
|
|
|
|
const localImageFile = ref<File | null>(null)
|
|
|
|
// Modal state
|
|
const resetting = ref(false)
|
|
const showModal = ref(false)
|
|
const modalTitle = ref('')
|
|
const modalSubtitle = ref('')
|
|
const modalMessage = ref('')
|
|
|
|
const showDeleteWarning = ref(false)
|
|
const confirmEmail = ref('')
|
|
const deletingAccount = ref(false)
|
|
const showDeleteSuccess = ref(false)
|
|
const showDeleteError = ref(false)
|
|
const deleteErrorMessage = ref('')
|
|
|
|
const showRestartConfirm = ref(false)
|
|
const showRestartSuccess = ref(false)
|
|
|
|
// Load profile
|
|
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()
|
|
firstName.value = data.first_name || ''
|
|
lastName.value = data.last_name || ''
|
|
imageId.value = data.image_id || null
|
|
email.value = data.email || ''
|
|
emailDigestEnabled.value = data.email_digest_enabled !== false
|
|
pushEnabled.value = data.push_notifications_enabled !== false && pushSubscribed
|
|
} catch {
|
|
errorMsg.value = 'Could not load user profile.'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
function handleNameFocusOut(event: FocusEvent) {
|
|
const wrapper = event.currentTarget as HTMLElement
|
|
const relatedTarget = event.relatedTarget as HTMLElement | null
|
|
if (relatedTarget && wrapper.contains(relatedTarget)) {
|
|
return
|
|
}
|
|
saveNames()
|
|
}
|
|
|
|
// ─── Auto-save: names ───
|
|
async function saveNames() {
|
|
if (saving.value) return
|
|
saving.value = true
|
|
errorMsg.value = ''
|
|
try {
|
|
const res = await fetch('/api/user/profile', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
first_name: firstName.value,
|
|
last_name: lastName.value,
|
|
}),
|
|
})
|
|
if (!res.ok) throw new Error('Failed to update profile')
|
|
} catch {
|
|
errorMsg.value = 'Failed to update profile.'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// ─── Auto-save: image ───
|
|
function onImageChange(id: string | null) {
|
|
if (id === 'local-upload') {
|
|
// localImageFile is set by onAddImage which fires first
|
|
uploadLocalImage()
|
|
} else {
|
|
localImageFile.value = null
|
|
imageId.value = id
|
|
saveImage(id)
|
|
}
|
|
}
|
|
|
|
function onAddImage({ id, file }: { id: string; file: File }) {
|
|
if (id === 'local-upload') {
|
|
localImageFile.value = file
|
|
}
|
|
}
|
|
|
|
async function uploadLocalImage() {
|
|
if (!localImageFile.value) return
|
|
saving.value = true
|
|
errorMsg.value = ''
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', localImageFile.value)
|
|
formData.append('type', '1')
|
|
formData.append('permanent', 'true')
|
|
const resp = await fetch('/api/image/upload', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
if (!resp.ok) throw new Error('Image upload failed')
|
|
const data = await resp.json()
|
|
imageId.value = data.id
|
|
await saveImage(data.id)
|
|
} catch {
|
|
errorMsg.value = 'Failed to upload image.'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function saveImage(id: string | null) {
|
|
saving.value = true
|
|
errorMsg.value = ''
|
|
try {
|
|
const res = await fetch('/api/user/profile', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ image_id: id }),
|
|
})
|
|
if (!res.ok) throw new Error('Failed to update profile')
|
|
} catch {
|
|
errorMsg.value = 'Failed to update profile.'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// ─── Auto-save: toggles ───
|
|
async function onToggleDigest(val: boolean) {
|
|
emailDigestEnabled.value = val
|
|
if (saving.value) return
|
|
saving.value = true
|
|
errorMsg.value = ''
|
|
try {
|
|
const res = await fetch('/api/user/profile', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email_digest_enabled: val }),
|
|
})
|
|
if (!res.ok) throw new Error('Failed to update profile')
|
|
} catch {
|
|
errorMsg.value = 'Failed to update profile.'
|
|
emailDigestEnabled.value = !val
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function onTogglePush(val: boolean) {
|
|
const prevPush = pushEnabled.value
|
|
pushEnabled.value = val
|
|
if (saving.value) return
|
|
saving.value = true
|
|
errorMsg.value = ''
|
|
pushError.value = ''
|
|
try {
|
|
const res = await fetch('/api/user/profile', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ push_notifications_enabled: val }),
|
|
})
|
|
if (!res.ok) throw new Error('Failed to update profile')
|
|
const pushOk = await applyPushChange(val)
|
|
if (!pushOk) {
|
|
pushEnabled.value = prevPush
|
|
}
|
|
} catch {
|
|
errorMsg.value = 'Failed to update profile.'
|
|
pushEnabled.value = prevPush
|
|
} finally {
|
|
saving.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
|
|
}
|
|
}
|
|
|
|
// ─── Tutorial toggle ───
|
|
async function onToggleTutorial(val: boolean) {
|
|
await setTutorialEnabled(val)
|
|
}
|
|
|
|
// ─── Password reset ───
|
|
async function resetPassword() {
|
|
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: email.value }),
|
|
})
|
|
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
|
|
}
|
|
}
|
|
|
|
async function handlePasswordModalClose() {
|
|
showModal.value = false
|
|
}
|
|
|
|
// ─── Navigation ───
|
|
function goToChangeParentPin() {
|
|
router.push({ name: 'ParentPinSetup' })
|
|
}
|
|
|
|
// ─── Delete account ───
|
|
function openDeleteWarning() {
|
|
confirmEmail.value = ''
|
|
showDeleteWarning.value = true
|
|
}
|
|
|
|
function closeDeleteWarning() {
|
|
showDeleteWarning.value = false
|
|
confirmEmail.value = ''
|
|
}
|
|
|
|
async function confirmDeleteAccount() {
|
|
if (!isEmailValid(confirmEmail.value)) return
|
|
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
|
|
}
|
|
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
|
|
fetch('/api/auth/logout', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
}).finally(() => {
|
|
logoutUser()
|
|
router.push('/')
|
|
})
|
|
}
|
|
|
|
function closeDeleteError() {
|
|
showDeleteError.value = false
|
|
deleteErrorMessage.value = ''
|
|
}
|
|
|
|
// ─── Tutorial restart ───
|
|
function openRestartConfirm() {
|
|
showRestartConfirm.value = true
|
|
}
|
|
|
|
async function confirmRestartTutorial() {
|
|
showRestartConfirm.value = false
|
|
await resetAllProgress()
|
|
showRestartSuccess.value = true
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.view {
|
|
max-width: 420px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
h2 {
|
|
text-align: center;
|
|
margin-bottom: 1.5rem;
|
|
color: #fff;
|
|
}
|
|
|
|
.profile-card {
|
|
background: var(--form-bg);
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 24px var(--form-shadow);
|
|
padding: 0.5rem 1.5rem 1rem;
|
|
}
|
|
|
|
.loading-message {
|
|
text-align: center;
|
|
color: var(--loading-color, #888);
|
|
font-size: 1rem;
|
|
padding: 2rem 0;
|
|
}
|
|
|
|
.error-banner {
|
|
color: var(--error, #e53e3e);
|
|
font-size: 0.95rem;
|
|
padding: 0.6rem 0;
|
|
margin-bottom: 0.5rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.field-group {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.field-group label {
|
|
display: block;
|
|
font-weight: 600;
|
|
color: var(--form-label, #444);
|
|
margin-bottom: 0.4rem;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.field-group input[type='text'],
|
|
.field-group input[type='email'] {
|
|
width: 100%;
|
|
padding: 0.6rem;
|
|
border-radius: 7px;
|
|
border: 1px solid var(--form-input-border, #e6e6e6);
|
|
font-size: 1rem;
|
|
background: var(--form-input-bg, #fff);
|
|
color: var(--text-primary, #222);
|
|
box-sizing: border-box;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.field-group input:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.readonly-input {
|
|
background: var(--form-input-bg, #f5f5f5) !important;
|
|
color: var(--form-label, #888) !important;
|
|
}
|
|
|
|
.action-links {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.toggle-stack {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.2rem;
|
|
}
|
|
|
|
.btn-link {
|
|
background: none;
|
|
border: none;
|
|
color: var(--btn-primary, #667eea);
|
|
font-size: 0.95rem;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.btn-link:hover {
|
|
color: var(--btn-primary-hover, #5a67d8);
|
|
}
|
|
|
|
.btn-link:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.modal-message {
|
|
text-align: center;
|
|
color: var(--dialog-message, #444);
|
|
font-size: 1rem;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
gap: 1rem;
|
|
justify-content: center;
|
|
margin-top: 1.2rem;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.profile-card {
|
|
padding: 0.5rem 1rem 1rem;
|
|
border-radius: 0;
|
|
box-shadow: none;
|
|
background: transparent;
|
|
}
|
|
|
|
.view {
|
|
max-width: 100%;
|
|
padding: 0 0.5rem;
|
|
}
|
|
}
|
|
</style>
|