feat: remove hashed passwords feature spec and migrate to archive
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m6s

fix: update login token expiration to 62 days

chore: bump version to 1.0.5RC1

test: add isParentPersistent to LoginButton.spec.ts

refactor: rename Assign Tasks button to Assign Chores in ParentView.vue

refactor: rename Assign Tasks to Assign Chores in TaskAssignView.vue

feat: add stay in parent mode checkbox and badge in LoginButton.vue

test: enhance LoginButton.spec.ts with persistent mode tests

test: add authGuard.spec.ts for logoutParent and enforceParentExpiry

feat: implement parent mode expiry logic in auth.ts

test: add auth.expiry.spec.ts for parent mode expiry tests

chore: create template for feature specs
This commit is contained in:
2026-02-20 16:31:13 -05:00
parent ba909100a7
commit 55e7dc7568
15 changed files with 657 additions and 38 deletions

View File

@@ -5,6 +5,7 @@ import { eventBus } from '@/common/eventBus'
import {
authenticateParent,
isParentAuthenticated,
isParentPersistent,
logoutParent,
logoutUser,
} from '../../stores/auth'
@@ -16,6 +17,7 @@ const router = useRouter()
const show = ref(false)
const pin = ref('')
const error = ref('')
const stayInParentMode = ref(false)
const pinInput = ref<HTMLInputElement | null>(null)
const dropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
@@ -102,6 +104,7 @@ const open = async () => {
const close = () => {
show.value = false
error.value = ''
stayInParentMode.value = false
}
const submit = async () => {
@@ -131,7 +134,7 @@ const submit = async () => {
return
}
// Authenticate parent and navigate
authenticateParent()
authenticateParent(stayInParentMode.value)
close()
router.push('/parent')
} catch (e) {
@@ -281,6 +284,12 @@ onUnmounted(() => {
/>
<span v-else class="avatar-text">{{ profileLoading ? '...' : avatarInitial }}</span>
</button>
<span
v-if="isParentAuthenticated && isParentPersistent"
class="persistent-badge"
aria-label="Persistent parent mode active"
>🔒</span
>
<Transition name="slide-fade">
<div
@@ -371,6 +380,10 @@ onUnmounted(() => {
placeholder="46 digits"
class="pin-input"
/>
<label class="stay-label">
<input type="checkbox" v-model="stayInParentMode" class="stay-checkbox" />
Stay in parent mode on this device
</label>
<div class="actions modal-actions">
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="pin.length < 4">OK</button>
@@ -445,11 +458,40 @@ onUnmounted(() => {
font-size: 1rem;
border-radius: 8px;
border: 1px solid #e6e6e6;
margin-bottom: 0.6rem;
margin-bottom: 0.8rem;
box-sizing: border-box;
text-align: center;
}
.stay-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--form-label, #444);
cursor: pointer;
margin-bottom: 1rem;
user-select: none;
}
.stay-checkbox {
width: 16px;
height: 16px;
accent-color: var(--btn-primary, #667eea);
cursor: pointer;
flex-shrink: 0;
}
.persistent-badge {
position: absolute;
bottom: -2px;
left: -2px;
font-size: 10px;
line-height: 1;
pointer-events: none;
user-select: none;
}
.dropdown-menu {
position: absolute;
right: 0;

View File

@@ -15,24 +15,21 @@ vi.mock('@/common/imageCache', () => ({
revokeAllImageUrls: vi.fn(),
}))
// Create a reactive ref for isParentAuthenticated using vi.hoisted
const { isParentAuthenticatedRef } = vi.hoisted(() => {
let value = false
// Create real Vue refs for isParentAuthenticated and isParentPersistent using vi.hoisted.
// Real Vue refs are required so Vue templates auto-unwrap them correctly in v-if conditions.
const { isParentAuthenticatedRef, isParentPersistentRef } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
isParentAuthenticatedRef: {
get value() {
return value
},
set value(v: boolean) {
value = v
},
},
isParentAuthenticatedRef: ref(false),
isParentPersistentRef: ref(false),
}
})
vi.mock('../../../stores/auth', () => ({
authenticateParent: vi.fn(),
isParentAuthenticated: isParentAuthenticatedRef,
isParentPersistent: isParentPersistentRef,
logoutParent: vi.fn(),
logoutUser: vi.fn(),
}))
@@ -45,6 +42,7 @@ describe('LoginButton', () => {
beforeEach(() => {
vi.clearAllMocks()
isParentAuthenticatedRef.value = false
isParentPersistentRef.value = false
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({
@@ -349,6 +347,104 @@ describe('LoginButton', () => {
})
})
describe('PIN Modal - checkbox and persistent mode', () => {
beforeEach(async () => {
isParentAuthenticatedRef.value = false
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('checkbox is unchecked by default when modal opens', async () => {
// Open modal by triggering the open-login event path
// Mock has-pin response
;(global.fetch as any)
.mockResolvedValueOnce({ ok: true, json: async () => ({ has_pin: true }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ valid: true }) })
const vm = wrapper.vm as any
await vm.open()
await nextTick()
const checkbox = wrapper.find('.stay-checkbox')
expect(checkbox.exists()).toBe(true)
expect((checkbox.element as HTMLInputElement).checked).toBe(false)
})
it('submitting with checkbox checked calls authenticateParent(true)', async () => {
const { authenticateParent } = await import('../../../stores/auth')
;(global.fetch as any)
.mockResolvedValueOnce({ ok: true, json: async () => ({ has_pin: true }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ valid: true }) })
const vm = wrapper.vm as any
await vm.open()
await nextTick()
const checkbox = wrapper.find('.stay-checkbox')
await checkbox.setValue(true)
await nextTick()
const pinInput = wrapper.find('.pin-input')
await pinInput.setValue('1234')
await wrapper.find('form').trigger('submit')
await nextTick()
expect(authenticateParent).toHaveBeenCalledWith(true)
})
it('submitting without checking checkbox calls authenticateParent(false)', async () => {
const { authenticateParent } = await import('../../../stores/auth')
;(global.fetch as any)
.mockResolvedValueOnce({ ok: true, json: async () => ({ has_pin: true }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ valid: true }) })
const vm = wrapper.vm as any
await vm.open()
await nextTick()
const pinInput = wrapper.find('.pin-input')
await pinInput.setValue('1234')
await wrapper.find('form').trigger('submit')
await nextTick()
expect(authenticateParent).toHaveBeenCalledWith(false)
})
})
describe('Lock badge visibility', () => {
it('lock badge is hidden when not authenticated', async () => {
isParentAuthenticatedRef.value = false
isParentPersistentRef.value = false
wrapper = mount(LoginButton)
await nextTick()
const badge = wrapper.find('.persistent-badge')
expect(badge.exists()).toBe(false)
})
it('lock badge is hidden when authenticated but non-persistent', async () => {
isParentAuthenticatedRef.value = true
isParentPersistentRef.value = false
wrapper = mount(LoginButton)
await nextTick()
const badge = wrapper.find('.persistent-badge')
expect(badge.exists()).toBe(false)
})
it('lock badge is visible when authenticated and persistent', async () => {
isParentAuthenticatedRef.value = true
isParentPersistentRef.value = true
wrapper = mount(LoginButton)
await nextTick()
const badge = wrapper.find('.persistent-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toBe('🔒')
})
})
describe('User Email Display', () => {
it('displays email in dropdown header when available', async () => {
isParentAuthenticatedRef.value = true