feat: add parent PIN setup functionality and email notifications
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:
2026-01-27 14:47:49 -05:00
parent cd9070ec99
commit 3066d7d356
19 changed files with 852 additions and 257 deletions

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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 46 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="46 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="46 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>