diff --git a/README.md b/README.md index cf2662b..867362c 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,10 @@ npm run test For detailed development patterns and conventions, see [`.github/copilot-instructions.md`](.github/copilot-instructions.md). +## 📚 References + +- Reset flow (token validation, JWT invalidation, cross-tab logout sync): [`docs/reset-password-reference.md`](docs/reset-password-reference.md) + ## 📄 License Private project - All rights reserved. diff --git a/backend/api/auth_api.py b/backend/api/auth_api.py index 5ee7c4e..25e09c7 100644 --- a/backend/api/auth_api.py +++ b/backend/api/auth_api.py @@ -162,6 +162,7 @@ def login(): payload = { 'email': norm_email, 'user_id': user.id, + 'token_version': user.token_version, 'exp': datetime.utcnow() + timedelta(hours=24*7) } token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') @@ -179,10 +180,13 @@ def me(): try: payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256']) user_id = payload.get('user_id', '') + token_version = payload.get('token_version', 0) user_dict = users_db.get(UserQuery.id == user_id) user = User.from_dict(user_dict) if user_dict else None if not user: return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404 + if token_version != user.token_version: + return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401 if user.marked_for_deletion: return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403 return jsonify({ @@ -268,9 +272,12 @@ def reset_password(): user.password = generate_password_hash(new_password) user.reset_token = None user.reset_token_created = None + user.token_version += 1 users_db.update(user.to_dict(), UserQuery.email == user.email) - return jsonify({'message': 'Password has been reset'}), 200 + resp = jsonify({'message': 'Password has been reset'}) + resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict') + return resp, 200 @auth_api.route('/logout', methods=['POST']) def logout(): diff --git a/backend/api/task_api.py b/backend/api/task_api.py index 4e95359..3182fe2 100644 --- a/backend/api/task_api.py +++ b/backend/api/task_api.py @@ -64,10 +64,24 @@ def list_tasks(): continue # Skip default if user version exists filtered_tasks.append(t) - # 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 + # Sort order: + # 1) good tasks first, then not-good tasks + # 2) within each group: user-created items first (by name), then default items (by name) + good_tasks = [t for t in filtered_tasks if t.get('is_good') is True] + not_good_tasks = [t for t in filtered_tasks if t.get('is_good') is not True] + + def sort_user_then_default(tasks_group): + user_created = sorted( + [t for t in tasks_group if t.get('user_id') == user_id], + key=lambda x: x['name'].lower(), + ) + default_items = sorted( + [t for t in tasks_group if t.get('user_id') is None], + key=lambda x: x['name'].lower(), + ) + return user_created + default_items + + sorted_tasks = sort_user_then_default(good_tasks) + sort_user_then_default(not_good_tasks) return jsonify({'tasks': sorted_tasks}), 200 diff --git a/backend/api/utils.py b/backend/api/utils.py index 8e1d26e..6476fd9 100644 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -29,6 +29,12 @@ def get_current_user_id(): user_id = payload.get('user_id') if not user_id: return None + token_version = payload.get('token_version', 0) + user = users_db.get(Query().id == user_id) + if not user: + return None + if token_version != user.get('token_version', 0): + return None return user_id except jwt.InvalidTokenError: return None diff --git a/backend/config/version.py b/backend/config/version.py index 53511e0..87ab433 100644 --- a/backend/config/version.py +++ b/backend/config/version.py @@ -2,7 +2,7 @@ # file: config/version.py import os -BASE_VERSION = "1.0.4RC2" # update manually when releasing features +BASE_VERSION = "1.0.4RC3" # update manually when releasing features def get_full_version() -> str: """ diff --git a/backend/models/user.py b/backend/models/user.py index a70f95a..376c482 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -21,6 +21,7 @@ class User(BaseModel): deletion_in_progress: bool = False deletion_attempted_at: str | None = None role: str = 'user' + token_version: int = 0 @classmethod def from_dict(cls, d: dict): @@ -43,6 +44,7 @@ class User(BaseModel): deletion_in_progress=d.get('deletion_in_progress', False), deletion_attempted_at=d.get('deletion_attempted_at'), role=d.get('role', 'user'), + token_version=d.get('token_version', 0), id=d.get('id'), created_at=d.get('created_at'), updated_at=d.get('updated_at') @@ -69,6 +71,7 @@ class User(BaseModel): 'marked_for_deletion_at': self.marked_for_deletion_at, 'deletion_in_progress': self.deletion_in_progress, 'deletion_attempted_at': self.deletion_attempted_at, - 'role': self.role + 'role': self.role, + 'token_version': self.token_version, }) return base diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 45990a7..331483d 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -100,6 +100,38 @@ def test_reset_password_hashes_new_password(client): assert user_dict['password'].startswith('scrypt:') assert check_password_hash(user_dict['password'], 'newpassword123') + +def test_reset_password_invalidates_existing_jwt(client): + users_db.remove(Query().email == 'test@example.com') + user = User( + first_name='Test', + last_name='User', + email='test@example.com', + password=generate_password_hash('oldpassword123'), + verified=True, + reset_token='validtoken2', + reset_token_created=datetime.utcnow().isoformat(), + ) + users_db.insert(user.to_dict()) + + login_response = client.post('/auth/login', json={'email': 'test@example.com', 'password': 'oldpassword123'}) + assert login_response.status_code == 200 + login_cookie = login_response.headers.get('Set-Cookie', '') + assert 'token=' in login_cookie + old_token = login_cookie.split('token=', 1)[1].split(';', 1)[0] + assert old_token + + reset_response = client.post('/auth/reset-password', json={'token': 'validtoken2', 'password': 'newpassword123'}) + assert reset_response.status_code == 200 + reset_cookie = reset_response.headers.get('Set-Cookie', '') + assert 'token=' in reset_cookie + + # Set the old token as a cookie and test that it's now invalid + client.set_cookie('token', old_token) + me_response = client.get('/auth/me') + assert me_response.status_code == 401 + assert me_response.json['code'] == 'INVALID_TOKEN' + def test_migration_script_hashes_plain_text_passwords(): """Test the migration script hashes plain text passwords.""" # Clean up diff --git a/backend/tests/test_task_api.py b/backend/tests/test_task_api.py index 20e5ac8..960605b 100644 --- a/backend/tests/test_task_api.py +++ b/backend/tests/test_task_api.py @@ -80,6 +80,36 @@ def test_list_tasks(client): assert len(data['tasks']) == 2 +def test_list_tasks_sorted_by_is_good_then_user_then_default_then_name(client): + task_db.truncate() + + task_db.insert({'id': 'u_good_z', 'name': 'Zoo', 'points': 1, 'is_good': True, 'user_id': 'testuserid'}) + task_db.insert({'id': 'u_good_a', 'name': 'Apple', 'points': 1, 'is_good': True, 'user_id': 'testuserid'}) + task_db.insert({'id': 'd_good_m', 'name': 'Mop', 'points': 1, 'is_good': True, 'user_id': None}) + task_db.insert({'id': 'd_good_b', 'name': 'Brush', 'points': 1, 'is_good': True, 'user_id': None}) + + task_db.insert({'id': 'u_bad_c', 'name': 'Chore', 'points': 1, 'is_good': False, 'user_id': 'testuserid'}) + task_db.insert({'id': 'u_bad_a', 'name': 'Alarm', 'points': 1, 'is_good': False, 'user_id': 'testuserid'}) + task_db.insert({'id': 'd_bad_y', 'name': 'Yell', 'points': 1, 'is_good': False, 'user_id': None}) + task_db.insert({'id': 'd_bad_b', 'name': 'Bicker', 'points': 1, 'is_good': False, 'user_id': None}) + + response = client.get('/task/list') + assert response.status_code == 200 + + tasks = response.json['tasks'] + ordered_ids = [t['id'] for t in tasks] + assert ordered_ids == [ + 'u_good_a', + 'u_good_z', + 'd_good_b', + 'd_good_m', + 'u_bad_a', + 'u_bad_c', + 'd_bad_b', + 'd_bad_y', + ] + + def test_get_task_not_found(client): response = client.get('/task/nonexistent-id') assert response.status_code == 404 diff --git a/docs/reset-password-reference.md b/docs/reset-password-reference.md new file mode 100644 index 0000000..caadcf5 --- /dev/null +++ b/docs/reset-password-reference.md @@ -0,0 +1,258 @@ +# Password Reset Reference + +This document explains the full password reset and forced re-auth flow implemented in the project. + +## Scope + +This covers: + +- reset token validation and reset submission +- JWT invalidation after reset +- behavior of `/auth/me` with stale tokens +- multi-tab synchronization in the frontend + +--- + +## High-Level Behavior + +After a successful password reset: + +1. Backend updates the password hash. +2. Backend increments the user's `token_version`. +3. Backend clears the `token` auth cookie in the reset response. +4. Existing JWTs in other tabs/devices become invalid because their embedded `token_version` no longer matches. +5. Frontend broadcasts a logout sync event so other tabs immediately redirect to login. + +--- + +## Backend Components + +### 1) User model versioning + +File: `backend/models/user.py` + +- Added `token_version: int = 0`. +- `from_dict()` defaults missing value to `0` for backward compatibility. +- `to_dict()` persists `token_version`. + +### 2) JWT issuance includes token version + +File: `backend/api/auth_api.py` (`/auth/login`) + +JWT payload now includes: + +- `email` +- `user_id` +- `token_version` +- `exp` + +### 3) `/auth/me` rejects stale tokens + +File: `backend/api/auth_api.py` (`/auth/me`) + +Flow: + +- decode JWT +- load user from DB +- compare `payload.token_version` (default 0) with `user.token_version` +- if mismatch, return: + - status: `401` + - code: `INVALID_TOKEN` + +### 4) reset-password invalidates sessions + +File: `backend/api/auth_api.py` (`/auth/reset-password`) + +On success: + +- hash and store new password +- clear `reset_token` and `reset_token_created` +- increment `user.token_version` +- persist user +- clear `token` cookie in response (`expires=0`, `httponly=True`, `secure=True`, `samesite='Strict'`) + +### 5) shared auth utility enforcement + +File: `backend/api/utils.py` (`get_current_user_id`) + +Protected endpoints that use this helper also enforce token version: + +- decode JWT +- load user by `user_id` +- compare JWT `token_version` vs DB `token_version` +- return `None` if mismatch + +--- + +## Frontend Components + +### 1) Reset password page + +File: `frontend/vue-app/src/components/auth/ResetPassword.vue` + +On successful `/api/auth/reset-password`: + +- calls `logoutUser()` from auth store +- still shows success modal +- Sign In action navigates to login + +### 2) Cross-tab logout sync + +File: `frontend/vue-app/src/stores/auth.ts` + +Implemented: + +- logout broadcast key: `authSyncEvent` +- `logoutUser()`: + - applies local logged-out state + - writes logout event to localStorage +- `initAuthSync()`: + - listens to `storage` events + - if logout event arrives, applies logged-out state and redirects to `/auth/login` when outside `/auth/*` +- `checkAuth()` now funnels failed `/api/auth/me` checks through `logoutUser()` + +### 3) Sync bootstrap + +File: `frontend/vue-app/src/main.ts` + +- calls `initAuthSync()` at app startup. + +### 4) Global `401 Unauthorized` handling + +Files: + +- `frontend/vue-app/src/common/api.ts` +- `frontend/vue-app/src/main.ts` + +Implemented: + +- `installUnauthorizedFetchInterceptor()` wraps global `fetch` +- if any response is `401`, frontend: + - calls `logoutUser()` + - redirects to `/auth` (unless already on `/auth/*`) + +This ensures protected pages consistently return users to auth landing when a session is invalid. + +--- + +## Sequence Diagram (Reset Success) + +```mermaid +sequenceDiagram + participant U as User (Tab A) + participant FE as ResetPassword.vue + participant BE as auth_api.py + participant DB as users_db + participant LS as localStorage + participant T2 as Browser Tab B + + U->>FE: Submit new password + token + FE->>BE: POST /api/auth/reset-password + BE->>DB: Validate reset token + expiry + BE->>DB: Update password hash + BE->>DB: token_version = token_version + 1 + BE-->>FE: 200 + clear auth cookie + + FE->>LS: logoutUser() writes authSyncEvent + LS-->>T2: storage event(authSyncEvent: logout) + T2->>T2: clear auth state + T2->>T2: redirect /auth/login +``` + +--- + +## Sequence Diagram (Stale Token Check) + +```mermaid +sequenceDiagram + participant T as Any Tab with old JWT + participant BE as /auth/me + participant DB as users_db + + T->>BE: GET /auth/me (old JWT token_version=N) + BE->>DB: Load user (current token_version=N+1) + BE-->>T: 401 { code: INVALID_TOKEN } +``` + +--- + +## Example API Calls + +### Validate reset token + +`GET /api/auth/validate-reset-token?token=` + +Possible failures: + +- `400 MISSING_TOKEN` +- `400 INVALID_TOKEN` +- `400 TOKEN_TIMESTAMP_MISSING` +- `400 TOKEN_EXPIRED` + +### Reset password + +`POST /api/auth/reset-password` + +Request body: + +```json +{ + "token": "", + "password": "newStrongPassword123" +} +``` + +Success: + +- `200 { "message": "Password has been reset" }` +- response also clears auth cookie + +### Auth check after reset with stale JWT + +`GET /api/auth/me` + +Expected: + +- `401 { "error": "Invalid token", "code": "INVALID_TOKEN" }` + +--- + +## SSE vs Cross-Tab Sync + +Current design intentionally does **not** rely on SSE to enforce logout correctness. + +Why: + +- Security correctness is guaranteed by cookie clearing + token_version invalidation. +- SSE can improve UX but is not required for correctness. +- Cross-tab immediate UX is handled client-side via localStorage `storage` events. + +--- + +## Test Coverage + +Backend: + +- `backend/tests/test_auth_api.py` +- includes regression test ensuring old JWT fails `/auth/me` after reset. + +Frontend: + +- `frontend/vue-app/src/stores/__tests__/auth.childmode.spec.ts` +- includes cross-tab storage logout behavior. +- `frontend/vue-app/src/common/__tests__/api.interceptor.spec.ts` +- verifies global `401` interceptor logout and redirect behavior. + +--- + +## Troubleshooting Checklist + +- If stale sessions still appear valid: + - verify `token_version` exists in user records + - confirm `/auth/login` includes `token_version` claim + - confirm `/auth/me` compares JWT vs DB token_version + - confirm `/auth/reset-password` increments token_version +- If other tabs do not redirect: + - verify `initAuthSync()` is called in `main.ts` + - verify `logoutUser()` is called on reset success + - check browser supports storage events across tabs for same origin diff --git a/frontend/vue-app/src/common/__tests__/api.interceptor.spec.ts b/frontend/vue-app/src/common/__tests__/api.interceptor.spec.ts new file mode 100644 index 0000000..c3f96f9 --- /dev/null +++ b/frontend/vue-app/src/common/__tests__/api.interceptor.spec.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const mockLogoutUser = vi.fn() + +vi.mock('@/stores/auth', () => ({ + logoutUser: () => mockLogoutUser(), +})) + +describe('installUnauthorizedFetchInterceptor', () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.resetModules() + mockLogoutUser.mockReset() + globalThis.fetch = vi.fn() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it('logs out and redirects to /auth on 401 outside auth routes', async () => { + const fetchMock = globalThis.fetch as unknown as ReturnType + fetchMock.mockResolvedValue({ status: 401 } as Response) + + window.history.pushState({}, '', '/parent/profile') + const redirectSpy = vi.fn() + + const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } = + await import('../api') + setUnauthorizedRedirectHandlerForTests(redirectSpy) + installUnauthorizedFetchInterceptor() + + await fetch('/api/user/profile') + + expect(mockLogoutUser).toHaveBeenCalledTimes(1) + expect(redirectSpy).toHaveBeenCalledTimes(1) + }) + + it('does not redirect when already on auth route', async () => { + const fetchMock = globalThis.fetch as unknown as ReturnType + fetchMock.mockResolvedValue({ status: 401 } as Response) + + window.history.pushState({}, '', '/auth/login') + const redirectSpy = vi.fn() + + const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } = + await import('../api') + setUnauthorizedRedirectHandlerForTests(redirectSpy) + installUnauthorizedFetchInterceptor() + + await fetch('/api/auth/me') + + expect(mockLogoutUser).toHaveBeenCalledTimes(1) + expect(redirectSpy).not.toHaveBeenCalled() + }) + + it('handles unauthorized redirect only once even for repeated 401 responses', async () => { + const fetchMock = globalThis.fetch as unknown as ReturnType + fetchMock.mockResolvedValue({ status: 401 } as Response) + + window.history.pushState({}, '', '/parent/tasks') + const redirectSpy = vi.fn() + + const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } = + await import('../api') + setUnauthorizedRedirectHandlerForTests(redirectSpy) + installUnauthorizedFetchInterceptor() + + await fetch('/api/task/add', { method: 'PUT' }) + await fetch('/api/image/list?type=2') + + expect(mockLogoutUser).toHaveBeenCalledTimes(1) + expect(redirectSpy).toHaveBeenCalledTimes(1) + }) + + it('does not log out for non-401 responses', async () => { + const fetchMock = globalThis.fetch as unknown as ReturnType + fetchMock.mockResolvedValue({ status: 200 } as Response) + + window.history.pushState({}, '', '/parent') + const redirectSpy = vi.fn() + + const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } = + await import('../api') + setUnauthorizedRedirectHandlerForTests(redirectSpy) + installUnauthorizedFetchInterceptor() + + await fetch('/api/child/list') + + expect(mockLogoutUser).not.toHaveBeenCalled() + expect(redirectSpy).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/vue-app/src/common/__tests__/backendEvents.spec.ts b/frontend/vue-app/src/common/__tests__/backendEvents.spec.ts new file mode 100644 index 0000000..28fc7b1 --- /dev/null +++ b/frontend/vue-app/src/common/__tests__/backendEvents.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, toRef } from 'vue' +import { useBackendEvents } from '../backendEvents' + +const { emitMock } = vi.hoisted(() => ({ + emitMock: vi.fn(), +})) + +vi.mock('../eventBus', () => ({ + eventBus: { + emit: emitMock, + }, +})) + +class MockEventSource { + static instances: MockEventSource[] = [] + public onmessage: ((event: MessageEvent) => void) | null = null + public close = vi.fn(() => { + this.closed = true + }) + public closed = false + + constructor(public url: string) { + MockEventSource.instances.push(this) + } +} + +const TestHarness = defineComponent({ + name: 'BackendEventsHarness', + props: { + userId: { + type: String, + required: true, + }, + }, + setup(props) { + useBackendEvents(toRef(props, 'userId')) + return () => h('div') + }, +}) + +describe('useBackendEvents', () => { + beforeEach(() => { + vi.clearAllMocks() + MockEventSource.instances = [] + vi.stubGlobal('EventSource', MockEventSource) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('connects when user id becomes available after mount', async () => { + const wrapper = mount(TestHarness, { props: { userId: '' } }) + + expect(MockEventSource.instances.length).toBe(0) + + await wrapper.setProps({ userId: 'user-1' }) + + expect(MockEventSource.instances.length).toBe(1) + expect(MockEventSource.instances[0]?.url).toBe('/events?user_id=user-1') + }) + + it('reconnects when user id changes and closes previous connection', async () => { + const wrapper = mount(TestHarness, { props: { userId: 'user-1' } }) + + expect(MockEventSource.instances.length).toBe(1) + const firstConnection = MockEventSource.instances[0] + + await wrapper.setProps({ userId: 'user-2' }) + + expect(firstConnection?.close).toHaveBeenCalledTimes(1) + expect(MockEventSource.instances.length).toBe(2) + expect(MockEventSource.instances[1]?.url).toBe('/events?user_id=user-2') + }) + + it('emits parsed backend events on message', async () => { + mount(TestHarness, { props: { userId: 'user-1' } }) + + const connection = MockEventSource.instances[0] + expect(connection).toBeDefined() + + connection?.onmessage?.({ + data: JSON.stringify({ type: 'profile_updated', payload: { id: 'user-1' } }), + } as MessageEvent) + + expect(emitMock).toHaveBeenCalledWith('profile_updated', { + type: 'profile_updated', + payload: { id: 'user-1' }, + }) + expect(emitMock).toHaveBeenCalledWith('sse', { + type: 'profile_updated', + payload: { id: 'user-1' }, + }) + }) + + it('closes the event source on unmount', () => { + const wrapper = mount(TestHarness, { props: { userId: 'user-1' } }) + + const connection = MockEventSource.instances[0] + wrapper.unmount() + + expect(connection?.close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/vue-app/src/common/api.ts b/frontend/vue-app/src/common/api.ts index f97e8d7..b90974f 100644 --- a/frontend/vue-app/src/common/api.ts +++ b/frontend/vue-app/src/common/api.ts @@ -1,3 +1,43 @@ +import { logoutUser } from '@/stores/auth' + +let unauthorizedInterceptorInstalled = false +let unauthorizedRedirectHandler: (() => void) | null = null +let unauthorizedHandlingInProgress = false + +export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | null): void { + unauthorizedRedirectHandler = handler +} + +function handleUnauthorizedResponse(): void { + if (unauthorizedHandlingInProgress) return + unauthorizedHandlingInProgress = true + logoutUser() + if (typeof window === 'undefined') return + if (window.location.pathname.startsWith('/auth')) return + if (unauthorizedRedirectHandler) { + unauthorizedRedirectHandler() + return + } + window.location.assign('/auth') +} + +export function installUnauthorizedFetchInterceptor(): void { + if (unauthorizedInterceptorInstalled || typeof window === 'undefined') return + unauthorizedInterceptorInstalled = true + + const originalFetch = globalThis.fetch.bind(globalThis) + const wrappedFetch = (async (...args: Parameters) => { + const response = await originalFetch(...args) + if (response.status === 401) { + handleUnauthorizedResponse() + } + return response + }) as typeof fetch + + window.fetch = wrappedFetch as typeof window.fetch + globalThis.fetch = wrappedFetch as typeof globalThis.fetch +} + export async function parseErrorResponse(res: Response): Promise<{ msg: string; code?: string }> { try { const data = await res.json() diff --git a/frontend/vue-app/src/common/models.ts b/frontend/vue-app/src/common/models.ts index 0d03bab..118cb16 100644 --- a/frontend/vue-app/src/common/models.ts +++ b/frontend/vue-app/src/common/models.ts @@ -13,6 +13,7 @@ export interface User { first_name: string last_name: string email: string + token_version: number image_id: string | null marked_for_deletion: boolean marked_for_deletion_at: string | null diff --git a/frontend/vue-app/src/components/auth/Login.vue b/frontend/vue-app/src/components/auth/Login.vue index 101afaf..e96e852 100644 --- a/frontend/vue-app/src/components/auth/Login.vue +++ b/frontend/vue-app/src/components/auth/Login.vue @@ -146,7 +146,7 @@ import { ALREADY_VERIFIED, } from '@/common/errorCodes' import { parseErrorResponse, isEmailValid } from '@/common/api' -import { loginUser } from '@/stores/auth' +import { loginUser, checkAuth } from '@/stores/auth' const router = useRouter() @@ -211,6 +211,7 @@ async function submitForm() { } loginUser() // <-- set user as logged in + await checkAuth() // hydrate currentUserId so SSE reconnects immediately await router.push({ path: '/' }).catch(() => (window.location.href = '/')) } catch (err) { diff --git a/frontend/vue-app/src/components/auth/ParentPinSetup.vue b/frontend/vue-app/src/components/auth/ParentPinSetup.vue index 0fc4554..9d9df99 100644 --- a/frontend/vue-app/src/components/auth/ParentPinSetup.vue +++ b/frontend/vue-app/src/components/auth/ParentPinSetup.vue @@ -39,6 +39,7 @@

Enter a new 4–6 digit Parent PIN. This will be required for parent access.

{ return /^\d{4,6}$/.test(p1) && /^\d{4,6}$/.test(p2) && p1 === p2 }) +function handlePinInput(event: Event) { + const target = event.target as HTMLInputElement + pin.value = target.value.replace(/\D/g, '').slice(0, 6) +} + +function handlePin2Input(event: Event) { + const target = event.target as HTMLInputElement + pin2.value = target.value.replace(/\D/g, '').slice(0, 6) +} + async function requestCode() { error.value = '' info.value = '' diff --git a/frontend/vue-app/src/components/auth/ResetPassword.vue b/frontend/vue-app/src/components/auth/ResetPassword.vue index 5556fa8..26c9f0f 100644 --- a/frontend/vue-app/src/components/auth/ResetPassword.vue +++ b/frontend/vue-app/src/components/auth/ResetPassword.vue @@ -129,6 +129,7 @@ import { ref, computed, onMounted } from 'vue' import { useRouter, useRoute } from 'vue-router' import { isPasswordStrong } from '@/common/api' +import { logoutUser } from '@/stores/auth' import ModalDialog from '@/components/shared/ModalDialog.vue' import '@/assets/styles.css' @@ -156,7 +157,7 @@ const formValid = computed( onMounted(async () => { // Get token from query string const raw = route.query.token ?? '' - token.value = Array.isArray(raw) ? raw[0] : String(raw || '') + token.value = (Array.isArray(raw) ? raw[0] : raw) || '' // Validate token with backend if (token.value) { @@ -223,6 +224,7 @@ async function submitForm() { return } // Success: Show modal instead of successMsg + logoutUser() showModal.value = true password.value = '' confirmPassword.value = '' diff --git a/frontend/vue-app/src/components/auth/__tests__/Login.spec.ts b/frontend/vue-app/src/components/auth/__tests__/Login.spec.ts new file mode 100644 index 0000000..1000681 --- /dev/null +++ b/frontend/vue-app/src/components/auth/__tests__/Login.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount } from '@vue/test-utils' +import Login from '../Login.vue' + +const { pushMock, loginUserMock, checkAuthMock } = vi.hoisted(() => ({ + pushMock: vi.fn(), + loginUserMock: vi.fn(), + checkAuthMock: vi.fn(), +})) + +vi.mock('vue-router', () => ({ + useRouter: vi.fn(() => ({ push: pushMock })), +})) + +vi.mock('@/stores/auth', () => ({ + loginUser: loginUserMock, + checkAuth: checkAuthMock, +})) + +vi.mock('@/common/api', async () => { + const actual = await vi.importActual('@/common/api') + return { + ...actual, + parseErrorResponse: vi.fn(async () => ({ + msg: 'bad credentials', + code: 'INVALID_CREDENTIALS', + })), + } +}) + +describe('Login.vue', () => { + beforeEach(() => { + vi.clearAllMocks() + checkAuthMock.mockResolvedValue(undefined) + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('hydrates auth state after successful login', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValue({ ok: true } as Response) + + const wrapper = mount(Login) + + await wrapper.get('#email').setValue('test@example.com') + await wrapper.get('#password').setValue('secret123') + await wrapper.get('form').trigger('submit') + + await Promise.resolve() + + expect(loginUserMock).toHaveBeenCalledTimes(1) + expect(checkAuthMock).toHaveBeenCalledTimes(1) + expect(pushMock).toHaveBeenCalledWith({ path: '/' }) + + const checkAuthOrder = checkAuthMock.mock.invocationCallOrder[0] + const pushOrder = pushMock.mock.invocationCallOrder[0] + expect(checkAuthOrder).toBeDefined() + expect(pushOrder).toBeDefined() + expect((checkAuthOrder ?? 0) < (pushOrder ?? 0)).toBe(true) + }) + + it('does not hydrate auth state when login fails', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValue({ ok: false, status: 401 } as Response) + + const wrapper = mount(Login) + + await wrapper.get('#email').setValue('test@example.com') + await wrapper.get('#password').setValue('badpassword') + await wrapper.get('form').trigger('submit') + + await Promise.resolve() + + expect(loginUserMock).not.toHaveBeenCalled() + expect(checkAuthMock).not.toHaveBeenCalled() + expect(pushMock).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/vue-app/src/components/child/ChildEditView.vue b/frontend/vue-app/src/components/child/ChildEditView.vue index 1a21f61..f07bc45 100644 --- a/frontend/vue-app/src/components/child/ChildEditView.vue +++ b/frontend/vue-app/src/components/child/ChildEditView.vue @@ -5,6 +5,7 @@ :fields="fields" :initialData="initialData" :isEdit="isEdit" + :requireDirty="isEdit" :loading="loading" :error="error" @submit="handleSubmit" @@ -16,22 +17,38 @@