Implement Login page with PocketBase auth (P1.6)
Add functional login page with email/password form: - Client component with controlled form inputs - PocketBase authentication integration - Error handling with visual feedback - Loading states (disabled inputs, button text change) - Form validation (prevents empty submissions) - Redirect to dashboard on successful login Test infrastructure improvements: - Add @testing-library/jest-dom for DOM matchers - Add global test setup with cleanup between tests - Configure vitest.config.ts with setupFiles 14 new tests covering form rendering, auth flow, error handling, and validation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,11 +40,11 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| POST /api/cron/garmin-sync | 501 | Has CRON_SECRET auth check, core logic TODO |
|
| POST /api/cron/garmin-sync | 501 | Has CRON_SECRET auth check, core logic TODO |
|
||||||
| POST /api/cron/notifications | 501 | Has CRON_SECRET auth check, core logic TODO |
|
| POST /api/cron/notifications | 501 | Has CRON_SECRET auth check, core logic TODO |
|
||||||
|
|
||||||
### Pages (7 total, ALL placeholders)
|
### Pages (7 total)
|
||||||
| Page | Status | Notes |
|
| Page | Status | Notes |
|
||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
| Dashboard (`/`) | Placeholder | Needs real data integration |
|
| Dashboard (`/`) | Placeholder | Needs real data integration |
|
||||||
| Login (`/login`) | Placeholder | Needs PocketBase auth integration |
|
| Login (`/login`) | **COMPLETE** | Email/password form with auth, error handling, loading states |
|
||||||
| Settings (`/settings`) | Placeholder | Needs form implementation |
|
| Settings (`/settings`) | Placeholder | Needs form implementation |
|
||||||
| Settings/Garmin (`/settings/garmin`) | Placeholder | Needs token management UI |
|
| Settings/Garmin (`/settings/garmin`) | Placeholder | Needs token management UI |
|
||||||
| Calendar (`/calendar`) | Placeholder | Needs MonthView integration |
|
| Calendar (`/calendar`) | Placeholder | Needs MonthView integration |
|
||||||
@@ -75,6 +75,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
|
|||||||
| `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) |
|
| `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) |
|
||||||
| `src/app/api/today/route.test.ts` | **EXISTS** - 22 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics) |
|
| `src/app/api/today/route.test.ts` | **EXISTS** - 22 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics) |
|
||||||
| `src/app/api/overrides/route.test.ts` | **EXISTS** - 14 tests (POST/DELETE overrides, auth, validation, type checks) |
|
| `src/app/api/overrides/route.test.ts` | **EXISTS** - 14 tests (POST/DELETE overrides, auth, validation, type checks) |
|
||||||
|
| `src/app/login/page.test.tsx` | **EXISTS** - 14 tests (form rendering, auth flow, error handling, validation) |
|
||||||
| `src/lib/nutrition.test.ts` | **MISSING** |
|
| `src/lib/nutrition.test.ts` | **MISSING** |
|
||||||
| `src/lib/email.test.ts` | **MISSING** |
|
| `src/lib/email.test.ts` | **MISSING** |
|
||||||
| `src/lib/ics.test.ts` | **MISSING** |
|
| `src/lib/ics.test.ts` | **MISSING** |
|
||||||
@@ -210,12 +211,15 @@ Minimum viable product - app can be used for daily decisions.
|
|||||||
- **Why:** Emergency overrides are critical for flare days
|
- **Why:** Emergency overrides are critical for flare days
|
||||||
- **Depends On:** P0.1, P0.2, P0.3
|
- **Depends On:** P0.1, P0.2, P0.3
|
||||||
|
|
||||||
### P1.6: Login Page Implementation
|
### P1.6: Login Page Implementation ✅ COMPLETE
|
||||||
- [ ] Functional login form with PocketBase auth
|
- [x] Functional login form with PocketBase auth
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- `src/app/login/page.tsx` - Form with email/password, error handling, redirect
|
- `src/app/login/page.tsx` - Client component with email/password form, error handling, loading states, redirect
|
||||||
- **Tests:**
|
- **Tests:**
|
||||||
- E2E test: valid login redirects to dashboard, invalid shows error
|
- `src/app/login/page.test.tsx` - 14 tests covering rendering, form submission, auth flow, error handling, validation
|
||||||
|
- **Infrastructure Added:**
|
||||||
|
- `src/test-setup.ts` - Global test setup with @testing-library/jest-dom and cleanup
|
||||||
|
- Updated `vitest.config.ts` to include setupFiles
|
||||||
- **Why:** Users need to authenticate to use the app
|
- **Why:** Users need to authenticate to use the app
|
||||||
- **Depends On:** P0.1
|
- **Depends On:** P0.1
|
||||||
|
|
||||||
@@ -507,6 +511,12 @@ P2.14 Mini calendar
|
|||||||
- [x] **POST /api/overrides** - Adds override to user.activeOverrides array, 14 tests (P1.5)
|
- [x] **POST /api/overrides** - Adds override to user.activeOverrides array, 14 tests (P1.5)
|
||||||
- [x] **DELETE /api/overrides** - Removes override from user.activeOverrides array, 14 tests (P1.5)
|
- [x] **DELETE /api/overrides** - Removes override from user.activeOverrides array, 14 tests (P1.5)
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6)
|
||||||
|
|
||||||
|
### Test Infrastructure
|
||||||
|
- [x] **test-setup.ts** - Global test setup with @testing-library/jest-dom matchers and cleanup
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Discovered Issues
|
## Discovered Issues
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"@biomejs/biome": "2.3.11",
|
"@biomejs/biome": "2.3.11",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.1",
|
"@testing-library/react": "^16.3.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
|||||||
60
pnpm-lock.yaml
generated
60
pnpm-lock.yaml
generated
@@ -57,6 +57,9 @@ importers:
|
|||||||
'@testing-library/dom':
|
'@testing-library/dom':
|
||||||
specifier: ^10.4.1
|
specifier: ^10.4.1
|
||||||
version: 10.4.1
|
version: 10.4.1
|
||||||
|
'@testing-library/jest-dom':
|
||||||
|
specifier: ^6.9.1
|
||||||
|
version: 6.9.1
|
||||||
'@testing-library/react':
|
'@testing-library/react':
|
||||||
specifier: ^16.3.1
|
specifier: ^16.3.1
|
||||||
version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -99,6 +102,9 @@ packages:
|
|||||||
'@acemir/cssom@0.9.30':
|
'@acemir/cssom@0.9.30':
|
||||||
resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==}
|
resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==}
|
||||||
|
|
||||||
|
'@adobe/css-tools@4.4.4':
|
||||||
|
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0':
|
'@alloc/quick-lru@5.2.0':
|
||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1184,6 +1190,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@testing-library/jest-dom@6.9.1':
|
||||||
|
resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
|
||||||
|
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
|
||||||
|
|
||||||
'@testing-library/react@16.3.1':
|
'@testing-library/react@16.3.1':
|
||||||
resolution: {integrity: sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==}
|
resolution: {integrity: sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1330,6 +1340,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
|
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
|
||||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||||
|
|
||||||
|
css.escape@1.5.1:
|
||||||
|
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
|
||||||
|
|
||||||
cssstyle@5.3.7:
|
cssstyle@5.3.7:
|
||||||
resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==}
|
resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -1364,6 +1377,9 @@ packages:
|
|||||||
dom-accessibility-api@0.5.16:
|
dom-accessibility-api@0.5.16:
|
||||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||||
|
|
||||||
|
dom-accessibility-api@0.6.3:
|
||||||
|
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||||
|
|
||||||
drizzle-kit@0.31.8:
|
drizzle-kit@0.31.8:
|
||||||
resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==}
|
resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1547,6 +1563,10 @@ packages:
|
|||||||
ics@3.8.1:
|
ics@3.8.1:
|
||||||
resolution: {integrity: sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==}
|
resolution: {integrity: sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==}
|
||||||
|
|
||||||
|
indent-string@4.0.0:
|
||||||
|
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
is-potential-custom-element-name@1.0.1:
|
is-potential-custom-element-name@1.0.1:
|
||||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
@@ -1668,6 +1688,10 @@ packages:
|
|||||||
mdn-data@2.12.2:
|
mdn-data@2.12.2:
|
||||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||||
|
|
||||||
|
min-indent@1.0.1:
|
||||||
|
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -1758,6 +1782,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
redent@3.0.0:
|
||||||
|
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
require-from-string@2.0.2:
|
require-from-string@2.0.2:
|
||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1825,6 +1853,10 @@ packages:
|
|||||||
std-env@3.10.0:
|
std-env@3.10.0:
|
||||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
|
strip-indent@3.0.0:
|
||||||
|
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
styled-jsx@5.1.6:
|
styled-jsx@5.1.6:
|
||||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -2045,6 +2077,8 @@ snapshots:
|
|||||||
|
|
||||||
'@acemir/cssom@0.9.30': {}
|
'@acemir/cssom@0.9.30': {}
|
||||||
|
|
||||||
|
'@adobe/css-tools@4.4.4': {}
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
||||||
'@asamuzakjp/css-color@4.1.1':
|
'@asamuzakjp/css-color@4.1.1':
|
||||||
@@ -2784,6 +2818,15 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
pretty-format: 27.5.1
|
pretty-format: 27.5.1
|
||||||
|
|
||||||
|
'@testing-library/jest-dom@6.9.1':
|
||||||
|
dependencies:
|
||||||
|
'@adobe/css-tools': 4.4.4
|
||||||
|
aria-query: 5.3.0
|
||||||
|
css.escape: 1.5.1
|
||||||
|
dom-accessibility-api: 0.6.3
|
||||||
|
picocolors: 1.1.1
|
||||||
|
redent: 3.0.0
|
||||||
|
|
||||||
'@testing-library/react@16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@testing-library/react@16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.28.4
|
'@babel/runtime': 7.28.4
|
||||||
@@ -2938,6 +2981,8 @@ snapshots:
|
|||||||
mdn-data: 2.12.2
|
mdn-data: 2.12.2
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
css.escape@1.5.1: {}
|
||||||
|
|
||||||
cssstyle@5.3.7:
|
cssstyle@5.3.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@asamuzakjp/css-color': 4.1.1
|
'@asamuzakjp/css-color': 4.1.1
|
||||||
@@ -2964,6 +3009,8 @@ snapshots:
|
|||||||
|
|
||||||
dom-accessibility-api@0.5.16: {}
|
dom-accessibility-api@0.5.16: {}
|
||||||
|
|
||||||
|
dom-accessibility-api@0.6.3: {}
|
||||||
|
|
||||||
drizzle-kit@0.31.8:
|
drizzle-kit@0.31.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@drizzle-team/brocli': 0.10.2
|
'@drizzle-team/brocli': 0.10.2
|
||||||
@@ -3127,6 +3174,8 @@ snapshots:
|
|||||||
runes2: 1.1.4
|
runes2: 1.1.4
|
||||||
yup: 1.7.1
|
yup: 1.7.1
|
||||||
|
|
||||||
|
indent-string@4.0.0: {}
|
||||||
|
|
||||||
is-potential-custom-element-name@1.0.1: {}
|
is-potential-custom-element-name@1.0.1: {}
|
||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
@@ -3232,6 +3281,8 @@ snapshots:
|
|||||||
|
|
||||||
mdn-data@2.12.2: {}
|
mdn-data@2.12.2: {}
|
||||||
|
|
||||||
|
min-indent@1.0.1: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
@@ -3311,6 +3362,11 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.3: {}
|
react@19.2.3: {}
|
||||||
|
|
||||||
|
redent@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
indent-string: 4.0.0
|
||||||
|
strip-indent: 3.0.0
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
resend@6.7.0:
|
resend@6.7.0:
|
||||||
@@ -3415,6 +3471,10 @@ snapshots:
|
|||||||
|
|
||||||
std-env@3.10.0: {}
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
|
strip-indent@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
min-indent: 1.0.1
|
||||||
|
|
||||||
styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3):
|
styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
|
|||||||
261
src/app/login/page.test.tsx
Normal file
261
src/app/login/page.test.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
// ABOUTME: Unit tests for the Login page component.
|
||||||
|
// ABOUTME: Tests form rendering, validation, auth flow, and error handling.
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
const mockPush = vi.fn();
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: mockPush,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock PocketBase
|
||||||
|
const mockAuthWithPassword = vi.fn();
|
||||||
|
vi.mock("@/lib/pocketbase", () => ({
|
||||||
|
pb: {
|
||||||
|
collection: () => ({
|
||||||
|
authWithPassword: mockAuthWithPassword,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import LoginPage from "./page";
|
||||||
|
|
||||||
|
describe("LoginPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders the login form with email and password inputs", () => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a sign in button", () => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /sign in/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the PhaseFlow branding", () => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/phaseflow/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has email input with type email", () => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
|
expect(emailInput).toHaveAttribute("type", "email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has password input with type password", () => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
|
expect(passwordInput).toHaveAttribute("type", "password");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("form submission", () => {
|
||||||
|
it("calls PocketBase auth with email and password on submit", async () => {
|
||||||
|
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAuthWithPassword).toHaveBeenCalledWith(
|
||||||
|
"test@example.com",
|
||||||
|
"password123",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to dashboard on successful login", async () => {
|
||||||
|
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPush).toHaveBeenCalledWith("/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading state while authenticating", async () => {
|
||||||
|
// Create a promise that we can control
|
||||||
|
let resolveAuth: (value: unknown) => void;
|
||||||
|
const authPromise = new Promise((resolve) => {
|
||||||
|
resolveAuth = resolve;
|
||||||
|
});
|
||||||
|
mockAuthWithPassword.mockReturnValue(authPromise);
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
// Button should show loading state
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /signing in/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve the auth
|
||||||
|
resolveAuth?.({ token: "test-token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables form inputs while loading", async () => {
|
||||||
|
let resolveAuth: (value: unknown) => void;
|
||||||
|
const authPromise = new Promise((resolve) => {
|
||||||
|
resolveAuth = resolve;
|
||||||
|
});
|
||||||
|
mockAuthWithPassword.mockReturnValue(authPromise);
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(emailInput).toBeDisabled();
|
||||||
|
expect(passwordInput).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
resolveAuth?.({ token: "test-token" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
it("shows error message on failed login", async () => {
|
||||||
|
mockAuthWithPassword.mockRejectedValueOnce(
|
||||||
|
new Error("Invalid credentials"),
|
||||||
|
);
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears error when user starts typing again", async () => {
|
||||||
|
mockAuthWithPassword.mockRejectedValueOnce(
|
||||||
|
new Error("Invalid credentials"),
|
||||||
|
);
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start typing again
|
||||||
|
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
||||||
|
|
||||||
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-enables form after error", async () => {
|
||||||
|
mockAuthWithPassword.mockRejectedValueOnce(
|
||||||
|
new Error("Invalid credentials"),
|
||||||
|
);
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form should be re-enabled
|
||||||
|
expect(emailInput).not.toBeDisabled();
|
||||||
|
expect(passwordInput).not.toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /sign in/i }),
|
||||||
|
).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validation", () => {
|
||||||
|
it("does not submit with empty email", async () => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const passwordInput = screen.getByLabelText(/password/i);
|
||||||
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
|
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "password123" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
// Should not call auth
|
||||||
|
expect(mockAuthWithPassword).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not submit with empty password", async () => {
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
|
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
// Should not call auth
|
||||||
|
expect(mockAuthWithPassword).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,111 @@
|
|||||||
// ABOUTME: Login page for user authentication.
|
// ABOUTME: Login page for user authentication.
|
||||||
// ABOUTME: Provides email/password login form using PocketBase auth.
|
// ABOUTME: Provides email/password login form using PocketBase auth.
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Basic validation - don't submit with empty fields
|
||||||
|
if (!email.trim() || !password.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection("users").authWithPassword(email, password);
|
||||||
|
router.push("/");
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Invalid credentials";
|
||||||
|
setError(message);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (
|
||||||
|
setter: React.Dispatch<React.SetStateAction<string>>,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
setter(value);
|
||||||
|
// Clear error when user starts typing again
|
||||||
|
if (error) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<div className="w-full max-w-md space-y-8 p-8">
|
<div className="w-full max-w-md space-y-8 p-8">
|
||||||
<h1 className="text-2xl font-bold text-center">PhaseFlow Login</h1>
|
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
|
||||||
{/* Login form will be implemented here */}
|
|
||||||
<p className="text-center text-gray-500">Login form placeholder</p>
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => handleInputChange(setEmail, e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => handleInputChange(setPassword, e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-blue-400 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? "Signing in..." : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
10
src/test-setup.ts
Normal file
10
src/test-setup.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// ABOUTME: Test setup file that configures testing utilities.
|
||||||
|
// ABOUTME: Imports jest-dom matchers and sets up cleanup after each test.
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
|
// Cleanup after each test to avoid component accumulation
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
@@ -14,5 +14,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
include: ["src/**/*.test.{ts,tsx}"],
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
setupFiles: ["./src/test-setup.ts"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user