Compare commits

...

4 Commits

Author SHA1 Message Date
3673119ae2 fix: update BASE_VERSION to remove release candidate suffix
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 1m30s
2026-02-20 16:47:27 -05:00
55e7dc7568 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
2026-02-20 16:31:13 -05:00
ba909100a7 Merge pull request 'fix: add FRONTEND_URL to environment variables and create .env.example file' (#24) from master into next
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m9s
Reviewed-on: #24
2026-02-20 13:18:10 -05:00
c43af7d43e Merge pull request 'master' (#23) from master into next
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 1m42s
Reviewed-on: #23
2026-02-20 10:01:06 -05:00
15 changed files with 657 additions and 38 deletions

View File

@@ -0,0 +1,141 @@
# Feature: Persistent and non-persistent parent mode
## Overview
When a parent is prompted to input the parent PIN, a checkbox should also be available that asks if the parent wants to 'stay' in parent mode. If that is checked, the parent mode remains persistent on the device until child mode is entered or until an expiry time of 2 days.
When the checkbox is not enabled (default) the parent authentication should expire in 1 minute or the next reload of the site.
**Goal:**
A parent that has a dedicated device should stay in parent mode for a max of 2 days before having to re-enter the PIN, a device dedicated to the child should not stay in parent mode for more than a minute before reverting back to child mode.
**User Story:**
As a parent, I want my personal device to be able to stay in parent mode until I enter child mode or 2 days expire.
As a parent, on my child's device, I want to be able to enter parent mode to make a change or two and not have to worry about exiting parent mode.
**Rules:**
Use .github/copilot-instructions.md
**Common files:**
frontend\vue-app\src\components\shared\LoginButton.vue
frontend\vue-app\src\stores\auth.ts
frontend\vue-app\src\router\index.ts
---
## Data Model Changes
### Backend Model
No backend changes required. PIN validation is already handled server-side via `POST /user/check-pin`. Parent mode session duration is a purely client-side concern.
### Frontend Model
**`localStorage['parentAuth']`** (written only for persistent mode):
```json
{ "expiresAt": 1234567890123 }
```
- Present only when "Stay in parent mode" was checked at PIN entry.
- Removed when the user clicks "Child Mode", on explicit logout, or when found expired on store init.
**Auth store state additions** (`frontend/vue-app/src/stores/auth.ts`):
- `parentAuthExpiresAt: Ref<number | null>` — epoch ms timestamp; `null` when not authenticated. Memory-only for non-persistent sessions, restored from `localStorage` for persistent ones.
- `isParentPersistent: Ref<boolean>``true` when the current parent session was marked "stay".
- `isParentAuthenticated: Ref<boolean>` — plain ref set to `true` by `authenticateParent()` and `false` by `logoutParent()`. Expiry is enforced by the 15-second background watcher and the router guard calling `enforceParentExpiry()`.
---
## Backend Implementation
No backend changes required.
---
## Backend Tests
- [x] No new backend tests required.
---
## Frontend Implementation
### 1. Refactor `auth.ts` — expiry-aware state
- Remove the plain `ref<boolean>` `isParentAuthenticated` and the `watch` that wrote `'true'/'false'` to `localStorage['isParentAuthenticated']`.
- Add `parentAuthExpiresAt: ref<number | null>` (initialized to `null`).
- Add `isParentPersistent: ref<boolean>` (initialized to `false`).
- Keep `isParentAuthenticated` as a plain `ref<boolean>` — set explicitly by `authenticateParent()` and `logoutParent()`. A background watcher and router guard enforce expiry by calling `logoutParent()` when `Date.now() >= parentAuthExpiresAt.value`.
- Update `authenticateParent(persistent: boolean)`:
- Non-persistent: set `parentAuthExpiresAt.value = Date.now() + 60_000`, `isParentPersistent.value = false`. Write nothing to `localStorage`. State is lost on page reload naturally.
- Persistent: set `parentAuthExpiresAt.value = Date.now() + 172_800_000` (2 days), `isParentPersistent.value = true`. Write `{ expiresAt }` to `localStorage['parentAuth']`.
- Both: set `isParentAuthenticated.value = true`, call `startParentExpiryWatcher()`.
- Update `logoutParent()`: clear all three refs (`null`/`false`/`false`), remove `localStorage['parentAuth']`, call `stopParentExpiryWatcher()`.
- Update `loginUser()`: call `logoutParent()` internally (already resets parent state on fresh login).
- On store initialization: read `localStorage['parentAuth']`; if present and `expiresAt > Date.now()`, restore as persistent auth; otherwise remove the stale key.
### 2. Add background expiry watcher to `auth.ts`
- Export `startParentExpiryWatcher()` and `stopParentExpiryWatcher()` that manage a 15-second `setInterval`.
- The interval checks `Date.now() >= parentAuthExpiresAt.value`; if true, calls `logoutParent()` and navigates to `/child` via `window.location.href`. This enforces expiry even while a parent is mid-page on a `/parent` route.
### 3. Update router navigation guard — `router/index.ts`
- Import `logoutParent` and `enforceParentExpiry` from the auth store.
- Before checking parent route access, call `enforceParentExpiry()` which evaluates `Date.now() >= parentAuthExpiresAt.value` directly and calls `logoutParent()` if expired.
- If not authenticated after the check: call `logoutParent()` (cleanup) then redirect to `/child`.
### 4. Update PIN modal in `LoginButton.vue` — checkbox
- Add `stayInParentMode: ref<boolean>` (default `false`).
- Add a checkbox below the PIN input, labelled **"Stay in parent mode on this device"**.
- Style checkbox with `:root` CSS variables from `colors.css`.
- Update `submit()` to call `authenticateParent(stayInParentMode.value)`.
- Reset `stayInParentMode.value = false` when the modal closes.
### 5. Add lock badge to avatar button — `LoginButton.vue`
- Import `isParentPersistent` from the auth store.
- Wrap the existing avatar button in a `position: relative` container.
- When `isParentAuthenticated && isParentPersistent`, render a small `🔒` emoji element absolutely positioned at `bottom: -2px; left: -2px` with a font size of ~10px.
- This badge disappears automatically when "Child Mode" is clicked (clears `isParentPersistent`).
---
## Frontend Tests
- [x] `auth.ts` — non-persistent: `authenticateParent(false)` sets expiry to `now + 60s`; `isParentAuthenticated` returns `false` after watcher fires past expiry (via fake timers).
- [x] `auth.ts` — persistent: `authenticateParent(true)` sets `parentAuthExpiresAt` to `now + 2 days`; `isParentAuthenticated` returns `false` after watcher fires past 2-day expiry.
- [x] `auth.ts``logoutParent()` clears refs, stops watcher.
- [x] `auth.ts``loginUser()` calls `logoutParent()` clearing all parent auth state.
- [x] `LoginButton.vue` — checkbox is unchecked by default; checking it and submitting calls `authenticateParent(true)`.
- [x] `LoginButton.vue` — submitting without checkbox calls `authenticateParent(false)`.
- [x] `LoginButton.vue` — lock badge `🔒` is visible only when `isParentAuthenticated && isParentPersistent`.
---
## Future Considerations
- Could offer a configurable expiry duration (e.g. 1 day, 3 days, 7 days) rather than a fixed 2-day cap.
- Could show a "session expiring soon" warning for the persistent mode (e.g. banner appears 1 hour before the 2-day expiry).
---
## Acceptance Criteria (Definition of Done)
### Backend
- [x] No backend changes required; all work is frontend-only.
### Frontend
- [x] PIN modal includes an unchecked "Stay in parent mode on this device" checkbox.
- [x] Non-persistent mode: parent auth is memory-only, expires after 1 minute, and is lost on page reload.
- [x] Persistent mode: `localStorage['parentAuth']` is written with a 2-day `expiresAt` timestamp; auth survives page reload and new tabs.
- [x] Router guard redirects silently to `/child` if parent mode has expired when navigating to any `/parent` route.
- [x] Background 15-second interval also enforces expiry while the user is mid-page on a `/parent` route.
- [x] "Child Mode" button clears both persistent and non-persistent auth state completely.
- [x] A `🔒` emoji badge appears on the lower-left of the parent avatar button only when persistent mode is active.
- [x] Opening a new tab while in persistent mode correctly restores parent mode from `localStorage`.
- [x] All frontend tests listed above pass.

49
.github/specs/template/feat-template.md vendored Normal file
View File

@@ -0,0 +1,49 @@
# Feature:
## Overview
**Goal:**
**User Story:**
**Rules:**
---
## Data Model Changes
### Backend Model
### Frontend Model
---
## Backend Implementation
## Backend Tests
- [ ]
---
## Frontend Implementation
## Frontend Tests
- [ ]
---
## Future Considerations
---
## Acceptance Criteria (Definition of Done)
### Backend
- [ ]
### Frontend
- [ ]

View File

@@ -163,7 +163,7 @@ def login():
'email': norm_email,
'user_id': user.id,
'token_version': user.token_version,
'exp': datetime.utcnow() + timedelta(hours=24*7)
'exp': datetime.utcnow() + timedelta(days=62)
}
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')

View File

@@ -2,7 +2,7 @@
# file: config/version.py
import os
BASE_VERSION = "1.0.4" # update manually when releasing features
BASE_VERSION = "1.0.5" # update manually when releasing features
def get_full_version() -> str:
"""

View File

@@ -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(),
}))

View File

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

View File

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

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

View File

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

View File

@@ -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')
}
})

View File

@@ -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 = '/'
})
})

View 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()
})
})
})

View File

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