1 Commits

Author SHA1 Message Date
d600dde97f wip 2026-02-16 18:04:00 -05:00
71 changed files with 697 additions and 2512 deletions

View File

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

View File

@@ -1,5 +1,5 @@
name: Chore App Build, Test, and Push Docker Images
run-name: ${{ gitea.actor }} is building Chores [${{ gitea.ref_name }}@${{ gitea.sha }}] 🚀
name: Chore App Build and Push Docker Images
run-name: ${{ gitea.actor }} is building the chore app 🚀
on:
push:
branches:
@@ -24,56 +24,26 @@ jobs:
echo "tag=next-$version-$current_date" >> $GITHUB_OUTPUT
fi
- name: Set up Python for backend tests
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install backend dependencies
run: |
python -m pip install --upgrade pip
pip install -r backend/requirements.txt
- name: Run backend unit tests
run: |
cd backend
pytest -q
- name: Set up Node.js for frontend tests
uses: actions/setup-node@v4
with:
node-version: "20.19.0"
cache: "npm"
cache-dependency-path: frontend/vue-app/package-lock.json
- name: Install frontend dependencies
run: npm ci
working-directory: frontend/vue-app
- name: Run frontend unit tests
run: npm run test:unit --if-present
working-directory: frontend/vue-app
- name: Build Backend Docker Image
run: |
docker build -t git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} ./backend
docker build -t git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} ./backend
- name: Build Frontend Docker Image
run: |
docker build -t git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app
docker build -t git.ryankegel.com:3000/ryan/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: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
username: ryan #${{ secrets.REGISTRY_USERNAME }} # Stored as a Gitea secret
password: 0x013h #${{ secrets.REGISTRY_TOKEN }} # Stored as a Gitea secret (use a PAT here)
- 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/kegel/chores/backend:${{ steps.vars.outputs.tag }}; then
if docker push git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }}; then
echo "Backend push succeeded on attempt $i"
break
else
@@ -86,18 +56,18 @@ jobs:
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
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
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
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
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
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
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/kegel/chores/frontend:${{ steps.vars.outputs.tag }}; then
if docker push git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }}; then
echo "Frontend push succeeded on attempt $i"
break
else
@@ -110,15 +80,14 @@ jobs:
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
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
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
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
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
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
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

@@ -1,141 +0,0 @@
# 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.

View File

@@ -1,49 +0,0 @@
# 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,4 +1,3 @@
.env
backend/test_data/db/children.json
backend/test_data/db/images.json
backend/test_data/db/pending_rewards.json

View File

@@ -164,10 +164,6 @@ 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.

View File

@@ -162,8 +162,7 @@ def login():
payload = {
'email': norm_email,
'user_id': user.id,
'token_version': user.token_version,
'exp': datetime.utcnow() + timedelta(days=62)
'exp': datetime.utcnow() + timedelta(hours=24*7)
}
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
@@ -180,13 +179,10 @@ 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({
@@ -272,12 +268,9 @@ 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)
resp = jsonify({'message': 'Password has been reset'})
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
return resp, 200
return jsonify({'message': 'Password has been reset'}), 200
@auth_api.route('/logout', methods=['POST'])
def logout():

View File

@@ -64,24 +64,10 @@ def list_tasks():
continue # Skip default if user version exists
filtered_tasks.append(t)
# 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)
# 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
return jsonify({'tasks': sorted_tasks}), 200

View File

@@ -231,13 +231,6 @@ def mark_for_deletion():
# Mark for deletion
user.marked_for_deletion = True
user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat()
# Invalidate any outstanding verification/reset tokens so they cannot be used after marking
user.verify_token = None
user.verify_token_created = None
user.reset_token = None
user.reset_token_created = None
users_db.update(user.to_dict(), UserQuery.id == user.id)
# Trigger SSE event

View File

@@ -29,12 +29,6 @@ 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

View File

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

View File

@@ -33,14 +33,13 @@ logger = logging.getLogger(__name__)
app = Flask(__name__)
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
#Todo - add prefix to all these routes instead of in each blueprint
app.register_blueprint(admin_api)
app.register_blueprint(child_api)
app.register_blueprint(child_override_api)
app.register_blueprint(reward_api)
app.register_blueprint(task_api)
app.register_blueprint(image_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(auth_api)
app.register_blueprint(user_api)
app.register_blueprint(tracking_api)

View File

@@ -21,7 +21,6 @@ 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):
@@ -44,7 +43,6 @@ 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')
@@ -71,7 +69,6 @@ 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,
'token_version': self.token_version,
'role': self.role
})
return base

View File

@@ -100,38 +100,6 @@ 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

View File

@@ -29,7 +29,7 @@ def add_test_user():
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
resp = client.post('/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
# Set cookie for subsequent requests
token = resp.headers.get("Set-Cookie")
@@ -40,7 +40,7 @@ def login_and_set_cookie(client):
def client():
app = Flask(__name__)
app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(auth_api)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client:

View File

@@ -46,7 +46,7 @@ def add_test_user():
def login_and_set_cookie(client):
"""Login and set authentication cookie."""
resp = client.post('/auth/login', json={
resp = client.post('/login', json={
"email": TEST_EMAIL,
"password": TEST_PASSWORD
})
@@ -59,7 +59,7 @@ def client():
app = Flask(__name__)
app.register_blueprint(child_override_api)
app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(auth_api)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'

View File

@@ -36,7 +36,7 @@ def add_test_user():
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
resp = client.post('/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
token = resp.headers.get("Set-Cookie")
assert token and "token=" in token
@@ -65,7 +65,7 @@ def remove_test_data():
def client():
app = Flask(__name__)
app.register_blueprint(image_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(auth_api)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as c:

View File

@@ -28,7 +28,7 @@ def add_test_user():
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
resp = client.post('/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
token = resp.headers.get("Set-Cookie")
assert token and "token=" in token
@@ -37,7 +37,7 @@ def login_and_set_cookie(client):
def client():
app = Flask(__name__)
app.register_blueprint(reward_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(auth_api)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client:

View File

@@ -27,7 +27,7 @@ def add_test_user():
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
resp = client.post('/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
token = resp.headers.get("Set-Cookie")
assert token and "token=" in token
@@ -36,7 +36,7 @@ def login_and_set_cookie(client):
def client():
app = Flask(__name__)
app.register_blueprint(task_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(auth_api)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client:
@@ -80,36 +80,6 @@ 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

View File

@@ -48,7 +48,7 @@ def add_test_users():
def login_and_get_token(client, email, password):
"""Login and extract JWT token from response."""
resp = client.post('/auth/login', json={"email": email, "password": password})
resp = client.post('/login', json={"email": email, "password": password})
assert resp.status_code == 200
# Extract token from Set-Cookie header
set_cookie = resp.headers.get("Set-Cookie")
@@ -61,7 +61,7 @@ def client():
"""Setup Flask test client with registered blueprints."""
app = Flask(__name__)
app.register_blueprint(user_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(auth_api)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
app.config['FRONTEND_URL'] = 'http://localhost:5173' # Needed for email_sender
@@ -100,7 +100,7 @@ def test_mark_user_for_deletion_success(authenticated_client):
def test_login_for_marked_user_returns_403(client):
"""Test that login for a marked-for-deletion user returns 403 Forbidden."""
response = client.post('/auth/login', json={
response = client.post('/login', json={
"email": MARKED_EMAIL,
"password": MARKED_PASSWORD
})
@@ -118,7 +118,7 @@ def test_mark_for_deletion_requires_auth(client):
def test_login_blocked_for_marked_user(client):
"""Test that login is blocked for users marked for deletion."""
response = client.post('/auth/login', json={
response = client.post('/login', json={
"email": MARKED_EMAIL,
"password": MARKED_PASSWORD
})
@@ -129,7 +129,7 @@ def test_login_blocked_for_marked_user(client):
def test_login_succeeds_for_unmarked_user(client):
"""Test that login works normally for users not marked for deletion."""
response = client.post('/auth/login', json={
response = client.post('/login', json={
"email": TEST_EMAIL,
"password": TEST_PASSWORD
})
@@ -139,7 +139,7 @@ def test_login_succeeds_for_unmarked_user(client):
def test_password_reset_ignored_for_marked_user(client):
"""Test that password reset requests return 403 for marked users."""
response = client.post('/auth/request-password-reset', json={"email": MARKED_EMAIL})
response = client.post('/request-password-reset', json={"email": MARKED_EMAIL})
assert response.status_code == 403
data = response.get_json()
assert 'error' in data
@@ -147,7 +147,7 @@ def test_password_reset_ignored_for_marked_user(client):
def test_password_reset_works_for_unmarked_user(client):
"""Test that password reset works normally for unmarked users."""
response = client.post('/auth/request-password-reset', json={"email": TEST_EMAIL})
response = client.post('/request-password-reset', json={"email": TEST_EMAIL})
assert response.status_code == 200
data = response.get_json()
assert 'message' in data
@@ -168,35 +168,6 @@ def test_mark_for_deletion_updates_timestamp(authenticated_client):
assert before_time <= marked_at <= after_time
def test_mark_for_deletion_clears_tokens(authenticated_client):
"""When an account is marked for deletion, verify/reset tokens must be cleared."""
# Seed verify/reset tokens for the user
UserQuery = Query()
now_iso = datetime.utcnow().isoformat()
users_db.update({
'verify_token': 'verify-abc',
'verify_token_created': now_iso,
'reset_token': 'reset-xyz',
'reset_token_created': now_iso
}, UserQuery.email == TEST_EMAIL)
# Ensure tokens are present before marking
user_before = users_db.search(UserQuery.email == TEST_EMAIL)[0]
assert user_before['verify_token'] is not None
assert user_before['reset_token'] is not None
# Mark account for deletion
response = authenticated_client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
assert response.status_code == 200
# Verify tokens were cleared in the DB
user_after = users_db.search(UserQuery.email == TEST_EMAIL)[0]
assert user_after.get('verify_token') is None
assert user_after.get('verify_token_created') is None
assert user_after.get('reset_token') is None
assert user_after.get('reset_token_created') is None
def test_mark_for_deletion_with_invalid_jwt(client):
"""Test marking for deletion with invalid JWT token."""
# Set invalid cookie manually

View File

@@ -2,8 +2,8 @@
version: "3.8"
services:
chores-test-app-backend: # Test backend service name
image: git.ryankegel.com:3000/kegel/chores/backend:next # Use latest next tag
chore-test-app-backend: # Test backend service name
image: git.ryankegel.com:3000/ryan/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
chores-test-app-frontend: # Test frontend service name
image: git.ryankegel.com:3000/kegel/chores/frontend:next # Use latest next tag
chore-test-app-frontend: # Test frontend service name
image: git.ryankegel.com:3000/ryan/frontend:next # Use latest next tag
ports:
- "446:443" # Host 446 -> Container 443 (HTTPS)
environment:
- BACKEND_HOST=chores-test-app-backend # Points to internal backend service
- BACKEND_HOST=chore-test-app-backend # Points to internal backend service
depends_on:
- chores-test-app-backend
- chore-test-app-backend
# Add volumes, networks, etc., as needed
networks:
chores-test-app-net:
chore-test-app-net:
driver: bridge
volumes:
chores-test-app-backend-data: {}
chore-test-app-backend-data: {}

View File

@@ -2,36 +2,35 @@
version: "3.8"
services:
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
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
ports:
- "5001:5000" # Host 5001 -> Container 5000
environment:
- FLASK_ENV=production
- FRONTEND_URL=${FRONTEND_URL}
volumes:
- chores-app-backend-data:/app/data # Assuming backend data storage; adjust path as needed
- chore-app-backend-data:/app/data # Assuming backend data storage; adjust path as needed
networks:
- chores-app-net
- chore-app-net
# Add other volumes, networks, etc., as needed
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
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
ports:
- "443:443" # Host 443 -> Container 443 (HTTPS)
environment:
- BACKEND_HOST=chores-app-backend # Points to internal backend service
- BACKEND_HOST=chore-app-backend # Points to internal backend service
depends_on:
- chores-app-backend
- chore-app-backend
networks:
- chores-app-net
- chore-app-net
# Add volumes, networks, etc., as needed
networks:
chores-app-net:
chore-app-net:
driver: bridge
volumes:
chores-app-backend-data: {}
chore-app-backend-data: {}

View File

@@ -1,258 +0,0 @@
# 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

View File

@@ -111,7 +111,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -664,7 +663,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -708,7 +706,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1958,7 +1955,6 @@
"integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.4",
"@typescript-eslint/types": "8.46.4",
@@ -2714,7 +2710,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2909,7 +2904,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -3415,7 +3409,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3476,7 +3469,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -3524,7 +3516,6 @@
"integrity": "sha512-SbR9ZBUFKgvWAbq3RrdCtWaW0IKm6wwUiApxf3BVTNfqUIo4IQQmreMg2iHFJJ6C/0wss3LXURBJ1OwS/MhFcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0",
@@ -4204,7 +4195,6 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -4982,7 +4972,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -5553,7 +5542,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5692,7 +5680,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5816,7 +5803,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -6037,7 +6023,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6051,7 +6036,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -6144,7 +6128,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.24",
"@vue/compiler-sfc": "3.5.24",

View File

@@ -10,12 +10,14 @@ describe('ItemList.vue', () => {
it('does not show delete button for system items', async () => {
const wrapper = mount(ItemList, {
props: {
fetchUrl: '',
itemKey: 'items',
itemFields: ['name'],
deletable: true,
testItems: [systemItem],
},
global: {
stubs: ['svg'],
},
})
await flushPromises()
expect(wrapper.find('.delete-btn').exists()).toBe(false)
@@ -24,12 +26,14 @@ describe('ItemList.vue', () => {
it('shows delete button for user items', async () => {
const wrapper = mount(ItemList, {
props: {
fetchUrl: '',
itemKey: 'items',
itemFields: ['name'],
deletable: true,
testItems: [userItem],
},
global: {
stubs: ['svg'],
},
})
await flushPromises()
expect(wrapper.find('.delete-btn').exists()).toBe(true)

View File

@@ -11,8 +11,8 @@ global.fetch = vi.fn()
const mockRouter = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/auth/login', name: 'Login', component: { template: '<div />' } },
{ path: '/profile', name: 'UserProfile', component: { template: '<div />' } },
{ path: '/auth/login', name: 'Login' },
{ path: '/profile', name: 'UserProfile' },
],
})

View File

@@ -85,12 +85,6 @@
pointer-events: none;
color: var(--btn-primary);
}
@media (max-width: 520px) {
.btn-link {
margin-top: 1rem;
margin-bottom: 1rem;
}
}
/* Rounded button */
.round-btn {

View File

@@ -1,94 +0,0 @@
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()
})
})

View File

@@ -1,106 +0,0 @@
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)
})
})

View File

@@ -1,43 +1,3 @@
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()

View File

@@ -8,6 +8,7 @@ export function useBackendEvents(userId: Ref<string>) {
const connect = () => {
if (eventSource) eventSource.close()
if (userId.value) {
console.log('Connecting to backend events for user:', userId.value)
eventSource = new EventSource(`/events?user_id=${userId.value}`)
eventSource.onmessage = (event) => {
@@ -23,6 +24,7 @@ export function useBackendEvents(userId: Ref<string>) {
onMounted(connect)
watch(userId, connect)
onBeforeUnmount(() => {
console.log('Disconnecting from backend events for user:', userId.value)
eventSource?.close()
})
}

View File

@@ -13,7 +13,6 @@ 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

View File

@@ -0,0 +1,205 @@
<template>
<ModalDialog v-if="isOpen" @backdrop-click="$emit('close')">
<div class="override-edit-modal">
<h3>Edit {{ entityName }}</h3>
<div class="modal-body">
<label :for="`override-input-${entityId}`">
{{ entityType === 'task' ? 'New Points' : 'New Cost' }}:
</label>
<input
:id="`override-input-${entityId}`"
v-model.number="inputValue"
type="number"
min="0"
max="10000"
:disabled="loading"
@input="validateInput"
/>
<div v-if="errorMessage" class="error-message">{{ errorMessage }}</div>
<div class="default-hint">Default: {{ defaultValue }}</div>
</div>
<div class="modal-actions">
<button @click="$emit('close')" :disabled="loading" class="btn-secondary">Cancel</button>
<button @click="save" :disabled="!isValid || loading" class="btn-primary">Save</button>
</div>
</div>
</ModalDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import ModalDialog from './shared/ModalDialog.vue'
import { setChildOverride, parseErrorResponse } from '@/common/api'
const props = defineProps<{
isOpen: boolean
childId: string
entityId: string
entityType: 'task' | 'reward'
entityName: string
defaultValue: number
currentOverride?: number
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const inputValue = ref<number>(0)
const errorMessage = ref<string>('')
const isValid = ref<boolean>(true)
const loading = ref<boolean>(false)
// Initialize input value when modal opens
watch(
() => props.isOpen,
(newVal) => {
if (newVal) {
inputValue.value = props.currentOverride ?? props.defaultValue
validateInput()
}
},
{ immediate: true },
)
function validateInput() {
const value = inputValue.value
if (value === null || value === undefined || isNaN(value)) {
errorMessage.value = 'Please enter a valid number'
isValid.value = false
return
}
if (value < 0 || value > 10000) {
errorMessage.value = 'Value must be between 0 and 10000'
isValid.value = false
return
}
errorMessage.value = ''
isValid.value = true
}
async function save() {
if (!isValid.value) {
return
}
loading.value = true
try {
const response = await setChildOverride(
props.childId,
props.entityId,
props.entityType,
inputValue.value,
)
if (!response.ok) {
const { msg } = parseErrorResponse(response)
alert(`Error: ${msg}`)
loading.value = false
return
}
emit('saved')
emit('close')
} catch (error) {
alert(`Error: ${error}`)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.override-edit-modal {
background: var(--modal-bg);
padding: var(--spacing-md);
border-radius: var(--border-radius-md);
min-width: 300px;
}
.override-edit-modal h3 {
margin: 0 0 var(--spacing-md) 0;
color: var(--text-primary);
font-size: var(--font-size-lg);
}
.modal-body {
margin-bottom: var(--spacing-md);
}
.modal-body label {
display: block;
margin-bottom: var(--spacing-xs);
color: var(--text-secondary);
font-weight: 500;
}
.modal-body input[type='number'] {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-md);
box-sizing: border-box;
}
.modal-body input[type='number']:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
color: var(--error-color);
font-size: var(--font-size-sm);
margin-top: var(--spacing-xs);
}
.default-hint {
color: var(--text-muted);
font-size: var(--font-size-sm);
margin-top: var(--spacing-xs);
}
.modal-actions {
display: flex;
gap: var(--spacing-sm);
justify-content: flex-end;
}
.modal-actions button {
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--border-radius-sm);
font-size: var(--font-size-md);
cursor: pointer;
transition: opacity 0.2s;
}
.modal-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--btn-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-secondary {
background: var(--btn-secondary);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
opacity: 0.8;
}
</style>

View File

@@ -11,7 +11,6 @@ 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

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import OverrideEditModal from '../OverrideEditModal.vue'
// Mock API functions
vi.mock('@/common/api', () => ({
setChildOverride: vi.fn(),
parseErrorResponse: vi.fn(() => ({ msg: 'Test error', code: 'TEST_ERROR' })),
}))
import { setChildOverride } from '@/common/api'
global.alert = vi.fn()
describe('OverrideEditModal', () => {
let wrapper: VueWrapper<any>
const defaultProps = {
isOpen: true,
childId: 'child-123',
entityId: 'task-456',
entityType: 'task' as 'task' | 'reward',
entityName: 'Test Task',
defaultValue: 100,
currentOverride: undefined,
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('Modal Display', () => {
it('renders when isOpen is true', () => {
wrapper = mount(OverrideEditModal, { props: defaultProps })
expect(wrapper.find('.modal-backdrop').exists()).toBe(true)
expect(wrapper.text()).toContain('Test Task')
})
it('does not render when isOpen is false', () => {
wrapper = mount(OverrideEditModal, { props: { ...defaultProps, isOpen: false } })
expect(wrapper.find('.modal-backdrop').exists()).toBe(false)
})
it('displays entity information correctly for tasks', () => {
wrapper = mount(OverrideEditModal, { props: defaultProps })
expect(wrapper.text()).toContain('Test Task')
expect(wrapper.text()).toContain('New Points')
})
it('displays entity information correctly for rewards', () => {
wrapper = mount(OverrideEditModal, {
props: { ...defaultProps, entityType: 'reward', entityName: 'Test Reward' },
})
expect(wrapper.text()).toContain('Test Reward')
expect(wrapper.text()).toContain('New Cost')
})
})
describe('Input Validation', () => {
it('initializes with default value when no override exists', async () => {
wrapper = mount(OverrideEditModal, { props: defaultProps })
await nextTick()
const input = wrapper.find('input[type="number"]')
expect((input.element as HTMLInputElement).value).toBe('100')
})
it('initializes with current override value when it exists', async () => {
wrapper = mount(OverrideEditModal, {
props: { ...defaultProps, currentOverride: 150 },
})
await nextTick()
const input = wrapper.find('input[type="number"]')
expect((input.element as HTMLInputElement).value).toBe('150')
})
it('validates input within range (0-10000)', async () => {
wrapper = mount(OverrideEditModal, { props: defaultProps })
const input = wrapper.find('input[type="number"]')
const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save')
// Valid value
await input.setValue(5000)
await nextTick()
expect(saveButton?.attributes('disabled')).toBeUndefined()
// Zero is valid
await input.setValue(0)
await nextTick()
expect(saveButton?.attributes('disabled')).toBeUndefined()
// Max is valid
await input.setValue(10000)
await nextTick()
expect(saveButton?.attributes('disabled')).toBeUndefined()
})
it('shows error for values outside range', async () => {
wrapper = mount(OverrideEditModal, { props: defaultProps })
const input = wrapper.find('input[type="number"]')
// Above max
await input.setValue(10001)
await nextTick()
expect(wrapper.text()).toContain('Value must be between 0 and 10000')
const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save')
expect(saveButton?.attributes('disabled')).toBeDefined()
})
})
describe('User Interactions', () => {
it('emits close event when Cancel is clicked', async () => {
wrapper = mount(OverrideEditModal, { props: defaultProps })
const cancelButton = wrapper.findAll('button').find((btn) => btn.text() === 'Cancel')
await cancelButton?.trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
it('emits close event when clicking backdrop', async () => {
wrapper = mount(OverrideEditModal, { props: defaultProps })
await wrapper.find('.modal-backdrop').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
it('does not close when clicking modal dialog', async () => {
wrapper = mount(OverrideEditModal, { props: defaultProps })
await wrapper.find('.modal-dialog').trigger('click')
expect(wrapper.emitted('close')).toBeFalsy()
})
it('calls API and emits events on successful save', async () => {
;(setChildOverride as any).mockResolvedValue({ ok: true })
wrapper = mount(OverrideEditModal, { props: defaultProps })
const input = wrapper.find('input[type="number"]')
await input.setValue(250)
await nextTick()
const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save')
await saveButton?.trigger('click')
await nextTick()
expect(setChildOverride).toHaveBeenCalledWith('child-123', 'task-456', 'task', 250)
expect(wrapper.emitted('saved')).toBeTruthy()
expect(wrapper.emitted('close')).toBeTruthy()
})
it('shows alert on API error', async () => {
;(setChildOverride as any).mockResolvedValue({ ok: false, status: 400 })
wrapper = mount(OverrideEditModal, { props: defaultProps })
const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save')
await saveButton?.trigger('click')
await nextTick()
expect(global.alert).toHaveBeenCalledWith('Error: Test error')
expect(wrapper.emitted('saved')).toBeFalsy()
})
it('does not save when validation fails', async () => {
wrapper = mount(OverrideEditModal, { props: defaultProps })
const input = wrapper.find('input[type="number"]')
await input.setValue(20000)
await nextTick()
const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save')
await saveButton?.trigger('click')
await nextTick()
expect(setChildOverride).not.toHaveBeenCalled()
})
})
describe('Modal State Updates', () => {
it('reinitializes value when modal reopens', async () => {
wrapper = mount(OverrideEditModal, { props: { ...defaultProps, isOpen: false } })
await nextTick()
await wrapper.setProps({ isOpen: true })
await nextTick()
const input = wrapper.find('input[type="number"]')
expect((input.element as HTMLInputElement).value).toBe('100')
})
it('uses updated currentOverride when modal reopens', async () => {
wrapper = mount(OverrideEditModal, {
props: { ...defaultProps, isOpen: true, currentOverride: 200 },
})
await nextTick()
await wrapper.setProps({ isOpen: false })
await nextTick()
await wrapper.setProps({ isOpen: true, currentOverride: 300 })
await nextTick()
const input = wrapper.find('input[type="number"]')
expect((input.element as HTMLInputElement).value).toBe('300')
})
})
})

View File

@@ -103,7 +103,7 @@ async function submitForm() {
if (!isFormValid.value) return
loading.value = true
try {
const res = await fetch('/api/auth/request-password-reset', {
const res = await fetch('/api/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value.trim() }),

View File

@@ -146,7 +146,7 @@ import {
ALREADY_VERIFIED,
} from '@/common/errorCodes'
import { parseErrorResponse, isEmailValid } from '@/common/api'
import { loginUser, checkAuth } from '@/stores/auth'
import { loginUser } from '@/stores/auth'
const router = useRouter()
@@ -176,7 +176,7 @@ async function submitForm() {
if (loading.value) return
loading.value = true
try {
const res = await fetch('/api/auth/login', {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value.trim(), password: password.value }),
@@ -211,7 +211,6 @@ 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) {
@@ -231,7 +230,7 @@ async function resendVerification() {
}
resendLoading.value = true
try {
const res = await fetch('/api/auth/resend-verify', {
const res = await fetch('/api/resend-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),

View File

@@ -18,13 +18,7 @@
We've sent a 6-digit code to your email. Enter it below to continue. The code is valid for
10 minutes.
</p>
<input
v-model="code"
maxlength="6"
class="code-input"
placeholder="6-digit code"
@keyup.enter="isCodeValid && verifyCode()"
/>
<input v-model="code" maxlength="6" class="code-input" placeholder="6-digit code" />
<div class="button-group">
<button
v-if="!loading"
@@ -45,8 +39,6 @@
<p>Enter a new 46 digit Parent PIN. This will be required for parent access.</p>
<input
v-model="pin"
@input="handlePinInput"
@keyup.enter="!loading && isPinValid && setPin()"
maxlength="6"
inputmode="numeric"
pattern="\d*"
@@ -55,8 +47,6 @@
/>
<input
v-model="pin2"
@input="handlePin2Input"
@keyup.enter="!loading && isPinValid && setPin()"
maxlength="6"
inputmode="numeric"
pattern="\d*"
@@ -102,16 +92,6 @@ 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 = ''

View File

@@ -129,7 +129,6 @@
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'
@@ -157,14 +156,12 @@ const formValid = computed(
onMounted(async () => {
// Get token from query string
const raw = route.query.token ?? ''
token.value = (Array.isArray(raw) ? raw[0] : raw) || ''
token.value = Array.isArray(raw) ? raw[0] : String(raw || '')
// Validate token with backend
if (token.value) {
try {
const res = await fetch(
`/api/auth/validate-reset-token?token=${encodeURIComponent(token.value)}`,
)
const res = await fetch(`/api/validate-reset-token?token=${encodeURIComponent(token.value)}`)
tokenChecked.value = true
if (res.ok) {
tokenValid.value = true
@@ -172,22 +169,16 @@ onMounted(async () => {
const data = await res.json().catch(() => ({}))
errorMsg.value = data.error || 'This password reset link is invalid or has expired.'
tokenValid.value = false
// Redirect to AuthLanding
router.push({ name: 'AuthLanding' }).catch(() => (window.location.href = '/auth'))
}
} catch {
errorMsg.value = 'Network error. Please try again.'
tokenValid.value = false
tokenChecked.value = true
// Redirect to AuthLanding
router.push({ name: 'AuthLanding' }).catch(() => (window.location.href = '/auth'))
}
} else {
errorMsg.value = 'No reset token provided.'
tokenValid.value = false
tokenChecked.value = true
// Redirect to AuthLanding
router.push({ name: 'AuthLanding' }).catch(() => (window.location.href = '/auth'))
}
})
@@ -199,7 +190,7 @@ async function submitForm() {
if (!formValid.value) return
loading.value = true
try {
const res = await fetch('/api/auth/reset-password', {
const res = await fetch('/api/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -224,7 +215,6 @@ async function submitForm() {
return
}
// Success: Show modal instead of successMsg
logoutUser()
showModal.value = true
password.value = ''
confirmPassword.value = ''

View File

@@ -199,7 +199,7 @@ async function submitForm() {
if (!formValid.value) return
try {
loading.value = true
const response = await fetch('/api/auth/signup', {
const response = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -182,15 +182,13 @@ async function verifyToken() {
const token = Array.isArray(raw) ? raw[0] : String(raw || '')
if (!token) {
verifyingLoading.value = false
// Redirect to AuthLanding
router.push({ name: 'AuthLanding' }).catch(() => (window.location.href = '/auth'))
router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
return
}
verifyingLoading.value = true
try {
const url = `/api/auth/verify?token=${encodeURIComponent(token)}`
const url = `/api/verify?token=${encodeURIComponent(token)}`
const res = await fetch(url, { method: 'GET' })
if (!res.ok) {
@@ -209,8 +207,6 @@ async function verifyToken() {
default:
verifyError.value = msg || `Verification failed with status ${res.status}.`
}
// Redirect to AuthLanding
router.push({ name: 'AuthLanding' }).catch(() => (window.location.href = '/auth'))
return
}
@@ -219,8 +215,6 @@ async function verifyToken() {
startRedirectCountdown()
} catch {
verifyError.value = 'Network error. Please try again.'
// Redirect to AuthLanding
router.push({ name: 'AuthLanding' }).catch(() => (window.location.href = '/auth'))
} finally {
verifyingLoading.value = false
}
@@ -261,7 +255,7 @@ async function handleResend() {
sendingDialog.value = true
resendLoading.value = true
try {
const res = await fetch('/api/auth/resend-verify', {
const res = await fetch('/api/resend-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: resendEmail.value.trim() }),

View File

@@ -1,81 +0,0 @@
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()
})
})

View File

@@ -1,12 +0,0 @@
import { describe, it, expect } from 'vitest'
describe('ResetPassword.vue', () => {
it('calls /api/auth/validate-reset-token endpoint (not /api/validate-reset-token)', () => {
// This test verifies that the component uses the /auth prefix
// The actual functionality is tested by the integration with the backend
// which is working correctly (183 backend tests passing)
// Verify that ResetPassword imports are working
expect(true).toBe(true)
})
})

View File

@@ -1,12 +0,0 @@
import { describe, it, expect } from 'vitest'
describe('VerifySignup.vue', () => {
it('calls /api/auth/verify endpoint (not /api/verify)', () => {
// This test verifies that the component uses the /auth prefix
// The actual functionality is tested by the integration with the backend
// which is working correctly (183 backend tests passing)
// Verify that VerifySignup imports are working
expect(true).toBe(true)
})
})

View File

@@ -5,7 +5,6 @@
:fields="fields"
:initialData="initialData"
:isEdit="isEdit"
:requireDirty="isEdit"
:loading="loading"
:error="error"
@submit="handleSubmit"
@@ -17,39 +16,22 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, 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)
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[] = [
const fields = [
{ name: 'name', label: 'Name', type: 'text', required: true, maxlength: 64 },
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120, maxlength: 3 },
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
{ name: 'image_id', label: 'Image', type: 'image', imageType: 1 },
]
const initialData = ref<ChildForm>({ name: '', age: null, image_id: null })
const initialData = ref({ name: '', age: null, image_id: null })
const localImageFile = ref<File | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
@@ -63,31 +45,15 @@ onMounted(async () => {
const data = await resp.json()
initialData.value = {
name: data.name ?? '',
age: data.age === null || data.age === undefined ? null : Number(data.age),
age: Number(data.age) ?? null,
image_id: data.image_id ?? null,
}
} catch {
} catch (e) {
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.
}
}
})
@@ -97,7 +63,7 @@ function handleAddImage({ id, file }: { id: string; file: File }) {
}
}
async function handleSubmit(form: ChildForm) {
async function handleSubmit(form: any) {
let imageId = form.image_id
error.value = null
if (!form.name.trim()) {
@@ -124,7 +90,7 @@ async function handleSubmit(form: ChildForm) {
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch {
} catch (err) {
error.value = 'Failed to upload image.'
loading.value = false
return
@@ -157,7 +123,7 @@ async function handleSubmit(form: ChildForm) {
}
if (!resp.ok) throw new Error('Failed to save child')
await router.push({ name: 'ParentChildrenListView' })
} catch {
} catch (err) {
error.value = 'Failed to save child.'
}
loading.value = false

View File

@@ -1,11 +1,10 @@
<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'
import StatusMessage from '../shared/StatusMessage.vue'
import RewardConfirmDialog from './RewardConfirmDialog.vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { eventBus } from '@/common/eventBus'
//import '@/assets/view-shared.css'
import '@/assets/styles.css'
@@ -13,6 +12,7 @@ import type {
Child,
Event,
Task,
Reward,
RewardStatus,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
@@ -32,10 +32,10 @@ const tasks = ref<string[]>([])
const rewards = ref<string[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const childRewardListRef = ref()
const showRewardDialog = ref(false)
const showCancelDialog = ref(false)
const dialogReward = ref<RewardStatus | null>(null)
const dialogReward = ref<Reward | null>(null)
const childRewardListRef = ref()
function handleTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
@@ -179,7 +179,21 @@ const triggerTask = async (task: Task) => {
}
}
// Child mode is speech-only; point changes are handled in parent mode.
// 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)
}
}
}
const triggerReward = (reward: RewardStatus) => {
@@ -197,18 +211,35 @@ const triggerReward = (reward: RewardStatus) => {
utter.volume = 1.0
window.speechSynthesis.speak(utter)
}
}
if (reward.redeeming) {
dialogReward.value = reward
showCancelDialog.value = true
return
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
@@ -237,23 +268,6 @@ async function confirmRedeemReward() {
}
}
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
}
}
async function fetchChildData(id: string | number) {
loading.value = true
try {
@@ -450,33 +464,36 @@ 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>
<!-- Redeem reward dialog -->
<RewardConfirmDialog
v-if="showRewardDialog"
:reward="dialogReward"
:childName="child?.name"
@confirm="confirmRedeemReward"
@cancel="cancelRedeemReward"
/>
<!-- Cancel pending reward dialog -->
<ModalDialog
v-if="showCancelDialog && dialogReward"
:imageUrl="dialogReward.image_url"
:imageUrl="dialogReward?.image_url"
:title="dialogReward.name"
subtitle="Reward Pending"
@backdrop-click="closeCancelDialog"
:subtitle="`${dialogReward.cost} pts`"
>
<div class="modal-message">
This reward is pending.<br />Would you like to cancel the request?
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>
<style scoped>
@@ -565,16 +582,4 @@ onUnmounted(() => {
pointer-events: none;
filter: grayscale(0.7);
}
.modal-message {
margin-bottom: 1.2rem;
font-size: 1rem;
color: var(--modal-message-color, #333);
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import ModalDialog from '../shared/ModalDialog.vue'
import PendingRewardDialog from './PendingRewardDialog.vue'
import TaskConfirmDialog from './TaskConfirmDialog.vue'
@@ -52,9 +52,6 @@ const overrideEditTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward'
const overrideCustomValue = ref(0)
const isOverrideValid = ref(true)
const readyItemId = ref<string | null>(null)
const pendingEditOverrideTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward' } | null>(
null,
)
function handleItemReady(itemId: string) {
readyItemId.value = itemId
@@ -217,12 +214,6 @@ function handleOverrideDeleted(event: Event) {
}
function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
// If editing a pending reward, warn first
if (type === 'reward' && (item as any).redeeming) {
pendingEditOverrideTarget.value = { entity: item, type }
showPendingRewardDialog.value = true
return
}
overrideEditTarget.value = { entity: item, type }
const defaultValue = type === 'task' ? (item as Task).points : (item as Reward).cost
overrideCustomValue.value = item.custom_value ?? defaultValue
@@ -230,34 +221,11 @@ function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
showOverrideModal.value = true
}
async function confirmPendingRewardAndEdit() {
if (!pendingEditOverrideTarget.value) return
const item = pendingEditOverrideTarget.value.entity as any
await cancelRewardById(item.id)
showPendingRewardDialog.value = false
const target = pendingEditOverrideTarget.value
pendingEditOverrideTarget.value = null
// Open override modal directly, bypassing the redeeming check
overrideEditTarget.value = target
const defaultValue =
target.type === 'task' ? (target.entity as Task).points : (target.entity as Reward).cost
overrideCustomValue.value = target.entity.custom_value ?? defaultValue
validateOverrideInput()
showOverrideModal.value = true
}
function validateOverrideInput() {
const val = overrideCustomValue.value
isOverrideValid.value = typeof val === 'number' && val >= 0 && val <= 10000
}
watch(showOverrideModal, async (newVal) => {
if (newVal) {
await nextTick()
document.getElementById('custom-value')?.focus()
}
})
async function saveOverride() {
if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return
@@ -571,7 +539,7 @@ function goToAssignRewards() {
</div>
</div>
<div class="assign-buttons">
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Chores</button>
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Tasks</button>
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
Assign Penalties
</button>
@@ -581,18 +549,8 @@ function goToAssignRewards() {
<!-- Pending Reward Dialog -->
<PendingRewardDialog
v-if="showPendingRewardDialog"
:message="
pendingEditOverrideTarget
? 'This reward is currently pending. Changing its cost will cancel the pending request. Would you like to proceed?'
: 'A reward is currently pending. It will be cancelled when a chore or penalty is triggered. Would you like to proceed?'
"
@confirm="pendingEditOverrideTarget ? confirmPendingRewardAndEdit() : cancelPendingReward()"
@cancel="
() => {
showPendingRewardDialog = false
pendingEditOverrideTarget = null
}
"
@confirm="cancelPendingReward"
@cancel="showPendingRewardDialog = false"
/>
<!-- Override Edit Modal -->

View File

@@ -1,7 +1,9 @@
<template>
<ModalDialog title="Warning!" @backdrop-click="$emit('cancel')">
<div class="modal-message">
{{ message }}
There is a pending reward request. The reward must be cancelled before triggering a new
task.<br />
Would you like to cancel the pending reward?
</div>
<div class="modal-actions">
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
@@ -13,15 +15,6 @@
<script setup lang="ts">
import ModalDialog from '../shared/ModalDialog.vue'
withDefaults(
defineProps<{
message?: string
}>(),
{
message: 'A reward is currently pending. It will be cancelled. Would you like to proceed?',
},
)
defineEmits<{
confirm: []
cancel: []

View File

@@ -1,9 +1,9 @@
<template>
<div class="task-assign-view">
<h2>Assign Chores</h2>
<h2>Assign Tasks</h2>
<div class="task-view">
<MessageBlock v-if="taskCountRef === 0" message="No chores">
<span> <button class="round-btn" @click="goToCreateTask">Create</button> a chore </span>
<MessageBlock v-if="taskCountRef === 0" message="No tasks">
<span> <button class="round-btn" @click="goToCreateTask">Create</button> a task </span>
</MessageBlock>
<ItemList
v-else

View File

@@ -191,16 +191,6 @@ 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
@@ -212,182 +202,6 @@ 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)
})
it('opens redeem dialog when reward is ready and not pending', () => {
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: false,
})
expect(wrapper.vm.showRewardDialog).toBe(true)
expect(wrapper.vm.showCancelDialog).toBe(false)
expect(wrapper.vm.dialogReward?.id).toBe('reward-1')
})
it('does not open redeem dialog when reward is not yet ready', () => {
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 10,
redeeming: false,
})
expect(wrapper.vm.showRewardDialog).toBe(false)
expect(wrapper.vm.showCancelDialog).toBe(false)
})
it('opens cancel dialog when reward is already pending', () => {
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: true,
})
expect(wrapper.vm.showCancelDialog).toBe(true)
expect(wrapper.vm.showRewardDialog).toBe(false)
expect(wrapper.vm.dialogReward?.id).toBe('reward-1')
})
})
describe('Reward Redeem Dialog', () => {
const readyReward = {
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: false,
}
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
wrapper.vm.triggerReward(readyReward)
await nextTick()
})
it('closes redeem dialog on cancelRedeemReward', async () => {
expect(wrapper.vm.showRewardDialog).toBe(true)
wrapper.vm.cancelRedeemReward()
expect(wrapper.vm.showRewardDialog).toBe(false)
expect(wrapper.vm.dialogReward).toBe(null)
})
it('calls request-reward API on confirmRedeemReward', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
await wrapper.vm.confirmRedeemReward()
expect(global.fetch).toHaveBeenCalledWith(
`/api/child/child-123/request-reward`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ reward_id: 'reward-1' }),
}),
)
})
it('closes redeem dialog after confirmRedeemReward', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
await wrapper.vm.confirmRedeemReward()
await nextTick()
expect(wrapper.vm.showRewardDialog).toBe(false)
expect(wrapper.vm.dialogReward).toBe(null)
})
})
describe('Cancel Pending Reward Dialog', () => {
const pendingReward = {
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: true,
}
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
wrapper.vm.triggerReward(pendingReward)
await nextTick()
})
it('closes cancel dialog on closeCancelDialog', async () => {
expect(wrapper.vm.showCancelDialog).toBe(true)
wrapper.vm.closeCancelDialog()
expect(wrapper.vm.showCancelDialog).toBe(false)
expect(wrapper.vm.dialogReward).toBe(null)
})
it('calls cancel-request-reward API on cancelPendingReward', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
await wrapper.vm.cancelPendingReward()
expect(global.fetch).toHaveBeenCalledWith(
`/api/child/child-123/cancel-request-reward`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ reward_id: 'reward-1' }),
}),
)
})
it('closes cancel dialog after cancelPendingReward', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
await wrapper.vm.cancelPendingReward()
await nextTick()
expect(wrapper.vm.showCancelDialog).toBe(false)
expect(wrapper.vm.dialogReward).toBe(null)
})
})
describe('SSE Event Handlers', () => {
beforeEach(async () => {
wrapper = mount(ChildView)

View File

@@ -348,106 +348,4 @@ describe('ParentView', () => {
expect(true).toBe(true) // Placeholder - template logic verified
})
})
describe('Override Edit - Pending Reward Guard', () => {
const pendingReward = {
id: 'reward-1',
name: 'Ice Cream',
cost: 100,
points_needed: 0,
redeeming: true,
image_url: '/images/reward.png',
custom_value: null,
}
beforeEach(async () => {
wrapper = mount(ParentView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('shows PendingRewardDialog instead of override modal when editing a pending reward', async () => {
wrapper.vm.handleEditItem(pendingReward, 'reward')
await nextTick()
expect(wrapper.vm.showPendingRewardDialog).toBe(true)
expect(wrapper.vm.showOverrideModal).toBe(false)
expect(wrapper.vm.pendingEditOverrideTarget).toEqual({
entity: pendingReward,
type: 'reward',
})
})
it('does not show PendingRewardDialog when editing a non-pending reward', async () => {
wrapper.vm.handleEditItem(mockReward, 'reward')
await nextTick()
expect(wrapper.vm.showPendingRewardDialog).toBe(false)
expect(wrapper.vm.showOverrideModal).toBe(true)
})
it('does not show PendingRewardDialog when editing a task regardless of pending rewards', async () => {
wrapper.vm.handleEditItem(mockTask, 'task')
await nextTick()
expect(wrapper.vm.showPendingRewardDialog).toBe(false)
expect(wrapper.vm.showOverrideModal).toBe(true)
})
it('cancels pending reward and opens override modal on confirmPendingRewardAndEdit', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
wrapper.vm.handleEditItem(pendingReward, 'reward')
await nextTick()
await wrapper.vm.confirmPendingRewardAndEdit()
await nextTick()
expect(global.fetch).toHaveBeenCalledWith(
`/api/child/child-123/cancel-request-reward`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ reward_id: 'reward-1' }),
}),
)
expect(wrapper.vm.showPendingRewardDialog).toBe(false)
expect(wrapper.vm.showOverrideModal).toBe(true)
expect(wrapper.vm.pendingEditOverrideTarget).toBe(null)
})
it('sets overrideCustomValue to reward cost when no custom_value on confirmPendingRewardAndEdit', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
wrapper.vm.handleEditItem(pendingReward, 'reward')
await wrapper.vm.confirmPendingRewardAndEdit()
expect(wrapper.vm.overrideCustomValue).toBe(pendingReward.cost)
expect(wrapper.vm.overrideEditTarget?.entity).toEqual(pendingReward)
expect(wrapper.vm.overrideEditTarget?.type).toBe('reward')
})
it('sets overrideCustomValue to custom_value when present on confirmPendingRewardAndEdit', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
const pendingWithOverride = { ...pendingReward, custom_value: 75 }
wrapper.vm.handleEditItem(pendingWithOverride, 'reward')
await wrapper.vm.confirmPendingRewardAndEdit()
expect(wrapper.vm.overrideCustomValue).toBe(75)
})
it('clears pendingEditOverrideTarget when cancel is clicked on PendingRewardDialog', async () => {
wrapper.vm.handleEditItem(pendingReward, 'reward')
await nextTick()
// Simulate cancel
wrapper.vm.showPendingRewardDialog = false
wrapper.vm.pendingEditOverrideTarget = null
await nextTick()
expect(wrapper.vm.showPendingRewardDialog).toBe(false)
expect(wrapper.vm.pendingEditOverrideTarget).toBe(null)
expect(wrapper.vm.showOverrideModal).toBe(false)
})
})
})

View File

@@ -5,7 +5,6 @@
<ItemList
v-else
:key="refreshKey"
:fetchUrl="`/api/pending-rewards`"
itemKey="rewards"
:itemFields="PENDING_REWARD_FIELDS"
@@ -31,43 +30,20 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import type { PendingReward, Event, ChildRewardRequestEventPayload } from '@/common/models'
import type { PendingReward } from '@/common/models'
import { PENDING_REWARD_FIELDS } from '@/common/models'
import { eventBus } from '@/common/eventBus'
const router = useRouter()
const notificationListCountRef = ref(-1)
const refreshKey = ref(0)
function handleNotificationClick(item: PendingReward) {
router.push({ name: 'ParentView', params: { id: item.child_id } })
}
function handleRewardRequest(event: Event) {
const payload = event.payload as ChildRewardRequestEventPayload
if (
payload.operation === 'CREATED' ||
payload.operation === 'CANCELLED' ||
payload.operation === 'GRANTED'
) {
// Reset count and bump key to force ItemList to re-mount and refetch
notificationListCountRef.value = -1
refreshKey.value++
}
}
onMounted(() => {
eventBus.on('child_reward_request', handleRewardRequest)
})
onUnmounted(() => {
eventBus.off('child_reward_request', handleRewardRequest)
})
</script>
<style scoped>

View File

@@ -15,18 +15,26 @@
<template #custom-field-email="{ modelValue }">
<div class="email-actions">
<input id="email" type="email" :value="modelValue" disabled class="readonly-input" />
<button type="button" class="btn-link btn-link-space" @click="goToChangeParentPin">
Change Parent PIN
<button
type="button"
class="btn-link align-start btn-link-space"
@click="goToChangeParentPin"
>
Change Parent Pin
</button>
<button
type="button"
class="btn-link btn-link-space"
class="btn-link align-start btn-link-space"
@click="resetPassword"
:disabled="resetting"
>
Change Password
</button>
<button type="button" class="btn-link btn-link-space" @click="openDeleteWarning">
<button
type="button"
class="btn-link align-start btn-link-space"
@click="openDeleteWarning"
>
Delete My Account
</button>
</div>
@@ -109,6 +117,7 @@ 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)
@@ -124,26 +133,14 @@ const showDeleteSuccess = ref(false)
const showDeleteError = ref(false)
const deleteErrorMessage = ref('')
const initialData = ref<{
image_id: string | null
first_name: string
last_name: string
email: string
}>({
const initialData = ref({
image_id: null,
first_name: '',
last_name: '',
email: '',
})
const fields: Array<{
name: string
label: string
type: 'image' | 'text' | 'custom'
imageType?: number
required?: boolean
maxlength?: number
}> = [
const fields = [
{ 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 },
@@ -266,7 +263,7 @@ async function resetPassword() {
resetting.value = true
errorMsg.value = ''
try {
const res = await fetch('/api/auth/request-password-reset', {
const res = await fetch('/api/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: initialData.value.email }),
@@ -298,6 +295,7 @@ function closeDeleteWarning() {
}
async function confirmDeleteAccount() {
console.log('Confirming delete account with email:', confirmEmail.value)
if (!isEmailValid(confirmEmail.value)) return
deletingAccount.value = true
@@ -334,15 +332,8 @@ async function confirmDeleteAccount() {
function handleDeleteSuccess() {
showDeleteSuccess.value = false
// Call logout API to clear server cookies
fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
}).finally(() => {
// Clear client-side auth and redirect, regardless of logout response
logoutUser()
router.push('/auth/login')
})
}
function closeDeleteError() {
@@ -366,6 +357,10 @@ function closeDeleteError() {
flex-direction: column;
gap: 0.5rem;
}
.align-start {
align-self: flex-start;
margin-top: 0.1rem;
}
.success-message {
color: var(--success, #16a34a);

View File

@@ -13,7 +13,7 @@
imageField="image_id"
deletable
@clicked="(reward: Reward) => $router.push({ name: 'EditReward', params: { id: reward.id } })"
@delete="(reward: Reward) => confirmDeleteReward(reward.id)"
@delete="confirmDeleteReward"
@loading-complete="(count) => (rewardCountRef = count)"
:getItemClass="(item) => `reward`"
>
@@ -52,7 +52,7 @@ const $router = useRouter()
const showConfirm = ref(false)
const rewardToDelete = ref<string | null>(null)
const rewardListRef = ref<InstanceType<typeof ItemList> | null>(null)
const rewardListRef = ref()
const rewardCountRef = ref<number>(-1)
function handleRewardModified(event: any) {
@@ -75,7 +75,10 @@ function confirmDeleteReward(rewardId: string) {
}
const deleteReward = async () => {
const id = rewardToDelete.value
const id =
typeof rewardToDelete.value === 'object' && rewardToDelete.value !== null
? rewardToDelete.value.id
: rewardToDelete.value
if (!id) return
try {
const resp = await fetch(`/api/reward/${id}`, {

View File

@@ -280,8 +280,8 @@ onBeforeUnmount(() => {
<div>
<MessageBlock v-if="children.length === 0" message="No children">
<span v-if="!isParentAuthenticated">
<button class="round-btn" @click="eventBus.emit('open-login')">Switch</button> to parent
mode to create a child
<button class="round-btn" @click="eventBus.emit('open-login')">Sign in</button> to create a
child
</span>
<span v-else> <button class="round-btn" @click="createChild">Create</button> a child </span>
</MessageBlock>

View File

@@ -1,7 +1,7 @@
<template>
<h2>{{ title ?? (isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}`) }}</h2>
<div v-if="loading" class="loading-message">Loading {{ entityLabel.toLowerCase() }}...</div>
<form v-else @submit.prevent="submit" class="entity-form" ref="formRef">
<form v-else @submit.prevent="submit" class="entity-form">
<template v-for="field in fields" :key="field.name">
<div class="group">
<label :for="field.name">
@@ -10,35 +10,18 @@
<slot
:name="`custom-field-${field.name}`"
:modelValue="formData[field.name]"
:update="(val: unknown) => (formData[field.name] = val)"
:update="(val) => (formData[field.name] = val)"
>
<!-- Default rendering if no slot provided -->
<input
v-if="field.type === 'text'"
v-if="field.type === 'text' || field.type === 'number'"
:id="field.name"
v-model="formData[field.name]"
type="text"
:type="field.type"
:required="field.required"
:maxlength="field.maxlength"
/>
<input
v-else-if="field.type === 'number'"
:id="field.name"
v-model="formData[field.name]"
type="number"
:required="field.required"
:min="field.min"
:max="field.max"
inputmode="numeric"
pattern="\\d{1,3}"
@input="
(e) => {
if (field.maxlength && e.target.value.length > field.maxlength) {
e.target.value = e.target.value.slice(0, field.maxlength)
formData[field.name] = e.target.value
}
}
"
/>
<ImagePicker
v-else-if="field.type === 'image'"
@@ -56,11 +39,7 @@
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
Cancel
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="loading || !isValid || (props.requireDirty && !isDirty)"
>
<button type="submit" class="btn btn-primary" :disabled="loading || !isDirty || !isValid">
{{ isEdit ? 'Save' : 'Create' }}
</button>
</div>
@@ -84,8 +63,7 @@ type Field = {
imageType?: number
}
const props = withDefaults(
defineProps<{
const props = defineProps<{
entityLabel: string
fields: Field[]
initialData?: Record<string, any>
@@ -93,43 +71,28 @@ const props = withDefaults(
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 })
const baselineData = ref<Record<string, any>>({ ...props.initialData })
const formRef = ref<HTMLFormElement | null>(null)
async function focusFirstInput() {
await nextTick()
const firstInput = formRef.value?.querySelector<HTMLElement>('input, select, textarea')
firstInput?.focus()
watch(
() => props.initialData,
(newVal) => {
if (newVal) {
formData.value = { ...newVal }
}
},
{ immediate: true, deep: true },
)
onMounted(async () => {
await nextTick()
isDirty.value = false
if (!props.loading) {
focusFirstInput()
}
// Optionally focus first input
})
watch(
() => props.loading,
(newVal, oldVal) => {
if (!newVal && oldVal === true) {
focusFirstInput()
}
},
)
function onAddImage({ id, file }: { id: string; file: File }) {
emit('add-image', { id, file })
}
@@ -146,36 +109,14 @@ 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')
.filter((f) => f.type !== 'custom' || f.name === 'is_good' || f.type === 'image')
.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 !valuesEqualForDirtyCheck(key, formData.value[key], baselineData.value[key])
return JSON.stringify(formData.value[key]) !== JSON.stringify(props.initialData?.[key])
})
}
@@ -190,7 +131,6 @@ 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
@@ -205,7 +145,8 @@ const isValid = computed(() => {
watch(
() => ({ ...formData.value }),
() => {
(newVal) => {
console.log('formData changed:', newVal)
checkDirty()
},
{ deep: true },
@@ -216,8 +157,7 @@ watch(
(newVal) => {
if (newVal) {
formData.value = { ...newVal }
baselineData.value = { ...newVal }
isDirty.value = false
checkDirty()
}
},
{ immediate: true, deep: true },

View File

@@ -90,14 +90,6 @@ onMounted(fetchItems)
watch(() => props.fetchUrl, fetchItems)
const handleClicked = (item: any) => {
if (props.selectable) {
const idx = selectedItems.value.indexOf(item.id)
if (idx === -1) {
selectedItems.value.push(item.id)
} else {
selectedItems.value.splice(idx, 1)
}
}
emit('clicked', item)
props.onClicked?.(item)
}

View File

@@ -5,7 +5,6 @@ import { eventBus } from '@/common/eventBus'
import {
authenticateParent,
isParentAuthenticated,
isParentPersistent,
logoutParent,
logoutUser,
} from '../../stores/auth'
@@ -17,7 +16,6 @@ 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)
@@ -38,6 +36,7 @@ const avatarInitial = ref<string>('?')
// Fetch user profile
async function fetchUserProfile() {
try {
console.log('Fetching user profile')
const res = await fetch('/api/user/profile', { credentials: 'include' })
if (!res.ok) {
console.error('Failed to fetch user profile')
@@ -104,7 +103,6 @@ const open = async () => {
const close = () => {
show.value = false
error.value = ''
stayInParentMode.value = false
}
const submit = async () => {
@@ -128,13 +126,10 @@ const submit = async () => {
}
if (!data.valid) {
error.value = 'Incorrect PIN'
pin.value = ''
await nextTick()
pinInput.value?.focus()
return
}
// Authenticate parent and navigate
authenticateParent(stayInParentMode.value)
authenticateParent()
close()
router.push('/parent')
} catch (e) {
@@ -142,11 +137,6 @@ 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')
@@ -223,7 +213,7 @@ function executeMenuItem(index: number) {
async function signOut() {
try {
await fetch('/api/auth/logout', { method: 'POST' })
await fetch('/api/logout', { method: 'POST' })
logoutUser()
router.push('/auth')
} catch {
@@ -284,12 +274,6 @@ 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
@@ -373,20 +357,15 @@ onUnmounted(() => {
<input
ref="pinInput"
v-model="pin"
@input="handlePinInput"
inputmode="numeric"
pattern="\d*"
maxlength="6"
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>
<button type="submit" class="btn btn-primary">OK</button>
</div>
</form>
<div v-if="error" class="error modal-message">{{ error }}</div>
@@ -458,40 +437,11 @@ onUnmounted(() => {
font-size: 1rem;
border-radius: 8px;
border: 1px solid #e6e6e6;
margin-bottom: 0.8rem;
margin-bottom: 0.6rem;
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

@@ -383,6 +383,6 @@ onBeforeUnmount(() => {
.empty {
text-align: center;
padding: 2rem 0;
color: #d6d6d6;
color: #888;
}
</style>

View File

@@ -1,70 +0,0 @@
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)
})
})

View File

@@ -4,10 +4,6 @@ import { nextTick } from 'vue'
import LoginButton from '../LoginButton.vue'
import { authenticateParent, logoutParent } from '../../../stores/auth'
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}))
// Mock imageCache module
vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn(async (imageId: string) => `blob:mock-url-${imageId}`),
@@ -15,21 +11,24 @@ vi.mock('@/common/imageCache', () => ({
revokeAllImageUrls: vi.fn(),
}))
// 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')
// Create a reactive ref for isParentAuthenticated using vi.hoisted
const { isParentAuthenticatedRef } = vi.hoisted(() => {
let value = false
return {
isParentAuthenticatedRef: ref(false),
isParentPersistentRef: ref(false),
isParentAuthenticatedRef: {
get value() {
return value
},
set value(v: boolean) {
value = v
},
},
}
})
vi.mock('../../../stores/auth', () => ({
authenticateParent: vi.fn(),
isParentAuthenticated: isParentAuthenticatedRef,
isParentPersistent: isParentPersistentRef,
logoutParent: vi.fn(),
logoutUser: vi.fn(),
}))
@@ -42,7 +41,6 @@ describe('LoginButton', () => {
beforeEach(() => {
vi.clearAllMocks()
isParentAuthenticatedRef.value = false
isParentPersistentRef.value = false
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({
@@ -347,104 +345,6 @@ 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

@@ -10,7 +10,6 @@ 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)
@@ -199,13 +198,6 @@ 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')
}
@@ -213,7 +205,7 @@ function updateLocalImage(url: string, file: File) {
<template>
<div class="picker">
<div ref="imageScrollRef" class="image-scroll">
<div class="image-scroll">
<div v-if="loadingImages" class="loading-images">Loading images...</div>
<div v-else class="image-list">
<img
@@ -231,6 +223,7 @@ function updateLocalImage(url: string, file: File) {
ref="fileInput"
type="file"
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
capture="environment"
style="display: none"
@change="onFileChange"
/>

View File

@@ -2,14 +2,9 @@ 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')

View File

@@ -1,155 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createRouter, createWebHistory } from 'vue-router'
// Use plain objects — the guard only reads `.value`, so full Vue refs are unnecessary
const { isAuthReadyMock, isUserLoggedInMock, isParentAuthenticatedMock } = vi.hoisted(() => ({
isAuthReadyMock: { value: true },
isUserLoggedInMock: { value: false },
isParentAuthenticatedMock: { value: false },
}))
vi.mock('@/stores/auth', () => ({
isAuthReady: isAuthReadyMock,
isUserLoggedIn: isUserLoggedInMock,
isParentAuthenticated: isParentAuthenticatedMock,
logoutParent: vi.fn(),
enforceParentExpiry: vi.fn(),
}))
// Import router AFTER mocks are in place
const { default: router } = await import('../index')
// Helper — navigate and return the resolved path
async function navigate(path: string): Promise<string> {
await router.push(path)
return router.currentRoute.value.path
}
describe('router auth guard', () => {
beforeEach(async () => {
isAuthReadyMock.value = true
// Park at /auth/reset-password as a neutral starting point:
// - it is always reachable when logged out
// - it doesn't match any route a test assertion lands on
isUserLoggedInMock.value = false
isParentAuthenticatedMock.value = false
await router.push('/auth/reset-password')
})
// ── Redirect logged-in users away from /auth ──────────────────────────────
it('redirects logged-in parent user from /auth to /parent', async () => {
isUserLoggedInMock.value = true
isParentAuthenticatedMock.value = true
const path = await navigate('/auth')
expect(path).toBe('/parent')
})
it('redirects logged-in child user from /auth to /child', async () => {
isUserLoggedInMock.value = true
isParentAuthenticatedMock.value = false
const path = await navigate('/auth')
expect(path).toBe('/child')
})
it('redirects logged-in parent user from /auth/login to /parent', async () => {
isUserLoggedInMock.value = true
isParentAuthenticatedMock.value = true
const path = await navigate('/auth/login')
expect(path).toBe('/parent')
})
it('redirects logged-in child user from /auth/signup to /child', async () => {
isUserLoggedInMock.value = true
isParentAuthenticatedMock.value = false
const path = await navigate('/auth/signup')
expect(path).toBe('/child')
})
it('redirects logged-in child user from /auth/forgot-password to /child', async () => {
isUserLoggedInMock.value = true
isParentAuthenticatedMock.value = false
const path = await navigate('/auth/forgot-password')
expect(path).toBe('/child')
})
// ── Unauthenticated users may access /auth ────────────────────────────────
it('allows unauthenticated user to access /auth', async () => {
isUserLoggedInMock.value = false
const path = await navigate('/auth')
expect(path).toBe('/auth')
})
it('allows unauthenticated user to access /auth/login', async () => {
isUserLoggedInMock.value = false
const path = await navigate('/auth/login')
expect(path).toBe('/auth/login')
})
// ── Unauthenticated users are redirected to /auth from protected routes ───
it('redirects unauthenticated user from /parent to /auth', async () => {
isUserLoggedInMock.value = false
const path = await navigate('/parent')
expect(path).toBe('/auth')
})
it('redirects unauthenticated user from /child to /auth', async () => {
isUserLoggedInMock.value = false
const path = await navigate('/child')
expect(path).toBe('/auth')
})
// ── Authenticated users are routed to the correct section ─────────────────
it('allows parent-authenticated user to access /parent', async () => {
isUserLoggedInMock.value = true
isParentAuthenticatedMock.value = true
const path = await navigate('/parent')
expect(path).toBe('/parent')
})
it('allows child user to access /child', async () => {
isUserLoggedInMock.value = true
isParentAuthenticatedMock.value = false
const path = await navigate('/child')
expect(path).toBe('/child')
})
it('redirects child user away from /parent to /child', async () => {
isUserLoggedInMock.value = true
isParentAuthenticatedMock.value = false
const path = await navigate('/parent')
expect(path).toBe('/child')
})
it('redirects parent user away from /child to /parent', async () => {
isUserLoggedInMock.value = true
isParentAuthenticatedMock.value = true
const path = await navigate('/child')
expect(path).toBe('/parent')
})
// ── ParentPinSetup is always accessible ───────────────────────────────────
it('allows access to /parent/pin-setup regardless of auth state', async () => {
isUserLoggedInMock.value = false
const path = await navigate('/parent/pin-setup')
expect(path).toBe('/parent/pin-setup')
})
})

View File

@@ -17,13 +17,7 @@ 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,
logoutParent,
enforceParentExpiry,
} from '../stores/auth'
import { isUserLoggedIn, isParentAuthenticated, isAuthReady } from '../stores/auth'
import ParentPinSetup from '@/components/auth/ParentPinSetup.vue'
const routes = [
@@ -181,9 +175,6 @@ const routes = [
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() {
return { top: 0, left: 0, behavior: 'smooth' }
},
})
// Auth guard
@@ -199,15 +190,6 @@ router.beforeEach(async (to, from, next) => {
})
}
// If already logged in and trying to access /auth, redirect to appropriate view
if (to.path.startsWith('/auth') && isUserLoggedIn.value) {
if (isParentAuthenticated.value) {
return next('/parent')
} else {
return next('/child')
}
}
// Always allow /auth and /parent/pin-setup
if (to.path.startsWith('/auth') || to.name === 'ParentPinSetup') {
return next()
@@ -219,8 +201,6 @@ 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()
}
@@ -234,8 +214,6 @@ 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,19 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
isParentAuthenticated,
isUserLoggedIn,
loginUser,
initAuthSync,
authenticateParent,
logoutParent,
} from '../auth'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { isParentAuthenticated, loginUser } from '../auth'
import { nextTick } from 'vue'
// Stub window.location to prevent jsdom "navigation to another Document" warnings
// triggered when the auth store sets window.location.href on logout.
const locationStub = { href: '', pathname: '/', assign: vi.fn(), replace: vi.fn(), reload: vi.fn() }
Object.defineProperty(window, 'location', { value: locationStub, writable: true })
// Helper to mock localStorage
global.localStorage = {
store: {} as Record<string, string>,
@@ -33,57 +21,13 @@ global.localStorage = {
describe('auth store - child mode on login', () => {
beforeEach(() => {
// Use authenticateParent() to set up parent-mode state
authenticateParent(false)
})
afterEach(() => {
logoutParent()
isParentAuthenticated.value = true
localStorage.setItem('isParentAuthenticated', 'true')
})
it('should clear isParentAuthenticated and localStorage on loginUser()', async () => {
expect(isParentAuthenticated.value).toBe(true)
loginUser()
await nextTick()
await nextTick() // flush Vue watcher
expect(isParentAuthenticated.value).toBe(false)
})
it('logs out on cross-tab storage logout event', async () => {
initAuthSync()
isUserLoggedIn.value = true
authenticateParent(false)
expect(isParentAuthenticated.value).toBe(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)
})
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

@@ -1,165 +0,0 @@
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,166 +1,58 @@
import { ref } from 'vue'
import { ref, watch } 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
// --- Background expiry watcher ---
let expiryWatcherIntervalId: ReturnType<typeof setInterval> | null = null
watch(isParentAuthenticated, (val) => {
if (hasLocalStorage && typeof localStorage.setItem === 'function') {
localStorage.setItem('isParentAuthenticated', val ? 'true' : 'false')
}
})
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
export function authenticateParent() {
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(PARENT_AUTH_KEY)
localStorage.removeItem('isParentAuthenticated')
}
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
applyParentLoggedOutState()
}
function applyLoggedOutState() {
isUserLoggedIn.value = false
currentUserId.value = ''
applyParentLoggedOutState()
}
function broadcastLogoutEvent() {
if (!hasLocalStorage || typeof localStorage.setItem !== 'function') return
localStorage.setItem(AUTH_SYNC_EVENT_KEY, JSON.stringify({ type: 'logout', at: Date.now() }))
isParentAuthenticated.value = false
}
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'
}
} else if (payload?.type === 'parent_logout') {
applyParentLoggedOutState()
if (window.location.pathname.startsWith('/parent')) {
window.location.href = '/child'
}
}
} catch {
// Ignore malformed sync events.
}
})
isUserLoggedIn.value = false
currentUserId.value = ''
logoutParent()
}
export async function checkAuth() {
try {
const res = await fetch('/api/auth/me', { method: 'GET' })
const res = await fetch('/api/me', { method: 'GET' })
if (res.ok) {
const data = await res.json()
currentUserId.value = data.id
isUserLoggedIn.value = true
} else {
logoutUser()
isUserLoggedIn.value = false
currentUserId.value = ''
}
} catch {
logoutUser()
isUserLoggedIn.value = false
currentUserId.value = ''
}
isAuthReady.value = true
}

View File

@@ -1,13 +0,0 @@
import { vi } from 'vitest'
// jsdom does not implement scrollTo — stub it to suppress "Not implemented" warnings
window.scrollTo = vi.fn()
// Globally mock imageCache so component tests don't make real fetch calls
// and don't spam "response.blob is not a function" errors in jsdom.
vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn().mockResolvedValue(''),
getCachedImageBlob: vi.fn().mockResolvedValue(new Blob()),
revokeImageUrl: vi.fn(),
revokeAllImageUrls: vi.fn(),
}))

View File

@@ -9,7 +9,6 @@ export default mergeConfig(
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
setupFiles: ['src/test/setup.ts'],
},
}),
)