WIP Sync
This commit is contained in:
5
.idea/.gitignore
generated
vendored
Normal file
5
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
15
.idea/Reward.iml
generated
Normal file
15
.idea/Reward.iml
generated
Normal 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.ask2agent.xml
generated
Normal file
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal 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>
|
||||||
10
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
10
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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>
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
4
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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/tasks.json
vendored
Normal file
31
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Save Work In Progress",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "git",
|
||||||
|
"args": ["savewip"],
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "silent",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Load Work In Progress",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "git",
|
||||||
|
"args": ["loadwip"],
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "silent",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
12
backend/events/types/profile_updated.py
Normal file
12
backend/events/types/profile_updated.py
Normal 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")
|
||||||
@@ -1,92 +1,92 @@
|
|||||||
{
|
{
|
||||||
"_default": {
|
"_default": {
|
||||||
"1": {
|
"1": {
|
||||||
"id": "479920ee-4d2c-4ff9-a7e4-749691183903",
|
"id": "0a380d32-881a-4886-9cd8-a8a84b4e1239",
|
||||||
"created_at": 1770772299.9946082,
|
"created_at": 1771031906.3919146,
|
||||||
"updated_at": 1770772299.9946082,
|
"updated_at": 1771031906.3919146,
|
||||||
"child_id": "child1",
|
"child_id": "child1",
|
||||||
"entity_id": "task1",
|
"entity_id": "task1",
|
||||||
"entity_type": "task",
|
"entity_type": "task",
|
||||||
"custom_value": 20
|
"custom_value": 20
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"id": "e1212f17-1986-4ae2-9936-3e8c4a487a79",
|
"id": "c3672d1a-3cef-4d11-a492-369aa657014d",
|
||||||
"created_at": 1770772300.0246155,
|
"created_at": 1771031906.4299235,
|
||||||
"updated_at": 1770772300.0246155,
|
"updated_at": 1771031906.4299235,
|
||||||
"child_id": "child2",
|
"child_id": "child2",
|
||||||
"entity_id": "task2",
|
"entity_id": "task2",
|
||||||
"entity_type": "task",
|
"entity_type": "task",
|
||||||
"custom_value": 25
|
"custom_value": 25
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
"id": "58068231-3bd8-425c-aba2-1e4444547f2b",
|
"id": "641d7614-8c92-4c93-a157-bca97640d0a8",
|
||||||
"created_at": 1770772300.0326169,
|
"created_at": 1771031906.4359252,
|
||||||
"updated_at": 1770772300.0326169,
|
"updated_at": 1771031906.4359252,
|
||||||
"child_id": "child3",
|
"child_id": "child3",
|
||||||
"entity_id": "task1",
|
"entity_id": "task1",
|
||||||
"entity_type": "task",
|
"entity_type": "task",
|
||||||
"custom_value": 10
|
"custom_value": 10
|
||||||
},
|
},
|
||||||
"4": {
|
"4": {
|
||||||
"id": "21299d89-29d1-4876-abc8-080a919dfa27",
|
"id": "ee56c0e9-a468-4e12-bf2c-baef5a06cd83",
|
||||||
"created_at": 1770772300.0326169,
|
"created_at": 1771031906.4359252,
|
||||||
"updated_at": 1770772300.0326169,
|
"updated_at": 1771031906.4359252,
|
||||||
"child_id": "child3",
|
"child_id": "child3",
|
||||||
"entity_id": "task2",
|
"entity_id": "task2",
|
||||||
"entity_type": "task",
|
"entity_type": "task",
|
||||||
"custom_value": 15
|
"custom_value": 15
|
||||||
},
|
},
|
||||||
"5": {
|
"5": {
|
||||||
"id": "4676589a-abcf-4407-806c-8d187a41dae3",
|
"id": "19466c51-44fb-4759-ad64-6a62bf423e30",
|
||||||
"created_at": 1770772300.0326169,
|
"created_at": 1771031906.4359252,
|
||||||
"updated_at": 1770772300.0326169,
|
"updated_at": 1771031906.4359252,
|
||||||
"child_id": "child3",
|
"child_id": "child3",
|
||||||
"entity_id": "reward1",
|
"entity_id": "reward1",
|
||||||
"entity_type": "reward",
|
"entity_type": "reward",
|
||||||
"custom_value": 100
|
"custom_value": 100
|
||||||
},
|
},
|
||||||
"33": {
|
"33": {
|
||||||
"id": "cd1473e2-241c-4bfd-b4b2-c2b5402d95d6",
|
"id": "3ac70695-0162-4ddb-b773-14cf31145619",
|
||||||
"created_at": 1770772307.3772185,
|
"created_at": 1771031913.7219265,
|
||||||
"updated_at": 1770772307.3772185,
|
"updated_at": 1771031913.7219265,
|
||||||
"child_id": "351c9e7f-5406-425c-a15a-2268aadbfdd5",
|
"child_id": "79f98e0e-c893-4699-a63f-e7ce18e3125f",
|
||||||
"entity_id": "90279979-e91e-4f51-af78-88ad70ffab57",
|
"entity_id": "2e5e9abc-ccbe-4d26-a943-a3d739bc55eb",
|
||||||
"entity_type": "task",
|
"entity_type": "task",
|
||||||
"custom_value": 5
|
"custom_value": 5
|
||||||
},
|
},
|
||||||
"34": {
|
"34": {
|
||||||
"id": "57ecb6f8-dff3-47a8-81a9-66979e1ce7b4",
|
"id": "8b71d091-0918-4077-a793-fc088b59e95c",
|
||||||
"created_at": 1770772307.3833773,
|
"created_at": 1771031913.7279284,
|
||||||
"updated_at": 1770772307.3833773,
|
"updated_at": 1771031913.7279284,
|
||||||
"child_id": "f12a42a9-105a-4a6f-84e8-1c3a8e076d33",
|
"child_id": "9fac1589-718a-4cb5-bda6-bbb58d68b62e",
|
||||||
"entity_id": "90279979-e91e-4f51-af78-88ad70ffab57",
|
"entity_id": "2e5e9abc-ccbe-4d26-a943-a3d739bc55eb",
|
||||||
"entity_type": "task",
|
"entity_type": "task",
|
||||||
"custom_value": 20
|
"custom_value": 20
|
||||||
},
|
},
|
||||||
"35": {
|
"35": {
|
||||||
"id": "d55b8b5c-39fc-449c-9848-99c2d572fdd8",
|
"id": "4ad1c2e6-3316-4847-97ba-a895f3cc9c40",
|
||||||
"created_at": 1770772307.618762,
|
"created_at": 1771031913.9652154,
|
||||||
"updated_at": 1770772307.618762,
|
"updated_at": 1771031913.9652154,
|
||||||
"child_id": "f84a1e5e-3f14-44fd-8b8b-b25ede62a1d2",
|
"child_id": "5dcea978-0d41-430b-b321-1645bed1e5b3",
|
||||||
"entity_id": "b64435a0-8856-4c8d-bf77-8438ff5d9061",
|
"entity_id": "b5d9775e-7836-4d17-a2a2-59e4141b6c5f",
|
||||||
"entity_type": "task",
|
"entity_type": "task",
|
||||||
"custom_value": 0
|
"custom_value": 0
|
||||||
},
|
},
|
||||||
"36": {
|
"36": {
|
||||||
"id": "a9777db2-6912-4b21-b668-4f36566d4ef8",
|
"id": "59eb1477-afbd-4e00-b0a1-13f5636e9360",
|
||||||
"created_at": 1770772307.8648667,
|
"created_at": 1771031914.2012715,
|
||||||
"updated_at": 1770772307.8648667,
|
"updated_at": 1771031914.2012715,
|
||||||
"child_id": "f84a1e5e-3f14-44fd-8b8b-b25ede62a1d2",
|
"child_id": "5dcea978-0d41-430b-b321-1645bed1e5b3",
|
||||||
"entity_id": "35cf2bde-9f47-4458-ac7b-36713063deb4",
|
"entity_id": "e84a6c84-2eb3-4097-b3f2-30700b7e666b",
|
||||||
"entity_type": "task",
|
"entity_type": "task",
|
||||||
"custom_value": 10000
|
"custom_value": 10000
|
||||||
},
|
},
|
||||||
"37": {
|
"37": {
|
||||||
"id": "04c54b24-914e-4ed6-b336-4263a4701c78",
|
"id": "0f00c3d9-bf2c-4812-82de-391c9807a3cb",
|
||||||
"created_at": 1770772308.104657,
|
"created_at": 1771031914.4634206,
|
||||||
"updated_at": 1770772308.104657,
|
"updated_at": 1771031914.4634206,
|
||||||
"child_id": "48bccc00-6d76-4bc9-a371-836d1a7db200",
|
"child_id": "27736f73-7c95-47cb-b4e3-476c92c517e7",
|
||||||
"entity_id": "ba725bf7-2dc8-4bdb-8a82-6ed88519f2ff",
|
"entity_id": "e08c01ea-90aa-46ab-bb8a-6922d0c71b19",
|
||||||
"entity_type": "reward",
|
"entity_type": "reward",
|
||||||
"custom_value": 75
|
"custom_value": 75
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,3 +176,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'
|
||||||
|
|||||||
108
frontend/vue-app/src/components/__tests__/LoginButton.spec.ts
Normal file
108
frontend/vue-app/src/components/__tests__/LoginButton.spec.ts
Normal 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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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 = ''
|
||||||
|
|||||||
@@ -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'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user