11 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
8148bfac51 fix: add FRONTEND_URL to environment variables and create .env.example file
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 4m0s
2026-02-20 13:07:55 -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
10216f49c9 fix: update service names and image paths to reflect new repository structure
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m7s
2026-02-20 09:47:08 -05:00
42d3567c22 fix: add condition to deploy test environment for 'next' branch
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 1m30s
2026-02-19 17:09:09 -05:00
be4a816a7c fix: update Docker image paths to reflect new structure
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m51s
2026-02-19 17:02:36 -05:00
773840d88b fix: update Docker image names to reflect new structure
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m9s
2026-02-19 16:52:00 -05:00
075160941a fix: update Docker image paths to use new repository structure
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 4m0s
2026-02-19 16:40:01 -05:00
d2fea646de fix: update Docker registry credentials to use Gitea secrets
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m0s
2026-02-19 16:21:34 -05:00
20 changed files with 696 additions and 73 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
FRONTEND_URL=https://yourdomain.com

View File

@@ -56,24 +56,24 @@ jobs:
- name: Build Backend Docker Image
run: |
docker build -t git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} ./backend
docker build -t git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} ./backend
- name: Build Frontend Docker Image
run: |
docker build -t git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app
docker build -t git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app
- name: Log in to Registry
uses: docker/login-action@v2
with:
registry: git.ryankegel.com:3000
username: ryan #${{ secrets.REGISTRY_USERNAME }} # Stored as a Gitea secret
password: 0x013h #${{ secrets.REGISTRY_TOKEN }} # Stored as a Gitea secret (use a PAT here)
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Push Backend Image to Gitea Registry
run: |
for i in {1..3}; do
echo "Attempt $i to push backend image..."
if docker push git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }}; then
if docker push git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }}; then
echo "Backend push succeeded on attempt $i"
break
else
@@ -86,18 +86,18 @@ jobs:
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:latest
docker push git.ryankegel.com:3000/ryan/backend:latest
docker tag git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/backend:latest
docker push git.ryankegel.com:3000/kegel/chores/backend:latest
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
docker tag git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:next
docker push git.ryankegel.com:3000/ryan/backend:next
docker tag git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/backend:next
docker push git.ryankegel.com:3000/kegel/chores/backend:next
fi
- name: Push Frontend Image to Gitea Registry
run: |
for i in {1..3}; do
echo "Attempt $i to push frontend image..."
if docker push git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }}; then
if docker push git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }}; then
echo "Frontend push succeeded on attempt $i"
break
else
@@ -110,14 +110,15 @@ jobs:
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:latest
docker push git.ryankegel.com:3000/ryan/frontend:latest
docker tag git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/frontend:latest
docker push git.ryankegel.com:3000/kegel/chores/frontend:latest
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
docker tag git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:next
docker push git.ryankegel.com:3000/ryan/frontend:next
docker tag git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/frontend:next
docker push git.ryankegel.com:3000/kegel/chores/frontend:next
fi
- name: Deploy Test Environment
if: gitea.ref == 'refs/heads/next'
uses: appleboy/ssh-action@v1.0.3 # Or equivalent Gitea action; adjust version if needed
with:
host: ${{ secrets.DEPLOY_TEST_HOST }}

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
- [ ]

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.env
backend/test_data/db/children.json
backend/test_data/db/images.json
backend/test_data/db/pending_rewards.json

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

@@ -2,8 +2,8 @@
version: "3.8"
services:
chore-test-app-backend: # Test backend service name
image: git.ryankegel.com:3000/ryan/backend:next # Use latest next tag
chores-test-app-backend: # Test backend service name
image: git.ryankegel.com:3000/kegel/chores/backend:next # Use latest next tag
ports:
- "5004:5000" # Host 5004 -> Container 5000
environment:
@@ -11,19 +11,19 @@ services:
- FRONTEND_URL=https://devserver.lan:446 # Add this for test env
# Add volumes, networks, etc., as needed
chore-test-app-frontend: # Test frontend service name
image: git.ryankegel.com:3000/ryan/frontend:next # Use latest next tag
chores-test-app-frontend: # Test frontend service name
image: git.ryankegel.com:3000/kegel/chores/frontend:next # Use latest next tag
ports:
- "446:443" # Host 446 -> Container 443 (HTTPS)
environment:
- BACKEND_HOST=chore-test-app-backend # Points to internal backend service
- BACKEND_HOST=chores-test-app-backend # Points to internal backend service
depends_on:
- chore-test-app-backend
- chores-test-app-backend
# Add volumes, networks, etc., as needed
networks:
chore-test-app-net:
chores-test-app-net:
driver: bridge
volumes:
chore-test-app-backend-data: {}
chores-test-app-backend-data: {}

View File

@@ -2,35 +2,36 @@
version: "3.8"
services:
chore-app-backend: # Production backend service name
image: git.ryankegel.com:3000/ryan/backend:latest # Or specific version tag
container_name: chore-app-backend-prod # Added for easy identification
chores-app-backend: # Production backend service name
image: git.ryankegel.com:3000/kegel/chores/backend:latest # Or specific version tag
container_name: chores-app-backend-prod # Added for easy identification
ports:
- "5001:5000" # Host 5001 -> Container 5000
environment:
- FLASK_ENV=production
- FRONTEND_URL=${FRONTEND_URL}
volumes:
- chore-app-backend-data:/app/data # Assuming backend data storage; adjust path as needed
- chores-app-backend-data:/app/data # Assuming backend data storage; adjust path as needed
networks:
- chore-app-net
- chores-app-net
# Add other volumes, networks, etc., as needed
chore-app-frontend: # Production frontend service name
image: git.ryankegel.com:3000/ryan/frontend:latest # Or specific version tag
container_name: chore-app-frontend-prod # Added for easy identification
chores-app-frontend: # Production frontend service name
image: git.ryankegel.com:3000/kegel/chores/frontend:latest # Or specific version tag
container_name: chores-app-frontend-prod # Added for easy identification
ports:
- "443:443" # Host 443 -> Container 443 (HTTPS)
environment:
- BACKEND_HOST=chore-app-backend # Points to internal backend service
- BACKEND_HOST=chores-app-backend # Points to internal backend service
depends_on:
- chore-app-backend
- chores-app-backend
networks:
- chore-app-net
- chores-app-net
# Add volumes, networks, etc., as needed
networks:
chore-app-net:
chores-app-net:
driver: bridge
volumes:
chore-app-backend-data: {}
chores-app-backend-data: {}

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.