diff --git a/frontend-spec.md b/frontend-spec.md index a2bb347..17b34e9 100644 --- a/frontend-spec.md +++ b/frontend-spec.md @@ -3,7 +3,7 @@ All core features are migrated to multi-tenancy with path-scoped endpoints and token-based auth. The codebase no longer uses the `X-Household-Slug` header. Tests and type checks are fully green. Status of tests and typing -- All tests pass: 27 files, 48 tests (router tests fixed via memory history fallback in non-browser envs). +- All tests pass: 29 files, 49 tests (slug-only routes; memory history fallback in non-browser envs; unauthenticated and refresh-401 guard redirects covered). - `tsc` and `vue-tsc` pass with no errors. # Frontend Specification: Household Multi-Tenancy (v2) @@ -78,33 +78,31 @@ This plan is adapted to the existing codebase, focusing on refactoring rather th 2. **[x] Update Router for Multi-Tenancy**: - **Modify `src/router/index.ts`**: - Added new public routes: `/create-account`, `/welcome`, and `/invitations/accept`. - - Feature flag `VUE_APP_MULTITENANT_ENABLED` controls nesting: - - When enabled: feature routes are nested under `/:householdSlug/...`. - - When disabled: legacy flat routes remain for backward compatibility. + - Removed legacy flag and flat routes: feature routes are always nested under `/:householdSlug/...`. - **Refactor the `beforeEach` guard**: - Allows public routes. - - When multitenant flag is on, after auth fetches households via `useAuth().fetchHouseholds()`. + - After auth, fetches households via `useAuth().fetchHouseholds()`. - If none: redirect to `/welcome`. - If at root (`/`): redirect to first household's `/:householdSlug/mealplan`. - Ensures `activeHousehold` is set when navigating within a household. - - **Nest existing routes**: Implemented behind feature flag. - - **History behavior**: Uses hash history in real browsers and memory history in tests/SSR (detected via `globalThis.location`). Router tests verify both flag modes. + - **Nest existing routes**: Always under `/:householdSlug` (legacy flat routes removed). + - **History behavior**: Uses hash history in real browsers and memory history in tests/SSR (detected via `globalThis.location`). Router tests assert slug-only mode. 3. **[~] Implement Onboarding and Invitation Flows**: - Build the `Welcome.vue` view for creating the first household. - Build the `CreateAccount.vue` view. - Build the "Accept Invitation" page (`/invitations/accept?token=...`). It should take the token from the URL, call the API, and redirect on success. - - Status: Welcome page implements create-household flow using `POST /api/v1/households` and redirects to `/:slug/mealplan`. Create Account UI implemented. Invitation Accept implemented: reads `token` from query, calls `POST /api/v1/invitations/accept`, and redirects to the accepted household. Uses a temporary raw fetch helper until OpenAPI adds this endpoint. + - Status: Welcome page implements create-household flow using `POST /api/v1/households` and redirects to `/:slug/mealplan`. Create Account UI implemented. Invitation Accept implemented: reads `token` from query, calls typed `POST /api/v1/invitations/accept`, and redirects to the accepted household. 4. **[~] Integrate Household Context into the App**: - **Create `src/composables/useHousehold.ts`**: Implemented. Extracts `householdSlug` from route and binds provider to API client. - **Implement `HouseholdSwitcher.vue`**: Implemented minimal version and mounted in `App.vue`. - - **Update API Services**: Implemented header-injection in `src/api/client.ts` via `X-Household-Slug` and `Authorization` using configurable providers; no path changes. - - Verified by `tests/household.header.test.ts` and auth tests. + - **Update API Services**: No household header injection; Authorization header provided by token provider. Cookies are only sent for the refresh endpoint; all other requests avoid credentials. SDK/services use typed path params. + - Verified by `tests/household.header.test.ts`, auth tests, and router guard tests. 5. **[~] Implement Invitation UI**: - Build the `HouseholdSettings.vue` view for inviting members and listing current members. - - Status: Invite form implemented (sends email via POST `/api/v1/invitations`). Members listing pending backend endpoint. + - Status: Invite form implemented (sends email via POST `/api/v1/invitations`). Members listing implemented using typed endpoint `GET /api/v1/households/{householdSlug}/members`. 6. **[ ] Final Review & Cleanup**: - Remove the old `persons` concept from the frontend code. The `user` from `useAuth` is now the primary identity. @@ -122,7 +120,7 @@ What exists now - `src/api/auth.ts`: email/password login and register; token-only refresh in `currentUser()` which then loads `/api/v1/users/me/households`. - `src/composables/useAuth.ts`: manages `user`, `households`, and `activeHousehold`; exposes login/logout/createAccount and `fetchHouseholds()`. - Routing - - `src/router/index.ts`: feature-flagged nesting under `/:householdSlug/...`; public routes include `/create-account`, `/welcome`, and `/invitations/accept`. + - `src/router/index.ts`: slug-only nesting under `/:householdSlug/...`; public routes include `/create-account`, `/welcome`, and `/invitations/accept`. - Guard fetches households, redirects root `/` to the first household's `mealplan`, and uses memory history in tests (hash in browser). - SDK/API - `src/api/sdk.ts`: recipes, meals, and shopping are migrated to `/api/v1/households/{householdSlug}/...` typed endpoints. Persons and parse use temporary raw fetch endpoints where OpenAPI lacks coverage. @@ -133,7 +131,7 @@ What exists now - MyShopping: page remains as in master with editable panel backed by legacy v1 stubs (`getMyShoppingList/saveMyShoppingList`) pending backend ad-hoc item endpoints. Status of tests and typing -- All tests pass: 27 files, 48 tests. +- All tests pass: 29 files, 49 tests. - `tsc` and `vue-tsc` pass with no errors. --- @@ -160,11 +158,11 @@ Progress Log (Nov 1, 2025) - Implemented JWT login/register in `auth.ts` and wired token to client provider. `useAuth` updated with households fetching. `currentUser` refactored to token-only refresh plus households load. - Router guard updated to handle public/multitenant routing and redirects. - Added `useAuth.createAccount` with state update and tests for it; implemented `CreateAccount.vue` with form and navigation. -- Implemented Invitation Accept flow: added `src/api/invitations.ts` with `acceptInvitation` (temporary raw fetch), `InvitationAccept.vue` reads token and redirects to household; added `tests/invitations.api.test.ts`. +- Implemented Invitation Accept flow: added `src/api/invitations.ts` with typed `acceptInvitation`, `InvitationAccept.vue` reads token and redirects to household; added `tests/invitations.api.test.ts`. - Next: Implement Household Settings (invite members form), then remove legacy Person UI. - Added a Settings link to `HouseholdSwitcher.vue` to surface the `household-settings` route for easier discovery. - - Implemented Household Settings invite form and members list UI. `src/views/HouseholdSettings.vue` now loads members via a temporary `listMembers()` in `src/api/households.ts` using the raw fetch helper. When the backend exposes a typed endpoint, we will swap to the generated client. - - Router uses memory history in tests to avoid relying on `window.location`. + - Implemented Household Settings invite form and members list UI. `src/views/HouseholdSettings.vue` now loads members via the typed endpoint. + - Router uses memory history in tests to avoid relying on `window.location`. Added unauthenticated and refresh-401 guard redirect tests. - Backend updated OpenAPI and codegen has been run: - Many endpoints are now path-scoped with `{householdSlug}` (recipes, meals, shopping, invitations (create), whoami). - Auth endpoints (login/register/refresh/logout) are fully typed; `refresh` returns only `{ accessToken, tokenType }`. @@ -235,7 +233,7 @@ Tests - Auth refresh: returns `{ accessToken, tokenType }`. After refresh, call user/household endpoints to populate app state. Use `whoami` to validate the active route’s slug when needed. - Cleanup/migration tasks: 1) Remove X-Household-Slug header injection in `api/client.ts` and refactor services to accept `householdSlug` via typed params. (Completed) - 2) Replace temporary raw fetch calls with generated typed endpoints where available: invitations and members listing are now typed; migrate usages. (Members listing migrated) Persons and parse remain raw for now. + 2) Replace temporary raw fetch calls with generated typed endpoints where available: invitations and members listing are now typed; migrate usages. Persons and parse remain raw for now. 3) Integrate the new `POST /shopping/current/ingredients` endpoint into the SDK (`requestIngredient(ingredientId: number)`) and expose via `useShopping`; refactor `MyShoppingPage.vue` accordingly and remove legacy stubs. (SDK + composable done; UI refactor next) 4) Legacy identity: continue using `User` as the primary identity. Keep `Person` in meal-related UIs where required by backend, but remove Person as the login/identity concept. @@ -243,7 +241,7 @@ Tests ## Migration Plan & Feature Flag -- `VUE_APP_MULTITENANT_ENABLED` flag controls nested household routes. With the migration complete, keep this flag for rollout control; default can be enabled once backend is stable across environments. +- Removed `VUE_APP_MULTITENANT_ENABLED` and legacy flat routes. The app now always uses slug-scoped routes. --- diff --git a/src/api/client.ts b/src/api/client.ts index 9c387cb..6f3bc9d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -25,15 +25,22 @@ export function setAuthTokenProvider(provider: (() => string | null) | null) { authTokenProvider = provider } -function isRefreshRequest(input: RequestInfo | URL): boolean { +function requestInfoToUrl(input: RequestInfo | URL): string | null { try { - const url = typeof input === 'string' ? input : (input as URL).toString() - return url.includes('/api/v1/auth/refresh') + if (typeof input === 'string') return input + if (typeof URL !== 'undefined' && input instanceof URL) return input.toString() + if (typeof Request !== 'undefined' && input instanceof Request) return input.url + return String(input) } catch { - return false + return null } } +function isRefreshRequest(input: RequestInfo | URL): boolean { + const url = requestInfoToUrl(input) + return !!url && url.includes('/api/v1/auth/refresh') +} + export const api = createClient({ baseUrl, fetch: (input: RequestInfo | URL, init?: RequestInit) => { diff --git a/src/api/invitations.ts b/src/api/invitations.ts index 119f28a..64ee781 100644 --- a/src/api/invitations.ts +++ b/src/api/invitations.ts @@ -2,33 +2,26 @@ import { api } from '@/api/client' export type Household = { id: number; name: string; slug: string } +function hasKey(obj: T, key: K): obj is T & Record { + return Object.prototype.hasOwnProperty.call(obj, key) +} + function isHousehold(value: unknown): value is Household { + if (typeof value !== 'object' || value === null) return false + const v = value as Record return ( - typeof value === 'object' && value !== null && - typeof (value as { id: unknown }).id === 'number' && - typeof (value as { name: unknown }).name === 'string' && - typeof (value as { slug: unknown }).slug === 'string' + typeof v.id === 'number' && + typeof v.name === 'string' && + typeof v.slug === 'string' ) } -async function safeJson(resp: Response): Promise { - try { - const d = await resp.json() - return d as T - } catch (_e) { - return null - } -} - export async function acceptInvitation(token: string): Promise { - const { data, error, response } = await api.POST('/api/v1/invitations/accept', { body: { token } as any }) + const { data, error, response } = await api.POST('/api/v1/invitations/accept', { body: { token } }) if (!response.ok) throw new Error(`${response.status} ${response.statusText || 'HTTP error'}${error ? `: ${String(error)}` : ''}`) - const dataObj = data as any - const dataWrapped: { household?: unknown } | null = (dataObj && typeof dataObj === 'object') ? dataObj : null - let h: unknown = dataWrapped && 'household' in dataWrapped ? dataWrapped.household : undefined - if (!isHousehold(h)) { - throw new Error('Invalid invitation accept response') - } + const obj = data && typeof data === 'object' ? data as Record : null + const h = obj && hasKey(obj, 'household') ? obj.household : undefined + if (!isHousehold(h)) throw new Error('Invalid invitation accept response') return { id: h.id, name: h.name, slug: h.slug } } diff --git a/src/components/meals/MealCard.vue b/src/components/meals/MealCard.vue index 12f0948..3506080 100644 --- a/src/components/meals/MealCard.vue +++ b/src/components/meals/MealCard.vue @@ -11,7 +11,7 @@ v-for="(chef, index) in meal.chefs" :key="chef.id" > - {{ chef.displayName }}{{ englishSeperator(index, meal.chefs) }} + {{ chef.displayName }}{{ englishSeperator(index, meal.chefs) }} somebody?

@@ -21,7 +21,7 @@ v-for="(consumer, index) in meal.consumers" :key="consumer.id" > - {{ consumer.displayName }}{{ englishSeperator(index, meal.consumers) }} + {{ consumer.displayName }}{{ englishSeperator(index, meal.consumers) }} somebody?

diff --git a/src/router/index.ts b/src/router/index.ts index 5144d3e..c76953a 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -43,7 +43,8 @@ export function createAppRouter(getCurrentUser: () => Promise | unknown // Use hash history in real browsers; fallback to memory history in tests/SSR where `globalThis.location` may be unavailable // Some test runners may polyfill `window` but not the global `location`, and vue-router's hash history uses the global. - const hasLocation = typeof globalThis !== 'undefined' && typeof (globalThis as any).location !== 'undefined' + // Avoid 'as' assertions; relying on typeof global 'location' is safe and non-throwing in Node + const hasLocation = typeof location !== 'undefined' const history = hasLocation ? createWebHashHistory() : createMemoryHistory() const router = createRouter({ diff --git a/tests/invitations.send.api.test.ts b/tests/invitations.send.api.test.ts index 4a9dd16..c183d2f 100644 --- a/tests/invitations.send.api.test.ts +++ b/tests/invitations.send.api.test.ts @@ -14,7 +14,7 @@ describe('invitations api (send invite)', () => { await loginWithPassword('x@y', 'pw') server.use( - http.post('*/api/v1/households/the-smiths/invitations', async ({ request, requestId, cookies, params }) => { + http.post('*/api/v1/households/the-smiths/invitations', async ({ request }) => { const body = await request.json() expect(body).toEqual({ email: 'invite@example.com' }) const auth = request.headers.get('authorization') diff --git a/tests/router.guard.refresh401.test.ts b/tests/router.guard.refresh401.test.ts new file mode 100644 index 0000000..294f55b --- /dev/null +++ b/tests/router.guard.refresh401.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest' + +describe('router guard refresh 401 handling', () => { + it('redirects to /login with redirect when getCurrentUser throws (e.g., refresh 401)', async () => { + const { createAppRouter } = await import('@/router/index') + const router = createAppRouter(async () => { throw new Error('401 Unauthorized') }) + + // Stub login route with inline component to avoid loading .vue files + try { router.removeRoute('login') } catch (_) { /* ignore */ } + router.addRoute({ path: '/login', name: 'login', component: { template: '
' } }) + + await router.push('/') + + const current = router.currentRoute.value + expect(current.name).toBe('login') + expect(current.query.redirect).toBe('/') + }) +}) diff --git a/tests/router.guard.unauth.test.ts b/tests/router.guard.unauth.test.ts new file mode 100644 index 0000000..1727338 --- /dev/null +++ b/tests/router.guard.unauth.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' + +describe('router guard unauthenticated redirect', () => { + it('redirects to /login with redirect query when not authenticated', async () => { + const { createAppRouter } = await import('@/router/index') + const router = createAppRouter(async () => null) + + // Replace the lazy .vue login route with an inline component to avoid plugin-vue in tests + try { router.removeRoute('login') } catch (_) { /* ignore */ } + router.addRoute({ path: '/login', name: 'login', component: { template: '
' } }) + + // Navigate to a protected route that uses an inline component to avoid lazy .vue imports + await router.push('/') + + const current = router.currentRoute.value + expect(current.name).toBe('login') + expect(current.query.redirect).toBe('/') + }) +})