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:
@@ -32,6 +32,7 @@ const deletingChildId = ref<string | number | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const openChildEditor = (child: Child, evt?: Event) => {
|
||||
console.log(' opening child editor for child id ', child.id)
|
||||
evt?.stopPropagation()
|
||||
router.push({ name: 'ChildEditView', params: { id: child.id } })
|
||||
}
|
||||
@@ -136,6 +137,7 @@ const fetchChildren = async (): Promise<Child[]> => {
|
||||
return Promise.resolve()
|
||||
}),
|
||||
)
|
||||
console.log(' fetched children list: ', childList)
|
||||
return childList
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
|
||||
@@ -172,6 +174,7 @@ onUnmounted(() => {
|
||||
const shouldIgnoreNextCardClick = ref(false)
|
||||
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
console.log(' document click detected ')
|
||||
if (activeMenuFor.value !== null) {
|
||||
const path = (e.composedPath && e.composedPath()) || (e as any).path || []
|
||||
const clickedInsideKebab = path.some((node: unknown) => {
|
||||
|
||||
@@ -1,51 +1,49 @@
|
||||
<template>
|
||||
<div class="entity-edit-view">
|
||||
<h2>{{ isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}` }}</h2>
|
||||
<div v-if="loading" class="loading-message">Loading {{ entityLabel.toLowerCase() }}...</div>
|
||||
<form v-else @submit.prevent="submit" class="entity-form">
|
||||
<template v-for="field in fields" :key="field.name">
|
||||
<div class="group">
|
||||
<label :for="field.name">
|
||||
{{ field.label }}
|
||||
<!-- Custom field slot -->
|
||||
<slot
|
||||
:name="`custom-field-${field.name}`"
|
||||
:modelValue="formData[field.name]"
|
||||
:update="(val) => (formData[field.name] = val)"
|
||||
>
|
||||
<!-- Default rendering if no slot provided -->
|
||||
<input
|
||||
v-if="field.type === 'text' || field.type === 'number'"
|
||||
:id="field.name"
|
||||
v-model="formData[field.name]"
|
||||
:type="field.type"
|
||||
:required="field.required"
|
||||
:maxlength="field.maxlength"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
/>
|
||||
<ImagePicker
|
||||
v-else-if="field.type === 'image'"
|
||||
:id="field.name"
|
||||
v-model="formData[field.name]"
|
||||
:image-type="field.imageType || 1"
|
||||
@add-image="onAddImage"
|
||||
/>
|
||||
</slot>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
{{ isEdit ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
<h2>{{ title ?? (isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}`) }}</h2>
|
||||
<div v-if="loading" class="loading-message">Loading {{ entityLabel.toLowerCase() }}...</div>
|
||||
<form v-else @submit.prevent="submit" class="entity-form">
|
||||
<template v-for="field in fields" :key="field.name">
|
||||
<div class="group">
|
||||
<label :for="field.name">
|
||||
{{ field.label }}
|
||||
<!-- Custom field slot -->
|
||||
<slot
|
||||
:name="`custom-field-${field.name}`"
|
||||
:modelValue="formData[field.name]"
|
||||
:update="(val) => (formData[field.name] = val)"
|
||||
>
|
||||
<!-- Default rendering if no slot provided -->
|
||||
<input
|
||||
v-if="field.type === 'text' || field.type === 'number'"
|
||||
:id="field.name"
|
||||
v-model="formData[field.name]"
|
||||
:type="field.type"
|
||||
:required="field.required"
|
||||
:maxlength="field.maxlength"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
/>
|
||||
<ImagePicker
|
||||
v-else-if="field.type === 'image'"
|
||||
:id="field.name"
|
||||
v-model="formData[field.name]"
|
||||
:image-type="field.imageType || 1"
|
||||
@add-image="onAddImage"
|
||||
/>
|
||||
</slot>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading || !isDirty">
|
||||
{{ isEdit ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -73,6 +71,7 @@ const props = defineProps<{
|
||||
isEdit?: boolean
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
title?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel', 'add-image'])
|
||||
@@ -107,9 +106,46 @@ function onCancel() {
|
||||
function submit() {
|
||||
emit('submit', { ...formData.value })
|
||||
}
|
||||
|
||||
// Editable field names (exclude custom fields that are not editable)
|
||||
const editableFieldNames = props.fields
|
||||
.filter((f) => f.type !== 'custom' || f.name === 'is_good' || f.type === 'image')
|
||||
.map((f) => f.name)
|
||||
|
||||
const isDirty = ref(false)
|
||||
|
||||
function checkDirty() {
|
||||
isDirty.value = editableFieldNames.some((key) => {
|
||||
return JSON.stringify(formData.value[key]) !== JSON.stringify(props.initialData?.[key])
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({ ...formData.value }),
|
||||
() => {
|
||||
checkDirty()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.initialData,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
formData.value = { ...newVal }
|
||||
checkDirty()
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--form-heading);
|
||||
}
|
||||
.entity-edit-view {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -3,8 +3,9 @@ import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import { authenticateParent, isParentAuthenticated, logoutParent } from '../../stores/auth'
|
||||
import '@/assets/modal.css'
|
||||
import '@/assets/actions-shared.css'
|
||||
import '@/assets/styles.css'
|
||||
import '@/assets/colors.css'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const show = ref(false)
|
||||
@@ -14,6 +15,21 @@ const pinInput = ref<HTMLInputElement | null>(null)
|
||||
const dropdownOpen = ref(false)
|
||||
|
||||
const open = async () => {
|
||||
// Check if user has a pin
|
||||
try {
|
||||
const res = await fetch('/api/user/has-pin', { credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Error checking PIN')
|
||||
if (!data.has_pin) {
|
||||
console.log('No PIN set, redirecting to setup')
|
||||
// Route to PIN setup view
|
||||
router.push('/parent/pin-setup')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Network error'
|
||||
return
|
||||
}
|
||||
pin.value = ''
|
||||
error.value = ''
|
||||
show.value = true
|
||||
@@ -26,22 +42,36 @@ const close = () => {
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const submit = async () => {
|
||||
const isDigits = /^\d{4,6}$/.test(pin.value)
|
||||
if (!isDigits) {
|
||||
error.value = 'Enter 4–6 digits'
|
||||
return
|
||||
}
|
||||
|
||||
if (pin.value !== '1179') {
|
||||
error.value = 'Incorrect PIN'
|
||||
return
|
||||
try {
|
||||
const res = await fetch('/api/user/check-pin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pin: pin.value }),
|
||||
credentials: 'include',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
error.value = data.error || 'Error validating PIN'
|
||||
return
|
||||
}
|
||||
if (!data.valid) {
|
||||
error.value = 'Incorrect PIN'
|
||||
return
|
||||
}
|
||||
// Authenticate parent and navigate
|
||||
authenticateParent()
|
||||
close()
|
||||
router.push('/parent')
|
||||
} catch (e) {
|
||||
error.value = 'Network error'
|
||||
}
|
||||
|
||||
// Authenticate parent and navigate
|
||||
authenticateParent()
|
||||
close()
|
||||
router.push('/parent')
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -117,27 +147,24 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="show" class="modal-backdrop" @click.self="close">
|
||||
<div class="modal">
|
||||
<h3>Enter parent PIN</h3>
|
||||
<form @submit.prevent="submit">
|
||||
<input
|
||||
ref="pinInput"
|
||||
v-model="pin"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
maxlength="6"
|
||||
placeholder="4–6 digits"
|
||||
class="pin-input"
|
||||
/>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModalDialog v-if="show" title="Enter parent PIN" @click.self="close" @close="close">
|
||||
<form @submit.prevent="submit">
|
||||
<input
|
||||
ref="pinInput"
|
||||
v-model="pin"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
maxlength="6"
|
||||
placeholder="4–6 digits"
|
||||
class="pin-input"
|
||||
/>
|
||||
<div class="actions modal-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="error" class="error modal-message">{{ error }}</div>
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user