Compare commits
2 Commits
v1.0.5
...
df832e2238
| Author | SHA1 | Date | |
|---|---|---|---|
| df832e2238 | |||
| d600dde97f |
@@ -1 +0,0 @@
|
|||||||
FRONTEND_URL=https://yourdomain.com
|
|
||||||
@@ -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 }}
|
||||||
|
|||||||
141
.github/specs/archive/feat-parent-mode-expire.md
vendored
141
.github/specs/archive/feat-parent-mode-expire.md
vendored
@@ -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.
|
|
||||||
49
.github/specs/template/feat-template.md
vendored
49
.github/specs/template/feat-template.md
vendored
@@ -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
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {}
|
||||||
|
|||||||
@@ -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: {}
|
||||||
|
|||||||
@@ -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
|
|
||||||
17
frontend/vue-app/package-lock.json
generated
17
frontend/vue-app/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
205
frontend/vue-app/src/components/OverrideEditModal.vue
Normal file
205
frontend/vue-app/src/components/OverrideEditModal.vue
Normal 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>
|
||||||
@@ -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(),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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() }),
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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 4–6 digit Parent PIN. This will be required for parent access.</p>
|
<p>Enter a new 4–6 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 = ''
|
||||||
|
|||||||
@@ -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 = ''
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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() }),
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,17 +211,34 @@ 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) {
|
if (reward.points_needed <= 0) {
|
||||||
dialogReward.value = reward
|
dialogReward.value = reward
|
||||||
showRewardDialog.value = true
|
showRewardDialog.value = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelPendingReward() {
|
||||||
|
if (!child.value?.id || !dialogReward.value) return
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/child/${child.value.id}/cancel-request-reward`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reward_id: dialogReward.value.id }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error('Failed to cancel pending reward')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to cancel pending reward:', err)
|
||||||
|
} finally {
|
||||||
|
showCancelDialog.value = false
|
||||||
|
dialogReward.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelRedeemReward() {
|
function cancelRedeemReward() {
|
||||||
@@ -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>
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
|
||||||
<!-- Redeem reward dialog -->
|
|
||||||
<RewardConfirmDialog
|
|
||||||
v-if="showRewardDialog"
|
|
||||||
:reward="dialogReward"
|
|
||||||
:childName="child?.name"
|
|
||||||
@confirm="confirmRedeemReward"
|
|
||||||
@cancel="cancelRedeemReward"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Cancel pending reward dialog -->
|
|
||||||
<ModalDialog
|
<ModalDialog
|
||||||
v-if="showCancelDialog && dialogReward"
|
v-if="showCancelDialog && dialogReward"
|
||||||
:imageUrl="dialogReward.image_url"
|
:imageUrl="dialogReward?.image_url"
|
||||||
:title="dialogReward.name"
|
:title="dialogReward.name"
|
||||||
subtitle="Reward Pending"
|
:subtitle="`${dialogReward.cost} pts`"
|
||||||
@backdrop-click="closeCancelDialog"
|
|
||||||
>
|
>
|
||||||
<div class="modal-message">
|
<div class="modal-message">
|
||||||
This reward is pending.<br />Would you like to cancel the request?
|
This reward is pending.<br />
|
||||||
|
Would you like to cancel the pending reward request?
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
|
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
|
||||||
<button @click="closeCancelDialog" class="btn btn-secondary">No</button>
|
<button @click="closeCancelDialog" class="btn btn-secondary">No</button>
|
||||||
</div>
|
</div>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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: []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
fetch('/api/auth/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
}).finally(() => {
|
|
||||||
// Clear client-side auth and redirect, regardless of logout response
|
|
||||||
logoutUser()
|
logoutUser()
|
||||||
router.push('/auth/login')
|
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);
|
||||||
|
|||||||
@@ -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}`, {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,8 +63,7 @@ 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>
|
||||||
@@ -93,43 +71,28 @@ const props = withDefaults(
|
|||||||
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 },
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="4–6 digits"
|
placeholder="4–6 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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 = '/'
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
}))
|
|
||||||
@@ -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'],
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user