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 name: Chore App Build and Push Docker Images
run-name: ${{ gitea.actor }} is building Chores [${{ gitea.ref_name }}@${{ gitea.sha }}] 🚀 run-name: ${{ gitea.actor }} is building the chore app 🚀
on: on:
push: push:
branches: branches:
@@ -24,56 +24,26 @@ jobs:
echo "tag=next-$version-$current_date" >> $GITHUB_OUTPUT echo "tag=next-$version-$current_date" >> $GITHUB_OUTPUT
fi 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 - name: Build Backend Docker Image
run: | 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 - name: Build Frontend Docker Image
run: | 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 - name: Log in to Registry
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
registry: git.ryankegel.com:3000 registry: git.ryankegel.com:3000
username: ${{ secrets.REGISTRY_USER }} username: ryan #${{ secrets.REGISTRY_USERNAME }} # Stored as a Gitea secret
password: ${{ secrets.REGISTRY_PASSWORD }} password: 0x013h #${{ secrets.REGISTRY_TOKEN }} # Stored as a Gitea secret (use a PAT here)
- name: Push Backend Image to Gitea Registry - name: Push Backend Image to Gitea Registry
run: | run: |
for i in {1..3}; do for i in {1..3}; do
echo "Attempt $i to push backend image..." 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" echo "Backend push succeeded on attempt $i"
break break
else else
@@ -86,18 +56,18 @@ jobs:
fi fi
done done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then 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 tag git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:latest
docker push git.ryankegel.com:3000/kegel/chores/backend:latest docker push git.ryankegel.com:3000/ryan/backend:latest
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then 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 tag git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:next
docker push git.ryankegel.com:3000/kegel/chores/backend:next docker push git.ryankegel.com:3000/ryan/backend:next
fi fi
- name: Push Frontend Image to Gitea Registry - name: Push Frontend Image to Gitea Registry
run: | run: |
for i in {1..3}; do for i in {1..3}; do
echo "Attempt $i to push frontend image..." 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" echo "Frontend push succeeded on attempt $i"
break break
else else
@@ -110,15 +80,14 @@ jobs:
fi fi
done done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then 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 tag git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:latest
docker push git.ryankegel.com:3000/kegel/chores/frontend:latest docker push git.ryankegel.com:3000/ryan/frontend:latest
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then 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 tag git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:next
docker push git.ryankegel.com:3000/kegel/chores/frontend:next docker push git.ryankegel.com:3000/ryan/frontend:next
fi fi
- name: Deploy Test Environment - 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 uses: appleboy/ssh-action@v1.0.3 # Or equivalent Gitea action; adjust version if needed
with: with:
host: ${{ secrets.DEPLOY_TEST_HOST }} 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/children.json
backend/test_data/db/images.json backend/test_data/db/images.json
backend/test_data/db/pending_rewards.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). 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 ## 📄 License
Private project - All rights reserved. Private project - All rights reserved.

View File

@@ -162,8 +162,7 @@ def login():
payload = { payload = {
'email': norm_email, 'email': norm_email,
'user_id': user.id, 'user_id': user.id,
'token_version': user.token_version, 'exp': datetime.utcnow() + timedelta(hours=24*7)
'exp': datetime.utcnow() + timedelta(days=62)
} }
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
@@ -180,13 +179,10 @@ def me():
try: try:
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256']) payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
user_id = payload.get('user_id', '') user_id = payload.get('user_id', '')
token_version = payload.get('token_version', 0)
user_dict = users_db.get(UserQuery.id == user_id) user_dict = users_db.get(UserQuery.id == user_id)
user = User.from_dict(user_dict) if user_dict else None user = User.from_dict(user_dict) if user_dict else None
if not user: if not user:
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404 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: if user.marked_for_deletion:
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403 return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
return jsonify({ return jsonify({
@@ -272,12 +268,9 @@ def reset_password():
user.password = generate_password_hash(new_password) user.password = generate_password_hash(new_password)
user.reset_token = None user.reset_token = None
user.reset_token_created = None user.reset_token_created = None
user.token_version += 1
users_db.update(user.to_dict(), UserQuery.email == user.email) users_db.update(user.to_dict(), UserQuery.email == user.email)
resp = jsonify({'message': 'Password has been reset'}) return jsonify({'message': 'Password has been reset'}), 200
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
return resp, 200
@auth_api.route('/logout', methods=['POST']) @auth_api.route('/logout', methods=['POST'])
def logout(): def logout():

View File

@@ -64,24 +64,10 @@ def list_tasks():
continue # Skip default if user version exists continue # Skip default if user version exists
filtered_tasks.append(t) filtered_tasks.append(t)
# Sort order: # Sort: user-created items first (by name), then default items (by name)
# 1) good tasks first, then not-good tasks user_created = sorted([t for t in filtered_tasks if t.get('user_id') == user_id], key=lambda x: x['name'].lower())
# 2) within each group: user-created items first (by name), then default items (by name) default_items = sorted([t for t in filtered_tasks if t.get('user_id') is None], key=lambda x: x['name'].lower())
good_tasks = [t for t in filtered_tasks if t.get('is_good') is True] sorted_tasks = user_created + default_items
not_good_tasks = [t for t in filtered_tasks if t.get('is_good') is not True]
def sort_user_then_default(tasks_group):
user_created = sorted(
[t for t in tasks_group if t.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[t for t in tasks_group if t.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return user_created + default_items
sorted_tasks = sort_user_then_default(good_tasks) + sort_user_then_default(not_good_tasks)
return jsonify({'tasks': sorted_tasks}), 200 return jsonify({'tasks': sorted_tasks}), 200

View File

@@ -231,13 +231,6 @@ def mark_for_deletion():
# Mark for deletion # Mark for deletion
user.marked_for_deletion = True user.marked_for_deletion = True
user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat() 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) users_db.update(user.to_dict(), UserQuery.id == user.id)
# Trigger SSE event # Trigger SSE event

View File

@@ -29,12 +29,6 @@ def get_current_user_id():
user_id = payload.get('user_id') user_id = payload.get('user_id')
if not user_id: if not user_id:
return None 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 return user_id
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
return None return None

View File

@@ -2,7 +2,7 @@
# file: config/version.py # file: config/version.py
import os 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: def get_full_version() -> str:
""" """

View File

@@ -33,14 +33,13 @@ logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}}) #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(admin_api)
app.register_blueprint(child_api) app.register_blueprint(child_api)
app.register_blueprint(child_override_api) app.register_blueprint(child_override_api)
app.register_blueprint(reward_api) app.register_blueprint(reward_api)
app.register_blueprint(task_api) app.register_blueprint(task_api)
app.register_blueprint(image_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(user_api)
app.register_blueprint(tracking_api) app.register_blueprint(tracking_api)

View File

@@ -21,7 +21,6 @@ class User(BaseModel):
deletion_in_progress: bool = False deletion_in_progress: bool = False
deletion_attempted_at: str | None = None deletion_attempted_at: str | None = None
role: str = 'user' role: str = 'user'
token_version: int = 0
@classmethod @classmethod
def from_dict(cls, d: dict): def from_dict(cls, d: dict):
@@ -44,7 +43,6 @@ class User(BaseModel):
deletion_in_progress=d.get('deletion_in_progress', False), deletion_in_progress=d.get('deletion_in_progress', False),
deletion_attempted_at=d.get('deletion_attempted_at'), deletion_attempted_at=d.get('deletion_attempted_at'),
role=d.get('role', 'user'), role=d.get('role', 'user'),
token_version=d.get('token_version', 0),
id=d.get('id'), id=d.get('id'),
created_at=d.get('created_at'), created_at=d.get('created_at'),
updated_at=d.get('updated_at') updated_at=d.get('updated_at')
@@ -71,7 +69,6 @@ class User(BaseModel):
'marked_for_deletion_at': self.marked_for_deletion_at, 'marked_for_deletion_at': self.marked_for_deletion_at,
'deletion_in_progress': self.deletion_in_progress, 'deletion_in_progress': self.deletion_in_progress,
'deletion_attempted_at': self.deletion_attempted_at, 'deletion_attempted_at': self.deletion_attempted_at,
'role': self.role, 'role': self.role
'token_version': self.token_version,
}) })
return base return base

View File

@@ -100,38 +100,6 @@ def test_reset_password_hashes_new_password(client):
assert user_dict['password'].startswith('scrypt:') assert user_dict['password'].startswith('scrypt:')
assert check_password_hash(user_dict['password'], 'newpassword123') 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(): def test_migration_script_hashes_plain_text_passwords():
"""Test the migration script hashes plain text passwords.""" """Test the migration script hashes plain text passwords."""
# Clean up # Clean up

View File

@@ -29,7 +29,7 @@ def add_test_user():
}) })
def login_and_set_cookie(client): 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 assert resp.status_code == 200
# Set cookie for subsequent requests # Set cookie for subsequent requests
token = resp.headers.get("Set-Cookie") token = resp.headers.get("Set-Cookie")
@@ -40,7 +40,7 @@ def login_and_set_cookie(client):
def client(): def client():
app = Flask(__name__) app = Flask(__name__)
app.register_blueprint(child_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['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client: with app.test_client() as client:

View File

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

View File

@@ -36,7 +36,7 @@ def add_test_user():
}) })
def login_and_set_cookie(client): 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 assert resp.status_code == 200
token = resp.headers.get("Set-Cookie") token = resp.headers.get("Set-Cookie")
assert token and "token=" in token assert token and "token=" in token
@@ -65,7 +65,7 @@ def remove_test_data():
def client(): def client():
app = Flask(__name__) app = Flask(__name__)
app.register_blueprint(image_api) app.register_blueprint(image_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api)
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as c: with app.test_client() as c:

View File

@@ -28,7 +28,7 @@ def add_test_user():
}) })
def login_and_set_cookie(client): 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 assert resp.status_code == 200
token = resp.headers.get("Set-Cookie") token = resp.headers.get("Set-Cookie")
assert token and "token=" in token assert token and "token=" in token
@@ -37,7 +37,7 @@ def login_and_set_cookie(client):
def client(): def client():
app = Flask(__name__) app = Flask(__name__)
app.register_blueprint(reward_api) app.register_blueprint(reward_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api)
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client: with app.test_client() as client:

View File

@@ -27,7 +27,7 @@ def add_test_user():
}) })
def login_and_set_cookie(client): 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 assert resp.status_code == 200
token = resp.headers.get("Set-Cookie") token = resp.headers.get("Set-Cookie")
assert token and "token=" in token assert token and "token=" in token
@@ -36,7 +36,7 @@ def login_and_set_cookie(client):
def client(): def client():
app = Flask(__name__) app = Flask(__name__)
app.register_blueprint(task_api) app.register_blueprint(task_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api)
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client: with app.test_client() as client:
@@ -80,36 +80,6 @@ def test_list_tasks(client):
assert len(data['tasks']) == 2 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): def test_get_task_not_found(client):
response = client.get('/task/nonexistent-id') response = client.get('/task/nonexistent-id')
assert response.status_code == 404 assert response.status_code == 404

View File

@@ -48,7 +48,7 @@ def add_test_users():
def login_and_get_token(client, email, password): def login_and_get_token(client, email, password):
"""Login and extract JWT token from response.""" """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 assert resp.status_code == 200
# Extract token from Set-Cookie header # Extract token from Set-Cookie header
set_cookie = resp.headers.get("Set-Cookie") set_cookie = resp.headers.get("Set-Cookie")
@@ -61,7 +61,7 @@ def client():
"""Setup Flask test client with registered blueprints.""" """Setup Flask test client with registered blueprints."""
app = Flask(__name__) app = Flask(__name__)
app.register_blueprint(user_api) app.register_blueprint(user_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api)
app.config['TESTING'] = True app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey' app.config['SECRET_KEY'] = 'supersecretkey'
app.config['FRONTEND_URL'] = 'http://localhost:5173' # Needed for email_sender 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): def test_login_for_marked_user_returns_403(client):
"""Test that login for a marked-for-deletion user returns 403 Forbidden.""" """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, "email": MARKED_EMAIL,
"password": MARKED_PASSWORD "password": MARKED_PASSWORD
}) })
@@ -118,7 +118,7 @@ def test_mark_for_deletion_requires_auth(client):
def test_login_blocked_for_marked_user(client): def test_login_blocked_for_marked_user(client):
"""Test that login is blocked for users marked for deletion.""" """Test that login is blocked for users marked for deletion."""
response = client.post('/auth/login', json={ response = client.post('/login', json={
"email": MARKED_EMAIL, "email": MARKED_EMAIL,
"password": MARKED_PASSWORD "password": MARKED_PASSWORD
}) })
@@ -129,7 +129,7 @@ def test_login_blocked_for_marked_user(client):
def test_login_succeeds_for_unmarked_user(client): def test_login_succeeds_for_unmarked_user(client):
"""Test that login works normally for users not marked for deletion.""" """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, "email": TEST_EMAIL,
"password": TEST_PASSWORD "password": TEST_PASSWORD
}) })
@@ -139,7 +139,7 @@ def test_login_succeeds_for_unmarked_user(client):
def test_password_reset_ignored_for_marked_user(client): def test_password_reset_ignored_for_marked_user(client):
"""Test that password reset requests return 403 for marked users.""" """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 assert response.status_code == 403
data = response.get_json() data = response.get_json()
assert 'error' in data 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): def test_password_reset_works_for_unmarked_user(client):
"""Test that password reset works normally for unmarked users.""" """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 assert response.status_code == 200
data = response.get_json() data = response.get_json()
assert 'message' in data assert 'message' in data
@@ -168,35 +168,6 @@ def test_mark_for_deletion_updates_timestamp(authenticated_client):
assert before_time <= marked_at <= after_time 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): def test_mark_for_deletion_with_invalid_jwt(client):
"""Test marking for deletion with invalid JWT token.""" """Test marking for deletion with invalid JWT token."""
# Set invalid cookie manually # Set invalid cookie manually

View File

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

View File

@@ -2,36 +2,35 @@
version: "3.8" version: "3.8"
services: services:
chores-app-backend: # Production backend service name chore-app-backend: # Production backend service name
image: git.ryankegel.com:3000/kegel/chores/backend:latest # Or specific version tag image: git.ryankegel.com:3000/ryan/backend:latest # Or specific version tag
container_name: chores-app-backend-prod # Added for easy identification container_name: chore-app-backend-prod # Added for easy identification
ports: ports:
- "5001:5000" # Host 5001 -> Container 5000 - "5001:5000" # Host 5001 -> Container 5000
environment: environment:
- FLASK_ENV=production - FLASK_ENV=production
- FRONTEND_URL=${FRONTEND_URL}
volumes: 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: networks:
- chores-app-net - chore-app-net
# Add other volumes, networks, etc., as needed # Add other volumes, networks, etc., as needed
chores-app-frontend: # Production frontend service name chore-app-frontend: # Production frontend service name
image: git.ryankegel.com:3000/kegel/chores/frontend:latest # Or specific version tag image: git.ryankegel.com:3000/ryan/frontend:latest # Or specific version tag
container_name: chores-app-frontend-prod # Added for easy identification container_name: chore-app-frontend-prod # Added for easy identification
ports: ports:
- "443:443" # Host 443 -> Container 443 (HTTPS) - "443:443" # Host 443 -> Container 443 (HTTPS)
environment: environment:
- BACKEND_HOST=chores-app-backend # Points to internal backend service - BACKEND_HOST=chore-app-backend # Points to internal backend service
depends_on: depends_on:
- chores-app-backend - chore-app-backend
networks: networks:
- chores-app-net - chore-app-net
# Add volumes, networks, etc., as needed # Add volumes, networks, etc., as needed
networks: networks:
chores-app-net: chore-app-net:
driver: bridge driver: bridge
volumes: 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==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -664,7 +663,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -708,7 +706,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -1958,7 +1955,6 @@
"integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/scope-manager": "8.46.4",
"@typescript-eslint/types": "8.46.4", "@typescript-eslint/types": "8.46.4",
@@ -2714,7 +2710,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2909,7 +2904,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.19", "baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751", "caniuse-lite": "^1.0.30001751",
@@ -3415,7 +3409,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3476,7 +3469,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@@ -3524,7 +3516,6 @@
"integrity": "sha512-SbR9ZBUFKgvWAbq3RrdCtWaW0IKm6wwUiApxf3BVTNfqUIo4IQQmreMg2iHFJJ6C/0wss3LXURBJ1OwS/MhFcQ==", "integrity": "sha512-SbR9ZBUFKgvWAbq3RrdCtWaW0IKm6wwUiApxf3BVTNfqUIo4IQQmreMg2iHFJJ6C/0wss3LXURBJ1OwS/MhFcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@@ -4204,7 +4195,6 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
@@ -4982,7 +4972,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -5553,7 +5542,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -5692,7 +5680,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -5816,7 +5803,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -6037,7 +6023,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -6051,7 +6036,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.4",
@@ -6144,7 +6128,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==", "integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.24", "@vue/compiler-dom": "3.5.24",
"@vue/compiler-sfc": "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 () => { it('does not show delete button for system items', async () => {
const wrapper = mount(ItemList, { const wrapper = mount(ItemList, {
props: { props: {
fetchUrl: '',
itemKey: 'items', itemKey: 'items',
itemFields: ['name'], itemFields: ['name'],
deletable: true, deletable: true,
testItems: [systemItem], testItems: [systemItem],
}, },
global: {
stubs: ['svg'],
},
}) })
await flushPromises() await flushPromises()
expect(wrapper.find('.delete-btn').exists()).toBe(false) expect(wrapper.find('.delete-btn').exists()).toBe(false)
@@ -24,12 +26,14 @@ describe('ItemList.vue', () => {
it('shows delete button for user items', async () => { it('shows delete button for user items', async () => {
const wrapper = mount(ItemList, { const wrapper = mount(ItemList, {
props: { props: {
fetchUrl: '',
itemKey: 'items', itemKey: 'items',
itemFields: ['name'], itemFields: ['name'],
deletable: true, deletable: true,
testItems: [userItem], testItems: [userItem],
}, },
global: {
stubs: ['svg'],
},
}) })
await flushPromises() await flushPromises()
expect(wrapper.find('.delete-btn').exists()).toBe(true) expect(wrapper.find('.delete-btn').exists()).toBe(true)

View File

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

View File

@@ -85,12 +85,6 @@
pointer-events: none; pointer-events: none;
color: var(--btn-primary); color: var(--btn-primary);
} }
@media (max-width: 520px) {
.btn-link {
margin-top: 1rem;
margin-bottom: 1rem;
}
}
/* Rounded button */ /* Rounded button */
.round-btn { .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 }> { export async function parseErrorResponse(res: Response): Promise<{ msg: string; code?: string }> {
try { try {
const data = await res.json() const data = await res.json()

View File

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

View File

@@ -13,7 +13,6 @@ export interface User {
first_name: string first_name: string
last_name: string last_name: string
email: string email: string
token_version: number
image_id: string | null image_id: string | null
marked_for_deletion: boolean marked_for_deletion: boolean
marked_for_deletion_at: string | null 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', () => ({ vi.mock('../../stores/auth', () => ({
authenticateParent: vi.fn(), authenticateParent: vi.fn(),
isParentAuthenticated: { value: false }, isParentAuthenticated: { value: false },
isParentPersistent: { value: false },
logoutParent: vi.fn(), logoutParent: vi.fn(),
logoutUser: 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 if (!isFormValid.value) return
loading.value = true loading.value = true
try { try {
const res = await fetch('/api/auth/request-password-reset', { const res = await fetch('/api/request-password-reset', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value.trim() }), body: JSON.stringify({ email: email.value.trim() }),

View File

@@ -146,7 +146,7 @@ import {
ALREADY_VERIFIED, ALREADY_VERIFIED,
} from '@/common/errorCodes' } from '@/common/errorCodes'
import { parseErrorResponse, isEmailValid } from '@/common/api' import { parseErrorResponse, isEmailValid } from '@/common/api'
import { loginUser, checkAuth } from '@/stores/auth' import { loginUser } from '@/stores/auth'
const router = useRouter() const router = useRouter()
@@ -176,7 +176,7 @@ async function submitForm() {
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
try { try {
const res = await fetch('/api/auth/login', { const res = await fetch('/api/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value.trim(), password: password.value }), body: JSON.stringify({ email: email.value.trim(), password: password.value }),
@@ -211,7 +211,6 @@ async function submitForm() {
} }
loginUser() // <-- set user as logged in loginUser() // <-- set user as logged in
await checkAuth() // hydrate currentUserId so SSE reconnects immediately
await router.push({ path: '/' }).catch(() => (window.location.href = '/')) await router.push({ path: '/' }).catch(() => (window.location.href = '/'))
} catch (err) { } catch (err) {
@@ -231,7 +230,7 @@ async function resendVerification() {
} }
resendLoading.value = true resendLoading.value = true
try { try {
const res = await fetch('/api/auth/resend-verify', { const res = await fetch('/api/resend-verify', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }), 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 We've sent a 6-digit code to your email. Enter it below to continue. The code is valid for
10 minutes. 10 minutes.
</p> </p>
<input <input v-model="code" maxlength="6" class="code-input" placeholder="6-digit code" />
v-model="code"
maxlength="6"
class="code-input"
placeholder="6-digit code"
@keyup.enter="isCodeValid && verifyCode()"
/>
<div class="button-group"> <div class="button-group">
<button <button
v-if="!loading" v-if="!loading"
@@ -45,8 +39,6 @@
<p>Enter a new 46 digit Parent PIN. This will be required for parent access.</p> <p>Enter a new 46 digit Parent PIN. This will be required for parent access.</p>
<input <input
v-model="pin" v-model="pin"
@input="handlePinInput"
@keyup.enter="!loading && isPinValid && setPin()"
maxlength="6" maxlength="6"
inputmode="numeric" inputmode="numeric"
pattern="\d*" pattern="\d*"
@@ -55,8 +47,6 @@
/> />
<input <input
v-model="pin2" v-model="pin2"
@input="handlePin2Input"
@keyup.enter="!loading && isPinValid && setPin()"
maxlength="6" maxlength="6"
inputmode="numeric" inputmode="numeric"
pattern="\d*" pattern="\d*"
@@ -102,16 +92,6 @@ const isPinValid = computed(() => {
return /^\d{4,6}$/.test(p1) && /^\d{4,6}$/.test(p2) && p1 === p2 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() { async function requestCode() {
error.value = '' error.value = ''
info.value = '' info.value = ''

View File

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

View File

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

View File

@@ -182,15 +182,13 @@ async function verifyToken() {
const token = Array.isArray(raw) ? raw[0] : String(raw || '') const token = Array.isArray(raw) ? raw[0] : String(raw || '')
if (!token) { if (!token) {
verifyingLoading.value = false router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
// Redirect to AuthLanding
router.push({ name: 'AuthLanding' }).catch(() => (window.location.href = '/auth'))
return return
} }
verifyingLoading.value = true verifyingLoading.value = true
try { try {
const url = `/api/auth/verify?token=${encodeURIComponent(token)}` const url = `/api/verify?token=${encodeURIComponent(token)}`
const res = await fetch(url, { method: 'GET' }) const res = await fetch(url, { method: 'GET' })
if (!res.ok) { if (!res.ok) {
@@ -209,8 +207,6 @@ async function verifyToken() {
default: default:
verifyError.value = msg || `Verification failed with status ${res.status}.` verifyError.value = msg || `Verification failed with status ${res.status}.`
} }
// Redirect to AuthLanding
router.push({ name: 'AuthLanding' }).catch(() => (window.location.href = '/auth'))
return return
} }
@@ -219,8 +215,6 @@ async function verifyToken() {
startRedirectCountdown() startRedirectCountdown()
} catch { } catch {
verifyError.value = 'Network error. Please try again.' verifyError.value = 'Network error. Please try again.'
// Redirect to AuthLanding
router.push({ name: 'AuthLanding' }).catch(() => (window.location.href = '/auth'))
} finally { } finally {
verifyingLoading.value = false verifyingLoading.value = false
} }
@@ -261,7 +255,7 @@ async function handleResend() {
sendingDialog.value = true sendingDialog.value = true
resendLoading.value = true resendLoading.value = true
try { try {
const res = await fetch('/api/auth/resend-verify', { const res = await fetch('/api/resend-verify', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: resendEmail.value.trim() }), 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" :fields="fields"
:initialData="initialData" :initialData="initialData"
:isEdit="isEdit" :isEdit="isEdit"
:requireDirty="isEdit"
:loading="loading" :loading="loading"
:error="error" :error="error"
@submit="handleSubmit" @submit="handleSubmit"
@@ -17,39 +16,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue' 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 EntityEditForm from '../shared/EntityEditForm.vue'
import '@/assets/styles.css' import '@/assets/styles.css'
const route = useRoute()
const router = useRouter() const router = useRouter()
const props = defineProps<{ id?: string }>() const props = defineProps<{ id?: string }>()
const isEdit = computed(() => !!props.id) const isEdit = computed(() => !!props.id)
type Field = { const fields = [
name: string
label: string
type: 'text' | 'number' | 'image' | 'custom'
required?: boolean
maxlength?: number
min?: number
max?: number
imageType?: number
}
type ChildForm = {
name: string
age: number | null
image_id: string | null
}
const fields: Field[] = [
{ name: 'name', label: 'Name', type: 'text', required: true, maxlength: 64 }, { name: '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 }, { 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 localImageFile = ref<File | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@@ -63,31 +45,15 @@ onMounted(async () => {
const data = await resp.json() const data = await resp.json()
initialData.value = { initialData.value = {
name: data.name ?? '', 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, image_id: data.image_id ?? null,
} }
} catch { } catch (e) {
error.value = 'Could not load child.' error.value = 'Could not load child.'
} finally { } finally {
loading.value = false loading.value = false
await nextTick() 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 let imageId = form.image_id
error.value = null error.value = null
if (!form.name.trim()) { if (!form.name.trim()) {
@@ -124,7 +90,7 @@ async function handleSubmit(form: ChildForm) {
if (!resp.ok) throw new Error('Image upload failed') if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json() const data = await resp.json()
imageId = data.id imageId = data.id
} catch { } catch (err) {
error.value = 'Failed to upload image.' error.value = 'Failed to upload image.'
loading.value = false loading.value = false
return return
@@ -157,7 +123,7 @@ async function handleSubmit(form: ChildForm) {
} }
if (!resp.ok) throw new Error('Failed to save child') if (!resp.ok) throw new Error('Failed to save child')
await router.push({ name: 'ParentChildrenListView' }) await router.push({ name: 'ParentChildrenListView' })
} catch { } catch (err) {
error.value = 'Failed to save child.' error.value = 'Failed to save child.'
} }
loading.value = false loading.value = false

View File

@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue' import { ref, onMounted, onUnmounted, computed } from 'vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue' import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue' import ScrollingList from '../shared/ScrollingList.vue'
import StatusMessage from '../shared/StatusMessage.vue' import StatusMessage from '../shared/StatusMessage.vue'
import RewardConfirmDialog from './RewardConfirmDialog.vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { eventBus } from '@/common/eventBus' import { eventBus } from '@/common/eventBus'
//import '@/assets/view-shared.css' //import '@/assets/view-shared.css'
import '@/assets/styles.css' import '@/assets/styles.css'
@@ -13,6 +12,7 @@ import type {
Child, Child,
Event, Event,
Task, Task,
Reward,
RewardStatus, RewardStatus,
ChildTaskTriggeredEventPayload, ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload, ChildRewardTriggeredEventPayload,
@@ -32,10 +32,10 @@ const tasks = ref<string[]>([])
const rewards = ref<string[]>([]) const rewards = ref<string[]>([])
const loading = ref(true) const loading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const childRewardListRef = ref()
const showRewardDialog = ref(false) const showRewardDialog = ref(false)
const showCancelDialog = 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) { function handleTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload 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) => { const triggerReward = (reward: RewardStatus) => {
@@ -197,16 +211,33 @@ const triggerReward = (reward: RewardStatus) => {
utter.volume = 1.0 utter.volume = 1.0
window.speechSynthesis.speak(utter) window.speechSynthesis.speak(utter)
} }
}
if (reward.redeeming) { if (reward.redeeming) {
dialogReward.value = reward dialogReward.value = reward
showCancelDialog.value = true showCancelDialog.value = true
return return // Do not allow redeeming if already pending
}
if (reward.points_needed <= 0) {
dialogReward.value = reward
showRewardDialog.value = true
}
} }
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
} }
} }
@@ -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) { async function fetchChildData(id: string | number) {
loading.value = true loading.value = true
try { try {
@@ -450,33 +464,36 @@ onUnmounted(() => {
</ScrollingList> </ScrollingList>
</div> </div>
</div> </div>
<ModalDialog
v-if="showRewardDialog && dialogReward"
:imageUrl="dialogReward?.image_url"
:title="dialogReward.name"
:subtitle="`${dialogReward.cost} pts`"
>
<div class="modal-message">Would you like to redeem this reward?</div>
<div class="modal-actions">
<button @click="confirmRedeemReward" class="btn btn-primary">Yes</button>
<button @click="cancelRedeemReward" class="btn btn-secondary">No</button>
</div>
</ModalDialog>
<ModalDialog
v-if="showCancelDialog && dialogReward"
:imageUrl="dialogReward?.image_url"
:title="dialogReward.name"
:subtitle="`${dialogReward.cost} pts`"
>
<div class="modal-message">
This reward is pending.<br />
Would you like to cancel the pending reward request?
</div>
<div class="modal-actions">
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
<button @click="closeCancelDialog" class="btn btn-secondary">No</button>
</div>
</ModalDialog>
</div> </div>
<!-- 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"
:title="dialogReward.name"
subtitle="Reward Pending"
@backdrop-click="closeCancelDialog"
>
<div class="modal-message">
This reward is pending.<br />Would you like to cancel the 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>
</template> </template>
<style scoped> <style scoped>
@@ -565,16 +582,4 @@ onUnmounted(() => {
pointer-events: none; pointer-events: none;
filter: grayscale(0.7); 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> </style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <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 ModalDialog from '../shared/ModalDialog.vue'
import PendingRewardDialog from './PendingRewardDialog.vue' import PendingRewardDialog from './PendingRewardDialog.vue'
import TaskConfirmDialog from './TaskConfirmDialog.vue' import TaskConfirmDialog from './TaskConfirmDialog.vue'
@@ -52,9 +52,6 @@ const overrideEditTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward'
const overrideCustomValue = ref(0) const overrideCustomValue = ref(0)
const isOverrideValid = ref(true) const isOverrideValid = ref(true)
const readyItemId = ref<string | null>(null) const readyItemId = ref<string | null>(null)
const pendingEditOverrideTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward' } | null>(
null,
)
function handleItemReady(itemId: string) { function handleItemReady(itemId: string) {
readyItemId.value = itemId readyItemId.value = itemId
@@ -217,12 +214,6 @@ function handleOverrideDeleted(event: Event) {
} }
function handleEditItem(item: Task | Reward, type: 'task' | 'reward') { 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 } overrideEditTarget.value = { entity: item, type }
const defaultValue = type === 'task' ? (item as Task).points : (item as Reward).cost const defaultValue = type === 'task' ? (item as Task).points : (item as Reward).cost
overrideCustomValue.value = item.custom_value ?? defaultValue overrideCustomValue.value = item.custom_value ?? defaultValue
@@ -230,34 +221,11 @@ function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
showOverrideModal.value = true 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() { function validateOverrideInput() {
const val = overrideCustomValue.value const val = overrideCustomValue.value
isOverrideValid.value = typeof val === 'number' && val >= 0 && val <= 10000 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() { async function saveOverride() {
if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return
@@ -571,7 +539,7 @@ function goToAssignRewards() {
</div> </div>
</div> </div>
<div class="assign-buttons"> <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"> <button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
Assign Penalties Assign Penalties
</button> </button>
@@ -581,18 +549,8 @@ function goToAssignRewards() {
<!-- Pending Reward Dialog --> <!-- Pending Reward Dialog -->
<PendingRewardDialog <PendingRewardDialog
v-if="showPendingRewardDialog" v-if="showPendingRewardDialog"
:message=" @confirm="cancelPendingReward"
pendingEditOverrideTarget @cancel="showPendingRewardDialog = false"
? '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
}
"
/> />
<!-- Override Edit Modal --> <!-- Override Edit Modal -->

View File

@@ -1,7 +1,9 @@
<template> <template>
<ModalDialog title="Warning!" @backdrop-click="$emit('cancel')"> <ModalDialog title="Warning!" @backdrop-click="$emit('cancel')">
<div class="modal-message"> <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>
<div class="modal-actions"> <div class="modal-actions">
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button> <button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
@@ -13,15 +15,6 @@
<script setup lang="ts"> <script setup lang="ts">
import ModalDialog from '../shared/ModalDialog.vue' 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<{ defineEmits<{
confirm: [] confirm: []
cancel: [] cancel: []

View File

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

View File

@@ -191,16 +191,6 @@ describe('ChildView', () => {
expect(window.speechSynthesis.speak).toHaveBeenCalled() 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', () => { it('does not crash if speechSynthesis is not available', () => {
const originalSpeechSynthesis = global.window.speechSynthesis const originalSpeechSynthesis = global.window.speechSynthesis
delete (global.window as any).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', () => { describe('SSE Event Handlers', () => {
beforeEach(async () => { beforeEach(async () => {
wrapper = mount(ChildView) wrapper = mount(ChildView)

View File

@@ -348,106 +348,4 @@ describe('ParentView', () => {
expect(true).toBe(true) // Placeholder - template logic verified 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 <ItemList
v-else v-else
:key="refreshKey"
:fetchUrl="`/api/pending-rewards`" :fetchUrl="`/api/pending-rewards`"
itemKey="rewards" itemKey="rewards"
:itemFields="PENDING_REWARD_FIELDS" :itemFields="PENDING_REWARD_FIELDS"
@@ -31,43 +30,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue' import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.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 { PENDING_REWARD_FIELDS } from '@/common/models'
import { eventBus } from '@/common/eventBus'
const router = useRouter() const router = useRouter()
const notificationListCountRef = ref(-1) const notificationListCountRef = ref(-1)
const refreshKey = ref(0)
function handleNotificationClick(item: PendingReward) { function handleNotificationClick(item: PendingReward) {
router.push({ name: 'ParentView', params: { id: item.child_id } }) 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> </script>
<style scoped> <style scoped>

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<template> <template>
<h2>{{ title ?? (isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}`) }}</h2> <h2>{{ title ?? (isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}`) }}</h2>
<div v-if="loading" class="loading-message">Loading {{ entityLabel.toLowerCase() }}...</div> <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"> <template v-for="field in fields" :key="field.name">
<div class="group"> <div class="group">
<label :for="field.name"> <label :for="field.name">
@@ -10,35 +10,18 @@
<slot <slot
:name="`custom-field-${field.name}`" :name="`custom-field-${field.name}`"
:modelValue="formData[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 --> <!-- Default rendering if no slot provided -->
<input <input
v-if="field.type === 'text'" v-if="field.type === 'text' || field.type === 'number'"
:id="field.name" :id="field.name"
v-model="formData[field.name]" v-model="formData[field.name]"
type="text" :type="field.type"
:required="field.required" :required="field.required"
:maxlength="field.maxlength" :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" :min="field.min"
:max="field.max" :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 <ImagePicker
v-else-if="field.type === 'image'" v-else-if="field.type === 'image'"
@@ -56,11 +39,7 @@
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading"> <button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
Cancel Cancel
</button> </button>
<button <button type="submit" class="btn btn-primary" :disabled="loading || !isDirty || !isValid">
type="submit"
class="btn btn-primary"
:disabled="loading || !isValid || (props.requireDirty && !isDirty)"
>
{{ isEdit ? 'Save' : 'Create' }} {{ isEdit ? 'Save' : 'Create' }}
</button> </button>
</div> </div>
@@ -84,52 +63,36 @@ type Field = {
imageType?: number imageType?: number
} }
const props = withDefaults( const props = defineProps<{
defineProps<{ entityLabel: string
entityLabel: string fields: Field[]
fields: Field[] initialData?: Record<string, any>
initialData?: Record<string, any> isEdit?: boolean
isEdit?: boolean loading?: boolean
loading?: boolean error?: string | null
error?: string | null title?: string
title?: string }>()
requireDirty?: boolean
}>(),
{
requireDirty: true,
},
)
const emit = defineEmits(['submit', 'cancel', 'add-image']) const emit = defineEmits(['submit', 'cancel', 'add-image'])
const router = useRouter() const router = useRouter()
const formData = ref<Record<string, any>>({ ...props.initialData }) 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() { watch(
await nextTick() () => props.initialData,
const firstInput = formRef.value?.querySelector<HTMLElement>('input, select, textarea') (newVal) => {
firstInput?.focus() if (newVal) {
} formData.value = { ...newVal }
}
},
{ immediate: true, deep: true },
)
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
isDirty.value = false // Optionally focus first input
if (!props.loading) {
focusFirstInput()
}
}) })
watch(
() => props.loading,
(newVal, oldVal) => {
if (!newVal && oldVal === true) {
focusFirstInput()
}
},
)
function onAddImage({ id, file }: { id: string; file: File }) { function onAddImage({ id, file }: { id: string; file: File }) {
emit('add-image', { id, file }) emit('add-image', { id, file })
} }
@@ -146,36 +109,14 @@ function submit() {
// Editable field names (exclude custom fields that are not editable) // Editable field names (exclude custom fields that are not editable)
const editableFieldNames = props.fields 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) .map((f) => f.name)
const isDirty = ref(false) 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() { function checkDirty() {
isDirty.value = editableFieldNames.some((key) => { 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 (field.type === 'number') {
if (value === '' || value === null || value === undefined) return false
const numValue = Number(value) const numValue = Number(value)
if (isNaN(numValue)) return false if (isNaN(numValue)) return false
if (field.min !== undefined && numValue < field.min) return false if (field.min !== undefined && numValue < field.min) return false
@@ -205,7 +145,8 @@ const isValid = computed(() => {
watch( watch(
() => ({ ...formData.value }), () => ({ ...formData.value }),
() => { (newVal) => {
console.log('formData changed:', newVal)
checkDirty() checkDirty()
}, },
{ deep: true }, { deep: true },
@@ -216,8 +157,7 @@ watch(
(newVal) => { (newVal) => {
if (newVal) { if (newVal) {
formData.value = { ...newVal } formData.value = { ...newVal }
baselineData.value = { ...newVal } checkDirty()
isDirty.value = false
} }
}, },
{ immediate: true, deep: true }, { immediate: true, deep: true },

View File

@@ -90,14 +90,6 @@ onMounted(fetchItems)
watch(() => props.fetchUrl, fetchItems) watch(() => props.fetchUrl, fetchItems)
const handleClicked = (item: any) => { 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) emit('clicked', item)
props.onClicked?.(item) props.onClicked?.(item)
} }

View File

@@ -5,7 +5,6 @@ import { eventBus } from '@/common/eventBus'
import { import {
authenticateParent, authenticateParent,
isParentAuthenticated, isParentAuthenticated,
isParentPersistent,
logoutParent, logoutParent,
logoutUser, logoutUser,
} from '../../stores/auth' } from '../../stores/auth'
@@ -17,7 +16,6 @@ const router = useRouter()
const show = ref(false) const show = ref(false)
const pin = ref('') const pin = ref('')
const error = ref('') const error = ref('')
const stayInParentMode = ref(false)
const pinInput = ref<HTMLInputElement | null>(null) const pinInput = ref<HTMLInputElement | null>(null)
const dropdownOpen = ref(false) const dropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null) const dropdownRef = ref<HTMLElement | null>(null)
@@ -38,6 +36,7 @@ const avatarInitial = ref<string>('?')
// Fetch user profile // Fetch user profile
async function fetchUserProfile() { async function fetchUserProfile() {
try { try {
console.log('Fetching user profile')
const res = await fetch('/api/user/profile', { credentials: 'include' }) const res = await fetch('/api/user/profile', { credentials: 'include' })
if (!res.ok) { if (!res.ok) {
console.error('Failed to fetch user profile') console.error('Failed to fetch user profile')
@@ -104,7 +103,6 @@ const open = async () => {
const close = () => { const close = () => {
show.value = false show.value = false
error.value = '' error.value = ''
stayInParentMode.value = false
} }
const submit = async () => { const submit = async () => {
@@ -128,13 +126,10 @@ const submit = async () => {
} }
if (!data.valid) { if (!data.valid) {
error.value = 'Incorrect PIN' error.value = 'Incorrect PIN'
pin.value = ''
await nextTick()
pinInput.value?.focus()
return return
} }
// Authenticate parent and navigate // Authenticate parent and navigate
authenticateParent(stayInParentMode.value) authenticateParent()
close() close()
router.push('/parent') router.push('/parent')
} catch (e) { } 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 = () => { const handleLogout = () => {
logoutParent() logoutParent()
router.push('/child') router.push('/child')
@@ -223,7 +213,7 @@ function executeMenuItem(index: number) {
async function signOut() { async function signOut() {
try { try {
await fetch('/api/auth/logout', { method: 'POST' }) await fetch('/api/logout', { method: 'POST' })
logoutUser() logoutUser()
router.push('/auth') router.push('/auth')
} catch { } catch {
@@ -284,12 +274,6 @@ onUnmounted(() => {
/> />
<span v-else class="avatar-text">{{ profileLoading ? '...' : avatarInitial }}</span> <span v-else class="avatar-text">{{ profileLoading ? '...' : avatarInitial }}</span>
</button> </button>
<span
v-if="isParentAuthenticated && isParentPersistent"
class="persistent-badge"
aria-label="Persistent parent mode active"
>🔒</span
>
<Transition name="slide-fade"> <Transition name="slide-fade">
<div <div
@@ -373,20 +357,15 @@ onUnmounted(() => {
<input <input
ref="pinInput" ref="pinInput"
v-model="pin" v-model="pin"
@input="handlePinInput"
inputmode="numeric" inputmode="numeric"
pattern="\d*" pattern="\d*"
maxlength="6" maxlength="6"
placeholder="46 digits" placeholder="46 digits"
class="pin-input" 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"> <div class="actions modal-actions">
<button type="button" class="btn btn-secondary" @click="close">Cancel</button> <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> </div>
</form> </form>
<div v-if="error" class="error modal-message">{{ error }}</div> <div v-if="error" class="error modal-message">{{ error }}</div>
@@ -458,40 +437,11 @@ onUnmounted(() => {
font-size: 1rem; font-size: 1rem;
border-radius: 8px; border-radius: 8px;
border: 1px solid #e6e6e6; border: 1px solid #e6e6e6;
margin-bottom: 0.8rem; margin-bottom: 0.6rem;
box-sizing: border-box; box-sizing: border-box;
text-align: center; 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 { .dropdown-menu {
position: absolute; position: absolute;
right: 0; right: 0;

View File

@@ -383,6 +383,6 @@ onBeforeUnmount(() => {
.empty { .empty {
text-align: center; text-align: center;
padding: 2rem 0; padding: 2rem 0;
color: #d6d6d6; color: #888;
} }
</style> </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 LoginButton from '../LoginButton.vue'
import { authenticateParent, logoutParent } from '../../../stores/auth' import { authenticateParent, logoutParent } from '../../../stores/auth'
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}))
// Mock imageCache module // Mock imageCache module
vi.mock('@/common/imageCache', () => ({ vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn(async (imageId: string) => `blob:mock-url-${imageId}`), getCachedImageUrl: vi.fn(async (imageId: string) => `blob:mock-url-${imageId}`),
@@ -15,21 +11,24 @@ vi.mock('@/common/imageCache', () => ({
revokeAllImageUrls: vi.fn(), revokeAllImageUrls: vi.fn(),
})) }))
// Create real Vue refs for isParentAuthenticated and isParentPersistent using vi.hoisted. // Create a reactive ref for isParentAuthenticated using vi.hoisted
// Real Vue refs are required so Vue templates auto-unwrap them correctly in v-if conditions. const { isParentAuthenticatedRef } = vi.hoisted(() => {
const { isParentAuthenticatedRef, isParentPersistentRef } = vi.hoisted(() => { let value = false
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return { return {
isParentAuthenticatedRef: ref(false), isParentAuthenticatedRef: {
isParentPersistentRef: ref(false), get value() {
return value
},
set value(v: boolean) {
value = v
},
},
} }
}) })
vi.mock('../../../stores/auth', () => ({ vi.mock('../../../stores/auth', () => ({
authenticateParent: vi.fn(), authenticateParent: vi.fn(),
isParentAuthenticated: isParentAuthenticatedRef, isParentAuthenticated: isParentAuthenticatedRef,
isParentPersistent: isParentPersistentRef,
logoutParent: vi.fn(), logoutParent: vi.fn(),
logoutUser: vi.fn(), logoutUser: vi.fn(),
})) }))
@@ -42,7 +41,6 @@ describe('LoginButton', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
isParentAuthenticatedRef.value = false isParentAuthenticatedRef.value = false
isParentPersistentRef.value = false
;(global.fetch as any).mockResolvedValue({ ;(global.fetch as any).mockResolvedValue({
ok: true, ok: true,
json: async () => ({ 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', () => { describe('User Email Display', () => {
it('displays email in dropdown header when available', async () => { it('displays email in dropdown header when available', async () => {
isParentAuthenticatedRef.value = true isParentAuthenticatedRef.value = true

View File

@@ -10,7 +10,6 @@ const props = defineProps<{
const emit = defineEmits(['update:modelValue', 'add-image']) const emit = defineEmits(['update:modelValue', 'add-image'])
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const imageScrollRef = ref<HTMLDivElement | null>(null)
const localImageUrl = ref<string | null>(null) const localImageUrl = ref<string | null>(null)
const showCamera = ref(false) const showCamera = ref(false)
const cameraStream = ref<MediaStream | null>(null) const cameraStream = ref<MediaStream | null>(null)
@@ -199,13 +198,6 @@ function updateLocalImage(url: string, file: File) {
} else { } else {
availableImages.value[idx].url = url availableImages.value[idx].url = url
} }
nextTick(() => {
if (imageScrollRef.value) {
imageScrollRef.value.scrollLeft = 0
}
})
emit('add-image', { id: 'local-upload', url, file }) emit('add-image', { id: 'local-upload', url, file })
emit('update:modelValue', 'local-upload') emit('update:modelValue', 'local-upload')
} }
@@ -213,7 +205,7 @@ function updateLocalImage(url: string, file: File) {
<template> <template>
<div class="picker"> <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-if="loadingImages" class="loading-images">Loading images...</div>
<div v-else class="image-list"> <div v-else class="image-list">
<img <img
@@ -231,6 +223,7 @@ function updateLocalImage(url: string, file: File) {
ref="fileInput" ref="fileInput"
type="file" type="file"
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif" accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
capture="environment"
style="display: none" style="display: none"
@change="onFileChange" @change="onFileChange"
/> />

View File

@@ -2,14 +2,9 @@ import '@/assets/colors.css'
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { initAuthSync } from './stores/auth'
import { installUnauthorizedFetchInterceptor } from './common/api'
const app = createApp(App) const app = createApp(App)
initAuthSync()
installUnauthorizedFetchInterceptor()
app.use(router) app.use(router)
app.mount('#app') 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 Signup from '@/components/auth/Signup.vue'
import AuthLanding from '@/components/auth/AuthLanding.vue' import AuthLanding from '@/components/auth/AuthLanding.vue'
import Login from '@/components/auth/Login.vue' import Login from '@/components/auth/Login.vue'
import { import { isUserLoggedIn, isParentAuthenticated, isAuthReady } from '../stores/auth'
isUserLoggedIn,
isParentAuthenticated,
isAuthReady,
logoutParent,
enforceParentExpiry,
} from '../stores/auth'
import ParentPinSetup from '@/components/auth/ParentPinSetup.vue' import ParentPinSetup from '@/components/auth/ParentPinSetup.vue'
const routes = [ const routes = [
@@ -181,9 +175,6 @@ const routes = [
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes, routes,
scrollBehavior() {
return { top: 0, left: 0, behavior: 'smooth' }
},
}) })
// Auth guard // 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 // Always allow /auth and /parent/pin-setup
if (to.path.startsWith('/auth') || to.name === 'ParentPinSetup') { if (to.path.startsWith('/auth') || to.name === 'ParentPinSetup') {
return next() return next()
@@ -219,8 +201,6 @@ router.beforeEach(async (to, from, next) => {
} }
// If parent-authenticated, allow all /parent routes // 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')) { if (isParentAuthenticated.value && to.path.startsWith('/parent')) {
return next() return next()
} }
@@ -234,8 +214,6 @@ router.beforeEach(async (to, from, next) => {
if (isParentAuthenticated.value) { if (isParentAuthenticated.value) {
return next('/parent') return next('/parent')
} else { } else {
// Ensure parent auth is fully cleared when redirecting away from /parent
logoutParent()
return next('/child') return next('/child')
} }
}) })

View File

@@ -1,19 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { import { isParentAuthenticated, loginUser } from '../auth'
isParentAuthenticated,
isUserLoggedIn,
loginUser,
initAuthSync,
authenticateParent,
logoutParent,
} from '../auth'
import { nextTick } from 'vue' 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 // Helper to mock localStorage
global.localStorage = { global.localStorage = {
store: {} as Record<string, string>, store: {} as Record<string, string>,
@@ -33,57 +21,13 @@ global.localStorage = {
describe('auth store - child mode on login', () => { describe('auth store - child mode on login', () => {
beforeEach(() => { beforeEach(() => {
// Use authenticateParent() to set up parent-mode state isParentAuthenticated.value = true
authenticateParent(false) localStorage.setItem('isParentAuthenticated', 'true')
})
afterEach(() => {
logoutParent()
}) })
it('should clear isParentAuthenticated and localStorage on loginUser()', async () => { it('should clear isParentAuthenticated and localStorage on loginUser()', async () => {
expect(isParentAuthenticated.value).toBe(true)
loginUser() loginUser()
await nextTick() await nextTick() // flush Vue watcher
expect(isParentAuthenticated.value).toBe(false) 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 = const hasLocalStorage =
typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function' 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 isUserLoggedIn = ref(false)
export const isAuthReady = ref(false) export const isAuthReady = ref(false)
export const currentUserId = ref('') export const currentUserId = ref('')
let authSyncInitialized = false
// --- Background expiry watcher --- watch(isParentAuthenticated, (val) => {
let expiryWatcherIntervalId: ReturnType<typeof setInterval> | null = null 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() { export function authenticateParent() {
stopParentExpiryWatcher()
expiryWatcherIntervalId = setInterval(runExpiryCheck, 15_000)
}
export function stopParentExpiryWatcher() {
if (expiryWatcherIntervalId !== null) {
clearInterval(expiryWatcherIntervalId)
expiryWatcherIntervalId = null
}
}
/**
* Explicitly checks whether parent auth has expired and clears it if so.
* Called by the router guard before allowing /parent routes.
*/
export function enforceParentExpiry() {
runExpiryCheck()
}
export function authenticateParent(persistent: boolean) {
const duration = persistent ? PARENT_AUTH_EXPIRY_PERSISTENT : PARENT_AUTH_EXPIRY_NON_PERSISTENT
parentAuthExpiresAt.value = Date.now() + duration
isParentPersistent.value = persistent
isParentAuthenticated.value = true isParentAuthenticated.value = true
if (persistent && hasLocalStorage && typeof localStorage.setItem === 'function') {
localStorage.setItem(PARENT_AUTH_KEY, JSON.stringify({ expiresAt: parentAuthExpiresAt.value }))
}
startParentExpiryWatcher()
} }
export function logoutParent() { export function logoutParent() {
applyParentLoggedOutState()
broadcastParentLogoutEvent()
}
function applyParentLoggedOutState() {
parentAuthExpiresAt.value = null
isParentPersistent.value = false
isParentAuthenticated.value = false isParentAuthenticated.value = false
if (hasLocalStorage && typeof localStorage.removeItem === 'function') { 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() { export function loginUser() {
isUserLoggedIn.value = true isUserLoggedIn.value = true
// Always start in child mode after login // Always start in child mode after login
applyParentLoggedOutState() isParentAuthenticated.value = false
}
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() }))
} }
export function logoutUser() { export function logoutUser() {
applyLoggedOutState() isUserLoggedIn.value = false
broadcastLogoutEvent() currentUserId.value = ''
} logoutParent()
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.
}
})
} }
export async function checkAuth() { export async function checkAuth() {
try { try {
const res = await fetch('/api/auth/me', { method: 'GET' }) const res = await fetch('/api/me', { method: 'GET' })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
currentUserId.value = data.id currentUserId.value = data.id
isUserLoggedIn.value = true isUserLoggedIn.value = true
} else { } else {
logoutUser() isUserLoggedIn.value = false
currentUserId.value = ''
} }
} catch { } catch {
logoutUser() isUserLoggedIn.value = false
currentUserId.value = ''
} }
isAuthReady.value = true 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', environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'], exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)), root: fileURLToPath(new URL('./', import.meta.url)),
setupFiles: ['src/test/setup.ts'],
}, },
}), }),
) )