feat: add parent PIN setup functionality and email notifications
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s
- Implemented User model updates to include PIN and related fields. - Created email sender utility for sending verification and reset emails. - Developed ParentPinSetup component for setting up a parent PIN with verification code. - Enhanced UserProfile and EntityEditForm components to support new features. - Updated routing to include PIN setup and authentication checks. - Added styles for new components and improved existing styles for consistency. - Introduced loading states and error handling in various components.
This commit is contained in:
@@ -1,86 +1,101 @@
|
||||
<template>
|
||||
<div class="profile-view">
|
||||
<h2>User Profile</h2>
|
||||
<form class="profile-form" @submit.prevent>
|
||||
<div class="group">
|
||||
<label for="child-image">Image</label>
|
||||
<ImagePicker
|
||||
id="child-image"
|
||||
v-model="selectedImageId"
|
||||
:image-type="1"
|
||||
@add-image="onAddImage"
|
||||
/>
|
||||
<div class="edit-view">
|
||||
<EntityEditForm
|
||||
entityLabel="User Profile"
|
||||
:fields="fields"
|
||||
:initialData="initialData"
|
||||
:isEdit="true"
|
||||
:loading="loading"
|
||||
:error="errorMsg"
|
||||
:title="'User Profile'"
|
||||
@submit="handleSubmit"
|
||||
@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 align-start btn-link-space"
|
||||
@click="goToChangeParentPin"
|
||||
>
|
||||
Change Parent Pin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link align-start btn-link-space"
|
||||
@click="resetPassword"
|
||||
:disabled="resetting"
|
||||
>
|
||||
Change Password
|
||||
</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="handleModalClose"
|
||||
>
|
||||
<div class="modal-message">{{ modalMessage }}</div>
|
||||
<div class="modal-actions" v-if="!resetting">
|
||||
<button class="btn btn-primary" @click="handleModalClose">OK</button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="first-name">First Name</label>
|
||||
<input id="first-name" v-model="firstName" type="text" disabled />
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="last-name">Last Name</label>
|
||||
<input id="last-name" v-model="lastName" type="text" disabled />
|
||||
</div>
|
||||
<div class="group">
|
||||
<label for="email">Email Address</label>
|
||||
<input id="email" v-model="email" type="email" disabled />
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn-link" @click="resetPassword" :disabled="resetting">
|
||||
{{ resetting ? 'Sending...' : 'Reset Password' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="successMsg" class="success-message" aria-live="polite">{{ successMsg }}</div>
|
||||
<div v-if="errorMsg" class="error-message" aria-live="polite">{{ errorMsg }}</div>
|
||||
</form>
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import { getCachedImageUrl } from '@/common/imageCache'
|
||||
import '@/assets/edit-forms.css'
|
||||
import '@/assets/actions-shared.css'
|
||||
import '@/assets/button-shared.css'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import '@/assets/styles.css'
|
||||
import '@/assets/colors.css'
|
||||
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const email = ref('')
|
||||
const avatarId = ref<string | null>(null)
|
||||
const avatarUrl = ref('/static/avatar-default.png')
|
||||
const selectedImageId = ref<string | null>(null)
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const successMsg = ref('')
|
||||
const resetting = ref(false)
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const showModal = ref(false)
|
||||
const modalTitle = ref('')
|
||||
const modalSubtitle = ref('')
|
||||
const modalMessage = ref('')
|
||||
|
||||
const initialData = ref({
|
||||
image_id: null,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
})
|
||||
|
||||
const fields = [
|
||||
{ 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()
|
||||
firstName.value = data.first_name || ''
|
||||
lastName.value = data.last_name || ''
|
||||
email.value = data.email || ''
|
||||
avatarId.value = data.image_id || null
|
||||
selectedImageId.value = data.image_id || null
|
||||
|
||||
// Use imageCache to get avatar URL
|
||||
if (avatarId.value) {
|
||||
avatarUrl.value = await getCachedImageUrl(avatarId.value)
|
||||
} else {
|
||||
avatarUrl.value = '/static/avatar-default.png'
|
||||
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.'
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for avatarId changes (e.g., after updating avatar)
|
||||
watch(avatarId, async (id) => {
|
||||
if (id) {
|
||||
avatarUrl.value = await getCachedImageUrl(id)
|
||||
} else {
|
||||
avatarUrl.value = '/static/avatar-default.png'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -89,7 +104,7 @@ function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
localImageFile.value = file
|
||||
} else {
|
||||
localImageFile.value = null
|
||||
selectedImageId.value = id
|
||||
initialData.value.image_id = id
|
||||
updateAvatar(id)
|
||||
}
|
||||
}
|
||||
@@ -104,15 +119,13 @@ async function updateAvatar(imageId: string) {
|
||||
body: JSON.stringify({ image_id: imageId }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update avatar')
|
||||
// Update avatarId, which will trigger the watcher to update avatarUrl
|
||||
avatarId.value = imageId
|
||||
initialData.value.image_id = imageId
|
||||
successMsg.value = 'Avatar updated!'
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to update avatar.'
|
||||
}
|
||||
}
|
||||
|
||||
// If uploading a new image file
|
||||
watch(localImageFile, async (file) => {
|
||||
if (!file) return
|
||||
errorMsg.value = ''
|
||||
@@ -128,35 +141,102 @@ watch(localImageFile, async (file) => {
|
||||
})
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
selectedImageId.value = data.id
|
||||
initialData.value.image_id = data.id
|
||||
await updateAvatar(data.id)
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to upload avatar image.'
|
||||
}
|
||||
})
|
||||
|
||||
function handleSubmit(form: {
|
||||
image_id: string | null
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
}) {
|
||||
errorMsg.value = ''
|
||||
loading.value = true
|
||||
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')
|
||||
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 handleModalClose() {
|
||||
showModal.value = false
|
||||
// Log out user and route to auth landing page
|
||||
try {
|
||||
await fetch('/api/logout', { method: 'POST' })
|
||||
} catch {}
|
||||
// Optionally clear any local auth state here if needed
|
||||
router.push({ name: 'AuthLanding' })
|
||||
}
|
||||
|
||||
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 = ''
|
||||
successMsg.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/request-password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.value }),
|
||||
body: JSON.stringify({ email: initialData.value.email }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to send reset email')
|
||||
successMsg.value =
|
||||
'If this email is registered, you will receive a password reset link shortly.'
|
||||
modalTitle.value = 'Password Change Email Sent'
|
||||
modalMessage.value =
|
||||
'If this email is registered, you will receive a password change link shortly.'
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to send password reset email.'
|
||||
modalTitle.value = 'Password Change Failed'
|
||||
modalMessage.value = 'Failed to send password change email.'
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToChangeParentPin() {
|
||||
router.push({ name: 'ParentPinSetup' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ...existing styles... */
|
||||
.email-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.align-start {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.btn-link-space {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.success-message {
|
||||
color: var(--success, #16a34a);
|
||||
font-size: 1rem;
|
||||
@@ -166,4 +246,14 @@ async function resetPassword() {
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user