feat: enhance child edit and view components with improved form handling and validation
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m4s
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m4s
- Added `requireDirty` prop to `EntityEditForm` for dirty state management. - Updated `ChildEditView` to handle initial data loading and image selection more robustly. - Refactored `ChildView` to remove unused reward dialog logic and prevent API calls in child mode. - Improved type definitions for form fields and initial data in `ChildEditView`. - Enhanced error handling in form submissions across components. - Implemented cross-tab logout synchronization on password reset in the auth store. - Added tests for login and entity edit form functionalities to ensure proper behavior. - Introduced global fetch interceptor for handling unauthorized responses. - Documented password reset flow and its implications on session management.
This commit is contained in:
@@ -164,6 +164,10 @@ npm run test
|
||||
|
||||
For detailed development patterns and conventions, see [`.github/copilot-instructions.md`](.github/copilot-instructions.md).
|
||||
|
||||
## 📚 References
|
||||
|
||||
- Reset flow (token validation, JWT invalidation, cross-tab logout sync): [`docs/reset-password-reference.md`](docs/reset-password-reference.md)
|
||||
|
||||
## 📄 License
|
||||
|
||||
Private project - All rights reserved.
|
||||
|
||||
@@ -162,6 +162,7 @@ def login():
|
||||
payload = {
|
||||
'email': norm_email,
|
||||
'user_id': user.id,
|
||||
'token_version': user.token_version,
|
||||
'exp': datetime.utcnow() + timedelta(hours=24*7)
|
||||
}
|
||||
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
||||
@@ -179,10 +180,13 @@ def me():
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||
user_id = payload.get('user_id', '')
|
||||
token_version = payload.get('token_version', 0)
|
||||
user_dict = users_db.get(UserQuery.id == user_id)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
||||
if token_version != user.token_version:
|
||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
|
||||
if user.marked_for_deletion:
|
||||
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
|
||||
return jsonify({
|
||||
@@ -268,9 +272,12 @@ def reset_password():
|
||||
user.password = generate_password_hash(new_password)
|
||||
user.reset_token = None
|
||||
user.reset_token_created = None
|
||||
user.token_version += 1
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
|
||||
return jsonify({'message': 'Password has been reset'}), 200
|
||||
resp = jsonify({'message': 'Password has been reset'})
|
||||
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
|
||||
return resp, 200
|
||||
|
||||
@auth_api.route('/logout', methods=['POST'])
|
||||
def logout():
|
||||
|
||||
@@ -64,10 +64,24 @@ def list_tasks():
|
||||
continue # Skip default if user version exists
|
||||
filtered_tasks.append(t)
|
||||
|
||||
# Sort: user-created items first (by name), then default items (by name)
|
||||
user_created = sorted([t for t in filtered_tasks if t.get('user_id') == user_id], key=lambda x: x['name'].lower())
|
||||
default_items = sorted([t for t in filtered_tasks if t.get('user_id') is None], key=lambda x: x['name'].lower())
|
||||
sorted_tasks = user_created + default_items
|
||||
# Sort order:
|
||||
# 1) good tasks first, then not-good tasks
|
||||
# 2) within each group: user-created items first (by name), then default items (by name)
|
||||
good_tasks = [t for t in filtered_tasks if t.get('is_good') is True]
|
||||
not_good_tasks = [t for t in filtered_tasks if t.get('is_good') is not True]
|
||||
|
||||
def sort_user_then_default(tasks_group):
|
||||
user_created = sorted(
|
||||
[t for t in tasks_group if t.get('user_id') == user_id],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
default_items = sorted(
|
||||
[t for t in tasks_group if t.get('user_id') is None],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
return user_created + default_items
|
||||
|
||||
sorted_tasks = sort_user_then_default(good_tasks) + sort_user_then_default(not_good_tasks)
|
||||
|
||||
return jsonify({'tasks': sorted_tasks}), 200
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ def get_current_user_id():
|
||||
user_id = payload.get('user_id')
|
||||
if not user_id:
|
||||
return None
|
||||
token_version = payload.get('token_version', 0)
|
||||
user = users_db.get(Query().id == user_id)
|
||||
if not user:
|
||||
return None
|
||||
if token_version != user.get('token_version', 0):
|
||||
return None
|
||||
return user_id
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# file: config/version.py
|
||||
import os
|
||||
|
||||
BASE_VERSION = "1.0.4RC2" # update manually when releasing features
|
||||
BASE_VERSION = "1.0.4RC3" # update manually when releasing features
|
||||
|
||||
def get_full_version() -> str:
|
||||
"""
|
||||
|
||||
@@ -21,6 +21,7 @@ class User(BaseModel):
|
||||
deletion_in_progress: bool = False
|
||||
deletion_attempted_at: str | None = None
|
||||
role: str = 'user'
|
||||
token_version: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
@@ -43,6 +44,7 @@ class User(BaseModel):
|
||||
deletion_in_progress=d.get('deletion_in_progress', False),
|
||||
deletion_attempted_at=d.get('deletion_attempted_at'),
|
||||
role=d.get('role', 'user'),
|
||||
token_version=d.get('token_version', 0),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
@@ -69,6 +71,7 @@ class User(BaseModel):
|
||||
'marked_for_deletion_at': self.marked_for_deletion_at,
|
||||
'deletion_in_progress': self.deletion_in_progress,
|
||||
'deletion_attempted_at': self.deletion_attempted_at,
|
||||
'role': self.role
|
||||
'role': self.role,
|
||||
'token_version': self.token_version,
|
||||
})
|
||||
return base
|
||||
|
||||
@@ -100,6 +100,38 @@ def test_reset_password_hashes_new_password(client):
|
||||
assert user_dict['password'].startswith('scrypt:')
|
||||
assert check_password_hash(user_dict['password'], 'newpassword123')
|
||||
|
||||
|
||||
def test_reset_password_invalidates_existing_jwt(client):
|
||||
users_db.remove(Query().email == 'test@example.com')
|
||||
user = User(
|
||||
first_name='Test',
|
||||
last_name='User',
|
||||
email='test@example.com',
|
||||
password=generate_password_hash('oldpassword123'),
|
||||
verified=True,
|
||||
reset_token='validtoken2',
|
||||
reset_token_created=datetime.utcnow().isoformat(),
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
|
||||
login_response = client.post('/auth/login', json={'email': 'test@example.com', 'password': 'oldpassword123'})
|
||||
assert login_response.status_code == 200
|
||||
login_cookie = login_response.headers.get('Set-Cookie', '')
|
||||
assert 'token=' in login_cookie
|
||||
old_token = login_cookie.split('token=', 1)[1].split(';', 1)[0]
|
||||
assert old_token
|
||||
|
||||
reset_response = client.post('/auth/reset-password', json={'token': 'validtoken2', 'password': 'newpassword123'})
|
||||
assert reset_response.status_code == 200
|
||||
reset_cookie = reset_response.headers.get('Set-Cookie', '')
|
||||
assert 'token=' in reset_cookie
|
||||
|
||||
# Set the old token as a cookie and test that it's now invalid
|
||||
client.set_cookie('token', old_token)
|
||||
me_response = client.get('/auth/me')
|
||||
assert me_response.status_code == 401
|
||||
assert me_response.json['code'] == 'INVALID_TOKEN'
|
||||
|
||||
def test_migration_script_hashes_plain_text_passwords():
|
||||
"""Test the migration script hashes plain text passwords."""
|
||||
# Clean up
|
||||
|
||||
@@ -80,6 +80,36 @@ def test_list_tasks(client):
|
||||
assert len(data['tasks']) == 2
|
||||
|
||||
|
||||
def test_list_tasks_sorted_by_is_good_then_user_then_default_then_name(client):
|
||||
task_db.truncate()
|
||||
|
||||
task_db.insert({'id': 'u_good_z', 'name': 'Zoo', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
|
||||
task_db.insert({'id': 'u_good_a', 'name': 'Apple', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
|
||||
task_db.insert({'id': 'd_good_m', 'name': 'Mop', 'points': 1, 'is_good': True, 'user_id': None})
|
||||
task_db.insert({'id': 'd_good_b', 'name': 'Brush', 'points': 1, 'is_good': True, 'user_id': None})
|
||||
|
||||
task_db.insert({'id': 'u_bad_c', 'name': 'Chore', 'points': 1, 'is_good': False, 'user_id': 'testuserid'})
|
||||
task_db.insert({'id': 'u_bad_a', 'name': 'Alarm', 'points': 1, 'is_good': False, 'user_id': 'testuserid'})
|
||||
task_db.insert({'id': 'd_bad_y', 'name': 'Yell', 'points': 1, 'is_good': False, 'user_id': None})
|
||||
task_db.insert({'id': 'd_bad_b', 'name': 'Bicker', 'points': 1, 'is_good': False, 'user_id': None})
|
||||
|
||||
response = client.get('/task/list')
|
||||
assert response.status_code == 200
|
||||
|
||||
tasks = response.json['tasks']
|
||||
ordered_ids = [t['id'] for t in tasks]
|
||||
assert ordered_ids == [
|
||||
'u_good_a',
|
||||
'u_good_z',
|
||||
'd_good_b',
|
||||
'd_good_m',
|
||||
'u_bad_a',
|
||||
'u_bad_c',
|
||||
'd_bad_b',
|
||||
'd_bad_y',
|
||||
]
|
||||
|
||||
|
||||
def test_get_task_not_found(client):
|
||||
response = client.get('/task/nonexistent-id')
|
||||
assert response.status_code == 404
|
||||
|
||||
258
docs/reset-password-reference.md
Normal file
258
docs/reset-password-reference.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Password Reset Reference
|
||||
|
||||
This document explains the full password reset and forced re-auth flow implemented in the project.
|
||||
|
||||
## Scope
|
||||
|
||||
This covers:
|
||||
|
||||
- reset token validation and reset submission
|
||||
- JWT invalidation after reset
|
||||
- behavior of `/auth/me` with stale tokens
|
||||
- multi-tab synchronization in the frontend
|
||||
|
||||
---
|
||||
|
||||
## High-Level Behavior
|
||||
|
||||
After a successful password reset:
|
||||
|
||||
1. Backend updates the password hash.
|
||||
2. Backend increments the user's `token_version`.
|
||||
3. Backend clears the `token` auth cookie in the reset response.
|
||||
4. Existing JWTs in other tabs/devices become invalid because their embedded `token_version` no longer matches.
|
||||
5. Frontend broadcasts a logout sync event so other tabs immediately redirect to login.
|
||||
|
||||
---
|
||||
|
||||
## Backend Components
|
||||
|
||||
### 1) User model versioning
|
||||
|
||||
File: `backend/models/user.py`
|
||||
|
||||
- Added `token_version: int = 0`.
|
||||
- `from_dict()` defaults missing value to `0` for backward compatibility.
|
||||
- `to_dict()` persists `token_version`.
|
||||
|
||||
### 2) JWT issuance includes token version
|
||||
|
||||
File: `backend/api/auth_api.py` (`/auth/login`)
|
||||
|
||||
JWT payload now includes:
|
||||
|
||||
- `email`
|
||||
- `user_id`
|
||||
- `token_version`
|
||||
- `exp`
|
||||
|
||||
### 3) `/auth/me` rejects stale tokens
|
||||
|
||||
File: `backend/api/auth_api.py` (`/auth/me`)
|
||||
|
||||
Flow:
|
||||
|
||||
- decode JWT
|
||||
- load user from DB
|
||||
- compare `payload.token_version` (default 0) with `user.token_version`
|
||||
- if mismatch, return:
|
||||
- status: `401`
|
||||
- code: `INVALID_TOKEN`
|
||||
|
||||
### 4) reset-password invalidates sessions
|
||||
|
||||
File: `backend/api/auth_api.py` (`/auth/reset-password`)
|
||||
|
||||
On success:
|
||||
|
||||
- hash and store new password
|
||||
- clear `reset_token` and `reset_token_created`
|
||||
- increment `user.token_version`
|
||||
- persist user
|
||||
- clear `token` cookie in response (`expires=0`, `httponly=True`, `secure=True`, `samesite='Strict'`)
|
||||
|
||||
### 5) shared auth utility enforcement
|
||||
|
||||
File: `backend/api/utils.py` (`get_current_user_id`)
|
||||
|
||||
Protected endpoints that use this helper also enforce token version:
|
||||
|
||||
- decode JWT
|
||||
- load user by `user_id`
|
||||
- compare JWT `token_version` vs DB `token_version`
|
||||
- return `None` if mismatch
|
||||
|
||||
---
|
||||
|
||||
## Frontend Components
|
||||
|
||||
### 1) Reset password page
|
||||
|
||||
File: `frontend/vue-app/src/components/auth/ResetPassword.vue`
|
||||
|
||||
On successful `/api/auth/reset-password`:
|
||||
|
||||
- calls `logoutUser()` from auth store
|
||||
- still shows success modal
|
||||
- Sign In action navigates to login
|
||||
|
||||
### 2) Cross-tab logout sync
|
||||
|
||||
File: `frontend/vue-app/src/stores/auth.ts`
|
||||
|
||||
Implemented:
|
||||
|
||||
- logout broadcast key: `authSyncEvent`
|
||||
- `logoutUser()`:
|
||||
- applies local logged-out state
|
||||
- writes logout event to localStorage
|
||||
- `initAuthSync()`:
|
||||
- listens to `storage` events
|
||||
- if logout event arrives, applies logged-out state and redirects to `/auth/login` when outside `/auth/*`
|
||||
- `checkAuth()` now funnels failed `/api/auth/me` checks through `logoutUser()`
|
||||
|
||||
### 3) Sync bootstrap
|
||||
|
||||
File: `frontend/vue-app/src/main.ts`
|
||||
|
||||
- calls `initAuthSync()` at app startup.
|
||||
|
||||
### 4) Global `401 Unauthorized` handling
|
||||
|
||||
Files:
|
||||
|
||||
- `frontend/vue-app/src/common/api.ts`
|
||||
- `frontend/vue-app/src/main.ts`
|
||||
|
||||
Implemented:
|
||||
|
||||
- `installUnauthorizedFetchInterceptor()` wraps global `fetch`
|
||||
- if any response is `401`, frontend:
|
||||
- calls `logoutUser()`
|
||||
- redirects to `/auth` (unless already on `/auth/*`)
|
||||
|
||||
This ensures protected pages consistently return users to auth landing when a session is invalid.
|
||||
|
||||
---
|
||||
|
||||
## Sequence Diagram (Reset Success)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User (Tab A)
|
||||
participant FE as ResetPassword.vue
|
||||
participant BE as auth_api.py
|
||||
participant DB as users_db
|
||||
participant LS as localStorage
|
||||
participant T2 as Browser Tab B
|
||||
|
||||
U->>FE: Submit new password + token
|
||||
FE->>BE: POST /api/auth/reset-password
|
||||
BE->>DB: Validate reset token + expiry
|
||||
BE->>DB: Update password hash
|
||||
BE->>DB: token_version = token_version + 1
|
||||
BE-->>FE: 200 + clear auth cookie
|
||||
|
||||
FE->>LS: logoutUser() writes authSyncEvent
|
||||
LS-->>T2: storage event(authSyncEvent: logout)
|
||||
T2->>T2: clear auth state
|
||||
T2->>T2: redirect /auth/login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sequence Diagram (Stale Token Check)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as Any Tab with old JWT
|
||||
participant BE as /auth/me
|
||||
participant DB as users_db
|
||||
|
||||
T->>BE: GET /auth/me (old JWT token_version=N)
|
||||
BE->>DB: Load user (current token_version=N+1)
|
||||
BE-->>T: 401 { code: INVALID_TOKEN }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example API Calls
|
||||
|
||||
### Validate reset token
|
||||
|
||||
`GET /api/auth/validate-reset-token?token=<token>`
|
||||
|
||||
Possible failures:
|
||||
|
||||
- `400 MISSING_TOKEN`
|
||||
- `400 INVALID_TOKEN`
|
||||
- `400 TOKEN_TIMESTAMP_MISSING`
|
||||
- `400 TOKEN_EXPIRED`
|
||||
|
||||
### Reset password
|
||||
|
||||
`POST /api/auth/reset-password`
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "<reset-token>",
|
||||
"password": "newStrongPassword123"
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
- `200 { "message": "Password has been reset" }`
|
||||
- response also clears auth cookie
|
||||
|
||||
### Auth check after reset with stale JWT
|
||||
|
||||
`GET /api/auth/me`
|
||||
|
||||
Expected:
|
||||
|
||||
- `401 { "error": "Invalid token", "code": "INVALID_TOKEN" }`
|
||||
|
||||
---
|
||||
|
||||
## SSE vs Cross-Tab Sync
|
||||
|
||||
Current design intentionally does **not** rely on SSE to enforce logout correctness.
|
||||
|
||||
Why:
|
||||
|
||||
- Security correctness is guaranteed by cookie clearing + token_version invalidation.
|
||||
- SSE can improve UX but is not required for correctness.
|
||||
- Cross-tab immediate UX is handled client-side via localStorage `storage` events.
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Backend:
|
||||
|
||||
- `backend/tests/test_auth_api.py`
|
||||
- includes regression test ensuring old JWT fails `/auth/me` after reset.
|
||||
|
||||
Frontend:
|
||||
|
||||
- `frontend/vue-app/src/stores/__tests__/auth.childmode.spec.ts`
|
||||
- includes cross-tab storage logout behavior.
|
||||
- `frontend/vue-app/src/common/__tests__/api.interceptor.spec.ts`
|
||||
- verifies global `401` interceptor logout and redirect behavior.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Checklist
|
||||
|
||||
- If stale sessions still appear valid:
|
||||
- verify `token_version` exists in user records
|
||||
- confirm `/auth/login` includes `token_version` claim
|
||||
- confirm `/auth/me` compares JWT vs DB token_version
|
||||
- confirm `/auth/reset-password` increments token_version
|
||||
- If other tabs do not redirect:
|
||||
- verify `initAuthSync()` is called in `main.ts`
|
||||
- verify `logoutUser()` is called on reset success
|
||||
- check browser supports storage events across tabs for same origin
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockLogoutUser = vi.fn()
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
logoutUser: () => mockLogoutUser(),
|
||||
}))
|
||||
|
||||
describe('installUnauthorizedFetchInterceptor', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
mockLogoutUser.mockReset()
|
||||
globalThis.fetch = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('logs out and redirects to /auth on 401 outside auth routes', async () => {
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||
fetchMock.mockResolvedValue({ status: 401 } as Response)
|
||||
|
||||
window.history.pushState({}, '', '/parent/profile')
|
||||
const redirectSpy = vi.fn()
|
||||
|
||||
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } =
|
||||
await import('../api')
|
||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
await fetch('/api/user/profile')
|
||||
|
||||
expect(mockLogoutUser).toHaveBeenCalledTimes(1)
|
||||
expect(redirectSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not redirect when already on auth route', async () => {
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||
fetchMock.mockResolvedValue({ status: 401 } as Response)
|
||||
|
||||
window.history.pushState({}, '', '/auth/login')
|
||||
const redirectSpy = vi.fn()
|
||||
|
||||
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } =
|
||||
await import('../api')
|
||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
await fetch('/api/auth/me')
|
||||
|
||||
expect(mockLogoutUser).toHaveBeenCalledTimes(1)
|
||||
expect(redirectSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles unauthorized redirect only once even for repeated 401 responses', async () => {
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||
fetchMock.mockResolvedValue({ status: 401 } as Response)
|
||||
|
||||
window.history.pushState({}, '', '/parent/tasks')
|
||||
const redirectSpy = vi.fn()
|
||||
|
||||
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } =
|
||||
await import('../api')
|
||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
await fetch('/api/task/add', { method: 'PUT' })
|
||||
await fetch('/api/image/list?type=2')
|
||||
|
||||
expect(mockLogoutUser).toHaveBeenCalledTimes(1)
|
||||
expect(redirectSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not log out for non-401 responses', async () => {
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||
fetchMock.mockResolvedValue({ status: 200 } as Response)
|
||||
|
||||
window.history.pushState({}, '', '/parent')
|
||||
const redirectSpy = vi.fn()
|
||||
|
||||
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } =
|
||||
await import('../api')
|
||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
await fetch('/api/child/list')
|
||||
|
||||
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||
expect(redirectSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
106
frontend/vue-app/src/common/__tests__/backendEvents.spec.ts
Normal file
106
frontend/vue-app/src/common/__tests__/backendEvents.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, toRef } from 'vue'
|
||||
import { useBackendEvents } from '../backendEvents'
|
||||
|
||||
const { emitMock } = vi.hoisted(() => ({
|
||||
emitMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../eventBus', () => ({
|
||||
eventBus: {
|
||||
emit: emitMock,
|
||||
},
|
||||
}))
|
||||
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = []
|
||||
public onmessage: ((event: MessageEvent) => void) | null = null
|
||||
public close = vi.fn(() => {
|
||||
this.closed = true
|
||||
})
|
||||
public closed = false
|
||||
|
||||
constructor(public url: string) {
|
||||
MockEventSource.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
const TestHarness = defineComponent({
|
||||
name: 'BackendEventsHarness',
|
||||
props: {
|
||||
userId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
useBackendEvents(toRef(props, 'userId'))
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
describe('useBackendEvents', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
MockEventSource.instances = []
|
||||
vi.stubGlobal('EventSource', MockEventSource)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('connects when user id becomes available after mount', async () => {
|
||||
const wrapper = mount(TestHarness, { props: { userId: '' } })
|
||||
|
||||
expect(MockEventSource.instances.length).toBe(0)
|
||||
|
||||
await wrapper.setProps({ userId: 'user-1' })
|
||||
|
||||
expect(MockEventSource.instances.length).toBe(1)
|
||||
expect(MockEventSource.instances[0]?.url).toBe('/events?user_id=user-1')
|
||||
})
|
||||
|
||||
it('reconnects when user id changes and closes previous connection', async () => {
|
||||
const wrapper = mount(TestHarness, { props: { userId: 'user-1' } })
|
||||
|
||||
expect(MockEventSource.instances.length).toBe(1)
|
||||
const firstConnection = MockEventSource.instances[0]
|
||||
|
||||
await wrapper.setProps({ userId: 'user-2' })
|
||||
|
||||
expect(firstConnection?.close).toHaveBeenCalledTimes(1)
|
||||
expect(MockEventSource.instances.length).toBe(2)
|
||||
expect(MockEventSource.instances[1]?.url).toBe('/events?user_id=user-2')
|
||||
})
|
||||
|
||||
it('emits parsed backend events on message', async () => {
|
||||
mount(TestHarness, { props: { userId: 'user-1' } })
|
||||
|
||||
const connection = MockEventSource.instances[0]
|
||||
expect(connection).toBeDefined()
|
||||
|
||||
connection?.onmessage?.({
|
||||
data: JSON.stringify({ type: 'profile_updated', payload: { id: 'user-1' } }),
|
||||
} as MessageEvent)
|
||||
|
||||
expect(emitMock).toHaveBeenCalledWith('profile_updated', {
|
||||
type: 'profile_updated',
|
||||
payload: { id: 'user-1' },
|
||||
})
|
||||
expect(emitMock).toHaveBeenCalledWith('sse', {
|
||||
type: 'profile_updated',
|
||||
payload: { id: 'user-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the event source on unmount', () => {
|
||||
const wrapper = mount(TestHarness, { props: { userId: 'user-1' } })
|
||||
|
||||
const connection = MockEventSource.instances[0]
|
||||
wrapper.unmount()
|
||||
|
||||
expect(connection?.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,43 @@
|
||||
import { logoutUser } from '@/stores/auth'
|
||||
|
||||
let unauthorizedInterceptorInstalled = false
|
||||
let unauthorizedRedirectHandler: (() => void) | null = null
|
||||
let unauthorizedHandlingInProgress = false
|
||||
|
||||
export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | null): void {
|
||||
unauthorizedRedirectHandler = handler
|
||||
}
|
||||
|
||||
function handleUnauthorizedResponse(): void {
|
||||
if (unauthorizedHandlingInProgress) return
|
||||
unauthorizedHandlingInProgress = true
|
||||
logoutUser()
|
||||
if (typeof window === 'undefined') return
|
||||
if (window.location.pathname.startsWith('/auth')) return
|
||||
if (unauthorizedRedirectHandler) {
|
||||
unauthorizedRedirectHandler()
|
||||
return
|
||||
}
|
||||
window.location.assign('/auth')
|
||||
}
|
||||
|
||||
export function installUnauthorizedFetchInterceptor(): void {
|
||||
if (unauthorizedInterceptorInstalled || typeof window === 'undefined') return
|
||||
unauthorizedInterceptorInstalled = true
|
||||
|
||||
const originalFetch = globalThis.fetch.bind(globalThis)
|
||||
const wrappedFetch = (async (...args: Parameters<typeof fetch>) => {
|
||||
const response = await originalFetch(...args)
|
||||
if (response.status === 401) {
|
||||
handleUnauthorizedResponse()
|
||||
}
|
||||
return response
|
||||
}) as typeof fetch
|
||||
|
||||
window.fetch = wrappedFetch as typeof window.fetch
|
||||
globalThis.fetch = wrappedFetch as typeof globalThis.fetch
|
||||
}
|
||||
|
||||
export async function parseErrorResponse(res: Response): Promise<{ msg: string; code?: string }> {
|
||||
try {
|
||||
const data = await res.json()
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface User {
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
token_version: number
|
||||
image_id: string | null
|
||||
marked_for_deletion: boolean
|
||||
marked_for_deletion_at: string | null
|
||||
|
||||
@@ -146,7 +146,7 @@ import {
|
||||
ALREADY_VERIFIED,
|
||||
} from '@/common/errorCodes'
|
||||
import { parseErrorResponse, isEmailValid } from '@/common/api'
|
||||
import { loginUser } from '@/stores/auth'
|
||||
import { loginUser, checkAuth } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -211,6 +211,7 @@ async function submitForm() {
|
||||
}
|
||||
|
||||
loginUser() // <-- set user as logged in
|
||||
await checkAuth() // hydrate currentUserId so SSE reconnects immediately
|
||||
|
||||
await router.push({ path: '/' }).catch(() => (window.location.href = '/'))
|
||||
} catch (err) {
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<p>Enter a new 4–6 digit Parent PIN. This will be required for parent access.</p>
|
||||
<input
|
||||
v-model="pin"
|
||||
@input="handlePinInput"
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
@@ -47,6 +48,7 @@
|
||||
/>
|
||||
<input
|
||||
v-model="pin2"
|
||||
@input="handlePin2Input"
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
@@ -92,6 +94,16 @@ const isPinValid = computed(() => {
|
||||
return /^\d{4,6}$/.test(p1) && /^\d{4,6}$/.test(p2) && p1 === p2
|
||||
})
|
||||
|
||||
function handlePinInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
pin.value = target.value.replace(/\D/g, '').slice(0, 6)
|
||||
}
|
||||
|
||||
function handlePin2Input(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
pin2.value = target.value.replace(/\D/g, '').slice(0, 6)
|
||||
}
|
||||
|
||||
async function requestCode() {
|
||||
error.value = ''
|
||||
info.value = ''
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { isPasswordStrong } from '@/common/api'
|
||||
import { logoutUser } from '@/stores/auth'
|
||||
import ModalDialog from '@/components/shared/ModalDialog.vue'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
@@ -156,7 +157,7 @@ const formValid = computed(
|
||||
onMounted(async () => {
|
||||
// Get token from query string
|
||||
const raw = route.query.token ?? ''
|
||||
token.value = Array.isArray(raw) ? raw[0] : String(raw || '')
|
||||
token.value = (Array.isArray(raw) ? raw[0] : raw) || ''
|
||||
|
||||
// Validate token with backend
|
||||
if (token.value) {
|
||||
@@ -223,6 +224,7 @@ async function submitForm() {
|
||||
return
|
||||
}
|
||||
// Success: Show modal instead of successMsg
|
||||
logoutUser()
|
||||
showModal.value = true
|
||||
password.value = ''
|
||||
confirmPassword.value = ''
|
||||
|
||||
81
frontend/vue-app/src/components/auth/__tests__/Login.spec.ts
Normal file
81
frontend/vue-app/src/components/auth/__tests__/Login.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Login from '../Login.vue'
|
||||
|
||||
const { pushMock, loginUserMock, checkAuthMock } = vi.hoisted(() => ({
|
||||
pushMock: vi.fn(),
|
||||
loginUserMock: vi.fn(),
|
||||
checkAuthMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: vi.fn(() => ({ push: pushMock })),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
loginUser: loginUserMock,
|
||||
checkAuth: checkAuthMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/common/api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/common/api')>('@/common/api')
|
||||
return {
|
||||
...actual,
|
||||
parseErrorResponse: vi.fn(async () => ({
|
||||
msg: 'bad credentials',
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Login.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
checkAuthMock.mockResolvedValue(undefined)
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('hydrates auth state after successful login', async () => {
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response)
|
||||
|
||||
const wrapper = mount(Login)
|
||||
|
||||
await wrapper.get('#email').setValue('test@example.com')
|
||||
await wrapper.get('#password').setValue('secret123')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(loginUserMock).toHaveBeenCalledTimes(1)
|
||||
expect(checkAuthMock).toHaveBeenCalledTimes(1)
|
||||
expect(pushMock).toHaveBeenCalledWith({ path: '/' })
|
||||
|
||||
const checkAuthOrder = checkAuthMock.mock.invocationCallOrder[0]
|
||||
const pushOrder = pushMock.mock.invocationCallOrder[0]
|
||||
expect(checkAuthOrder).toBeDefined()
|
||||
expect(pushOrder).toBeDefined()
|
||||
expect((checkAuthOrder ?? 0) < (pushOrder ?? 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not hydrate auth state when login fails', async () => {
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 401 } as Response)
|
||||
|
||||
const wrapper = mount(Login)
|
||||
|
||||
await wrapper.get('#email').setValue('test@example.com')
|
||||
await wrapper.get('#password').setValue('badpassword')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(loginUserMock).not.toHaveBeenCalled()
|
||||
expect(checkAuthMock).not.toHaveBeenCalled()
|
||||
expect(pushMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@
|
||||
:fields="fields"
|
||||
:initialData="initialData"
|
||||
:isEdit="isEdit"
|
||||
:requireDirty="isEdit"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
@submit="handleSubmit"
|
||||
@@ -16,22 +17,38 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const props = defineProps<{ id?: string }>()
|
||||
const isEdit = computed(() => !!props.id)
|
||||
|
||||
const fields = [
|
||||
type Field = {
|
||||
name: string
|
||||
label: string
|
||||
type: 'text' | 'number' | 'image' | 'custom'
|
||||
required?: boolean
|
||||
maxlength?: number
|
||||
min?: number
|
||||
max?: number
|
||||
imageType?: number
|
||||
}
|
||||
|
||||
type ChildForm = {
|
||||
name: string
|
||||
age: number | null
|
||||
image_id: string | null
|
||||
}
|
||||
|
||||
const fields: Field[] = [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true, maxlength: 64 },
|
||||
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
|
||||
{ name: 'image_id', label: 'Image', type: 'image', imageType: 1 },
|
||||
]
|
||||
|
||||
const initialData = ref({ name: '', age: null, image_id: null })
|
||||
const initialData = ref<ChildForm>({ name: '', age: null, image_id: null })
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
@@ -45,15 +62,31 @@ onMounted(async () => {
|
||||
const data = await resp.json()
|
||||
initialData.value = {
|
||||
name: data.name ?? '',
|
||||
age: Number(data.age) ?? null,
|
||||
age: data.age === null || data.age === undefined ? null : Number(data.age),
|
||||
image_id: data.image_id ?? null,
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
error.value = 'Could not load child.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const resp = await fetch('/api/image/list?type=1')
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
const ids = data.ids || []
|
||||
if (ids.length > 0) {
|
||||
initialData.value = {
|
||||
...initialData.value,
|
||||
image_id: ids[0],
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore default image lookup failures and keep existing behavior.
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -63,7 +96,7 @@ function handleAddImage({ id, file }: { id: string; file: File }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(form: any) {
|
||||
async function handleSubmit(form: ChildForm) {
|
||||
let imageId = form.image_id
|
||||
error.value = null
|
||||
if (!form.name.trim()) {
|
||||
@@ -90,7 +123,7 @@ async function handleSubmit(form: any) {
|
||||
if (!resp.ok) throw new Error('Image upload failed')
|
||||
const data = await resp.json()
|
||||
imageId = data.id
|
||||
} catch (err) {
|
||||
} catch {
|
||||
error.value = 'Failed to upload image.'
|
||||
loading.value = false
|
||||
return
|
||||
@@ -123,7 +156,7 @@ async function handleSubmit(form: any) {
|
||||
}
|
||||
if (!resp.ok) throw new Error('Failed to save child')
|
||||
await router.push({ name: 'ParentChildrenListView' })
|
||||
} catch (err) {
|
||||
} catch {
|
||||
error.value = 'Failed to save child.'
|
||||
}
|
||||
loading.value = false
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
@@ -12,7 +11,6 @@ import type {
|
||||
Child,
|
||||
Event,
|
||||
Task,
|
||||
Reward,
|
||||
RewardStatus,
|
||||
ChildTaskTriggeredEventPayload,
|
||||
ChildRewardTriggeredEventPayload,
|
||||
@@ -32,9 +30,6 @@ const tasks = ref<string[]>([])
|
||||
const rewards = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const showRewardDialog = ref(false)
|
||||
const showCancelDialog = ref(false)
|
||||
const dialogReward = ref<Reward | null>(null)
|
||||
const childRewardListRef = ref()
|
||||
|
||||
function handleTaskTriggered(event: Event) {
|
||||
@@ -179,21 +174,7 @@ const triggerTask = async (task: Task) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger the task via API
|
||||
if (child.value?.id && task.id) {
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/trigger-task`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: task.id }),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
console.error('Failed to trigger task')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error triggering task:', err)
|
||||
}
|
||||
}
|
||||
// Child mode is speech-only; point changes are handled in parent mode.
|
||||
}
|
||||
|
||||
const triggerReward = (reward: RewardStatus) => {
|
||||
@@ -211,60 +192,6 @@ const triggerReward = (reward: RewardStatus) => {
|
||||
utter.volume = 1.0
|
||||
window.speechSynthesis.speak(utter)
|
||||
}
|
||||
|
||||
if (reward.redeeming) {
|
||||
dialogReward.value = reward
|
||||
showCancelDialog.value = true
|
||||
return // Do not allow redeeming if already pending
|
||||
}
|
||||
if (reward.points_needed <= 0) {
|
||||
dialogReward.value = reward
|
||||
showRewardDialog.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelPendingReward() {
|
||||
if (!child.value?.id || !dialogReward.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/cancel-request-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: dialogReward.value.id }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Failed to cancel pending reward')
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel pending reward:', err)
|
||||
} finally {
|
||||
showCancelDialog.value = false
|
||||
dialogReward.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRedeemReward() {
|
||||
showRewardDialog.value = false
|
||||
dialogReward.value = null
|
||||
}
|
||||
|
||||
function closeCancelDialog() {
|
||||
showCancelDialog.value = false
|
||||
dialogReward.value = null
|
||||
}
|
||||
|
||||
async function confirmRedeemReward() {
|
||||
if (!child.value?.id || !dialogReward.value) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${child.value.id}/request-reward`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reward_id: dialogReward.value.id }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
} catch (err) {
|
||||
console.error('Failed to redeem reward:', err)
|
||||
} finally {
|
||||
showRewardDialog.value = false
|
||||
dialogReward.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,35 +391,6 @@ onUnmounted(() => {
|
||||
</ScrollingList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalDialog
|
||||
v-if="showRewardDialog && dialogReward"
|
||||
:imageUrl="dialogReward?.image_url"
|
||||
:title="dialogReward.name"
|
||||
:subtitle="`${dialogReward.cost} pts`"
|
||||
>
|
||||
<div class="modal-message">Would you like to redeem this reward?</div>
|
||||
<div class="modal-actions">
|
||||
<button @click="confirmRedeemReward" class="btn btn-primary">Yes</button>
|
||||
<button @click="cancelRedeemReward" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
|
||||
<ModalDialog
|
||||
v-if="showCancelDialog && dialogReward"
|
||||
:imageUrl="dialogReward?.image_url"
|
||||
:title="dialogReward.name"
|
||||
:subtitle="`${dialogReward.cost} pts`"
|
||||
>
|
||||
<div class="modal-message">
|
||||
This reward is pending.<br />
|
||||
Would you like to cancel the pending reward request?
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
|
||||
<button @click="closeCancelDialog" class="btn btn-secondary">No</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -191,6 +191,16 @@ describe('ChildView', () => {
|
||||
expect(window.speechSynthesis.speak).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call trigger-task API in child mode', async () => {
|
||||
await wrapper.vm.triggerTask(mockChore)
|
||||
|
||||
expect(
|
||||
(global.fetch as any).mock.calls.some((call: [string]) =>
|
||||
call[0].includes('/trigger-task'),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not crash if speechSynthesis is not available', () => {
|
||||
const originalSpeechSynthesis = global.window.speechSynthesis
|
||||
delete (global.window as any).speechSynthesis
|
||||
@@ -202,6 +212,43 @@ describe('ChildView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reward Triggering', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = mount(ChildView)
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
it('speaks reward text when triggered', () => {
|
||||
wrapper.vm.triggerReward({
|
||||
id: 'reward-1',
|
||||
name: 'Ice Cream',
|
||||
cost: 50,
|
||||
points_needed: 10,
|
||||
redeeming: false,
|
||||
})
|
||||
|
||||
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
|
||||
expect(window.speechSynthesis.speak).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call reward request/cancel APIs in child mode', () => {
|
||||
wrapper.vm.triggerReward({
|
||||
id: 'reward-1',
|
||||
name: 'Ice Cream',
|
||||
cost: 50,
|
||||
points_needed: 0,
|
||||
redeeming: false,
|
||||
})
|
||||
|
||||
const requestCalls = (global.fetch as any).mock.calls.filter(
|
||||
(call: [string]) =>
|
||||
call[0].includes('/request-reward') || call[0].includes('/cancel-request-reward'),
|
||||
)
|
||||
expect(requestCalls.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSE Event Handlers', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = mount(ChildView)
|
||||
|
||||
@@ -117,7 +117,6 @@ import '@/assets/styles.css'
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const successMsg = ref('')
|
||||
const resetting = ref(false)
|
||||
const localImageFile = ref<File | null>(null)
|
||||
const showModal = ref(false)
|
||||
@@ -133,14 +132,26 @@ const showDeleteSuccess = ref(false)
|
||||
const showDeleteError = ref(false)
|
||||
const deleteErrorMessage = ref('')
|
||||
|
||||
const initialData = ref({
|
||||
const initialData = ref<{
|
||||
image_id: string | null
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
}>({
|
||||
image_id: null,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
})
|
||||
|
||||
const fields = [
|
||||
const fields: Array<{
|
||||
name: string
|
||||
label: string
|
||||
type: 'image' | 'text' | 'custom'
|
||||
imageType?: number
|
||||
required?: boolean
|
||||
maxlength?: number
|
||||
}> = [
|
||||
{ name: 'image_id', label: 'Image', type: 'image', imageType: 1 },
|
||||
{ name: 'first_name', label: 'First Name', type: 'text', required: true, maxlength: 64 },
|
||||
{ name: 'last_name', label: 'Last Name', type: 'text', required: true, maxlength: 64 },
|
||||
|
||||
@@ -52,7 +52,7 @@ const $router = useRouter()
|
||||
|
||||
const showConfirm = ref(false)
|
||||
const rewardToDelete = ref<string | null>(null)
|
||||
const rewardListRef = ref()
|
||||
const rewardListRef = ref<InstanceType<typeof ItemList> | null>(null)
|
||||
const rewardCountRef = ref<number>(-1)
|
||||
|
||||
function handleRewardModified(event: any) {
|
||||
@@ -75,10 +75,7 @@ function confirmDeleteReward(rewardId: string) {
|
||||
}
|
||||
|
||||
const deleteReward = async () => {
|
||||
const id =
|
||||
typeof rewardToDelete.value === 'object' && rewardToDelete.value !== null
|
||||
? rewardToDelete.value.id
|
||||
: rewardToDelete.value
|
||||
const id = rewardToDelete.value
|
||||
if (!id) return
|
||||
try {
|
||||
const resp = await fetch(`/api/reward/${id}`, {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<slot
|
||||
:name="`custom-field-${field.name}`"
|
||||
:modelValue="formData[field.name]"
|
||||
:update="(val) => (formData[field.name] = val)"
|
||||
:update="(val: unknown) => (formData[field.name] = val)"
|
||||
>
|
||||
<!-- Default rendering if no slot provided -->
|
||||
<input
|
||||
@@ -39,7 +39,11 @@
|
||||
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading || !isDirty || !isValid">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="loading || !isValid || (props.requireDirty && !isDirty)"
|
||||
>
|
||||
{{ isEdit ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -63,34 +67,31 @@ type Field = {
|
||||
imageType?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
entityLabel: string
|
||||
fields: Field[]
|
||||
initialData?: Record<string, any>
|
||||
isEdit?: boolean
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
title?: string
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
entityLabel: string
|
||||
fields: Field[]
|
||||
initialData?: Record<string, any>
|
||||
isEdit?: boolean
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
title?: string
|
||||
requireDirty?: boolean
|
||||
}>(),
|
||||
{
|
||||
requireDirty: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel', 'add-image'])
|
||||
|
||||
const router = useRouter()
|
||||
const formData = ref<Record<string, any>>({ ...props.initialData })
|
||||
|
||||
watch(
|
||||
() => props.initialData,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
formData.value = { ...newVal }
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
const baselineData = ref<Record<string, any>>({ ...props.initialData })
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
// Optionally focus first input
|
||||
isDirty.value = false
|
||||
})
|
||||
|
||||
function onAddImage({ id, file }: { id: string; file: File }) {
|
||||
@@ -109,14 +110,36 @@ function submit() {
|
||||
|
||||
// Editable field names (exclude custom fields that are not editable)
|
||||
const editableFieldNames = props.fields
|
||||
.filter((f) => f.type !== 'custom' || f.name === 'is_good' || f.type === 'image')
|
||||
.filter((f) => f.type !== 'custom' || f.name === 'is_good')
|
||||
.map((f) => f.name)
|
||||
|
||||
const isDirty = ref(false)
|
||||
|
||||
function getFieldByName(name: string): Field | undefined {
|
||||
return props.fields.find((field) => field.name === name)
|
||||
}
|
||||
|
||||
function valuesEqualForDirtyCheck(
|
||||
fieldName: string,
|
||||
currentValue: unknown,
|
||||
initialValue: unknown,
|
||||
): boolean {
|
||||
const field = getFieldByName(fieldName)
|
||||
|
||||
if (field?.type === 'number') {
|
||||
const currentEmpty = currentValue === '' || currentValue === null || currentValue === undefined
|
||||
const initialEmpty = initialValue === '' || initialValue === null || initialValue === undefined
|
||||
if (currentEmpty && initialEmpty) return true
|
||||
if (currentEmpty !== initialEmpty) return false
|
||||
return Number(currentValue) === Number(initialValue)
|
||||
}
|
||||
|
||||
return JSON.stringify(currentValue) === JSON.stringify(initialValue)
|
||||
}
|
||||
|
||||
function checkDirty() {
|
||||
isDirty.value = editableFieldNames.some((key) => {
|
||||
return JSON.stringify(formData.value[key]) !== JSON.stringify(props.initialData?.[key])
|
||||
return !valuesEqualForDirtyCheck(key, formData.value[key], baselineData.value[key])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,6 +154,7 @@ const isValid = computed(() => {
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
if (value === '' || value === null || value === undefined) return false
|
||||
const numValue = Number(value)
|
||||
if (isNaN(numValue)) return false
|
||||
if (field.min !== undefined && numValue < field.min) return false
|
||||
@@ -145,8 +169,7 @@ const isValid = computed(() => {
|
||||
|
||||
watch(
|
||||
() => ({ ...formData.value }),
|
||||
(newVal) => {
|
||||
console.log('formData changed:', newVal)
|
||||
() => {
|
||||
checkDirty()
|
||||
},
|
||||
{ deep: true },
|
||||
@@ -157,7 +180,8 @@ watch(
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
formData.value = { ...newVal }
|
||||
checkDirty()
|
||||
baselineData.value = { ...newVal }
|
||||
isDirty.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
|
||||
@@ -137,6 +137,11 @@ const submit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
function handlePinInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
pin.value = target.value.replace(/\D/g, '').slice(0, 6)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutParent()
|
||||
router.push('/child')
|
||||
@@ -357,6 +362,7 @@ onUnmounted(() => {
|
||||
<input
|
||||
ref="pinInput"
|
||||
v-model="pin"
|
||||
@input="handlePinInput"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
maxlength="6"
|
||||
@@ -365,7 +371,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
<div class="actions modal-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">OK</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="pin.length < 4">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="error" class="error modal-message">{{ error }}</div>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import EntityEditForm from '../EntityEditForm.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
back: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('EntityEditForm', () => {
|
||||
it('keeps Create disabled when required number field is empty', async () => {
|
||||
const wrapper = mount(EntityEditForm, {
|
||||
props: {
|
||||
entityLabel: 'Child',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
|
||||
],
|
||||
initialData: {
|
||||
name: '',
|
||||
age: null,
|
||||
},
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
requireDirty: false,
|
||||
},
|
||||
})
|
||||
|
||||
const nameInput = wrapper.find('#name')
|
||||
const ageInput = wrapper.find('#age')
|
||||
|
||||
await nameInput.setValue('Sam')
|
||||
await ageInput.setValue('')
|
||||
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
expect(submitButton.text()).toBe('Create')
|
||||
expect((submitButton.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables Create when required Name and Age are both valid', async () => {
|
||||
const wrapper = mount(EntityEditForm, {
|
||||
props: {
|
||||
entityLabel: 'Child',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
|
||||
],
|
||||
initialData: {
|
||||
name: '',
|
||||
age: null,
|
||||
},
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
requireDirty: false,
|
||||
},
|
||||
})
|
||||
|
||||
const nameInput = wrapper.find('#name')
|
||||
const ageInput = wrapper.find('#age')
|
||||
|
||||
await nameInput.setValue('Sam')
|
||||
await ageInput.setValue('8')
|
||||
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
expect(submitButton.text()).toBe('Create')
|
||||
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits(['update:modelValue', 'add-image'])
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const imageScrollRef = ref<HTMLDivElement | null>(null)
|
||||
const localImageUrl = ref<string | null>(null)
|
||||
const showCamera = ref(false)
|
||||
const cameraStream = ref<MediaStream | null>(null)
|
||||
@@ -198,6 +199,13 @@ function updateLocalImage(url: string, file: File) {
|
||||
} else {
|
||||
availableImages.value[idx].url = url
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
if (imageScrollRef.value) {
|
||||
imageScrollRef.value.scrollLeft = 0
|
||||
}
|
||||
})
|
||||
|
||||
emit('add-image', { id: 'local-upload', url, file })
|
||||
emit('update:modelValue', 'local-upload')
|
||||
}
|
||||
@@ -205,7 +213,7 @@ function updateLocalImage(url: string, file: File) {
|
||||
|
||||
<template>
|
||||
<div class="picker">
|
||||
<div class="image-scroll">
|
||||
<div ref="imageScrollRef" class="image-scroll">
|
||||
<div v-if="loadingImages" class="loading-images">Loading images...</div>
|
||||
<div v-else class="image-list">
|
||||
<img
|
||||
|
||||
@@ -2,9 +2,14 @@ import '@/assets/colors.css'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { initAuthSync } from './stores/auth'
|
||||
import { installUnauthorizedFetchInterceptor } from './common/api'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
initAuthSync()
|
||||
installUnauthorizedFetchInterceptor()
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { isParentAuthenticated, loginUser } from '../auth'
|
||||
import { isParentAuthenticated, isUserLoggedIn, loginUser, initAuthSync } from '../auth'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
// Helper to mock localStorage
|
||||
@@ -30,4 +30,20 @@ describe('auth store - child mode on login', () => {
|
||||
await nextTick() // flush Vue watcher
|
||||
expect(isParentAuthenticated.value).toBe(false)
|
||||
})
|
||||
|
||||
it('logs out on cross-tab storage logout event', async () => {
|
||||
initAuthSync()
|
||||
isUserLoggedIn.value = true
|
||||
isParentAuthenticated.value = true
|
||||
|
||||
const logoutEvent = new StorageEvent('storage', {
|
||||
key: 'authSyncEvent',
|
||||
newValue: JSON.stringify({ type: 'logout', at: Date.now() }),
|
||||
})
|
||||
window.dispatchEvent(logoutEvent)
|
||||
|
||||
await nextTick()
|
||||
expect(isUserLoggedIn.value).toBe(false)
|
||||
expect(isParentAuthenticated.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ref, watch } from 'vue'
|
||||
|
||||
const hasLocalStorage =
|
||||
typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function'
|
||||
const AUTH_SYNC_EVENT_KEY = 'authSyncEvent'
|
||||
|
||||
export const isParentAuthenticated = ref(
|
||||
hasLocalStorage ? localStorage.getItem('isParentAuthenticated') === 'true' : false,
|
||||
@@ -9,6 +10,7 @@ export const isParentAuthenticated = ref(
|
||||
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') {
|
||||
@@ -33,12 +35,42 @@ export function loginUser() {
|
||||
isParentAuthenticated.value = false
|
||||
}
|
||||
|
||||
export function logoutUser() {
|
||||
function applyLoggedOutState() {
|
||||
isUserLoggedIn.value = false
|
||||
currentUserId.value = ''
|
||||
logoutParent()
|
||||
}
|
||||
|
||||
function broadcastLogoutEvent() {
|
||||
if (!hasLocalStorage || typeof localStorage.setItem !== 'function') return
|
||||
localStorage.setItem(AUTH_SYNC_EVENT_KEY, JSON.stringify({ type: 'logout', at: Date.now() }))
|
||||
}
|
||||
|
||||
export function logoutUser() {
|
||||
applyLoggedOutState()
|
||||
broadcastLogoutEvent()
|
||||
}
|
||||
|
||||
export function initAuthSync() {
|
||||
if (authSyncInitialized || typeof window === 'undefined') return
|
||||
authSyncInitialized = true
|
||||
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key !== AUTH_SYNC_EVENT_KEY || !event.newValue) return
|
||||
try {
|
||||
const payload = JSON.parse(event.newValue)
|
||||
if (payload?.type === 'logout') {
|
||||
applyLoggedOutState()
|
||||
if (!window.location.pathname.startsWith('/auth')) {
|
||||
window.location.href = '/auth/login'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed sync events.
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', { method: 'GET' })
|
||||
@@ -47,12 +79,10 @@ export async function checkAuth() {
|
||||
currentUserId.value = data.id
|
||||
isUserLoggedIn.value = true
|
||||
} else {
|
||||
isUserLoggedIn.value = false
|
||||
currentUserId.value = ''
|
||||
logoutUser()
|
||||
}
|
||||
} catch {
|
||||
isUserLoggedIn.value = false
|
||||
currentUserId.value = ''
|
||||
logoutUser()
|
||||
}
|
||||
isAuthReady.value = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user