feat: enhance child edit and view components with improved form handling and validation
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m4s

- Added `requireDirty` prop to `EntityEditForm` for dirty state management.
- Updated `ChildEditView` to handle initial data loading and image selection more robustly.
- Refactored `ChildView` to remove unused reward dialog logic and prevent API calls in child mode.
- Improved type definitions for form fields and initial data in `ChildEditView`.
- Enhanced error handling in form submissions across components.
- Implemented cross-tab logout synchronization on password reset in the auth store.
- Added tests for login and entity edit form functionalities to ensure proper behavior.
- Introduced global fetch interceptor for handling unauthorized responses.
- Documented password reset flow and its implications on session management.
This commit is contained in:
2026-02-17 17:18:03 -05:00
parent 5e22e5e0ee
commit 31ea76f013
29 changed files with 1000 additions and 164 deletions

View File

@@ -146,7 +146,7 @@ import {
ALREADY_VERIFIED,
} from '@/common/errorCodes'
import { parseErrorResponse, isEmailValid } from '@/common/api'
import { loginUser } from '@/stores/auth'
import { loginUser, checkAuth } from '@/stores/auth'
const router = useRouter()
@@ -211,6 +211,7 @@ async function submitForm() {
}
loginUser() // <-- set user as logged in
await checkAuth() // hydrate currentUserId so SSE reconnects immediately
await router.push({ path: '/' }).catch(() => (window.location.href = '/'))
} catch (err) {

View File

@@ -39,6 +39,7 @@
<p>Enter a new 46 digit Parent PIN. This will be required for parent access.</p>
<input
v-model="pin"
@input="handlePinInput"
maxlength="6"
inputmode="numeric"
pattern="\d*"
@@ -47,6 +48,7 @@
/>
<input
v-model="pin2"
@input="handlePin2Input"
maxlength="6"
inputmode="numeric"
pattern="\d*"
@@ -92,6 +94,16 @@ const isPinValid = computed(() => {
return /^\d{4,6}$/.test(p1) && /^\d{4,6}$/.test(p2) && p1 === p2
})
function handlePinInput(event: Event) {
const target = event.target as HTMLInputElement
pin.value = target.value.replace(/\D/g, '').slice(0, 6)
}
function handlePin2Input(event: Event) {
const target = event.target as HTMLInputElement
pin2.value = target.value.replace(/\D/g, '').slice(0, 6)
}
async function requestCode() {
error.value = ''
info.value = ''

View File

@@ -129,6 +129,7 @@
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { isPasswordStrong } from '@/common/api'
import { logoutUser } from '@/stores/auth'
import ModalDialog from '@/components/shared/ModalDialog.vue'
import '@/assets/styles.css'
@@ -156,7 +157,7 @@ const formValid = computed(
onMounted(async () => {
// Get token from query string
const raw = route.query.token ?? ''
token.value = Array.isArray(raw) ? raw[0] : String(raw || '')
token.value = (Array.isArray(raw) ? raw[0] : raw) || ''
// Validate token with backend
if (token.value) {
@@ -223,6 +224,7 @@ async function submitForm() {
return
}
// Success: Show modal instead of successMsg
logoutUser()
showModal.value = true
password.value = ''
confirmPassword.value = ''

View File

@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import Login from '../Login.vue'
const { pushMock, loginUserMock, checkAuthMock } = vi.hoisted(() => ({
pushMock: vi.fn(),
loginUserMock: vi.fn(),
checkAuthMock: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: pushMock })),
}))
vi.mock('@/stores/auth', () => ({
loginUser: loginUserMock,
checkAuth: checkAuthMock,
}))
vi.mock('@/common/api', async () => {
const actual = await vi.importActual<typeof import('@/common/api')>('@/common/api')
return {
...actual,
parseErrorResponse: vi.fn(async () => ({
msg: 'bad credentials',
code: 'INVALID_CREDENTIALS',
})),
}
})
describe('Login.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
checkAuthMock.mockResolvedValue(undefined)
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('hydrates auth state after successful login', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue({ ok: true } as Response)
const wrapper = mount(Login)
await wrapper.get('#email').setValue('test@example.com')
await wrapper.get('#password').setValue('secret123')
await wrapper.get('form').trigger('submit')
await Promise.resolve()
expect(loginUserMock).toHaveBeenCalledTimes(1)
expect(checkAuthMock).toHaveBeenCalledTimes(1)
expect(pushMock).toHaveBeenCalledWith({ path: '/' })
const checkAuthOrder = checkAuthMock.mock.invocationCallOrder[0]
const pushOrder = pushMock.mock.invocationCallOrder[0]
expect(checkAuthOrder).toBeDefined()
expect(pushOrder).toBeDefined()
expect((checkAuthOrder ?? 0) < (pushOrder ?? 0)).toBe(true)
})
it('does not hydrate auth state when login fails', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValue({ ok: false, status: 401 } as Response)
const wrapper = mount(Login)
await wrapper.get('#email').setValue('test@example.com')
await wrapper.get('#password').setValue('badpassword')
await wrapper.get('form').trigger('submit')
await Promise.resolve()
expect(loginUserMock).not.toHaveBeenCalled()
expect(checkAuthMock).not.toHaveBeenCalled()
expect(pushMock).not.toHaveBeenCalled()
})
})