Compare commits
4 Commits
8148bfac51
...
3673119ae2
| Author | SHA1 | Date | |
|---|---|---|---|
| 3673119ae2 | |||
| 55e7dc7568 | |||
| ba909100a7 | |||
| c43af7d43e |
141
.github/specs/archive/feat-parent-mode-expire.md
vendored
Normal file
141
.github/specs/archive/feat-parent-mode-expire.md
vendored
Normal 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
49
.github/specs/template/feat-template.md
vendored
Normal 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
|
||||
|
||||
- [ ]
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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