Compare commits
2 Commits
1.0.4
...
df832e2238
| Author | SHA1 | Date | |
|---|---|---|---|
| df832e2238 | |||
| d600dde97f |
@@ -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,36 +24,6 @@ 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/ryan/backend:${{ steps.vars.outputs.tag }} ./backend
|
docker build -t git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} ./backend
|
||||||
|
|||||||
@@ -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,7 +162,6 @@ 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(hours=24*7)
|
||||||
}
|
}
|
||||||
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.4" # 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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,18 +211,35 @@ 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() {
|
||||||
showRewardDialog.value = false
|
showRewardDialog.value = false
|
||||||
@@ -237,23 +268,6 @@ async function confirmRedeemReward() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelPendingReward() {
|
|
||||||
if (!child.value?.id || !dialogReward.value) return
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/child/${child.value.id}/cancel-request-reward`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ reward_id: dialogReward.value.id }),
|
|
||||||
})
|
|
||||||
if (!resp.ok) throw new Error('Failed to cancel pending reward')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to cancel pending reward:', err)
|
|
||||||
} finally {
|
|
||||||
showCancelDialog.value = false
|
|
||||||
dialogReward.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchChildData(id: string | number) {
|
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
|
||||||
|
|
||||||
@@ -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: []
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,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')
|
||||||
@@ -125,9 +126,6 @@ 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
|
||||||
@@ -139,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')
|
||||||
@@ -220,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 {
|
||||||
@@ -364,7 +357,6 @@ 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"
|
||||||
@@ -373,7 +365,7 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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,153 +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,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -175,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
|
||||||
@@ -193,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()
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { isParentAuthenticated, isUserLoggedIn, loginUser, initAuthSync } from '../auth'
|
import { isParentAuthenticated, loginUser } 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>,
|
||||||
@@ -35,20 +30,4 @@ describe('auth store - child mode on login', () => {
|
|||||||
await nextTick() // flush Vue watcher
|
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
|
|
||||||
isParentAuthenticated.value = true
|
|
||||||
|
|
||||||
const logoutEvent = new StorageEvent('storage', {
|
|
||||||
key: 'authSyncEvent',
|
|
||||||
newValue: JSON.stringify({ type: 'logout', at: Date.now() }),
|
|
||||||
})
|
|
||||||
window.dispatchEvent(logoutEvent)
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
expect(isUserLoggedIn.value).toBe(false)
|
|
||||||
expect(isParentAuthenticated.value).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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'
|
|
||||||
|
|
||||||
export const isParentAuthenticated = ref(
|
export const isParentAuthenticated = ref(
|
||||||
hasLocalStorage ? localStorage.getItem('isParentAuthenticated') === 'true' : false,
|
hasLocalStorage ? localStorage.getItem('isParentAuthenticated') === 'true' : false,
|
||||||
@@ -10,7 +9,6 @@ export const isParentAuthenticated = ref(
|
|||||||
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
|
|
||||||
|
|
||||||
watch(isParentAuthenticated, (val) => {
|
watch(isParentAuthenticated, (val) => {
|
||||||
if (hasLocalStorage && typeof localStorage.setItem === 'function') {
|
if (hasLocalStorage && typeof localStorage.setItem === 'function') {
|
||||||
@@ -35,54 +33,26 @@ export function loginUser() {
|
|||||||
isParentAuthenticated.value = false
|
isParentAuthenticated.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyLoggedOutState() {
|
export function logoutUser() {
|
||||||
isUserLoggedIn.value = false
|
isUserLoggedIn.value = false
|
||||||
currentUserId.value = ''
|
currentUserId.value = ''
|
||||||
logoutParent()
|
logoutParent()
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastLogoutEvent() {
|
|
||||||
if (!hasLocalStorage || typeof localStorage.setItem !== 'function') return
|
|
||||||
localStorage.setItem(AUTH_SYNC_EVENT_KEY, JSON.stringify({ type: 'logout', at: Date.now() }))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logoutUser() {
|
|
||||||
applyLoggedOutState()
|
|
||||||
broadcastLogoutEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initAuthSync() {
|
|
||||||
if (authSyncInitialized || typeof window === 'undefined') return
|
|
||||||
authSyncInitialized = true
|
|
||||||
|
|
||||||
window.addEventListener('storage', (event) => {
|
|
||||||
if (event.key !== AUTH_SYNC_EVENT_KEY || !event.newValue) return
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.newValue)
|
|
||||||
if (payload?.type === 'logout') {
|
|
||||||
applyLoggedOutState()
|
|
||||||
if (!window.location.pathname.startsWith('/auth')) {
|
|
||||||
window.location.href = '/auth/login'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed sync events.
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkAuth() {
|
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