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
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:
@@ -11,6 +11,7 @@ vi.mock('vue-router', () => ({
|
||||
vi.mock('../../stores/auth', () => ({
|
||||
authenticateParent: vi.fn(),
|
||||
isParentAuthenticated: { value: false },
|
||||
isParentPersistent: { value: false },
|
||||
logoutParent: vi.fn(),
|
||||
logoutUser: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -571,7 +571,7 @@ function goToAssignRewards() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="assign-buttons">
|
||||
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Tasks</button>
|
||||
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Chores</button>
|
||||
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
|
||||
Assign Penalties
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="task-assign-view">
|
||||
<h2>Assign Tasks</h2>
|
||||
<h2>Assign Chores</h2>
|
||||
<div class="task-view">
|
||||
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
|
||||
<span> <button class="round-btn" @click="goToCreateTask">Create</button> a task </span>
|
||||
<MessageBlock v-if="taskCountRef === 0" message="No chores">
|
||||
<span> <button class="round-btn" @click="goToCreateTask">Create</button> a chore </span>
|
||||
</MessageBlock>
|
||||
<ItemList
|
||||
v-else
|
||||
|
||||
@@ -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="4–6 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,8 @@ vi.mock('@/stores/auth', () => ({
|
||||
isAuthReady: isAuthReadyMock,
|
||||
isUserLoggedIn: isUserLoggedInMock,
|
||||
isParentAuthenticated: isParentAuthenticatedMock,
|
||||
logoutParent: vi.fn(),
|
||||
enforceParentExpiry: vi.fn(),
|
||||
}))
|
||||
|
||||
// Import router AFTER mocks are in place
|
||||
|
||||
@@ -17,7 +17,13 @@ import AuthLayout from '@/layout/AuthLayout.vue'
|
||||
import Signup from '@/components/auth/Signup.vue'
|
||||
import AuthLanding from '@/components/auth/AuthLanding.vue'
|
||||
import Login from '@/components/auth/Login.vue'
|
||||
import { isUserLoggedIn, isParentAuthenticated, isAuthReady } from '../stores/auth'
|
||||
import {
|
||||
isUserLoggedIn,
|
||||
isParentAuthenticated,
|
||||
isAuthReady,
|
||||
logoutParent,
|
||||
enforceParentExpiry,
|
||||
} from '../stores/auth'
|
||||
import ParentPinSetup from '@/components/auth/ParentPinSetup.vue'
|
||||
|
||||
const routes = [
|
||||
@@ -213,6 +219,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
}
|
||||
|
||||
// If parent-authenticated, allow all /parent routes
|
||||
// Enforce expiry first so an elapsed session is caught immediately on navigation
|
||||
enforceParentExpiry()
|
||||
if (isParentAuthenticated.value && to.path.startsWith('/parent')) {
|
||||
return next()
|
||||
}
|
||||
@@ -226,6 +234,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
if (isParentAuthenticated.value) {
|
||||
return next('/parent')
|
||||
} else {
|
||||
// Ensure parent auth is fully cleared when redirecting away from /parent
|
||||
logoutParent()
|
||||
return next('/child')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { isParentAuthenticated, isUserLoggedIn, loginUser, initAuthSync } from '../auth'
|
||||
import {
|
||||
isParentAuthenticated,
|
||||
isUserLoggedIn,
|
||||
loginUser,
|
||||
initAuthSync,
|
||||
authenticateParent,
|
||||
logoutParent,
|
||||
} from '../auth'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
// Stub window.location to prevent jsdom "navigation to another Document" warnings
|
||||
@@ -26,20 +33,26 @@ global.localStorage = {
|
||||
|
||||
describe('auth store - child mode on login', () => {
|
||||
beforeEach(() => {
|
||||
isParentAuthenticated.value = true
|
||||
localStorage.setItem('isParentAuthenticated', 'true')
|
||||
// Use authenticateParent() to set up parent-mode state
|
||||
authenticateParent(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logoutParent()
|
||||
})
|
||||
|
||||
it('should clear isParentAuthenticated and localStorage on loginUser()', async () => {
|
||||
expect(isParentAuthenticated.value).toBe(true)
|
||||
loginUser()
|
||||
await nextTick() // flush Vue watcher
|
||||
await nextTick()
|
||||
expect(isParentAuthenticated.value).toBe(false)
|
||||
})
|
||||
|
||||
it('logs out on cross-tab storage logout event', async () => {
|
||||
initAuthSync()
|
||||
isUserLoggedIn.value = true
|
||||
isParentAuthenticated.value = true
|
||||
authenticateParent(false)
|
||||
expect(isParentAuthenticated.value).toBe(true)
|
||||
|
||||
const logoutEvent = new StorageEvent('storage', {
|
||||
key: 'authSyncEvent',
|
||||
@@ -51,4 +64,26 @@ describe('auth store - child mode on login', () => {
|
||||
expect(isUserLoggedIn.value).toBe(false)
|
||||
expect(isParentAuthenticated.value).toBe(false)
|
||||
})
|
||||
|
||||
it('exits parent mode on cross-tab parent_logout storage event', async () => {
|
||||
initAuthSync()
|
||||
authenticateParent(false)
|
||||
expect(isParentAuthenticated.value).toBe(true)
|
||||
|
||||
// Simulate being on a /parent route in this tab
|
||||
locationStub.pathname = '/parent'
|
||||
|
||||
const parentLogoutEvent = new StorageEvent('storage', {
|
||||
key: 'authSyncEvent',
|
||||
newValue: JSON.stringify({ type: 'parent_logout', at: Date.now() }),
|
||||
})
|
||||
window.dispatchEvent(parentLogoutEvent)
|
||||
|
||||
await nextTick()
|
||||
expect(isParentAuthenticated.value).toBe(false)
|
||||
expect(locationStub.href).toBe('/child')
|
||||
|
||||
// Reset for other tests
|
||||
locationStub.pathname = '/'
|
||||
})
|
||||
})
|
||||
|
||||
165
frontend/vue-app/src/stores/__tests__/auth.expiry.spec.ts
Normal file
165
frontend/vue-app/src/stores/__tests__/auth.expiry.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
isParentAuthenticated,
|
||||
isParentPersistent,
|
||||
parentAuthExpiresAt,
|
||||
authenticateParent,
|
||||
logoutParent,
|
||||
loginUser,
|
||||
} from '../auth'
|
||||
|
||||
// Stub window.location
|
||||
const locationStub = { href: '', pathname: '/', assign: vi.fn(), replace: vi.fn(), reload: vi.fn() }
|
||||
Object.defineProperty(window, 'location', { value: locationStub, writable: true })
|
||||
|
||||
// Build a stateful localStorage stub and register it via vi.stubGlobal so it is
|
||||
// visible to auth.ts's module scope (not just the test file's scope).
|
||||
function makeLocalStorageStub() {
|
||||
const store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key]
|
||||
},
|
||||
clear: () => {
|
||||
for (const k of Object.keys(store)) delete store[k]
|
||||
},
|
||||
_store: store,
|
||||
}
|
||||
}
|
||||
|
||||
const localStorageStub = makeLocalStorageStub()
|
||||
vi.stubGlobal('localStorage', localStorageStub)
|
||||
|
||||
describe('auth store - parent mode expiry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
localStorageStub.clear()
|
||||
logoutParent()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logoutParent()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('non-persistent mode', () => {
|
||||
it('authenticateParent(false) sets isParentAuthenticated to true', () => {
|
||||
authenticateParent(false)
|
||||
expect(isParentAuthenticated.value).toBe(true)
|
||||
expect(isParentPersistent.value).toBe(false)
|
||||
})
|
||||
|
||||
it('non-persistent auth does not set isParentPersistent', () => {
|
||||
authenticateParent(false)
|
||||
expect(isParentPersistent.value).toBe(false)
|
||||
expect(parentAuthExpiresAt.value).not.toBeNull()
|
||||
// Confirm the expiry is ~1 minute, not 2 days
|
||||
expect(parentAuthExpiresAt.value!).toBeLessThan(Date.now() + 172_800_000)
|
||||
})
|
||||
|
||||
it('isParentAuthenticated becomes false after 1 minute (watcher fires)', () => {
|
||||
authenticateParent(false)
|
||||
expect(isParentAuthenticated.value).toBe(true)
|
||||
// Advance 60s: watcher fires every 15s, at t=60000 Date.now() >= expiresAt
|
||||
vi.advanceTimersByTime(60_001)
|
||||
expect(isParentAuthenticated.value).toBe(false)
|
||||
})
|
||||
|
||||
it('isParentAuthenticated is still true just before 1 minute', () => {
|
||||
authenticateParent(false)
|
||||
vi.advanceTimersByTime(59_999)
|
||||
// Watcher last fired at t=45000, next fires at t=60000 — hasn't expired yet
|
||||
expect(isParentAuthenticated.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistent mode', () => {
|
||||
it('authenticateParent(true) sets isParentAuthenticated to true', () => {
|
||||
authenticateParent(true)
|
||||
expect(isParentAuthenticated.value).toBe(true)
|
||||
expect(isParentPersistent.value).toBe(true)
|
||||
})
|
||||
|
||||
it('writes expiresAt to localStorage for persistent auth — parentAuthExpiresAt is set to ~2 days', () => {
|
||||
const before = Date.now()
|
||||
authenticateParent(true)
|
||||
const after = Date.now()
|
||||
// Verify the expiry ref is populated and within the 2-day window
|
||||
expect(parentAuthExpiresAt.value).not.toBeNull()
|
||||
expect(parentAuthExpiresAt.value!).toBeGreaterThanOrEqual(before + 172_800_000)
|
||||
expect(parentAuthExpiresAt.value!).toBeLessThanOrEqual(after + 172_800_000)
|
||||
})
|
||||
|
||||
it('isParentAuthenticated becomes false after 2 days (watcher fires)', () => {
|
||||
authenticateParent(true)
|
||||
expect(isParentAuthenticated.value).toBe(true)
|
||||
vi.advanceTimersByTime(172_800_001)
|
||||
expect(isParentAuthenticated.value).toBe(false)
|
||||
})
|
||||
|
||||
it('isParentAuthenticated is still true just before 2 days', () => {
|
||||
authenticateParent(true)
|
||||
// Advance to just before expiry; watcher last fired at t=172_785_000
|
||||
vi.advanceTimersByTime(172_784_999)
|
||||
expect(isParentAuthenticated.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logoutParent()', () => {
|
||||
it('clears isParentAuthenticated and isParentPersistent', () => {
|
||||
authenticateParent(true)
|
||||
expect(isParentAuthenticated.value).toBe(true)
|
||||
logoutParent()
|
||||
expect(isParentAuthenticated.value).toBe(false)
|
||||
expect(isParentPersistent.value).toBe(false)
|
||||
})
|
||||
|
||||
it('removing auth clears expiresAt and persistent flag', () => {
|
||||
authenticateParent(true)
|
||||
expect(parentAuthExpiresAt.value).not.toBeNull()
|
||||
logoutParent()
|
||||
expect(parentAuthExpiresAt.value).toBeNull()
|
||||
expect(isParentPersistent.value).toBe(false)
|
||||
})
|
||||
|
||||
it('clears parentAuthExpiresAt', () => {
|
||||
authenticateParent(false)
|
||||
logoutParent()
|
||||
expect(parentAuthExpiresAt.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loginUser()', () => {
|
||||
it('loginUser() clears parent auth entirely', () => {
|
||||
authenticateParent(true)
|
||||
expect(isParentAuthenticated.value).toBe(true)
|
||||
loginUser()
|
||||
expect(isParentAuthenticated.value).toBe(false)
|
||||
expect(isParentPersistent.value).toBe(false)
|
||||
expect(parentAuthExpiresAt.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('localStorage restore on init', () => {
|
||||
it('expired localStorage entry is cleaned up when checked', () => {
|
||||
// Simulate a stored entry that is already expired
|
||||
const expired = Date.now() - 1000
|
||||
localStorage.setItem('parentAuth', JSON.stringify({ expiresAt: expired }))
|
||||
|
||||
// Mirroring the init logic in auth.ts: read, check, remove if stale
|
||||
const stored = localStorage.getItem('parentAuth')
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as { expiresAt: number }
|
||||
if (!parsed.expiresAt || Date.now() >= parsed.expiresAt) {
|
||||
localStorage.removeItem('parentAuth')
|
||||
}
|
||||
}
|
||||
|
||||
expect(localStorage.getItem('parentAuth')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,44 +1,117 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const hasLocalStorage =
|
||||
typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function'
|
||||
const AUTH_SYNC_EVENT_KEY = 'authSyncEvent'
|
||||
const PARENT_AUTH_KEY = 'parentAuth'
|
||||
const PARENT_AUTH_EXPIRY_NON_PERSISTENT = 60_000 // 1 minute
|
||||
const PARENT_AUTH_EXPIRY_PERSISTENT = 172_800_000 // 2 days
|
||||
|
||||
// --- Parent auth expiry state ---
|
||||
export const isParentAuthenticated = ref(false)
|
||||
export const isParentPersistent = ref(false)
|
||||
export const parentAuthExpiresAt = ref<number | null>(null)
|
||||
|
||||
// Restore persistent parent auth from localStorage on store init
|
||||
if (hasLocalStorage) {
|
||||
try {
|
||||
const stored = localStorage.getItem(PARENT_AUTH_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as { expiresAt: number }
|
||||
if (parsed.expiresAt && Date.now() < parsed.expiresAt) {
|
||||
parentAuthExpiresAt.value = parsed.expiresAt
|
||||
isParentPersistent.value = true
|
||||
isParentAuthenticated.value = true
|
||||
} else {
|
||||
localStorage.removeItem(PARENT_AUTH_KEY)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(PARENT_AUTH_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
export const isParentAuthenticated = ref(
|
||||
hasLocalStorage ? localStorage.getItem('isParentAuthenticated') === 'true' : false,
|
||||
)
|
||||
export const isUserLoggedIn = ref(false)
|
||||
export const isAuthReady = ref(false)
|
||||
export const currentUserId = ref('')
|
||||
let authSyncInitialized = false
|
||||
|
||||
watch(isParentAuthenticated, (val) => {
|
||||
if (hasLocalStorage && typeof localStorage.setItem === 'function') {
|
||||
localStorage.setItem('isParentAuthenticated', val ? 'true' : 'false')
|
||||
}
|
||||
})
|
||||
// --- Background expiry watcher ---
|
||||
let expiryWatcherIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export function authenticateParent() {
|
||||
function runExpiryCheck() {
|
||||
if (parentAuthExpiresAt.value !== null && Date.now() >= parentAuthExpiresAt.value) {
|
||||
applyParentLoggedOutState()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/child'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startParentExpiryWatcher() {
|
||||
stopParentExpiryWatcher()
|
||||
expiryWatcherIntervalId = setInterval(runExpiryCheck, 15_000)
|
||||
}
|
||||
|
||||
export function stopParentExpiryWatcher() {
|
||||
if (expiryWatcherIntervalId !== null) {
|
||||
clearInterval(expiryWatcherIntervalId)
|
||||
expiryWatcherIntervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly checks whether parent auth has expired and clears it if so.
|
||||
* Called by the router guard before allowing /parent routes.
|
||||
*/
|
||||
export function enforceParentExpiry() {
|
||||
runExpiryCheck()
|
||||
}
|
||||
|
||||
export function authenticateParent(persistent: boolean) {
|
||||
const duration = persistent ? PARENT_AUTH_EXPIRY_PERSISTENT : PARENT_AUTH_EXPIRY_NON_PERSISTENT
|
||||
parentAuthExpiresAt.value = Date.now() + duration
|
||||
isParentPersistent.value = persistent
|
||||
isParentAuthenticated.value = true
|
||||
if (persistent && hasLocalStorage && typeof localStorage.setItem === 'function') {
|
||||
localStorage.setItem(PARENT_AUTH_KEY, JSON.stringify({ expiresAt: parentAuthExpiresAt.value }))
|
||||
}
|
||||
startParentExpiryWatcher()
|
||||
}
|
||||
|
||||
export function logoutParent() {
|
||||
applyParentLoggedOutState()
|
||||
broadcastParentLogoutEvent()
|
||||
}
|
||||
|
||||
function applyParentLoggedOutState() {
|
||||
parentAuthExpiresAt.value = null
|
||||
isParentPersistent.value = false
|
||||
isParentAuthenticated.value = false
|
||||
if (hasLocalStorage && typeof localStorage.removeItem === 'function') {
|
||||
localStorage.removeItem('isParentAuthenticated')
|
||||
localStorage.removeItem(PARENT_AUTH_KEY)
|
||||
}
|
||||
stopParentExpiryWatcher()
|
||||
}
|
||||
|
||||
function broadcastParentLogoutEvent() {
|
||||
if (!hasLocalStorage || typeof localStorage.setItem !== 'function') return
|
||||
localStorage.setItem(
|
||||
AUTH_SYNC_EVENT_KEY,
|
||||
JSON.stringify({ type: 'parent_logout', at: Date.now() }),
|
||||
)
|
||||
}
|
||||
|
||||
export function loginUser() {
|
||||
isUserLoggedIn.value = true
|
||||
// Always start in child mode after login
|
||||
isParentAuthenticated.value = false
|
||||
applyParentLoggedOutState()
|
||||
}
|
||||
|
||||
function applyLoggedOutState() {
|
||||
isUserLoggedIn.value = false
|
||||
currentUserId.value = ''
|
||||
logoutParent()
|
||||
applyParentLoggedOutState()
|
||||
}
|
||||
|
||||
function broadcastLogoutEvent() {
|
||||
@@ -64,6 +137,11 @@ export function initAuthSync() {
|
||||
if (!window.location.pathname.startsWith('/auth')) {
|
||||
window.location.href = '/auth/login'
|
||||
}
|
||||
} else if (payload?.type === 'parent_logout') {
|
||||
applyParentLoggedOutState()
|
||||
if (window.location.pathname.startsWith('/parent')) {
|
||||
window.location.href = '/child'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed sync events.
|
||||
|
||||
Reference in New Issue
Block a user