From aa510d7e2396a190a55735ff201e6727bff2b9fd Mon Sep 17 00:00:00 2001
From: jableader
Date: Sat, 1 Nov 2025 19:12:12 +1100
Subject: [PATCH] push prog 1
---
frontend-spec.md | 34 +++++++++++++--------------
src/api/client.ts | 15 ++++++++----
src/api/invitations.ts | 33 ++++++++++----------------
src/components/meals/MealCard.vue | 4 ++--
src/router/index.ts | 3 ++-
tests/invitations.send.api.test.ts | 2 +-
tests/router.guard.refresh401.test.ts | 18 ++++++++++++++
tests/router.guard.unauth.test.ts | 19 +++++++++++++++
8 files changed, 82 insertions(+), 46 deletions(-)
create mode 100644 tests/router.guard.refresh401.test.ts
create mode 100644 tests/router.guard.unauth.test.ts
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('/')
+ })
+})