28 Commits

Author SHA1 Message Date
df832e2238 wip 2026-02-16 23:30:37 -05:00
d600dde97f wip 2026-02-16 18:04:00 -05:00
3e1715e487 added universal launcher
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 44s
2026-02-16 16:20:04 -05:00
11e7fda997 wip 2026-02-16 16:17:17 -05:00
09d42b14c5 wip 2026-02-16 16:04:44 -05:00
3848be32e8 Merge branch 'next' of https://git.ryankegel.com/ryan/chore into next
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 47s
2026-02-16 15:37:17 -05:00
1aff366fd8 - removed test_data
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m36s
2026-02-16 15:33:56 -05:00
0ab40f85a4 wip 2026-02-16 15:29:33 -05:00
22889caab4 wip 2026-02-16 15:13:22 -05:00
b538782c09 Merge remote-tracking branch 'origin/wip-sync' into next 2026-02-16 15:02:51 -05:00
Ryan Kegel
7a827b14ef wip 2026-02-16 15:00:52 -05:00
9238d7e3a5 wip 2026-02-15 22:51:51 -05:00
c17838241a WIP Sync 2026-02-14 17:00:43 -05:00
d183e0a4b6 - First round of fixes for RC1
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 2m18s
2026-02-13 16:43:57 -05:00
b25ebaaec0 -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 57s
2026-02-12 16:17:07 -05:00
ae5b40512c -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 30s
2026-02-12 16:15:14 -05:00
92635a356c -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 45s
2026-02-12 16:12:52 -05:00
235269bdb6 -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 29s
2026-02-11 23:11:54 -05:00
5d4b0ec2c9 -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 22s
2026-02-11 22:55:28 -05:00
a21cb60aeb -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m38s
2026-02-11 21:36:45 -05:00
e604870e26 -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 2m7s
2026-02-11 17:08:23 -05:00
c3e35258a1 -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 52s
2026-02-11 17:00:45 -05:00
d2a56e36c7 -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 8s
2026-02-11 16:58:32 -05:00
3bfca4e2b0 -test environment
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 1m59s
2026-02-11 16:01:35 -05:00
f5d68aec4a -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 25s
2026-02-11 15:24:14 -05:00
38c637cc67 updated requirements
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 48s
2026-02-11 15:16:24 -05:00
f29c90897f -test environment
Some checks failed
Chore App Build and Push Docker Images / build-and-push (push) Failing after 21s
2026-02-11 14:56:32 -05:00
efb65b6da3 -attempt to use global ip for registry
All checks were successful
Chore App Build and Push Docker Images / build-and-push (push) Successful in 45s
2026-02-11 10:31:28 -05:00
54 changed files with 1425 additions and 275 deletions

View File

@@ -24,40 +24,88 @@ jobs:
echo "tag=next-$version-$current_date" >> $GITHUB_OUTPUT echo "tag=next-$version-$current_date" >> $GITHUB_OUTPUT
fi fi
- name: Resolve Gitea Server IP
id: gitea_ip
run: |
ip=$(getent hosts gitea-server | awk '{ print $1 }')
echo "ip=$ip" >> $GITHUB_OUTPUT
echo "Resolved Gitea server IP: $ip"
- name: Build Backend Docker Image - name: Build Backend Docker Image
run: | run: |
docker build -t ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/backend:${{ steps.vars.outputs.tag }} ./backend docker build -t git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} ./backend
- name: Build Frontend Docker Image - name: Build Frontend Docker Image
run: | run: |
docker build -t ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app docker build -t git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app
- name: Log in to Registry - name: Log in to Registry
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
registry: ${{ steps.gitea_ip.outputs.ip }}:3000 registry: git.ryankegel.com:3000
username: ryan #${{ secrets.REGISTRY_USERNAME }} # Stored as a Gitea secret username: ryan #${{ secrets.REGISTRY_USERNAME }} # Stored as a Gitea secret
password: 0x013h #${{ secrets.REGISTRY_TOKEN }} # Stored as a Gitea secret (use a PAT here) password: 0x013h #${{ secrets.REGISTRY_TOKEN }} # Stored as a Gitea secret (use a PAT here)
- name: Push Backend Image to Gitea Registry - name: Push Backend Image to Gitea Registry
run: | run: |
docker push ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/backend:${{ steps.vars.outputs.tag }} for i in {1..3}; do
echo "Attempt $i to push backend image..."
if docker push git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }}; then
echo "Backend push succeeded on attempt $i"
break
else
echo "Backend push failed on attempt $i"
if [ $i -lt 3 ]; then
sleep 10
else
exit 1
fi
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/backend:${{ steps.vars.outputs.tag }} ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/backend:latest docker tag git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:latest
docker push ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/backend:latest docker push git.ryankegel.com:3000/ryan/backend:latest
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
docker tag git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:next
docker push git.ryankegel.com:3000/ryan/backend:next
fi fi
- name: Push Frontend Image to Gitea Registry - name: Push Frontend Image to Gitea Registry
run: | run: |
docker push ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/frontend:${{ steps.vars.outputs.tag }} for i in {1..3}; do
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then echo "Attempt $i to push frontend image..."
docker tag ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/frontend:${{ steps.vars.outputs.tag }} ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/frontend:latest if docker push git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }}; then
docker push ${{ steps.gitea_ip.outputs.ip }}:3000/ryan/frontend:latest echo "Frontend push succeeded on attempt $i"
break
else
echo "Frontend push failed on attempt $i"
if [ $i -lt 3 ]; then
sleep 10
else
exit 1
fi fi
fi
done
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
docker tag git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:latest
docker push git.ryankegel.com:3000/ryan/frontend:latest
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
docker tag git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:next
docker push git.ryankegel.com:3000/ryan/frontend:next
fi
- name: Deploy Test Environment
uses: appleboy/ssh-action@v1.0.3 # Or equivalent Gitea action; adjust version if needed
with:
host: ${{ secrets.DEPLOY_TEST_HOST }}
username: ${{ secrets.DEPLOY_TEST_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22 # Default SSH port; change if different
script: |
cd /tmp
# Pull the repository to get the latest docker-compose.dev.yml
if [ -d "chore" ]; then
cd chore
git pull origin next || true # Pull latest changes; ignore if it fails (e.g., first run)
else
git clone --branch next https://git.ryankegel.com/ryan/chore.git
cd chore
fi
echo "Bringing down previous test environment..."
docker-compose -f docker-compose.test.yml down --volumes --remove-orphans || true
echo "Starting new test environment..."
docker-compose -f docker-compose.test.yml pull # Ensure latest images are pulled
docker-compose -f docker-compose.test.yml up -d

19
.github/alias.txt vendored Normal file
View File

@@ -0,0 +1,19 @@
**Powershell
git config --global alias.save-wip "!f() { git add . ; if (git log -1 --format=%s -eq 'wip') { git commit --amend --no-edit } else { git commit -m 'wip' }; git push origin `$(git branch --show-current):wip-sync --force-with-lease; }; f"
git config --global alias.load-wip "!f() { if (git diff-index --quiet HEAD --) { git fetch origin wip-sync; git merge origin/wip-sync; if (git log -1 --format=%s -eq 'wip') { git reset --soft HEAD~1; echo 'WIP Loaded and unwrapped.' } else { echo 'No WIP found. Merge complete.' } } else { echo 'Error: Uncommitted changes detected.'; exit 1 }; }; f"
git config --global alias.abort-wip "git reset --hard HEAD"
**Git Bash
git config --global alias.save-wip '!f() { git add . ; if [ "$(git log -1 --format=%s)" = "wip" ]; then git commit --amend --no-edit; else git commit -m "wip"; fi; git push origin $(git branch --show-current):wip-sync --force-with-lease; }; f'
git config --global alias.load-wip '!f() { if ! git diff-index --quiet HEAD --; then echo "Error: Uncommitted changes detected."; exit 1; fi; git fetch origin wip-sync && git merge origin/wip-sync && if [ "$(git log -1 --format=%s)" = "wip" ]; then git reset --soft HEAD~1; echo "WIP Loaded and unwrapped."; else echo "No WIP found. Merge complete."; fi; }; f'
git config --global alias.abort-wip 'git reset --hard HEAD'
**Mac
git config --global alias.save-wip '!f() { git add . ; if [ "$(git log -1 --format=%s)" = "wip" ]; then git commit --amend --no-edit; else git commit -m "wip"; fi; git push origin $(git branch --show-current):wip-sync --force-with-lease; }; f'
git config --global alias.load-wip '!f() { if ! git diff-index --quiet HEAD --; then echo "Error: Uncommitted changes detected."; exit 1; fi; git fetch origin wip-sync && git merge origin/wip-sync && if [ "$(git log -1 --format=%s)" = "wip" ]; then git reset --soft HEAD~1; echo "WIP Loaded and unwrapped."; else echo "No WIP found. Merge complete."; fi; }; f'
git config --global alias.abort-wip 'git reset --hard HEAD'
***Reset wip-sync
git push origin --delete wip-sync

5
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

15
.idea/Reward.iml generated Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.12 (Reward)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>

6
.idea/copilot.data.migration.agent.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

6
.idea/copilot.data.migration.edit.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -0,0 +1,10 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<list />
</option>
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

4
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (Reward)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Reward.iml" filepath="$PROJECT_DIR$/.idea/Reward.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

31
.vscode/launch.json vendored
View File

@@ -3,7 +3,7 @@
"configurations": [ "configurations": [
{ {
"name": "Python: Flask", "name": "Python: Flask",
"type": "debugpy", "type": "python",
"request": "launch", "request": "launch",
"module": "flask", "module": "flask",
"python": "${command:python.interpreterPath}", "python": "${command:python.interpreterPath}",
@@ -17,7 +17,9 @@
"--port=5000", "--port=5000",
"--no-debugger", "--no-debugger",
"--no-reload" "--no-reload"
] ],
"cwd": "${workspaceFolder}/backend",
"console": "integratedTerminal"
}, },
{ {
"name": "Vue: Dev Server", "name": "Vue: Dev Server",
@@ -32,17 +34,17 @@
"console": "integratedTerminal" "console": "integratedTerminal"
}, },
{ {
"name": "Chrome: Attach to Vue App", "name": "Chrome: Launch (Vue App)",
"type": "chrome", "type": "pwa-chrome",
"request": "launch", "request": "launch",
"url": "https://localhost:5173", // or your Vite dev server port "url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/frontend/vue-app" "webRoot": "${workspaceFolder}/frontend/vue-app"
}, },
{ {
"name": "Python: Backend Tests", "name": "Python: Backend Tests",
"type": "python", "type": "python",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/backend/.venv/Scripts/pytest.exe", "module": "pytest",
"args": [ "args": [
"tests/" "tests/"
], ],
@@ -56,12 +58,21 @@
"name": "Vue: Frontend Tests", "name": "Vue: Frontend Tests",
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"runtimeExecutable": "npx", "runtimeExecutable": "npm",
"windows": {
"runtimeExecutable": "npm.cmd"
},
"runtimeArgs": [ "runtimeArgs": [
"vitest" "run",
"test:unit"
], ],
"cwd": "${workspaceFolder}/frontend/vue-app", "cwd": "${workspaceFolder}/frontend/vue-app",
"console": "integratedTerminal" "console": "integratedTerminal",
"osx": {
"env": {
"PATH": "/opt/homebrew/bin:${env:PATH}"
}
}
} }
], ],
"compounds": [ "compounds": [
@@ -70,7 +81,7 @@
"configurations": [ "configurations": [
"Python: Flask", "Python: Flask",
"Vue: Dev Server", "Vue: Dev Server",
"Chrome: Attach to Vue App" "Chrome: Launch (Vue App)"
] ]
} }
] ]

77
.vscode/launch.json.bak vendored Normal file
View File

@@ -0,0 +1,77 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Flask",
"type": "debugpy",
"request": "launch",
"module": "flask",
"python": "${command:python.interpreterPath}",
"env": {
"FLASK_APP": "backend/main.py",
"FLASK_DEBUG": "1"
},
"args": [
"run",
"--host=0.0.0.0",
"--port=5000",
"--no-debugger",
"--no-reload"
]
},
{
"name": "Vue: Dev Server",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
],
"cwd": "${workspaceFolder}/frontend/vue-app",
"console": "integratedTerminal"
},
{
"name": "Chrome: Attach to Vue App",
"type": "chrome",
"request": "launch",
"url": "https://localhost:5173", // or your Vite dev server port
"webRoot": "${workspaceFolder}/frontend/vue-app"
},
{
"name": "Python: Backend Tests",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/backend/.venv/Scripts/pytest.exe",
"args": [
"tests/"
],
"cwd": "${workspaceFolder}/backend",
"console": "integratedTerminal",
"env": {
"PYTHONPATH": "${workspaceFolder}/backend"
}
},
{
"name": "Vue: Frontend Tests",
"type": "node",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeArgs": [
"vitest"
],
"cwd": "${workspaceFolder}/frontend/vue-app",
"console": "integratedTerminal"
}
],
"compounds": [
{
"name": "Full Stack (Backend + Frontend)",
"configurations": [
"Python: Flask",
"Vue: Dev Server",
"Chrome: Attach to Vue App"
]
}
]
}

45
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,45 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Git: Save WIP",
"type": "shell",
"command": "git save-wip",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Load WIP",
"type": "shell",
"command": "git load-wip",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Reset Cloud WIP",
"type": "shell",
"command": "git push origin --delete wip-sync",
"presentation": {
"reveal": "always",
"panel": "shared"
}
},
{
"label": "Git: Abort WIP (Reset Local)",
"type": "shell",
"command": "git abort-wip",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"echo": true
}
}
]
}

1
backend/.gitignore vendored
View File

@@ -42,6 +42,7 @@ env/
*.sqlite3 *.sqlite3
data/db/*.json data/db/*.json
data/images/ data/images/
test_data/
# Flask # Flask
instance/ instance/

View File

@@ -39,7 +39,11 @@ def signup():
email = data.get('email', '') email = data.get('email', '')
norm_email = normalize_email(email) norm_email = normalize_email(email)
if users_db.search(UserQuery.email == norm_email): existing = users_db.get(UserQuery.email == norm_email)
if existing:
user = User.from_dict(existing)
if user.marked_for_deletion:
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400 return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
@@ -78,6 +82,10 @@ def verify():
status = 'error' status = 'error'
reason = 'Invalid token' reason = 'Invalid token'
code = INVALID_TOKEN code = INVALID_TOKEN
elif user.marked_for_deletion:
status = 'error'
reason = 'Account marked for deletion'
code = ACCOUNT_MARKED_FOR_DELETION
else: else:
created_str = user.verify_token_created created_str = user.verify_token_created
if not created_str: if not created_str:
@@ -175,6 +183,8 @@ def me():
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 user.marked_for_deletion:
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
return jsonify({ return jsonify({
'email': user.email, 'email': user.email,
'id': user_id, 'id': user_id,
@@ -201,8 +211,8 @@ def request_password_reset():
user_dict = users_db.get(UserQuery.email == norm_email) user_dict = users_db.get(UserQuery.email == norm_email)
user = User.from_dict(user_dict) if user_dict else None user = User.from_dict(user_dict) if user_dict else None
if user: if user:
# Silently ignore reset requests for marked accounts (don't leak account status) if user.marked_for_deletion:
if not user.marked_for_deletion: return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
now_iso = datetime.utcnow().isoformat() now_iso = datetime.utcnow().isoformat()
user.reset_token = token user.reset_token = token

View File

@@ -166,7 +166,7 @@ def assign_task_to_child(id):
if task_id not in child.get('tasks', []): if task_id not in child.get('tasks', []):
child['tasks'].append(task_id) child['tasks'].append(task_id)
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id) child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
return jsonify({'message': f'Task {task_id} assigned to {child['name']}.'}), 200 return jsonify({'message': f"Task {task_id} assigned to {child.get('name')}."}), 200
# python # python
@child_api.route('/child/<id>/set-tasks', methods=['PUT']) @child_api.route('/child/<id>/set-tasks', methods=['PUT'])

View File

@@ -65,7 +65,13 @@ def list_rewards():
if r.get('user_id') is None and r['name'].strip().lower() in user_rewards: if r.get('user_id') is None and r['name'].strip().lower() in user_rewards:
continue # Skip default if user version exists continue # Skip default if user version exists
filtered_rewards.append(r) filtered_rewards.append(r)
return jsonify({'rewards': filtered_rewards}), 200
# Sort: user-created items first (by name), then default items (by name)
user_created = sorted([r for r in filtered_rewards if r.get('user_id') == user_id], key=lambda x: x['name'].lower())
default_items = sorted([r for r in filtered_rewards if r.get('user_id') is None], key=lambda x: x['name'].lower())
sorted_rewards = user_created + default_items
return jsonify({'rewards': sorted_rewards}), 200
@reward_api.route('/reward/<id>', methods=['DELETE']) @reward_api.route('/reward/<id>', methods=['DELETE'])
def delete_reward(id): def delete_reward(id):

View File

@@ -63,7 +63,13 @@ def list_tasks():
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks: if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue # Skip default if user version exists continue # Skip default if user version exists
filtered_tasks.append(t) filtered_tasks.append(t)
return jsonify({'tasks': filtered_tasks}), 200
# Sort: user-created items first (by name), then default items (by name)
user_created = sorted([t for t in filtered_tasks if t.get('user_id') == user_id], key=lambda x: x['name'].lower())
default_items = sorted([t for t in filtered_tasks if t.get('user_id') is None], key=lambda x: x['name'].lower())
sorted_tasks = user_created + default_items
return jsonify({'tasks': sorted_tasks}), 200
@task_api.route('/task/<id>', methods=['DELETE']) @task_api.route('/task/<id>', methods=['DELETE'])
def delete_task(id): def delete_task(id):

View File

@@ -12,6 +12,10 @@ from api.utils import get_validated_user_id, normalize_email, send_event_for_cur
from api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED from api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED
from events.types.event_types import EventType from events.types.event_types import EventType
from events.types.event import Event from events.types.event import Event
from events.types.profile_updated import ProfileUpdated
from utils.tracking_logger import log_tracking_event
from models.tracking_event import TrackingEvent
from db.tracking import insert_tracking_event
user_api = Blueprint('user_api', __name__) user_api = Blueprint('user_api', __name__)
UserQuery = Query() UserQuery = Query()
@@ -63,6 +67,32 @@ def update_profile():
if image_id is not None: if image_id is not None:
user.image_id = image_id user.image_id = image_id
users_db.update(user.to_dict(), UserQuery.email == user.email) users_db.update(user.to_dict(), UserQuery.email == user.email)
# Create tracking event
metadata = {}
if first_name is not None:
metadata['first_name_updated'] = True
if last_name is not None:
metadata['last_name_updated'] = True
if image_id is not None:
metadata['image_updated'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=None, # No child for user profile
entity_type='user',
entity_id=user.id,
action='updated',
points_before=0, # Not relevant
points_after=0,
metadata=metadata
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
# Send SSE event
send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id)))
return jsonify({'message': 'Profile updated'}), 200 return jsonify({'message': 'Profile updated'}), 200
@user_api.route('/user/image', methods=['PUT']) @user_api.route('/user/image', methods=['PUT'])
@@ -201,6 +231,13 @@ def mark_for_deletion():
# Mark for deletion # Mark for deletion
user.marked_for_deletion = True user.marked_for_deletion = True
user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat() user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat()
# Invalidate any outstanding verification/reset tokens so they cannot be used after marking
user.verify_token = None
user.verify_token_created = None
user.reset_token = None
user.reset_token_created = None
users_db.update(user.to_dict(), UserQuery.id == user.id) users_db.update(user.to_dict(), UserQuery.id == user.id)
# Trigger SSE event # Trigger SSE event

View File

@@ -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:
""" """

View File

@@ -21,3 +21,5 @@ class EventType(Enum):
CHILD_OVERRIDE_SET = "child_override_set" CHILD_OVERRIDE_SET = "child_override_set"
CHILD_OVERRIDE_DELETED = "child_override_deleted" CHILD_OVERRIDE_DELETED = "child_override_deleted"
PROFILE_UPDATED = "profile_updated"

View File

@@ -0,0 +1,12 @@
from events.types.payload import Payload
class ProfileUpdated(Payload):
def __init__(self, user_id: str):
super().__init__({
'user_id': user_id,
})
@property
def user_id(self) -> str:
return self.get("user_id")

View File

@@ -1,5 +1,6 @@
import logging import logging
import sys import sys
import os
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from flask_cors import CORS from flask_cors import CORS
@@ -49,7 +50,7 @@ app.config.update(
MAIL_USERNAME='ryan.kegel@gmail.com', MAIL_USERNAME='ryan.kegel@gmail.com',
MAIL_PASSWORD='ruyj hxjf nmrz buar', MAIL_PASSWORD='ruyj hxjf nmrz buar',
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com', MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
FRONTEND_URL='https://localhost:5173', # Adjust as needed FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost
SECRET_KEY='supersecretkey' # Replace with a secure key in production SECRET_KEY='supersecretkey' # Replace with a secure key in production
) )

Binary file not shown.

View File

@@ -1,94 +0,0 @@
{
"_default": {
"1": {
"id": "479920ee-4d2c-4ff9-a7e4-749691183903",
"created_at": 1770772299.9946082,
"updated_at": 1770772299.9946082,
"child_id": "child1",
"entity_id": "task1",
"entity_type": "task",
"custom_value": 20
},
"2": {
"id": "e1212f17-1986-4ae2-9936-3e8c4a487a79",
"created_at": 1770772300.0246155,
"updated_at": 1770772300.0246155,
"child_id": "child2",
"entity_id": "task2",
"entity_type": "task",
"custom_value": 25
},
"3": {
"id": "58068231-3bd8-425c-aba2-1e4444547f2b",
"created_at": 1770772300.0326169,
"updated_at": 1770772300.0326169,
"child_id": "child3",
"entity_id": "task1",
"entity_type": "task",
"custom_value": 10
},
"4": {
"id": "21299d89-29d1-4876-abc8-080a919dfa27",
"created_at": 1770772300.0326169,
"updated_at": 1770772300.0326169,
"child_id": "child3",
"entity_id": "task2",
"entity_type": "task",
"custom_value": 15
},
"5": {
"id": "4676589a-abcf-4407-806c-8d187a41dae3",
"created_at": 1770772300.0326169,
"updated_at": 1770772300.0326169,
"child_id": "child3",
"entity_id": "reward1",
"entity_type": "reward",
"custom_value": 100
},
"33": {
"id": "cd1473e2-241c-4bfd-b4b2-c2b5402d95d6",
"created_at": 1770772307.3772185,
"updated_at": 1770772307.3772185,
"child_id": "351c9e7f-5406-425c-a15a-2268aadbfdd5",
"entity_id": "90279979-e91e-4f51-af78-88ad70ffab57",
"entity_type": "task",
"custom_value": 5
},
"34": {
"id": "57ecb6f8-dff3-47a8-81a9-66979e1ce7b4",
"created_at": 1770772307.3833773,
"updated_at": 1770772307.3833773,
"child_id": "f12a42a9-105a-4a6f-84e8-1c3a8e076d33",
"entity_id": "90279979-e91e-4f51-af78-88ad70ffab57",
"entity_type": "task",
"custom_value": 20
},
"35": {
"id": "d55b8b5c-39fc-449c-9848-99c2d572fdd8",
"created_at": 1770772307.618762,
"updated_at": 1770772307.618762,
"child_id": "f84a1e5e-3f14-44fd-8b8b-b25ede62a1d2",
"entity_id": "b64435a0-8856-4c8d-bf77-8438ff5d9061",
"entity_type": "task",
"custom_value": 0
},
"36": {
"id": "a9777db2-6912-4b21-b668-4f36566d4ef8",
"created_at": 1770772307.8648667,
"updated_at": 1770772307.8648667,
"child_id": "f84a1e5e-3f14-44fd-8b8b-b25ede62a1d2",
"entity_id": "35cf2bde-9f47-4458-ac7b-36713063deb4",
"entity_type": "task",
"custom_value": 10000
},
"37": {
"id": "04c54b24-914e-4ed6-b336-4263a4701c78",
"created_at": 1770772308.104657,
"updated_at": 1770772308.104657,
"child_id": "48bccc00-6d76-4bc9-a371-836d1a7db200",
"entity_id": "ba725bf7-2dc8-4bdb-8a82-6ed88519f2ff",
"entity_type": "reward",
"custom_value": 75
}
}
}

View File

@@ -0,0 +1,82 @@
import pytest
from flask import Flask
from api.auth_api import auth_api
from db.db import users_db
from tinydb import Query
from models.user import User
from werkzeug.security import generate_password_hash
from datetime import datetime, timedelta
import jwt
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client:
yield client
def setup_marked_user(email, verified=False, verify_token=None, reset_token=None):
users_db.remove(Query().email == email)
user = User(
first_name='Marked',
last_name='User',
email=email,
password=generate_password_hash('password123'),
verified=verified,
marked_for_deletion=True,
verify_token=verify_token,
verify_token_created=datetime.utcnow().isoformat() if verify_token else None,
reset_token=reset_token,
reset_token_created=datetime.utcnow().isoformat() if reset_token else None
)
users_db.insert(user.to_dict())
def test_signup_marked_for_deletion(client):
setup_marked_user('marked@example.com')
data = {
'first_name': 'Marked',
'last_name': 'User',
'email': 'marked@example.com',
'password': 'password123'
}
response = client.post('/auth/signup', json=data)
assert response.status_code == 403
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
def test_verify_marked_for_deletion(client):
setup_marked_user('marked2@example.com', verify_token='verifytoken123')
response = client.get('/auth/verify', query_string={'token': 'verifytoken123'})
assert response.status_code == 400
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
def test_request_password_reset_marked_for_deletion(client):
setup_marked_user('marked3@example.com')
response = client.post('/auth/request-password-reset', json={'email': 'marked3@example.com'})
assert response.status_code == 403
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
def test_me_marked_for_deletion(client):
email = 'marked4@example.com'
setup_marked_user(email, verified=True)
# Get the user to access the ID
user_dict = users_db.get(Query().email == email)
user = User.from_dict(user_dict)
# Create a valid JWT token for the marked user
payload = {
'email': email,
'user_id': user.id,
'exp': datetime.utcnow() + timedelta(hours=24)
}
token = jwt.encode(payload, 'supersecretkey', algorithm='HS256')
# Make request with token cookie
client.set_cookie('token', token)
response = client.get('/auth/me')
assert response.status_code == 403
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'

View File

@@ -15,6 +15,7 @@ from utils.account_deletion_scheduler import (
delete_user_data, delete_user_data,
process_deletion_queue, process_deletion_queue,
check_interrupted_deletions, check_interrupted_deletions,
trigger_deletion_manually,
MAX_DELETION_ATTEMPTS MAX_DELETION_ATTEMPTS
) )
from models.user import User from models.user import User
@@ -953,3 +954,163 @@ class TestIntegration:
assert users_db.get(Query_.id == user_id) is None assert users_db.get(Query_.id == user_id) is None
assert child_db.get(Query_.id == child_id) is None assert child_db.get(Query_.id == child_id) is None
assert not os.path.exists(user_image_dir) assert not os.path.exists(user_image_dir)
class TestManualDeletionTrigger:
"""Tests for manually triggered deletion (admin endpoint)."""
def setup_method(self):
"""Clear test databases before each test."""
users_db.truncate()
child_db.truncate()
task_db.truncate()
reward_db.truncate()
image_db.truncate()
pending_reward_db.truncate()
def teardown_method(self):
"""Clean up test directories after each test."""
for user_id in ['manual_user_1', 'manual_user_2', 'manual_user_3', 'manual_user_retry', 'recent_user']:
user_dir = get_user_image_dir(user_id)
if os.path.exists(user_dir):
try:
shutil.rmtree(user_dir)
except:
pass
def test_manual_trigger_deletes_immediately(self):
"""Test that manual trigger deletes users marked recently (not past threshold)."""
user_id = 'manual_user_1'
# Create user marked only 1 hour ago (well before 720 hour threshold)
marked_time = (datetime.now() - timedelta(hours=1)).isoformat()
user = User(
id=user_id,
email='manual1@example.com',
first_name='Manual',
last_name='Test',
password='hash',
marked_for_deletion=True,
marked_for_deletion_at=marked_time,
deletion_in_progress=False,
deletion_attempted_at=None
)
users_db.insert(user.to_dict())
# Verify user is NOT due for deletion under normal circumstances
assert is_user_due_for_deletion(user) is False
# Manually trigger deletion
result = trigger_deletion_manually()
# Verify user was deleted despite not being past threshold
Query_ = Query()
assert users_db.get(Query_.id == user_id) is None
assert result['triggered'] is True
def test_manual_trigger_deletes_multiple_users(self):
"""Test that manual trigger deletes all marked users regardless of time."""
# Create multiple users marked at different times
users_data = [
('manual_user_1', 1), # 1 hour ago
('manual_user_2', 100), # 100 hours ago
('manual_user_3', 800), # 800 hours ago (past threshold)
]
for user_id, hours_ago in users_data:
marked_time = (datetime.now() - timedelta(hours=hours_ago)).isoformat()
user = User(
id=user_id,
email=f'{user_id}@example.com',
first_name='Manual',
last_name='Test',
password='hash',
marked_for_deletion=True,
marked_for_deletion_at=marked_time,
deletion_in_progress=False,
deletion_attempted_at=None
)
users_db.insert(user.to_dict())
# Verify only one is due under normal circumstances
all_users = users_db.all()
due_count = sum(1 for u in all_users if is_user_due_for_deletion(User.from_dict(u)))
assert due_count == 1 # Only the 800 hour old one
# Manually trigger deletion
trigger_deletion_manually()
# Verify ALL marked users were deleted
Query_ = Query()
assert len(users_db.all()) == 0
def test_manual_trigger_respects_retry_limit(self):
"""Test that manual trigger still respects max retry limit."""
user_id = 'manual_user_retry'
# Create user marked recently with max attempts already
marked_time = (datetime.now() - timedelta(hours=1)).isoformat()
attempted_time = (datetime.now() - timedelta(hours=1)).isoformat()
user = User(
id=user_id,
email='retry@example.com',
first_name='Retry',
last_name='Test',
password='hash',
marked_for_deletion=True,
marked_for_deletion_at=marked_time,
deletion_in_progress=False,
deletion_attempted_at=attempted_time # Has 1 attempt
)
users_db.insert(user.to_dict())
# Mock delete_user_data to fail consistently
with patch('utils.account_deletion_scheduler.delete_user_data', return_value=False):
# Trigger multiple times to exceed retry limit
for _ in range(MAX_DELETION_ATTEMPTS):
trigger_deletion_manually()
# User should still exist after max attempts
Query_ = Query()
remaining_user = users_db.get(Query_.id == user_id)
assert remaining_user is not None
def test_manual_trigger_with_no_marked_users(self):
"""Test that manual trigger handles empty queue gracefully."""
result = trigger_deletion_manually()
assert result['triggered'] is True
assert result['queued_users'] == 0
def test_normal_scheduler_still_respects_threshold(self):
"""Test that normal scheduler run (force=False) still respects time threshold."""
user_id = 'recent_user'
# Create user marked only 1 hour ago
marked_time = (datetime.now() - timedelta(hours=1)).isoformat()
user = User(
id=user_id,
email='recent@example.com',
first_name='Recent',
last_name='Test',
password='hash',
marked_for_deletion=True,
marked_for_deletion_at=marked_time,
deletion_in_progress=False,
deletion_attempted_at=None
)
users_db.insert(user.to_dict())
# Run normal scheduler (not manual trigger)
process_deletion_queue(force=False)
# User should still exist because not past threshold
Query_ = Query()
assert users_db.get(Query_.id == user_id) is not None
# Now run with force=True
process_deletion_queue(force=True)
# User should be deleted
assert users_db.get(Query_.id == user_id) is None

View File

@@ -138,11 +138,12 @@ def test_login_succeeds_for_unmarked_user(client):
assert 'message' in data assert 'message' in data
def test_password_reset_ignored_for_marked_user(client): def test_password_reset_ignored_for_marked_user(client):
"""Test that password reset requests are silently ignored for marked users.""" """Test that password reset requests return 403 for marked users."""
response = client.post('/request-password-reset', json={"email": MARKED_EMAIL}) response = client.post('/request-password-reset', json={"email": MARKED_EMAIL})
assert response.status_code == 200 assert response.status_code == 403
data = response.get_json() data = response.get_json()
assert 'message' in data assert 'error' in data
assert data['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
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."""
@@ -167,6 +168,35 @@ def test_mark_for_deletion_updates_timestamp(authenticated_client):
assert before_time <= marked_at <= after_time assert before_time <= marked_at <= after_time
def test_mark_for_deletion_clears_tokens(authenticated_client):
"""When an account is marked for deletion, verify/reset tokens must be cleared."""
# Seed verify/reset tokens for the user
UserQuery = Query()
now_iso = datetime.utcnow().isoformat()
users_db.update({
'verify_token': 'verify-abc',
'verify_token_created': now_iso,
'reset_token': 'reset-xyz',
'reset_token_created': now_iso
}, UserQuery.email == TEST_EMAIL)
# Ensure tokens are present before marking
user_before = users_db.search(UserQuery.email == TEST_EMAIL)[0]
assert user_before['verify_token'] is not None
assert user_before['reset_token'] is not None
# Mark account for deletion
response = authenticated_client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
assert response.status_code == 200
# Verify tokens were cleared in the DB
user_after = users_db.search(UserQuery.email == TEST_EMAIL)[0]
assert user_after.get('verify_token') is None
assert user_after.get('verify_token_created') is None
assert user_after.get('reset_token') is None
assert user_after.get('reset_token_created') is None
def test_mark_for_deletion_with_invalid_jwt(client): def test_mark_for_deletion_with_invalid_jwt(client):
"""Test marking for deletion with invalid JWT token.""" """Test marking for deletion with invalid JWT token."""
# Set invalid cookie manually # Set invalid cookie manually
@@ -176,3 +206,21 @@ def test_mark_for_deletion_with_invalid_jwt(client):
assert response.status_code == 401 assert response.status_code == 401
data = response.get_json() data = response.get_json()
assert 'error' in data assert 'error' in data
def test_update_profile_success(authenticated_client):
"""Test successfully updating user profile."""
response = authenticated_client.put('/user/profile', json={
'first_name': 'Updated',
'last_name': 'Name',
'image_id': 'new_image'
})
assert response.status_code == 200
data = response.get_json()
assert data['message'] == 'Profile updated'
# Verify database was updated
UserQuery = Query()
user = users_db.search(UserQuery.email == TEST_EMAIL)[0]
assert user['first_name'] == 'Updated'
assert user['last_name'] == 'Name'
assert user['image_id'] == 'new_image'

View File

@@ -210,10 +210,17 @@ def delete_user_data(user: User) -> bool:
pass pass
return False return False
def process_deletion_queue(): def process_deletion_queue(force=False):
""" """
Process the deletion queue: find users due for deletion and delete them. Process the deletion queue: find users due for deletion and delete them.
Args:
force (bool): If True, delete all marked users immediately without checking threshold.
If False, only delete users past the threshold time.
""" """
if force:
logger.info("Starting FORCED deletion scheduler run (bypassing time threshold)")
else:
logger.info("Starting deletion scheduler run") logger.info("Starting deletion scheduler run")
processed = 0 processed = 0
@@ -235,8 +242,8 @@ def process_deletion_queue():
user = User.from_dict(user_dict) user = User.from_dict(user_dict)
processed += 1 processed += 1
# Check if user is due for deletion # Check if user is due for deletion (skip check if force=True)
if not is_user_due_for_deletion(user): if not force and not is_user_due_for_deletion(user):
continue continue
# Check retry limit # Check retry limit
@@ -346,10 +353,11 @@ def stop_deletion_scheduler():
def trigger_deletion_manually(): def trigger_deletion_manually():
""" """
Manually trigger the deletion process (for admin use). Manually trigger the deletion process (for admin use).
Deletes all marked users immediately without waiting for threshold.
Returns stats about the run. Returns stats about the run.
""" """
logger.info("Manual deletion trigger requested") logger.info("Manual deletion trigger requested - forcing immediate deletion")
process_deletion_queue() process_deletion_queue(force=True)
# Return stats (simplified version) # Return stats (simplified version)
Query_ = Query() Query_ = Query()

29
docker-compose.test.yml Normal file
View File

@@ -0,0 +1,29 @@
# yaml
version: "3.8"
services:
chore-test-app-backend: # Test backend service name
image: git.ryankegel.com:3000/ryan/backend:next # Use latest next tag
ports:
- "5004:5000" # Host 5004 -> Container 5000
environment:
- FLASK_ENV=development
- FRONTEND_URL=https://devserver.lan:446 # Add this for test env
# Add volumes, networks, etc., as needed
chore-test-app-frontend: # Test frontend service name
image: git.ryankegel.com:3000/ryan/frontend:next # Use latest next tag
ports:
- "446:443" # Host 446 -> Container 443 (HTTPS)
environment:
- BACKEND_HOST=chore-test-app-backend # Points to internal backend service
depends_on:
- chore-test-app-backend
# Add volumes, networks, etc., as needed
networks:
chore-test-app-net:
driver: bridge
volumes:
chore-test-app-backend-data: {}

View File

@@ -1,26 +1,32 @@
# yaml # yaml
version: '3.8' version: "3.8"
services: services:
chore-app-backend: chore-app-backend: # Production backend service name
image: devserver.lan:5900/chore-app-backend:production image: git.ryankegel.com:3000/ryan/backend:latest # Or specific version tag
container_name: chore-app-backend container_name: chore-app-backend-prod # Added for easy identification
restart: unless-stopped
expose:
- "5000"
networks:
- chore-app-net
volumes:
- chore-app-backend-data:/app/data # persists backend data
chore-app-frontend:
image: devserver.lan:5900/chore-app-frontend:production
container_name: chore-app-frontend
restart: unless-stopped
ports: ports:
- "4600:443" - "5001:5000" # Host 5001 -> Container 5000
environment:
- FLASK_ENV=production
volumes:
- chore-app-backend-data:/app/data # Assuming backend data storage; adjust path as needed
networks: networks:
- chore-app-net - chore-app-net
# Add other volumes, networks, etc., as needed
chore-app-frontend: # Production frontend service name
image: git.ryankegel.com:3000/ryan/frontend:latest # Or specific version tag
container_name: chore-app-frontend-prod # Added for easy identification
ports:
- "443:443" # Host 443 -> Container 443 (HTTPS)
environment:
- BACKEND_HOST=chore-app-backend # Points to internal backend service
depends_on:
- chore-app-backend
networks:
- chore-app-net
# Add volumes, networks, etc., as needed
networks: networks:
chore-app-net: chore-app-net:

View File

@@ -9,11 +9,18 @@ RUN npm run build
# Stage 2: Serve with nginx # Stage 2: Serve with nginx
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf.template /etc/nginx/nginx.conf.template
# Copy SSL certificate and key # Copy SSL certificate and key
COPY 192.168.1.102+1.pem /etc/nginx/ssl/server.crt COPY 192.168.1.102+1.pem /etc/nginx/ssl/server.crt
COPY 192.168.1.102+1-key.pem /etc/nginx/ssl/server.key COPY 192.168.1.102+1-key.pem /etc/nginx/ssl/server.key
EXPOSE 80 EXPOSE 80
EXPOSE 443 EXPOSE 443
CMD ["nginx", "-g", "daemon off;"] # Copy nginx.conf
COPY nginx.conf.template /etc/nginx/nginx.conf.template
# Set default BACKEND_HOST (can be overridden at runtime)
ENV BACKEND_HOST=chore-app-backend
# Use sed to replace $BACKEND_HOST with the env value, then start Nginx
CMD ["/bin/sh", "-c", "sed 's/\\$BACKEND_HOST/'\"$BACKEND_HOST\"'/g' /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf && nginx -g 'daemon off;'"]

View File

@@ -17,15 +17,15 @@ http {
ssl_ciphers HIGH:!aNULL:!MD5; ssl_ciphers HIGH:!aNULL:!MD5;
location /api/ { location /api/ {
proxy_pass http://chore-app-backend:5000/; proxy_pass http://$BACKEND_HOST:5000/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /events { location /events {
proxy_pass http://chore-app-backend:5000/events; proxy_pass http://$BACKEND_HOST:5000/events;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header Connection ''; proxy_set_header Connection '';
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -34,7 +34,7 @@ location /events {
proxy_cache off; proxy_cache off;
proxy_read_timeout 36000s; proxy_read_timeout 36000s;
proxy_send_timeout 36000s; proxy_send_timeout 36000s;
} }
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

View File

@@ -0,0 +1,43 @@
events {}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
client_max_body_size 2M;
listen 443 ssl;
server_name _;
root /usr/share/nginx/html;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /api/ {
proxy_pass http://$BACKEND_HOST:5000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /events {
proxy_pass http://$BACKEND_HOST:5000/events;
proxy_set_header Host $host;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 36000s;
proxy_send_timeout 36000s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}

View File

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

View File

@@ -283,3 +283,207 @@ describe('UserProfile - Delete Account', () => {
expect(wrapper.vm.deleteErrorMessage).toBe('This account is already marked for deletion.') expect(wrapper.vm.deleteErrorMessage).toBe('This account is already marked for deletion.')
}) })
}) })
describe('UserProfile - Profile Update', () => {
let wrapper: VueWrapper<any>
beforeEach(() => {
vi.clearAllMocks()
;(global.fetch as any).mockClear()
// Mock fetch for profile loading in onMounted
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({
image_id: 'initial-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
}),
})
// Mount component with router
wrapper = mount(UserProfile, {
global: {
plugins: [mockRouter],
stubs: {
EntityEditForm: {
template: '<div class="mock-form"><slot /></div>',
props: ['initialData', 'fields', 'loading', 'error', 'isEdit', 'entityLabel', 'title'],
emits: ['submit', 'cancel', 'add-image'],
},
ModalDialog: {
template: '<div class="mock-modal"><slot /></div>',
},
},
},
})
})
it('updates initialData after successful profile save', async () => {
await flushPromises()
await nextTick()
// Initial image_id should be set from mount
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
// Mock successful save response
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
// Simulate form submission with new image_id
const newFormData = {
image_id: 'new-image-id',
first_name: 'Updated',
last_name: 'Name',
email: 'test@example.com',
}
await wrapper.vm.handleSubmit(newFormData)
await flushPromises()
// initialData should now be updated to match the saved form
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
expect(wrapper.vm.initialData.first_name).toBe('Updated')
expect(wrapper.vm.initialData.last_name).toBe('Name')
})
it('allows dirty detection after save when reverting to original value', async () => {
await flushPromises()
await nextTick()
// Start with initial-image-id
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
// Mock successful save
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
// Change and save to new-image-id
await wrapper.vm.handleSubmit({
image_id: 'new-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
// initialData should now be new-image-id
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
// Now if user changes back to initial-image-id, it should be detected as different
// (because initialData is now new-image-id)
const currentInitial = wrapper.vm.initialData.image_id
expect(currentInitial).toBe('new-image-id')
expect(currentInitial).not.toBe('initial-image-id')
})
it('handles image upload during profile save', async () => {
await flushPromises()
await nextTick()
const mockFile = new File(['test'], 'test.png', { type: 'image/png' })
wrapper.vm.localImageFile = mockFile
// Mock image upload response
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'uploaded-image-id' }),
})
// Mock profile update response
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
await wrapper.vm.handleSubmit({
image_id: 'local-upload',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
// Should have called image upload
expect(global.fetch).toHaveBeenCalledWith(
'/api/image/upload',
expect.objectContaining({
method: 'POST',
}),
)
// initialData should be updated with uploaded image ID
expect(wrapper.vm.initialData.image_id).toBe('uploaded-image-id')
})
it('shows error message on failed image upload', async () => {
await flushPromises()
await nextTick()
const mockFile = new File(['test'], 'test.png', { type: 'image/png' })
wrapper.vm.localImageFile = mockFile
// Mock failed image upload
;(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 500,
})
await wrapper.vm.handleSubmit({
image_id: 'local-upload',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
expect(wrapper.vm.errorMsg).toBe('Failed to upload image.')
expect(wrapper.vm.loading).toBe(false)
})
it('shows success modal after profile update', async () => {
await flushPromises()
await nextTick()
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
await wrapper.vm.handleSubmit({
image_id: 'some-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
expect(wrapper.vm.showModal).toBe(true)
expect(wrapper.vm.modalTitle).toBe('Profile Updated')
expect(wrapper.vm.modalMessage).toBe('Your profile was updated successfully.')
})
it('shows error message on failed profile update', async () => {
await flushPromises()
await nextTick()
;(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 500,
})
await wrapper.vm.handleSubmit({
image_id: 'some-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
expect(wrapper.vm.errorMsg).toBe('Failed to update profile.')
expect(wrapper.vm.loading).toBe(false)
})
})

View File

@@ -74,7 +74,7 @@
--fab-hover-bg: #5a67d8; --fab-hover-bg: #5a67d8;
--fab-active-bg: #4c51bf; --fab-active-bg: #4c51bf;
--message-block-color: #fdfdfd; --message-block-color: #fdfdfd;
--sub-message-color: #c1d0f1; --sub-message-color: #9eaac4;
--sign-in-btn-bg: #fff; --sign-in-btn-bg: #fff;
--sign-in-btn-color: #2563eb; --sign-in-btn-color: #2563eb;
--sign-in-btn-border: #2563eb; --sign-in-btn-border: #2563eb;

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import LoginButton from '../shared/LoginButton.vue'
// Mock dependencies
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}))
vi.mock('../../stores/auth', () => ({
authenticateParent: vi.fn(),
isParentAuthenticated: { value: false },
logoutParent: vi.fn(),
logoutUser: vi.fn(),
}))
vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn(),
getCachedImageBlob: vi.fn(),
}))
vi.mock('@/common/eventBus', () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
},
}))
import { eventBus } from '@/common/eventBus'
describe('LoginButton', () => {
let wrapper: VueWrapper<any>
let mockFetch: any
beforeEach(() => {
vi.clearAllMocks()
mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
vi.unstubAllGlobals()
})
describe('Event Listeners', () => {
it('registers event listeners on mount', () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ first_name: '', last_name: '', email: '', image_id: null }),
})
wrapper = mount(LoginButton)
expect(eventBus.on).toHaveBeenCalledWith('open-login', expect.any(Function))
expect(eventBus.on).toHaveBeenCalledWith('profile_updated', expect.any(Function))
})
it('unregisters event listeners on unmount', () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ first_name: '', last_name: '', email: '', image_id: null }),
})
wrapper = mount(LoginButton)
wrapper.unmount()
expect(eventBus.off).toHaveBeenCalledWith('open-login', expect.any(Function))
expect(eventBus.off).toHaveBeenCalledWith('profile_updated', expect.any(Function))
})
it('refetches profile when profile_updated event is received', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ first_name: '', last_name: '', email: '', image_id: null }),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
first_name: 'Updated',
last_name: 'User',
email: 'updated@example.com',
image_id: 'new-image-id',
}),
})
wrapper = mount(LoginButton)
// Get the profile_updated callback
const profileUpdatedCall = eventBus.on.mock.calls.find(
(call) => call[0] === 'profile_updated',
)
const profileUpdatedCallback = profileUpdatedCall[1]
// Call the callback
await profileUpdatedCallback()
// Check that fetch was called for profile
expect(mockFetch).toHaveBeenCalledWith('/api/user/profile', { credentials: 'include' })
})
})
})

View File

@@ -4,7 +4,7 @@
<h1>Welcome</h1> <h1>Welcome</h1>
<p>Please sign in or create an account to continue.</p> <p>Please sign in or create an account to continue.</p>
<div class="auth-actions"> <div class="auth-actions">
<button class="btn btn-primary" @click="goToLogin">Log In</button> <button class="btn btn-primary" @click="goToLogin">Sign In</button>
<button class="btn btn-secondary" @click="goToSignup">Sign Up</button> <button class="btn btn-secondary" @click="goToSignup">Sign Up</button>
</div> </div>
</div> </div>

View File

@@ -12,14 +12,14 @@
autocomplete="username" autocomplete="username"
autofocus autofocus
v-model="email" v-model="email"
:class="{ 'input-error': submitAttempted && !isEmailValid }" :class="{ 'input-error': submitAttempted && !isFormValid }"
required required
/> />
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite"> <small v-if="submitAttempted && !email" class="error-message" aria-live="polite">
Email is required. Email is required.
</small> </small>
<small <small
v-else-if="submitAttempted && !isEmailValid" v-else-if="submitAttempted && !isFormValid"
class="error-message" class="error-message"
aria-live="polite" aria-live="polite"
> >
@@ -40,7 +40,7 @@
</div> </div>
<div class="form-group actions" style="margin-top: 0.4rem"> <div class="form-group actions" style="margin-top: 0.4rem">
<button type="submit" class="btn btn-primary" :disabled="loading || !isEmailValid"> <button type="submit" class="btn btn-primary" :disabled="loading || !isFormValid">
{{ loading ? 'Sending…' : 'Send Reset Link' }} {{ loading ? 'Sending…' : 'Send Reset Link' }}
</button> </button>
</div> </div>
@@ -92,12 +92,15 @@ const successMsg = ref('')
const isEmailValidRef = computed(() => isEmailValid(email.value)) const isEmailValidRef = computed(() => isEmailValid(email.value))
// Add computed for form validity: email must be non-empty and valid
const isFormValid = computed(() => email.value.trim() !== '' && isEmailValidRef.value)
async function submitForm() { async function submitForm() {
submitAttempted.value = true submitAttempted.value = true
errorMsg.value = '' errorMsg.value = ''
successMsg.value = '' successMsg.value = ''
if (!isEmailValidRef.value) return if (!isFormValid.value) return
loading.value = true loading.value = true
try { try {
const res = await fetch('/api/request-password-reset', { const res = await fetch('/api/request-password-reset', {

View File

@@ -20,7 +20,14 @@
</p> </p>
<input v-model="code" maxlength="6" class="code-input" placeholder="6-digit code" /> <input v-model="code" maxlength="6" class="code-input" placeholder="6-digit code" />
<div class="button-group"> <div class="button-group">
<button v-if="!loading" class="btn btn-primary" @click="verifyCode">Verify Code</button> <button
v-if="!loading"
class="btn btn-primary"
@click="verifyCode"
:disabled="!isCodeValid"
>
Verify Code
</button>
<button class="btn btn-link" @click="resendCode" v-if="showResend" :disabled="loading"> <button class="btn btn-link" @click="resendCode" v-if="showResend" :disabled="loading">
Resend Code Resend Code
</button> </button>
@@ -46,7 +53,7 @@
class="pin-input" class="pin-input"
placeholder="Confirm PIN" placeholder="Confirm PIN"
/> />
<button class="btn btn-primary" @click="setPin" :disabled="loading"> <button class="btn btn-primary" @click="setPin" :disabled="loading || !isPinValid">
{{ loading ? 'Saving...' : 'Set PIN' }} {{ loading ? 'Saving...' : 'Set PIN' }}
</button> </button>
<div v-if="error" class="error-message">{{ error }}</div> <div v-if="error" class="error-message">{{ error }}</div>
@@ -60,7 +67,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { logoutParent } from '@/stores/auth' import { logoutParent } from '@/stores/auth'
import '@/assets/styles.css' import '@/assets/styles.css'
@@ -77,6 +84,14 @@ const showResend = ref(false)
let resendTimeout: ReturnType<typeof setTimeout> | null = null let resendTimeout: ReturnType<typeof setTimeout> | null = null
const router = useRouter() const router = useRouter()
const isCodeValid = computed(() => code.value.length === 6)
const isPinValid = computed(() => {
const p1 = pin.value
const p2 = pin2.value
return /^\d{4,6}$/.test(p1) && /^\d{4,6}$/.test(p2) && p1 === p2
})
async function requestCode() { async function requestCode() {
error.value = '' error.value = ''
info.value = '' info.value = ''

View File

@@ -112,6 +112,16 @@
</div> </div>
<div v-else style="margin-top: 2rem; text-align: center">Checking reset link...</div> <div v-else style="margin-top: 2rem; text-align: center">Checking reset link...</div>
</div> </div>
<!-- Success Modal -->
<ModalDialog v-if="showModal" title="Password Reset Successful" @backdrop-click="closeModal">
<p class="modal-message">
Your password has been reset successfully. You can now sign in with your new password.
</p>
<div class="modal-actions">
<button @click="goToLogin" class="btn btn-primary">Sign In</button>
</div>
</ModalDialog>
</div> </div>
</template> </template>
@@ -119,6 +129,7 @@
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 ModalDialog from '@/components/shared/ModalDialog.vue'
import '@/assets/styles.css' import '@/assets/styles.css'
const router = useRouter() const router = useRouter()
@@ -133,6 +144,7 @@ const successMsg = ref('')
const token = ref('') const token = ref('')
const tokenValid = ref(false) const tokenValid = ref(false)
const tokenChecked = ref(false) const tokenChecked = ref(false)
const showModal = ref(false)
const isPasswordStrongRef = computed(() => isPasswordStrong(password.value)) const isPasswordStrongRef = computed(() => isPasswordStrong(password.value))
const passwordsMatch = computed(() => password.value === confirmPassword.value) const passwordsMatch = computed(() => password.value === confirmPassword.value)
@@ -202,10 +214,11 @@ async function submitForm() {
errorMsg.value = msg errorMsg.value = msg
return return
} }
successMsg.value = 'Your password has been reset. You may now sign in.' // Success: Show modal instead of successMsg
showModal.value = true
password.value = '' password.value = ''
confirmPassword.value = '' confirmPassword.value = ''
submitAttempted.value = false // <-- add this line submitAttempted.value = false
} catch { } catch {
errorMsg.value = 'Network error. Please try again.' errorMsg.value = 'Network error. Please try again.'
} finally { } finally {
@@ -213,6 +226,10 @@ async function submitForm() {
} }
} }
function closeModal() {
showModal.value = false
}
async function goToLogin() { async function goToLogin() {
await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login')) await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
} }

View File

@@ -136,7 +136,7 @@
>. Please open the email and follow the instructions to verify your account. >. Please open the email and follow the instructions to verify your account.
</p> </p>
<div class="card-actions"> <div class="card-actions">
<button class="form-btn" @click="goToLogin">Go to Sign In</button> <button class="btn btn-primary" @click="goToLogin">Sign In</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -165,20 +165,53 @@ function handleRewardModified(event: Event) {
} }
} }
const triggerTask = (task: Task) => { const triggerTask = async (task: Task) => {
if ('speechSynthesis' in window && task.name) { // Cancel any pending speech to avoid conflicts
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel()
if (task.name) {
const utter = new window.SpeechSynthesisUtterance(task.name) const utter = new window.SpeechSynthesisUtterance(task.name)
utter.rate = 1.0
utter.pitch = 1.0
utter.volume = 1.0
window.speechSynthesis.speak(utter) window.speechSynthesis.speak(utter)
} }
}
// 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) => {
if ('speechSynthesis' in window && reward.name) { // Cancel any pending speech to avoid conflicts
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel()
if (reward.name) {
const utterString = const utterString =
reward.name + reward.name +
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`) (reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
const utter = new window.SpeechSynthesisUtterance(utterString) const utter = new window.SpeechSynthesisUtterance(utterString)
utter.rate = 1.0
utter.pitch = 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
@@ -271,6 +304,12 @@ function removeInactivityListeners() {
if (inactivityTimer) clearTimeout(inactivityTimer) if (inactivityTimer) clearTimeout(inactivityTimer)
} }
const readyItemId = ref<string | null>(null)
function handleItemReady(itemId: string) {
readyItemId.value = itemId
}
const hasPendingRewards = computed(() => const hasPendingRewards = computed(() =>
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming), childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
) )
@@ -333,6 +372,9 @@ onUnmounted(() => {
:ids="tasks" :ids="tasks"
itemKey="tasks" itemKey="tasks"
imageField="image_id" imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerTask" @trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })" :getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn=" :filter-fn="
@@ -364,6 +406,9 @@ onUnmounted(() => {
:ids="tasks" :ids="tasks"
itemKey="tasks" itemKey="tasks"
imageField="image_id" imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerTask" @trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })" :getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn=" :filter-fn="
@@ -394,6 +439,9 @@ onUnmounted(() => {
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`" :fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
itemKey="reward_status" itemKey="reward_status"
imageField="image_id" imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerReward" @trigger-item="triggerReward"
:getItemClass=" :getItemClass="
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming }) (item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })

View File

@@ -85,6 +85,7 @@ describe('ChildView', () => {
// Mock speech synthesis // Mock speech synthesis
global.window.speechSynthesis = { global.window.speechSynthesis = {
speak: vi.fn(), speak: vi.fn(),
cancel: vi.fn(),
} as any } as any
global.window.SpeechSynthesisUtterance = vi.fn() as any global.window.SpeechSynthesisUtterance = vi.fn() as any
}) })
@@ -186,13 +187,18 @@ describe('ChildView', () => {
it('speaks task name when triggered', () => { it('speaks task name when triggered', () => {
wrapper.vm.triggerTask(mockChore) wrapper.vm.triggerTask(mockChore)
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
expect(window.speechSynthesis.speak).toHaveBeenCalled() expect(window.speechSynthesis.speak).toHaveBeenCalled()
}) })
it('does not crash if speechSynthesis is not available', () => { it('does not crash if speechSynthesis is not available', () => {
const originalSpeechSynthesis = global.window.speechSynthesis
delete (global.window as any).speechSynthesis delete (global.window as any).speechSynthesis
expect(() => wrapper.vm.triggerTask(mockChore)).not.toThrow() expect(() => wrapper.vm.triggerTask(mockChore)).not.toThrow()
// Restore for other tests
global.window.speechSynthesis = originalSpeechSynthesis
}) })
}) })
@@ -309,4 +315,95 @@ describe('ChildView', () => {
expect(mockRefresh).not.toHaveBeenCalled() expect(mockRefresh).not.toHaveBeenCalled()
}) })
}) })
describe('Item Ready State Management', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('initializes readyItemId to null', () => {
expect(wrapper.vm.readyItemId).toBe(null)
})
it('updates readyItemId when handleItemReady is called with an item ID', () => {
wrapper.vm.handleItemReady('task-1')
expect(wrapper.vm.readyItemId).toBe('task-1')
wrapper.vm.handleItemReady('reward-2')
expect(wrapper.vm.readyItemId).toBe('reward-2')
})
it('clears readyItemId when handleItemReady is called with empty string', () => {
wrapper.vm.readyItemId = 'task-1'
wrapper.vm.handleItemReady('')
expect(wrapper.vm.readyItemId).toBe('')
})
it('passes readyItemId prop to Chores ScrollingList', async () => {
wrapper.vm.readyItemId = 'task-1'
await nextTick()
const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0]
expect(choresScrollingList.props('readyItemId')).toBe('task-1')
})
it('passes readyItemId prop to Penalties ScrollingList', async () => {
wrapper.vm.readyItemId = 'task-2'
await nextTick()
const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1]
expect(penaltiesScrollingList.props('readyItemId')).toBe('task-2')
})
it('passes readyItemId prop to Rewards ScrollingList', async () => {
wrapper.vm.readyItemId = 'reward-1'
await nextTick()
const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2]
expect(rewardsScrollingList.props('readyItemId')).toBe('reward-1')
})
it('handles item-ready event from Chores ScrollingList', async () => {
const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0]
choresScrollingList.vm.$emit('item-ready', 'task-1')
await nextTick()
expect(wrapper.vm.readyItemId).toBe('task-1')
})
it('handles item-ready event from Penalties ScrollingList', async () => {
const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1]
penaltiesScrollingList.vm.$emit('item-ready', 'task-2')
await nextTick()
expect(wrapper.vm.readyItemId).toBe('task-2')
})
it('handles item-ready event from Rewards ScrollingList', async () => {
const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2]
rewardsScrollingList.vm.$emit('item-ready', 'reward-1')
await nextTick()
expect(wrapper.vm.readyItemId).toBe('reward-1')
})
it('maintains 2-step click workflow: first click sets ready, second click triggers', async () => {
// Initial state
expect(wrapper.vm.readyItemId).toBe(null)
// First click - item should become ready
wrapper.vm.handleItemReady('task-1')
expect(wrapper.vm.readyItemId).toBe('task-1')
// Second click would trigger the item (tested via ScrollingList component)
// After trigger, ready state should be cleared
wrapper.vm.handleItemReady('')
expect(wrapper.vm.readyItemId).toBe('')
})
})
}) })

View File

@@ -105,7 +105,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue' import EntityEditForm from '../shared/EntityEditForm.vue'
import ModalDialog from '../shared/ModalDialog.vue' import ModalDialog from '../shared/ModalDialog.vue'
@@ -172,52 +172,9 @@ function onAddImage({ id, file }: { id: string; file: File }) {
} else { } else {
localImageFile.value = null localImageFile.value = null
initialData.value.image_id = id initialData.value.image_id = id
updateAvatar(id)
} }
} }
async function updateAvatar(imageId: string) {
errorMsg.value = ''
successMsg.value = ''
//todo update avatar loading state
try {
const res = await fetch('/api/user/avatar', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_id: imageId }),
})
if (!res.ok) throw new Error('Failed to update avatar')
initialData.value.image_id = imageId
successMsg.value = 'Avatar updated!'
} catch {
//errorMsg.value = 'Failed to update avatar.'
//todo update avatar error handling
errorMsg.value = ''
}
}
watch(localImageFile, async (file) => {
if (!file) return
errorMsg.value = ''
successMsg.value = ''
const formData = new FormData()
formData.append('file', file)
formData.append('type', '2')
formData.append('permanent', 'true')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
initialData.value.image_id = data.id
await updateAvatar(data.id)
} catch {
errorMsg.value = 'Failed to upload avatar image.'
}
})
function handleSubmit(form: { function handleSubmit(form: {
image_id: string | null image_id: string | null
first_name: string first_name: string
@@ -226,6 +183,43 @@ function handleSubmit(form: {
}) { }) {
errorMsg.value = '' errorMsg.value = ''
loading.value = true loading.value = true
// Handle image upload if local file
let imageId = form.image_id
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '1')
formData.append('permanent', 'true')
fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
.then(async (resp) => {
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
// Now update profile
return updateProfile({
...form,
image_id: imageId,
})
})
.catch(() => {
errorMsg.value = 'Failed to upload image.'
loading.value = false
})
} else {
updateProfile(form)
}
}
async function updateProfile(form: {
image_id: string | null
first_name: string
last_name: string
email: string
}) {
fetch('/api/user/profile', { fetch('/api/user/profile', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -237,6 +231,8 @@ function handleSubmit(form: {
}) })
.then(async (res) => { .then(async (res) => {
if (!res.ok) throw new Error('Failed to update profile') if (!res.ok) throw new Error('Failed to update profile')
// Update initialData to reflect the saved state
initialData.value = { ...form }
modalTitle.value = 'Profile Updated' modalTitle.value = 'Profile Updated'
modalSubtitle.value = '' modalSubtitle.value = ''
modalMessage.value = 'Your profile was updated successfully.' modalMessage.value = 'Your profile was updated successfully.'
@@ -251,7 +247,11 @@ function handleSubmit(form: {
} }
async function handlePasswordModalClose() { async function handlePasswordModalClose() {
const wasProfileUpdate = modalTitle.value === 'Profile Updated'
showModal.value = false showModal.value = false
if (wasProfileUpdate) {
router.back()
}
} }
async function resetPassword() { async function resetPassword() {

View File

@@ -36,7 +36,7 @@ const fields: {
}[] = [ }[] = [
{ name: 'name', label: 'Reward Name', type: 'text', required: true, maxlength: 64 }, { name: 'name', label: 'Reward Name', type: 'text', required: true, maxlength: 64 },
{ name: 'description', label: 'Description', type: 'text', maxlength: 128 }, { name: 'description', label: 'Description', type: 'text', maxlength: 128 },
{ name: 'cost', label: 'Cost', type: 'number', required: true, min: 1, max: 1000 }, { name: 'cost', label: 'Cost', type: 'number', required: true, min: 1, max: 10000 },
{ name: 'image_id', label: 'Image', type: 'image', imageType: 2 }, { name: 'image_id', label: 'Image', type: 'image', imageType: 2 },
] ]
// removed duplicate defineProps // removed duplicate defineProps

View File

@@ -39,7 +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 type="submit" class="btn btn-primary" :disabled="loading || !isDirty"> <button type="submit" class="btn btn-primary" :disabled="loading || !isDirty || !isValid">
{{ isEdit ? 'Save' : 'Create' }} {{ isEdit ? 'Save' : 'Create' }}
</button> </button>
</div> </div>
@@ -47,7 +47,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue' import { ref, onMounted, nextTick, watch, computed } from 'vue'
import ImagePicker from '@/components/utils/ImagePicker.vue' import ImagePicker from '@/components/utils/ImagePicker.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import '@/assets/styles.css' import '@/assets/styles.css'
@@ -120,9 +120,33 @@ function checkDirty() {
}) })
} }
// Validation logic
const isValid = computed(() => {
return props.fields.every((field) => {
if (!field.required) return true
const value = formData.value[field.name]
if (field.type === 'text') {
return typeof value === 'string' && value.trim().length > 0
}
if (field.type === 'number') {
const numValue = Number(value)
if (isNaN(numValue)) return false
if (field.min !== undefined && numValue < field.min) return false
if (field.max !== undefined && numValue > field.max) return false
return true
}
// For other types, just check it's not null/undefined
return value != null
})
})
watch( watch(
() => ({ ...formData.value }), () => ({ ...formData.value }),
() => { (newVal) => {
console.log('formData changed:', newVal)
checkDirty() checkDirty()
}, },
{ deep: true }, { deep: true },

View File

@@ -239,12 +239,14 @@ function handleClickOutside(event: MouseEvent) {
onMounted(() => { onMounted(() => {
eventBus.on('open-login', open) eventBus.on('open-login', open)
eventBus.on('profile_updated', fetchUserProfile)
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
fetchUserProfile() fetchUserProfile()
}) })
onUnmounted(() => { onUnmounted(() => {
eventBus.off('open-login', open) eventBus.off('open-login', open)
eventBus.off('profile_updated', fetchUserProfile)
document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('mousedown', handleClickOutside)
// Revoke object URL to free memory // Revoke object URL to free memory
@@ -372,6 +374,10 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
.error {
color: var(--error);
}
.avatar-btn { .avatar-btn {
width: 44px; width: 44px;
min-width: 44px; min-width: 44px;

View File

@@ -73,7 +73,7 @@ const fields: {
imageType?: number imageType?: number
}[] = [ }[] = [
{ name: 'name', label: 'Task Name', type: 'text', required: true, maxlength: 64 }, { name: 'name', label: 'Task Name', type: 'text', required: true, maxlength: 64 },
{ name: 'points', label: 'Task Points', type: 'number', required: true, min: 1, max: 100 }, { name: 'points', label: 'Task Points', type: 'number', required: true, min: 1, max: 1000 },
{ name: 'is_good', label: 'Task Type', type: 'custom' }, { name: 'is_good', label: 'Task Type', type: 'custom' },
{ name: 'image_id', label: 'Image', type: 'image', imageType: 2 }, { name: 'image_id', label: 'Image', type: 'image', imageType: 2 },
] ]

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="layout-root"> <div class="layout-root">
<header class="topbar"> <header class="topbar">
<div class="back-btn-container"> <div class="end-button-container">
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0"> Back</button> <button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0"> Back</button>
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
@@ -55,7 +55,7 @@ const showBack = computed(
box-sizing: border-box; box-sizing: border-box;
} }
.back-btn-container { .end-button-container {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@@ -45,7 +45,7 @@ onMounted(async () => {
<template> <template>
<div class="layout-root"> <div class="layout-root">
<header class="topbar"> <header class="topbar">
<div class="back-btn-container edge-btn-container"> <div class="end-button-container">
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0"> Back</button> <button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0"> Back</button>
</div> </div>
<nav v-if="!hideViewSelector" class="view-selector"> <nav v-if="!hideViewSelector" class="view-selector">
@@ -153,7 +153,8 @@ onMounted(async () => {
</svg> </svg>
</button> </button>
</nav> </nav>
<div class="login-btn-container edge-btn-container"> <div v-else class="spacer"></div>
<div class="end-button-container">
<LoginButton /> <LoginButton />
</div> </div>
</header> </header>
@@ -186,7 +187,7 @@ onMounted(async () => {
box-sizing: border-box; box-sizing: border-box;
} }
.edge-btn-container { .end-button-container {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -227,6 +228,13 @@ onMounted(async () => {
color 0.18s; color 0.18s;
} }
.spacer {
flex: 1 1 auto;
height: 100%;
display: flex;
align-items: center;
}
@media (max-width: 480px) { @media (max-width: 480px) {
.back-btn { .back-btn {
font-size: 0.7rem; font-size: 0.7rem;